chiark / gitweb /
Licence: Provide CAF Login Exception
[cgi-auth-flexible.git] / cgi-auth-flexible.pm
1 # -*- perl -*-
2
3 # This is part of CGI::Auth::Flexible, a perl CGI authentication module.
4 # Copyright (C) 2012 Ian Jackson.
5 # Copyright (C) 2012 Citrix.
6
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version, with the "CAF Login Exception"
11 # as published by Ian Jackson (version 1, or at your option any 
12 # later version) as an Additional Permission.
13
14 # This program is distributed in the hope that it will be useful,
15 # but WITHOUT ANY WARRANTY; without even the implied warranty of
16 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17 # GNU Affero General Public License for more details.
18
19 # You should have received a copy of the GNU Affero General Public
20 # License and the CAF Login Exception along with this program, in the
21 # file AGPLv3+CAFv1.  If not, email Ian Jackson
22 # <ijackson@chiark.greenend.org.uk>.
23
24 use strict;
25 use warnings FATAL => 'all';
26
27 package CGI::Auth::Flexible;
28 require Exporter;
29
30 BEGIN {
31     use Exporter   ();
32     our ($VERSION, @ISA, @EXPORT, @EXPORT_OK, %EXPORT_TAGS);
33
34     $VERSION     = 1.00;
35     @ISA         = qw(Exporter);
36     @EXPORT      = qw();
37     %EXPORT_TAGS = ( );     # eg: TAG => [ qw!name1 name2! ],
38 }
39 our @EXPORT_OK;
40
41 use DBI;
42 use CGI qw/escapeHTML/;
43 use Locale::gettext;
44 use URI;
45 use IO::File;
46 use Fcntl qw(:flock);
47 use POSIX;
48 use Digest;
49 use Digest::HMAC;
50 use Digest::SHA;
51 use Data::Dumper;
52 use File::Copy;
53 use Cwd qw/realpath/;
54
55
56 #---------- public utilities ----------
57
58 sub flatten_params ($) {
59     my ($p) = @_;
60     my @p;
61     foreach my $k (keys %$p) {
62         next if $k eq '';
63         foreach my $v (@{ $p->{$k} }) {
64             push @p, $k, $v;
65         }
66     }
67     return @p;
68 }
69
70 #---------- default callbacks ----------
71
72 sub has_a_param ($$) {
73     my ($r,$cn) = @_;
74     foreach my $pn (@{ $r->{S}{$cn} }) {
75         return 1 if $r->_ch('get_param',$pn);
76     }
77     return 0;
78 }
79
80 sub get_params ($) {
81     my ($r) = @_;
82     my $c = $r->{Cgi};
83     my $vars = $c->Vars();
84     my %p;
85     foreach my $name (keys %$vars) {
86         $p{$name} = [ split "\0", $vars->{$name} ];
87     }
88     return \%p;
89 }
90
91 sub get_cookie_domain ($$$) {
92     my ($c,$r) = @_;
93     my $uri = new URI $r->_ch('get_url');
94     return $uri->host();
95 }
96
97 sub login_ok_password ($$) {
98     my ($c, $r) = @_;
99     my $username_params = $r->{S}{username_param_names};
100     my $username = $r->_ch('get_param',$username_params->[0]);
101     my $password = $r->_rp('password_param_name');
102     my $error = $r->_ch('username_password_error', $username, $password);
103     return defined($error) ? (undef,$error) : ($username,undef);
104 }
105
106 sub do_redirect_cgi ($$$$) {
107     my ($c, $r, $new_url, $cookie) = @_;
108     $r->_print($c->header($r->_cgi_header_args($cookie,
109                                                -status => '303 See other',
110                                                -location => $new_url)),
111                $r->_ch('gen_start_html',$r->_gt('Redirection')),
112                '<a href="'.escapeHTML($new_url).'">',
113                $r->_gt("If you aren't redirected, click to continue."),
114                "</a>",
115                $r->_ch('gen_end_html'));
116 }
117
118 sub gen_some_form ($$) {
119     my ($r, $params, $bodyfn) = @_;
120     # Calls $bodyfn->($c,$r) which returns @formbits
121     my $c = $r->{Cgi};
122     my @form;
123     my $pathinfo = '';
124     $pathinfo .= $params->{''}[0] if $params->{''};
125     push @form, ('<form method="POST" action="'.
126                  escapeHTML($r->_ch('get_url').$pathinfo).'">');
127     push @form, $bodyfn->($c,$r);
128     foreach my $n (keys %$params) {
129         next if $n eq '';
130         foreach my $val (@{ $params->{$n} }) {
131             push @form, ('<input type="hidden"'.
132                          ' name="'.escapeHTML($n).'"'.
133                          ' value="'.escapeHTML($val).'">');
134         }
135     }
136     push @form, ('</form>');
137     return join "\n", @form;
138 }
139
140 sub gen_plain_login_form ($$) {
141     my ($c,$r, $params) = @_;
142     return $r->gen_some_form($params, sub {
143         my @form;
144         push @form, ('<table>');
145         my $sz = 'size="'.$r->{S}{form_entry_size}.'"';
146         foreach my $up (@{ $r->{S}{username_param_names}}) {
147             push @form, ('<tr><td>',$r->_gt(ucfirst $up),'</td>',
148                          '<td><input type="text" '.$sz.
149                          ' name='.$up.'></td></tr>');
150         }
151         push @form, ('<tr><td>'.$r->_gt('Password').'</td>',
152                      '<td><input type="password" '.$sz.
153                      ' name="'.$r->{S}{password_param_name}.'"></td></tr>');
154         push @form, ('<tr><td colspan="2">',
155                      '<input type="submit"'.
156                      ' name="'.$r->{S}{dummy_param_name_prefix}.'login"'.
157                      ' value="'.$r->_gt('Login').'"></td></tr>',
158                      '</table>');
159         return @form;
160     });
161 }
162
163 sub gen_postmainpage_form ($$$) {
164     my ($c,$r, $params) = @_;
165     return $r->gen_some_form($params, sub {
166         my @form;
167         push @form, ('<input type="submit"',
168                      ' name="'.$r->{S}{dummy_param_name_prefix}.'submit"'.
169                      ' value="'.$r->_gt('Continue').'">');
170         return @form;
171     });
172 }
173
174 sub gen_plain_login_link ($$) {
175     my ($c,$r, $params) = @_;
176     my $url = $r->url_with_query_params($params);
177     return ('<a href="'.escapeHTML($url).'">'.
178             $r->_gt('Log in again to continue.').
179             '</a>');
180 }
181
182 sub gen_srcdump_link_html ($$$$) {
183     my ($c,$r,$anchor,$specval) = @_;
184     my %params = ($r->{S}{srcdump_param_name} => [ $specval ]);
185     return '<a href="'.
186         escapeHTML($r->url_with_query_params(\%params,'SRCDUMP')).
187         '">'.$anchor."</a>";
188 }
189 sub gen_plain_licence_link_html ($$) {
190     my ($c,$r) = @_;
191     gen_srcdump_link_html($c,$r, 'GNU Affero GPL with CAF Login Exception',
192                           'licence');
193 }
194 sub gen_plain_source_link_html ($$) {
195     my ($c,$r) = @_;
196     gen_srcdump_link_html($c,$r, 'Source available', 'source');
197 }
198
199 sub gen_plain_footer_html ($$) {
200     my ($c,$r) = @_;
201     return ('<hr><address>',
202             ("Powered by Free / Libre / Open Source Software".
203              " according to the ".$r->_ch('gen_licence_link_html')."."),
204             $r->_ch('gen_source_link_html').".",
205             '</address>');
206 }
207
208 #---------- licence and source code ----------
209
210 sub srcdump_dump ($$$) {
211     my ($c,$r, $thing) = @_;
212     die if $thing =~ m/\W/ || $thing !~ m/\w/;
213     my $path = $r->_get_path('srcdump');
214     my $ctf = new IO::File "$path/$thing.ctype", 'r'
215         or die "$path/$thing.ctype $!";
216     my $ct = <$ctf>;
217     chomp $ct or die "$path/$thing ?";
218     $ctf->close or die "$path/$thing $!";
219     my $df = new IO::File "$path/$thing.data", 'r'
220         or die "$path/$thing.data $!";
221     $r->_ch('dump', $ct, $df);
222 }
223
224 sub dump_plain ($$$$) {
225     my ($c, $r, $ct, $df) = @_;
226     $r->_print($c->header('-type' => $ct));
227     my $buffer;
228     for (;;) {
229         my $got = read $df, $buffer, 65536;
230         die $! unless defined $got;
231         return if !$got;
232         $r->_print($buffer);
233     }
234 }
235
236 sub srcdump_process_item ($$$$$$) {
237     my ($c, $v, $dumpdir, $item, $outfn, $needlicence, $dirsdone) = @_;
238     if ($v->_ch('srcdump_system_dir', $item)) {
239         $outfn->("srcdump_process_item: srcdump_system_dir, skipping $item");
240         return;
241     }
242     my $upwards = $item;
243     for (;;) {
244         $upwards =~ s#/+$##;
245         $upwards =~ s#/+\.$##;
246         last unless $upwards =~ m#[^/]#;
247         foreach my $try (@{ $v->{S}{srcdump_vcs_dirs} }) {
248 #print STDERR "TRY $item $upwards $try\n";
249             if (!stat "$upwards/$try") {
250                 $!==&ENOENT or $!==&ENOTDIR or die "check $upwards/$try $!";
251                 next;
252             }
253 #print STDERR "VCS $item $upwards $try\n";
254             if ($dirsdone->{$upwards}++) {
255                 $outfn->("srcdump_process_item: did $upwards,".
256                          " skipping $item");
257                 return;
258             }
259 #print STDERR "VCS $item $upwards $try GO\n";
260             $try =~ m/\w+/ or die;
261             return $v->_ch('srcdump_byvcs', $dumpdir, $upwards, $outfn, lc $&);
262         }
263         $upwards =~ s#/*[^/]+$##;
264     }
265     return $v->_ch('srcdump_novcs', $dumpdir, $item, $outfn);
266 }
267
268 sub srcdump_novcs ($$$$$) {
269     my ($c, $v, $dumpdir, $item, $outfn) = @_;
270     stat $item or die "$item $!";
271     if (-d _) {
272         my $script = 'find -type f -perm +004';
273         foreach my $excl (@{ $v->{S}{srcdump_excludes} }) {
274             $script .= " \\! -name '$excl'";
275         }
276         $script .= " -print0";
277         return srcdump_dir_cpio($c,$v,$dumpdir,$item,$outfn,'novcs',$script);
278     } elsif (-f _) {
279         return srcdump_file($c,$v,$dumpdir,$item,$outfn);
280     } else {
281         die "$item not file or directory";
282     }
283 }
284
285 sub srcdump_byvcs ($$$$$$) {
286     my ($c, $v, $dumpdir, $dir, $outfn, $vcs) = @_;
287 #print STDERR "BYVCS GIT $dir\n";
288     my $script = $v->{S}{"srcdump_vcsscript"}{$vcs};
289     die "no script for vcs $vcs" unless defined $script;
290     return srcdump_dir_cpio($c,$v,$dumpdir,$dir,$outfn,$vcs,$script);
291 }
292
293 sub srcdump_file ($$$$) {
294     my ($c,$v,$dumpdir,$file,$outfn) = @_;
295     my $outfile = $outfn->("srcdump_file saved $file", "src");
296     copy($file,$outfile) or die "$file $outfile $!";
297 }
298
299 sub srcdump_dir_cpio ($$$$$$$) {
300     my ($c,$v,$dumpdir,$dir,$outfn,$how,$script) = @_;
301     my $outfile = $outfn->("srcdump_dir_cpio $how saved $dir", "tar");
302 #print STDERR "CPIO $dir >$script<\n";
303     my $pid = fork();
304     defined $pid or die $!;
305     if (!$pid) {
306         $SIG{__DIE__} = sub {
307             print STDERR "CGI::Auth::Flexible srcdump error: $@\n";
308             exit 127;
309         };
310         open STDOUT, ">", $outfile or die "$outfile $!";
311         chdir $dir or die "chdir $dir: $!";
312         exec '/bin/bash','-ec',"
313             set -o pipefail
314             (
315              $script
316             ) | (
317              cpio -Hustar -o --quiet -0 -R 1000:1000 || \
318              cpio -Hustar -o --quiet -0
319             )
320             ";
321         die $!;
322     }
323     $!=0; (waitpid $pid, 0) == $pid or die "$!";
324     die "$dir ($how $script) $outfile $?" if $?;
325 }
326
327 sub srcdump_dirscan_prepare ($$) {
328     my ($c, $v) = @_;
329     my $dumpdir = $v->_get_path('srcdump');
330     mkdir $dumpdir or $!==&EEXIST or die "mkdir $dumpdir $!";
331     my $lockf = new IO::File "$dumpdir/generate.lock", 'w+'
332         or die "$dumpdir/generate.lock $!";
333     flock $lockf, LOCK_EX or die "$dumpdir/generate.lock $!";
334     my $needlicence = "$dumpdir/licence.tmp";
335     unlink $needlicence or $!==&ENOENT or die "rm $needlicence $!";
336     if (defined $v->{S}{srcdump_licence_path}) {
337         copy($v->{S}{srcdump_licence_path}, $needlicence)
338             or die "$v->{S}{srcdump_licence_path} $!";
339         $needlicence = undef;
340     }
341     unlink <"$dumpdir/s.[a-z][a-z][a-z].*">;
342     my @srcfiles = qw(licence.data manifest.txt);
343     my $srcoutcounter = 'aaa';
344
345     my $reportfh = new IO::File "$dumpdir/manifest.txt", 'w' or die $!;
346     my $outfn = sub {
347         my ($message, $extension) = @_;
348         if (defined $extension) {
349             my $leaf = "s.$srcoutcounter.$extension";
350             $srcoutcounter++;
351             push @srcfiles, $leaf;
352             print $reportfh "$leaf: $message\n" or die $!;
353             return "$dumpdir/$leaf";
354         } else {
355             print $reportfh "none: $message\n" or die $!;
356             return undef;
357         }
358     };
359     my %dirsdone;
360     foreach my $item ($v->_ch('srcdump_listitems')) {
361         next unless defined $item;
362         if ($item eq '.' && $v->{S}{srcdump_filter_cwd}) {
363             my @bad = grep { !m#^/# } values %INC;
364             die "filtering . from srcdump items and \@INC but already".
365                 " included @bad " if @bad;
366             @INC = grep { $_ ne '.' } @INC;
367             next;
368         }
369         if (!lstat "$item") {
370             die "stat $item $!" unless $!==&ENOENT;
371             $outfn->("srcdump_dirscan_prepare stat ENOENT, skipping $item");
372             next;
373         };
374         if (-l _) {
375             $item = realpath($item);
376             if (!defined $item) {
377                 die "realpath $item $!" unless $!==&ENOENT;
378                 $outfn->("srcdump_dirscan_prepare realpath ENOENT,".
379                          " skipping $item");
380             }
381         }
382         if (defined $needlicence) {
383             foreach my $try (@{ $v->{S}{srcdump_licence_files} }) {
384                 last if copy("$item/$try", $needlicence);
385                 $!==&ENOENT or $!==&ENOTDIR or die "copy $item/$try $!";
386             }
387         }
388         $v->_ch('srcdump_process_item', $dumpdir, $item,
389                 $outfn, \$needlicence, \%dirsdone);
390         $dirsdone{$item}++;
391     }
392     close $reportfh or die $!;
393     srcdump_install($c,$v, $dumpdir, 'licence', 'text/plain');
394     $!=0;
395     my @cmd = (qw(sh -ec), 'exec >&2 "$@"', qw(x),
396                qw(tar -zvvcf), "$dumpdir/source.tmp",
397                "-C", $dumpdir, qw(  --), @srcfiles);
398     my $r = system(@cmd);
399     if ($r) {
400         print STDERR "CGI::Auth::Flexible tar failed ($r $!) @cmd\n";
401         die "tar failed";
402     }
403     die "licence file not found" unless defined $needlicence;
404     srcdump_install($c,$v, $dumpdir, 'source', 'application/octet-stream');
405     close $lockf or die $!;
406 }
407
408 sub srcdump_install ($$$$$) {
409     my ($c,$v, $dumpdir, $which, $ctype) = @_;
410     rename "$dumpdir/$which.tmp", "$dumpdir/$which.data"
411         or die "$dumpdir/$which.data $!";
412     my $ctf = new IO::File "$dumpdir/$which.tmp", 'w'
413         or die "$dumpdir/$which.tmp $!";
414     print $ctf $ctype, "\n" or die $!;
415     close $ctf or die $!;
416     rename "$dumpdir/$which.tmp", "$dumpdir/$which.ctype"
417         or die "$dumpdir/$which.ctype $!";
418 }
419
420 #---------- verifier object methods ----------
421
422 sub new_verifier {
423     my $class = shift;
424     my $verifier = {
425         S => {
426             dir => undef,
427             db_dbh => undef, # must have AutoCommit=0, RaiseError=1
428             db_path => 'caf.db',
429             keys_path => 'caf-keys',
430             srcdump_path => 'caf-srcdump',
431             db_dsn => undef,
432             db_user => '',
433             db_password => '',
434             db_prefix => 'caf',
435             random_source => '/dev/urandom',
436             secretbits => 128, # bits
437             hash_algorithm => "SHA-256",
438             login_timeout => 86400, # seconds
439             login_form_timeout => 3600, # seconds
440             key_rollover => 86400, # seconds
441             assoc_param_name => 'caf_assochash',
442             dummy_param_name_prefix => 'caf__',
443             cookie_name => "caf_assocsecret",
444             password_param_name => 'password',
445             srcdump_param_name => 'caf_srcdump',
446             username_param_names => [qw(username)],
447             form_entry_size => 60,
448             logout_param_names => [qw(caf_logout)],
449             loggedout_param_names => [qw(caf_loggedout)],
450             promise_check_mutate => 0,
451             get_param => sub { $_[0]->param($_[2]) },
452             get_params => sub { $_[1]->get_params() },
453             get_path_info => sub { $_[0]->path_info() },
454             get_cookie => sub { $_[0]->cookie($_[1]->{S}{cookie_name}) },
455             get_method => sub { $_[0]->request_method() },
456             is_https => sub { !!$_[0]->https() },
457             get_url => sub { $_[0]->url(); },
458             is_login => sub { defined $_[1]->_rp('password_param_name') },
459             login_ok => \&login_ok_password,
460             username_password_error => sub { die },
461             is_logout => sub { $_[1]->has_a_param('logout_param_names') },
462             is_loggedout => sub { $_[1]->has_a_param('loggedout_param_names') },
463             handle_divert => sub { return 0 },
464             do_redirect => \&do_redirect_cgi, # this hook is allowed to throw
465             cookie_path => "/",
466             get_cookie_domain => \&get_cookie_domain,
467             encrypted_only => 1,
468             gen_start_html => sub { $_[0]->start_html($_[2]); },
469             gen_footer_html => \&gen_plain_footer_html,
470             gen_licence_link_html => \&gen_plain_licence_link_html,
471             gen_source_link_html => \&gen_plain_source_link_html,
472             gen_end_html => sub { $_[0]->end_html(); },
473             gen_login_form => \&gen_plain_login_form,
474             gen_login_link => \&gen_plain_login_link,
475             gen_postmainpage_form => \&gen_postmainpage_form,
476             srcdump_dump => \&srcdump_dump,
477             srcdump_prepare => \&srcdump_dirscan_prepare,
478             srcdump_licence_path => undef,
479             srcdump_licence_files => [qw(AGPLv3+CAFv1 CGI/Auth/Flexible/AGPLv3+CAFv1)],
480             srcdump_listitems => sub { (@INC, $ENV{'SCRIPT_FILENAME'}, $0); },
481             srcdump_filter_cwd => 1,
482             srcdump_system_dir => sub {
483                 $_[2] =~ m#^/etc/|^/usr/(?!local/)(?!lib/cgi)#;
484             },
485             srcdump_process_item => \&srcdump_process_item,
486             srcdump_vcs_dirs => [qw(.git .hg .bzr .svn)],
487             srcdump_vcsscript => {git => "
488                  git ls-files -z
489                  git ls-files -z --others --exclude-from=.gitignore
490                  find .git ! -name \\*~ -print0
491                             "},
492             srcdump_byvcs => \&srcdump_byvcs,
493             srcdump_novcs => \&srcdump_novcs,
494             srcdump_excludes => [qw(*~ *.bak *.tmp), '#*#'],
495             dump => \&dump_plain,
496             gettext => sub { gettext($_[2]); },
497             print => sub { print $_[2] or die $!; },
498             debug => sub { }, # like print; msgs contain trailing \n
499         },
500         Dbh => undef,
501     };
502     my ($k,$v);
503     while (($k,$v,@_) = @_) {
504         die "unknown setting $k" unless
505             $k =~ m/^promise_/ or
506             exists $verifier->{S}{$k};
507         $verifier->{S}{$k} = $v;
508     }
509     $verifier->{S}{db_setup_stmts} //=
510         ["CREATE TABLE $verifier->{S}{db_prefix}_assocs (".
511          " assochash VARCHAR PRIMARY KEY,".
512          " username VARCHAR NOT NULL,".
513          " last INTEGER NOT NULL".
514          ")"
515          ,
516          "CREATE INDEX $verifier->{S}{db_prefix}_assocs_timeout_index".
517          " ON $verifier->{S}{db_prefix}_assocs".
518          " (last)"
519         ];
520     bless $verifier, $class;
521     $verifier->_dbopen();
522     $verifier->_ch('srcdump_prepare');
523     return $verifier;
524 }
525
526 sub _db_setup_do ($$) {
527     my ($v, $sql) = @_;
528     my $dbh = $v->{Dbh};
529     eval {
530         $v->_db_transaction(sub {
531             local ($dbh->{PrintError}) = 0;
532             $dbh->do($sql);
533         });
534     };
535 }
536
537 sub _dbopen ($) {
538     my ($v) = @_;
539     my $dbh = $v->{Dbh};
540     return $dbh if $dbh; 
541
542     $dbh = $v->{S}{db_dbh};
543     if ($dbh) {
544         die if $dbh->{AutoCommit};
545         die unless $dbh->{RaiseError};
546     } else {
547         $v->{S}{db_dsn} ||= "dbi:SQLite:dbname=".$v->_get_path('db');
548         my $dsn = $v->{S}{db_dsn};
549
550         my $u = umask 077;
551         $dbh = DBI->connect($dsn, $v->{S}{db_user},
552                             $v->{S}{db_password}, {
553                                 AutoCommit => 0,
554                                 RaiseError => 1,
555                                 ShowErrorStatement => 1,
556                             });
557         umask $u;
558         die "$dsn $! ?" unless $dbh;
559     }
560     $v->{Dbh} = $dbh;
561
562     foreach my $stmt (@{ $v->{S}{db_setup_stmts} }) {
563         $v->_db_setup_do($stmt);
564     }
565     return $dbh;
566 }
567
568 sub disconnect ($) {
569     my ($v) = @_;
570     my $dbh = $v->{Dbh};
571     return unless $dbh;
572     $dbh->disconnect();
573 }
574
575 sub _db_transaction ($$) {
576     my ($v, $fn) = @_;
577     my $retries = 10;
578     my $rv;
579     my $dbh = $v->{Dbh};
580 #print STDERR "DT entry\n";
581     for (;;) {
582 #print STDERR "DT loop\n";
583         if (!eval {
584             $rv = $fn->();
585 #print STDERR "DT fn ok\n";
586             1;
587         }) {
588 #print STDERR "DT fn error\n";
589             { local ($@); $dbh->rollback(); }
590 #print STDERR "DT fn throwing\n";
591             die $@;
592         }
593 #print STDERR "DT fn eval ok\n";
594         if (eval {
595             $dbh->commit();
596 #print STDERR "DT commit ok\n";
597             1;
598         }) {
599 #print STDERR "DT commit eval ok ",Dumper($rv);
600             return $rv;
601         }
602 #print STDERR "DT commit throw?\n";
603         die $@ if !--$retries;
604 #print STDERR "DT loop again\n";
605     }
606 }
607
608 #---------- request object methods ----------
609
610 sub new_request {
611     my ($classbase, $cgi, @extra) = @_;
612     if (!ref $classbase) {
613         $classbase = $classbase->new_verifier(@extra);
614     } else {
615         die if @extra;
616     }
617     my $r = {
618         V => $classbase,
619         S => $classbase->{S},
620         Dbh => $classbase->{Dbh},
621         Cgi => $cgi,
622     };
623     bless $r, ref $classbase;
624 }
625
626 sub _ch ($$@) { # calls an application hook
627     my ($r,$methname, @args) = @_;
628     my $methfunc = $r->{S}{$methname};
629     die "$methname ?" unless $methfunc;
630     return $methfunc->($r->{Cgi}, $r, @args);
631 }
632
633 sub _rp ($$@) {
634     my ($r,$pnvb) = @_;
635     my $pn = $r->{S}{$pnvb};
636     my $p = scalar $r->_ch('get_param',$pn)
637 }
638
639 sub _debug ($@) {
640     my ($r,@args) = @_;
641     $r->_ch('debug',@args);
642 }
643
644 sub _get_path ($$) {
645     my ($r,$keybase) = @_;
646     my $leaf = $r->{S}{"${keybase}_path"};
647     return $r->_absify_path($leaf);
648 }
649
650 sub _absify_path ($$) {
651     my ($v,$leaf) = @_;
652     return $leaf if $leaf =~ m,^/,;
653     my $dir = $v->{S}{dir};
654     die "relying on cwd by default ?!  set dir" unless defined $dir;
655     return "$dir/$leaf";
656 }
657
658 sub _gt ($$) { my ($r, $t) = @_; return $r->_ch('gettext',$t); }
659 sub _print ($$) { my ($r, @t) = @_; return $r->_ch('print', join '', @t); }
660
661 sub construct_cookie ($$$) {
662     my ($r, $cooks) = @_;
663     return undef unless $cooks;
664     my $c = $r->{Cgi};
665     my @ca = (
666         -name => $r->{S}{cookie_name},
667         -value => $cooks,
668         -path => $r->{S}{cookie_path},
669         -domain => $r->_ch('get_cookie_domain'),
670         -expires => '+'.$r->{S}{login_timeout}.'s',
671         -secure => $r->{S}{encrypted_only}
672         );
673     my $cookie = $c->cookie(@ca);
674 #print STDERR "CC $r $c $cooks $cookie (@ca).\n";
675     return $cookie;
676 }
677
678 # pages/param-sets are
679 #   n normal non-mutating page
680 #   r retrieval of information for JS etc., non-mutating
681 #   m mutating page
682 #   u update of information by JS etc., mutating
683 #   i login
684 #   o logout
685 #   O "you have just logged out" page load
686
687 # in cook and par,
688 #    -         no value supplied (represented in code as $cookt='')
689 #    n, nN     value not in our db
690 #    t, tN     temporary value (in our db, no logged in user yet)
691 #    y, yN     value corresponds to logged-in user
692 # and, aggregated conditions:
693 #    a, aN     anything including -
694 #    x, xN     t or y
695 # if N differs the case applies only when the two values differ
696 # (eg,   a1 y2   does not apply when the logged-in value is supplied twice)
697
698 # "stale session" means request originates from a page from a login
699 # session which has been revoked (eg by logout); "cleared session"
700 # means request originates from a browser which has a different (or
701 # no) cookie.
702
703     # Case analysis, cookie mode, app promises re mutate:
704     # cook parm meth form
705     #                      
706     #  any -   POST  nrmuoi   bug or attack, fail
707     #  any -   GET    rmuoi   bug or attack, fail
708     #  any any GET     muoi   bug or attack, fail
709     #
710     #  -   -   GET         O  "just logged out" page
711     #  (any other)         O  bug or attack, fail
712     #
713     #  a1  a2  POST      o    logout
714     #                           if a1 is valid, revoke it
715     #                           if a2 is valid, revoke it
716     #                           delete cookie
717     #                           redirect to "just logged out" page
718     #                             (which contains link to login form)
719     #
720     #  -   t   POST       i   complain about cookies being disabled
721     #                           (with link to login form)
722     #
723     #  t1  t1  POST       i   login (or switch user)
724     #                           if bad
725     #                             show new login form
726     #                           if good
727     #                             upgrade t1 to y1 in our db (setting username)
728     #                             redirect to GET of remaining params
729     #
730     #  y1  a2  POST       i   complain about stale login form
731     #                           revoke y1
732     #                           show new login form
733     #                           
734     #  (other) POST       i   complain about stale login form
735     #                           show new login form
736     #
737     #  t1  a2  ANY   nrmu     treat as  - a2 ANY
738     #
739     #  y   -   GET   n        cross-site link
740     #                           show data
741     #
742     #  y   y   GET   nr       fine, show page or send data
743     #  y   y   POST  nrmu     mutation is OK, do operation
744     #
745     #  y1  y2  GET   nr       request from stale page
746     #                           do not revoke y2 as not RESTful
747     #                           treat as   y1 n GET
748     #
749     #  y1  y2  POST  nrmu     request from stale page
750     #                           revoke y2
751     #                           treat as   y1 n POST
752     #
753     #  y   nt  GET   n        intra-site link from stale page,
754     #                           treat as cross-site link, show data
755     #
756     #  y   nt  POST  n m      intra-site form submission from stale page
757     #                           show "session interrupted"
758     #                           with link to main data page
759     #
760     #  y   nt  GET    r       intra-site request from stale page
761     #                           fail
762     #
763     #  y   nt  POST   r u     intra-site request from stale page
764     #                           fail
765     #
766     #  -n  y2  GET   nr       intra-site link from cleared session
767     #                           do not revoke y2 as not RESTful
768     #                           treat as   -/n n GET
769     #
770     #  -n  y2  POST  nrmu     request from cleared session
771     #                           revoke y2
772     #                           treat as   -/n n POST
773     #
774     #  -nt -nt GET   n        cross-site link but user not logged in
775     #                           show login form with redirect to orig params
776     #                           generate fresh cookie
777     #
778     #  -nt nt  GET    rmu     user not logged in
779     #                           fail
780     #
781     #  -nt nt  POST  n m      user not logged in
782     #                           show login form
783     #
784     #  -nt nt  POST   r u     user not logged in
785     #                           fail
786
787 sub _check_divert_core ($) {
788     my ($r) = @_;
789
790     my $srcdump = $r->_rp('srcdump_param_name');
791     if ($srcdump) {
792         die if $srcdump =~ m/\W/;
793         return ({ Kind => 'SRCDUMP-'.uc $srcdump,
794                   Message => undef,
795                   _CookieRaw => undef,
796                   Params => { } });
797     }
798
799     my $cooksraw = $r->_ch('get_cookie');
800     my $cooks = $r->_unblind($cooksraw);
801
802     if ($r->{S}{encrypted_only} && !$r->_ch('is_https')) {
803         return ({ Kind => 'REDIRECT-HTTPS',
804                   Message => $r->_gt("Redirecting to secure server..."),
805                   _CookieRaw => undef,
806                   Params => { } });
807     }
808
809     my $meth = $r->_ch('get_method');
810     my $parmhraw = $r->_rp('assoc_param_name');
811     my $parmh = $r->_unblind($parmhraw);
812     my $cookh = defined $cooks ? $r->hash($cooks) : undef;
813
814     my ($cookt,$cooku) = $r->_identify($cookh, $cooks);
815     my $parms = (defined $cooks && defined $parmh && $parmh eq $cookh)
816         ? $cooks : undef;
817     my ($parmt) = $r->_identify($parmh, $parms);
818
819     $r->_debug("_c_d_c cookt=$cookt parmt=$parmt\n");
820
821     if ($r->_ch('is_logout')) {
822         $r->_must_be_post();
823         die unless $parmt;
824         $r->_db_revoke($cookh);
825         $r->_db_revoke($parmh);
826         return ({ Kind => 'REDIRECT-LOGGEDOUT',
827                   Message => $r->_gt("Logging out..."),
828                   _CookieRaw => '',
829                   Params => {
830                       $r->{S}{loggedout_param_names}[0] => [ 1 ],
831                   } });
832     }
833     if ($r->_ch('is_loggedout')) {
834         die unless $meth eq 'GET';
835         die if $cookt eq 'y';
836         die if $parmt;
837         return ({ Kind => 'SMALLPAGE-LOGGEDOUT',
838                   Message => $r->_gt("You have been logged out."),
839                   _CookieRaw => '',
840                   Params => { } });
841     }
842     if ($r->_ch('is_login')) {
843         $r->_must_be_post();
844         die unless $parmt;
845         if (!$cookt && $parmt eq 'n') {
846             return ({ Kind => 'SMALLPAGE-NOCOOKIE',
847                       Message => $r->_gt("You do not seem to have cookies".
848                                          " enabled.  You must enable cookies".
849                                          " as we use them for login."),
850                       _CookieRaw => $r->_fresh_secret(),
851                       Params => $r->chain_params() })
852         }
853         if (!$cookt || $cookt eq 'n' || $cookh ne $parmh) {
854             $r->_db_revoke($cookh);
855             return ({ Kind => 'LOGIN-STALE',
856                       Message => $r->_gt("Stale session;".
857                                          " you need to log in again."),
858                       _CookieRaw => $r->_fresh_secret(),
859                       Params => { } })
860         }
861         die unless $parmt eq 't' || $parmt eq 'y';
862         my ($username, $login_errormessage) = $r->_ch('login_ok');
863         unless (defined $username && length $username) {
864             $login_errormessage = $r->_gt("Incorrect username/password.")
865                 if !$login_errormessage;
866             return ({ Kind => 'LOGIN-BAD',
867                       Message => $login_errormessage,
868                       _CookieRaw => $cooks,
869                       Params => $r->chain_params() })
870         }
871         $r->_db_record_login_ok($parmh,$username);
872         return ({ Kind => 'REDIRECT-LOGGEDIN',
873                   Message => $r->_gt("Logging in..."),
874                   _CookieRaw => $cooks,
875                   Params => $r->chain_params() });
876     }
877     if ($cookt eq 't') {
878         $cookt = '';
879     }
880
881     if ($cookt eq 'y' && $parmt eq 'y' && $cookh ne $parmh) {
882         $r->_db_revoke($parmh) if $meth eq 'POST';
883         $parmt = 'n';
884     }
885
886     if ($cookt ne 'y') {
887         die unless !$cookt || $cookt eq 'n';
888         die unless !$parmt || $parmt eq 't' || $parmt eq 'n' || $parmt eq 'y';
889         my $news = $r->_fresh_secret();
890         if ($meth eq 'GET') {
891             return ({ Kind => 'LOGIN-INCOMINGLINK',
892                       Message => $r->_gt("You need to log in."),
893                       _CookieRaw => $news,
894                       Params => $r->chain_params() });
895         } else {
896             $r->_db_revoke($parmh);
897             return ({ Kind => 'LOGIN-FRESH',
898                       Message => $r->_gt("You need to log in."),
899                       _CookieRaw => $news,
900                       Params => { } });
901         }
902     }
903
904     if (!$r->{S}{promise_check_mutate}) {
905         if ($meth ne 'POST') {
906             return ({ Kind => 'MAINPAGEONLY',
907                       Message => $r->_gt('Entering via cross-site link.'),
908                       _CookieRaw => $cooks,
909                       Params => { } });
910             # NB caller must then ignore params & path!
911             # if this is too hard they can spit out a small form
912             # with a "click to continue"
913         }
914     }
915
916     die unless $cookt eq 'y';
917     unless ($r->{S}{promise_check_mutate} && $meth eq 'GET') {
918         if ($parmt eq 't' || $parmt eq 'n') {
919             return ({ Kind => 'STALE',
920                       Message => $r->_gt("Login session interrupted."),
921                       _CookieRaw => $cooks,
922                       Params => { } });
923         }
924         die unless $parmt eq 'y';
925         die unless $cookh eq $parmh;
926     }
927     $r->_db_update_last($cooku,$parmh);
928
929     $r->{ParmT} = $parmt;
930     $r->{AssocRaw} = $cooks;
931     $r->{UserOK} = $cooku;
932 #print STDERR "C-D-C OK\n";
933     return undef;
934 }
935
936 sub chain_params ($) {
937     my ($r) = @_;
938     my %p = %{ $r->_ch('get_params') };
939     foreach my $pncn (keys %{ $r->{S} }) {
940         my $names;
941         if ($pncn =~ m/_param_name$/) {
942             my $name = $r->{S}{$pncn};
943             die "$pncn ?" if ref $name;
944             $names = [ $name ];
945         } elsif ($pncn =~ m/_param_names$/) {
946             $names = $r->{S}{$pncn};
947         } else {
948             next;
949         }
950         foreach my $name (@$names) {
951             delete $p{$name};
952         }
953     }
954     my $dummy_prefix = $r->{S}{dummy_param_name_prefix};
955     foreach my $name (grep /^$dummy_prefix/, keys %p) {
956         delete $p{$name};
957     }
958     die if exists $p{''};
959     $p{''} = [ $r->_ch('get_path_info') ];
960     return \%p;
961 }
962
963 sub _identify ($$) {
964     my ($r,$h,$s) = @_;
965     # returns ($t,$username)
966     # where $t is one of "t" "y" "n", or "" (for -)
967     # either $s must be undef, or $h eq $r->hash($s)
968
969 #print STDERR "_identify\n";
970     return '' unless defined $h && length $h;
971 #print STDERR "_identify h=$h s=".(defined $s ? $s : '<undef>')."\n";
972
973     my $dbh = $r->{Dbh};
974
975     $dbh->do("DELETE FROM $r->{S}{db_prefix}_assocs".
976              " WHERE last < ?", {},
977              time - $r->{S}{login_timeout});
978
979     my $row = $dbh->selectrow_arrayref("SELECT username, last".
980                               " FROM $r->{S}{db_prefix}_assocs".
981                               " WHERE assochash = ?", {}, $h);
982     if (defined $row) {
983 #print STDERR "_identify h=$h s=$s YES @$row\n";
984         my ($nusername, $nlast) = @$row;
985         return ('y', $nusername);
986     }
987
988     # Well, it's not in the database.  But maybe it's a hash of a
989     # temporary secret.
990
991     return 'n' unless defined $s;
992
993     my ($keyt, $signature, $message, $noncet, $nonce) =
994         $s =~ m/^(\d+)\.(\w+)\.((\d+)\.(\w+))$/ or die;
995
996     return 'n' if time > $noncet + $r->{S}{login_form_timeout};
997
998 #print STDERR "_identify noncet=$noncet ok\n";
999
1000     my $keys = $r->_open_keys();
1001     while (my ($rkeyt, $rkey, $line) = $r->_read_key($keys)) {
1002 #print STDERR "_identify  search rkeyt=$rkeyt rkey=$rkey\n";
1003         last if $rkeyt < $keyt; # too far down in the file
1004         my $trysignature = $r->_hmac($rkey, $message);
1005 #print STDERR "_identify  search rkeyt=$rkeyt rkey=$rkey try=$trysignature\n";
1006         return 't' if $trysignature eq $signature;
1007     }
1008     # oh well
1009 #print STDERR "_identify NO\n";
1010
1011     $keys->error and die $!;
1012     return 'n';
1013 }
1014
1015 sub _db_revoke ($$) {
1016     # revokes $h if it's valid; no-op if it's not
1017     my ($r,$h) = @_;
1018
1019     my $dbh = $r->{Dbh};
1020
1021     $dbh->do("DELETE FROM $r->{S}{db_prefix}_assocs".
1022              " WHERE assochash = ?", {}, $h);
1023 }
1024
1025 sub _db_record_login_ok ($$$) {
1026     my ($r,$h,$user) = @_;
1027     $r->_db_revoke($h);
1028     my $dbh = $r->{Dbh};
1029     $dbh->do("INSERT INTO $r->{S}{db_prefix}_assocs".
1030              " (assochash, username, last) VALUES (?,?,?)", {},
1031              $h, $user, time);
1032 }
1033
1034 sub _db_update_last ($$) {
1035     # revokes $h if it's valid; no-op if it's not
1036     my ($r,$user,$h) = @_;
1037     my $dbh = $r->{Dbh};
1038     $dbh->do("UPDATE $r->{S}{db_prefix}_assocs".
1039              " SET last = ?".
1040              " WHERE username = ? AND assochash = ?", {},
1041              time, $user, $h);
1042 }
1043
1044 sub check_divert ($) {
1045     my ($r) = @_;
1046     if (exists $r->{Divert}) {
1047         return $r->{Divert};
1048     }
1049     my $dbh = $r->{Dbh};
1050     $r->{Divert} = $r->_db_transaction(sub { $r->_check_divert_core(); });
1051     $dbh->commit();
1052
1053     my $divert = $r->{Divert};
1054     my $cookraw = $divert && $divert->{_CookieRaw};
1055     if ($cookraw) {
1056         $divert->{CookieSecret} = $r->_blind($cookraw);
1057         $divert->{Params}{$r->{S}{assoc_param_name}} = [
1058             $r->_blind($r->hash($cookraw))
1059             ];
1060     }
1061
1062     $r->_debug(Data::Dumper->Dump([$divert],[qw(divert)]));
1063     return $divert;
1064 }
1065
1066 sub get_divert ($) {
1067     my ($r) = @_;
1068     die "unchecked" unless exists $r->{Divert};
1069     return $r->{Divert};
1070 }
1071
1072 sub get_username ($) {
1073     my ($r) = @_;
1074     my $divert = $r->get_divert();
1075     return undef if $divert;
1076     return $r->{UserOK};
1077 }
1078
1079 sub url_with_query_params ($$;$) {
1080     my ($r, $params, $nonpagetype) = @_;
1081 #print STDERR "PARAMS ",Dumper($params);
1082     my $uri = URI->new($r->_ch('get_url'));
1083     $uri->path($uri->path() . $params->{''}[0]) if $params->{''};
1084     my @flatparams = flatten_params($params);
1085     if (defined $nonpagetype && $r->need_add_hidden('GET',$nonpagetype)) {
1086         push @flatparams, $r->{S}{assoc_param_name}, $r->secret_hidden_val();
1087     }
1088     $uri->query_form(@flatparams);
1089     return $uri->as_string();
1090 }
1091
1092 sub _cgi_header_args ($$@) {
1093     my ($r, $cookie, @ha) = @_;
1094     unshift @ha, qw(-type text/html);
1095     push @ha, (-cookie => $cookie) if defined $cookie;
1096 #print STDERR "_cgi_header_args ",join('|',@ha),".\n";
1097     return @ha;
1098 }
1099
1100 sub check_ok ($) {
1101     my ($r) = @_;
1102
1103     my ($divert) = $r->check_divert();
1104     return 1 if !$divert;
1105
1106     my $handled = $r->_ch('handle_divert',$divert);
1107     return 0 if $handled;
1108
1109     my $kind = $divert->{Kind};
1110     my $cookiesecret = $divert->{CookieSecret};
1111     my $params = $divert->{Params};
1112     my $cookie = $r->construct_cookie($cookiesecret);
1113
1114     if ($kind =~ m/^SRCDUMP-(\w+)$/) {
1115         $r->_ch('srcdump_dump', (lc $1));
1116         return 0;
1117     }
1118
1119     if ($kind =~ m/^REDIRECT-/) {
1120         # for redirects, we honour stored Params and Cookie,
1121         # as we would for non-divert
1122         if ($kind eq 'REDIRECT-LOGGEDOUT') {
1123         } elsif ($kind =~ m/REDIRECT-(?:LOGGEDIN|HTTPS)/) {
1124         } else {
1125             die;
1126         }
1127         my $new_url = $r->url_with_query_params($params);
1128         if ($kind eq 'REDIRECT-HTTPS') {
1129             my $uri = URI->new($new_url);
1130             die unless $uri->scheme eq 'http';
1131             $uri->scheme('https');
1132             $new_url = $uri->as_string();
1133         }
1134         $r->_ch('do_redirect',$new_url, $cookie);
1135         return 0;
1136     }
1137
1138     my ($title, @body);
1139     if ($kind =~ m/^LOGIN-/) {
1140         $title = $r->_gt('Login');
1141         push @body, $divert->{Message};
1142         push @body, $r->_ch('gen_login_form', $params);
1143     } elsif ($kind =~ m/^SMALLPAGE-/) {
1144         $title = $r->_gt('Not logged in');
1145         push @body, $divert->{Message};
1146         push @body, $r->_ch('gen_login_link', $params);
1147     } elsif ($kind =~ m/^STALE/) {
1148         $title = $r->_gt('Re-entering secure site.');
1149         push @body, $divert->{Message};
1150         push @body, $r->_ch('gen_postmainpage_form', $params);
1151     } elsif ($kind =~ m/^MAINPAGEONLY$/) {
1152         $title = $r->_gt('Entering secure site.');
1153         push @body, $divert->{Message};
1154         push @body, $r->_ch('gen_postmainpage_form', $params);
1155     } else {
1156         die $kind;
1157     }
1158
1159     $r->_print($r->{Cgi}->header($r->_cgi_header_args($cookie)),
1160                $r->_ch('gen_start_html',$title),
1161                (join "\n", (@body,
1162                             $r->_ch('gen_footer_html'),
1163                             $r->_ch('gen_end_html'))));
1164     return 0;
1165 }
1166
1167 sub _random ($$) {
1168     my ($r, $bytes) = @_;
1169     my $v = $r->{V};
1170     my $rsf = $v->{RandomHandle};
1171     my $rsp = $r->{S}{random_source};
1172     if (!$rsf) {
1173         $v->{RandomHandle} = $rsf = new IO::File $rsp, '<' or die "$rsp $!";
1174 #print STDERR "RH $rsf\n";
1175     }
1176     my $bin;
1177     $!=0;
1178     read($rsf,$bin,$bytes) == $bytes or die "$rsp $!";
1179     my $out = unpack "H*", $bin;
1180 #print STDERR "_random out $out\n";
1181     return $out;
1182 }
1183
1184 sub _blind_len ($$) {
1185     my ($r, $str) = @_;
1186     return length($str =~ y/0-9a-f//cdr);
1187 }
1188
1189 sub _blind_combine ($$$) {
1190     my ($r, $in, $mask) = @_;
1191     my @mask = split //, $mask;
1192     $in =~ s{[0-9a-f]}{
1193         my $m = shift @mask;
1194         sprintf "%x", hex($m) ^ hex($&);
1195     }ge;
1196     return $in;
1197 }
1198
1199 sub _blind ($$) {
1200     my ($r, $in) = @_;
1201     return $in unless $in;
1202     my $l = $r->_blind_len($in);
1203     my $mask = $r->_random(($l+1)>>1);
1204     $mask = substr $mask, 0, $l;
1205     my $blound = $r->_blind_combine($in, $mask);
1206     return "$blound.$mask";
1207 }
1208
1209 sub _unblind ($$) {
1210     my ($r, $in) = @_;
1211     return $in unless $in;
1212     my ($blound,$mask) = ($in =~ m#^(.*)\.([0-9a-f]+)$#) or die "$in ?";
1213     my $l = $r->_blind_len($blound);
1214     $l == length($mask) or die "$in ?";
1215     return $r->_blind_combine($blound, $mask);
1216 }
1217
1218 sub _random_key ($) {
1219     my ($r) = @_;
1220 #print STDERR "_random_key\n";
1221     my $bytes = ($r->{S}{secretbits} + 7) >> 3;
1222     return $r->_random($bytes);
1223 }
1224
1225 sub _read_key ($$) {
1226     my ($r, $keys) = @_;
1227     # returns $gen_time_t, $key_value_in_hex, $complete_line
1228     while (<$keys>) {
1229         my ($gen, $k) = m/^(\d+) (\S+)$/ or die "$_ ?";
1230         my $age = time - $gen;
1231         next if $age > $r->{S}{key_rollover} &&
1232             $age > $r->{S}{login_form_timeout}*2;
1233         return ($gen, $k, $_);
1234     }
1235     return ();
1236 }
1237
1238 sub _open_keys ($) {
1239     my ($r) = @_;
1240     my $spath = $r->_get_path('keys');
1241     for (;;) {
1242 #print STDERR "_open_keys\n";
1243         my $keys = new IO::File $spath, 'r+';
1244         if ($keys) {
1245 #print STDERR "_open_keys open\n";
1246             stat $keys or die $!; # NB must not disturb stat _
1247             my $size = (stat _)[7];
1248             my $age = time - (stat _)[9];
1249 #print STDERR "_open_keys open size=$size age=$age\n";
1250             return $keys
1251                 if $size && $age <= $r->{S}{key_rollover} / 2;
1252 #print STDERR "_open_keys open bad\n";
1253         }
1254         # file doesn't exist, or is empty or too old
1255         if (!$keys) {
1256 #print STDERR "_open_keys closed\n";
1257             die "$spath $!" unless $!==&ENOENT;
1258             # doesn't exist, so create it just so we can lock it
1259             $keys = new IO::File $spath, 'a+';
1260             die "$keys $!" unless $keys;
1261             stat $keys or die $!; # NB must not disturb stat _
1262             my $size = (stat _)[7];
1263 #print STDERR "_open_keys created size=$size\n";
1264             next if $size; # oh someone else has done it, reopen and read it
1265         }
1266         # file now exists is empty or too old, we must try to replace it
1267         my $our_inum = (stat _)[1]; # last use of that stat _
1268         flock $keys, LOCK_EX or die "$spath $!";
1269         stat $spath or die "$spath $!";
1270         my $path_inum = (stat _)[1];
1271 #print STDERR "_open_keys locked our=$our_inum path=$path_inum\n";
1272         next if $our_inum != $path_inum; # someone else has done it
1273         # We now hold the lock!
1274 #print STDERR "_open_keys creating\n";
1275         my $newkeys = new IO::Handle;
1276         sysopen $newkeys, "$spath.new", O_CREAT|O_TRUNC|O_WRONLY, 0600
1277             or die "$spath.new $!";
1278         # we add the new key to the front which means it's always sorted
1279         print $newkeys time, ' ', $r->_random_key(), "\n" or die $!;
1280         while (my ($gen,$key,$line) = $r->_read_key($keys)) {
1281 #print STDERR "_open_keys copy1\n";
1282             print $newkeys, $line or die $!;
1283         }
1284         $keys->error and die $!;
1285         close $newkeys or die "$spath.new $!";
1286         rename "$spath.new", "$spath" or die "$spath: $!";
1287 #print STDERR "_open_keys installed\n";
1288         # that rename effective unlocks, since it makes the name refer
1289         #  to the new file which we haven't locked
1290         # we go round again opening the file at the beginning
1291         #  so that our caller gets a fresh handle onto the existing key file
1292     }
1293 }
1294
1295 sub _fresh_secret ($) {
1296     my ($r) = @_;
1297 #print STDERR "_fresh_secret\n";
1298
1299     my $keys = $r->_open_keys();
1300     my ($keyt, $key) = $r->_read_key($keys);
1301     die unless defined $keyt;
1302
1303     my $nonce = $r->_random_key();
1304     my $noncet = time;
1305     my $message = "$noncet.$nonce";
1306
1307     my $signature = $r->_hmac($key, $message);
1308     my $secret = "$keyt.$signature.$message";
1309 #print STDERR "FRESH $secret\n";
1310     return $secret;
1311 }
1312
1313 sub _hmac ($$$) {
1314     my ($r, $keyhex, $message) = @_;
1315     my $keybin = pack "H*", $keyhex;
1316     my $alg = $r->{S}{hash_algorithm};
1317 #print STDERR "hmac $alg\n";
1318     my $base = new Digest $alg;
1319 #print STDERR "hmac $alg $base\n";
1320     my $digest = new Digest::HMAC $keybin, $base;
1321 #print STDERR "hmac $alg $base $digest\n";
1322     $digest->add($message);
1323     return $digest->hexdigest();
1324 }
1325
1326 sub hash ($$) {
1327     my ($r, $message) = @_;
1328     my $alg = $r->{S}{hash_algorithm};
1329 #print STDERR "hash $alg\n";
1330     my $digest = new Digest $alg;
1331     $digest->add($message);
1332     return $digest->hexdigest();
1333 }
1334
1335 sub _assert_checked ($) {
1336     my ($r) = @_;
1337     die "unchecked" unless exists $r->{Divert};
1338 }
1339
1340 sub _is_post ($) {
1341     my ($r) = @_;
1342     my $meth = $r->_ch('get_method');
1343     return $meth eq 'POST';
1344 }
1345
1346 sub _must_be_post ($) {
1347     my ($r) = @_;
1348     my $meth = $r->_ch('get_method');
1349     die "mutating non-POST" if $meth ne 'POST';
1350 }
1351
1352 sub check_mutate ($) {
1353     my ($r) = @_;
1354     $r->_assert_checked();
1355     die if $r->{Divert};
1356     $r->_must_be_post();
1357 }
1358
1359 our %_resource_get_needs_secret_hidden =
1360     (map { $_ => 0 } qw(PAGE FRAME IFRAME SRCDUMP STYLESHEET FAVICON ROBOTS),
1361      map { $_ => 1 } qw(IMAGE SCRIPT AJAX-XML AJAX-JSON AJAX-OTHER));
1362
1363 sub update_get_need_add_hidden ($$;$) {
1364     my ($r, $reqtype, $value, $force) = @_;
1365     my $hash = ref $r
1366         ? ($r->{GetNeedsSecretHidden} ||= { })
1367         : \%_resource_get_needs_secret_hidden;
1368     return if !$force &&
1369         (exists $_resource_get_needs_secret_hidden{$reqtype} ||
1370          exists $hash->{$reqtype});
1371     $hash->{$reqtype} = $value;
1372 }
1373
1374 sub need_add_hidden ($$) {
1375     my ($r, $method, $reqtype) = @_;
1376     return 1 if $method ne 'GET';
1377     if (ref $r) {
1378         my $ent = $r->{GetNeedsSecretHidden}{$reqtype};
1379         return $ent if defined $ent;
1380     }
1381     my $ent = $_resource_get_needs_secret_hidden{$reqtype};
1382     return $ent if defined $ent;
1383     die "unsupported nonpage GET type $reqtype";
1384 }
1385
1386 sub check_nonpage ($$) {
1387     my ($r, $reqtype) = @_;
1388     $r->_assert_checked();
1389     return unless $r->resource_get_needs_secret_hidden($reqtype);
1390     return if $r->{ParmT} eq 'y';
1391     die "missing hidden secret parameter on nonpage request $reqtype";
1392 }
1393
1394 #---------- output ----------
1395
1396 sub secret_cookie_val ($) {
1397     my ($r) = @_;
1398     $r->_assert_checked();
1399     return defined $r->{AssocRaw} ? $r->_blind($r->{AssocRaw}) : '';
1400 }
1401
1402 sub secret_hidden_val ($) {
1403     my ($r) = @_;
1404     $r->_assert_checked();
1405     return defined $r->{AssocRaw} ? $r->_blind($r->hash($r->{AssocRaw})) : '';
1406 }
1407
1408 sub secret_hidden_html ($) {
1409     my ($r) = @_;
1410     return $r->{Cgi}->hidden(-name => $r->{S}{assoc_param_name},
1411                              -default => $r->secret_hidden_val());
1412 }
1413
1414 sub secret_cookie ($) {
1415     my ($r) = @_;
1416     my $secret = $r->secret_cookie_val();
1417     return undef if !defined $secret;
1418 #print STDERR "SC\n";
1419     my $cookv = $r->construct_cookie($secret); 
1420 #print STDERR "SC=$cookv\n";
1421     return $cookv;
1422 }
1423
1424 1;