#! /usr/bin/perl -w ###----- Licensing notice --------------------------------------------------- ### ### This program is free software: you can redistribute it and/or modify it ### under the terms of the GNU General Public License as published by ### the Free Software Foundation; either version 2 of the License, or (at ### your option) any later version. ### ### This program is distributed in the hope that it will be useful, but ### WITHOUT ANY WARRANTY; without even the implied warranty of ### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General ### Public License for more details. ### ### You should have received a copy of the GNU General License along with ### mLib. If not, write to the Free Software Foundation, Inc., 59 Temple ### Place - Suite 330, Boston, MA 02111-1307, USA. ###-------------------------------------------------------------------------- ### External modules. use autodie; use strict; use Carp; use IO::Handle; use MIME::Base32; use MIME::Base64; use POSIX; use Time::Local; ###-------------------------------------------------------------------------- ### Other preliminaries. (our $PROG = $0) =~ s!^.*/!!; our $VERSION = '@VERSION@'; ## Check arguments. This is easy: there aren't any. sub usage (*) { my ($f) = @_; print $f "usage: $PROG\n"; } if (@ARGV) { my $arg = $ARGV[0]; if (@ARGV > 1) { } elsif ($arg eq "-h" || $arg eq "--help") { usage STDOUT; exit 0; } elsif ($arg eq "-v" || $arg eq "--version") { print "$PROG, version $VERSION\n"; exit 0; } usage STDERR; exit 2; } sub maybe_mkdir ($;$) { my ($dir, $mode) = @_; ## Create a directory DIR with permissions MODE, defaulting to 0777, before ## umask. Ignore errors complaining that the directory already exists. eval { mkdir $dir, $mode // 0777; }; die if $@ && $@->errno != EEXIST; } sub maybe_unlink ($) { my ($file) = @_; ## Delete FILE. Ignore errors complaining that the FILE doesn't exist. eval { unlink $file; }; die if $@ and $@->errno != ENOENT; } sub present_time ($) { my ($t) = @_; ## Format T as a human-readable time. return strftime "%Y-%m-%d %H:%M:%S %z", localtime $t; } sub dump_file ($$) { my ($fout, $file) = @_; ## Copy the contents of FILE to the stream FOUT. open my $fin, "<", $file; my $buf; while (sysread $fin, $buf, 65536) { print $fout $buf; } close $fin; } sub parse_stamp ($) { my ($stamp) = @_; ## Parse an ISO8601 zulu-time stamp to POSIX `time_t'. my ($y, $mo, $d, $h, $mi, $s) = $stamp =~ m{^ (\d{4}) - (\d{2}) - (\d{2}) T (\d{2}) : (\d{2}) : (\d{2}) Z $}x or die "bad timestamp `$stamp'"; return timegm $s, $mi, $h, $d, $mo - 1, $y - 1900; } sub present_stamp ($) { my ($t) = @_; ## Format a POSIX `time_t' as a string. my ($s, $mi, $h, $d, $mo, $y, $wd, $yd, $dstp) = gmtime $t; return sprintf "%04d-%02d-%02dT%02d:%02d:%02dZ", $y + 1900, $mo + 1, $d, $h, $mi, $s; } sub running (&@) { my ($body, @prog) = @_; ## Run the BODY, printing a banner above and below, including a proposed ## program name and arguments PROG, and the exit status. my $label = sprintf "RUN %s ", join " ", @prog; print $label . ">" x (77 - length $label) . "\n"; my @r; if (wantarray) { @r = $body->(); } else { @r = scalar $body->(); } print "<" x 77 . "\n"; if (!$?) { } elsif ($?%256) { printf "command killed by signal %d\n", $?%256; } else { printf "command exited with status %d\n", int($?/256); } die "command failed" if $?; return @r; } sub run (@) { my (@prog) = @_; ## Run PROG. running { system @prog; } @prog; } ## Set the current time. our $NOW; if (!defined $ENV{"DKIM_KEYS_TIMENOW"}) { $NOW = time; } else { $NOW = parse_stamp $ENV{"DKIM_KEYS_TIMENOW"}; printf "pretending time is %s\n", present_time $NOW; } printf "%s BEGINS at %s\n", $PROG, present_time $NOW; ###-------------------------------------------------------------------------- ### BER decoding. ## BER tag classes. sub C_UNV () { return 0; } # universal sub C_APP () { return 1; } # application sub C_CTX () { return 2; } # contextual sub C_PRV () { return 3; } # private ## BER universal tag numbers. sub TY_INT () { return 2; } # INTEGER sub TY_BITSTR () { return 3; } # BIT STRING sub TY_OCTSTR () { return 4; } # OCTET STRING sub TY_NULL () { return 5; } # NULL sub TY_OID () { return 6; } # OBJECT IDENTIFIER sub TY_SEQ () { return 16; } # SEQUENCE sub TY_SET () { return 17; } # SET sub ber_decoid ($) { my ($oid) = @_; ## Decode an encoded OID body. Return the OID in text format, as a list ## of integers separated by `.'. my ($h, @d) = unpack "C w*", $oid; if ($h >= 0x80) { die "malformed BER (invalid OID)"; } return join ".", int($h/40), $h%40, @d; } sub ber_decnext (\$) { my ($str_inout) = @_; ## Decode the next value from a BER-encoded string, updating STR_INOUT to ## hold the remaining material. Returns four items (C, K, T, X), where C ## is the tag class; K is a flag which is truish if the encoding is ## constructed or falsish if primitive; T is the tag number; and X is the ## encoded body. my ($h0, $r) = unpack "C a*", $$str_inout; my ($c, $k, $t) = (($h0 >> 6)&0x03, ($h0 >> 5)&0x01, ($h0 >> 0)&0x1f); if ($t == 0x1f) { ($t, $r) = unpack "w a*", $r; } (my $n, $r) = unpack "C a*", $r; if ($n == 0x80) { die "indefinite encodings not supported"; } elsif ($n == 0xff) { die "malformed BER (invalid length)"; } elsif ($n > 0x80) { $n &= 0x7f; my @n = unpack +(sprintf "C%d", $n), $r; $r = substr $r, $n; $n = 0; for my $i (@n) { $n = 256*$n + $i; } } $$str_inout = substr $r, $n; return ($c, $k, $t, substr $r, 0, $n); } sub read_pem ($$) { my ($file, $label) = @_; ## Read a PEM-encoded message from FILE, with LABEL quoted in the boundary ## markers, and return the binary contents. my $body = ""; open my $f, "<", $file; LINE: while (<$f>) { last LINE if /^-----BEGIN \Q$label\E-----$/; } LINE: while (<$f>) { last LINE if /^-----END \Q$label\E-----$/; $body .= $_; } close $f; return decode_base64 $body; } sub ident ($) { my ($pub) = @_; ## Parse the (raw BER) RSA public key data PUB, returning a key identifier. ## This is the least significant 80 bits of the modulus, Base32-encoded, ## which comes out at 16 characters. my ($c, $k, $t); ## Outer SEQUENCE. ($c, $k, $t, my $x) = ber_decnext $pub; $c == C_UNV && $k && $t == TY_SEQ or die "malformed key (expected seq)"; ## Inner SEQUENCE. ($c, $k, $t, my $y) = ber_decnext $x; $c == C_UNV && $k && $t == TY_SEQ or die "malformed key (expected seq)"; ## Key type OID; must be `rsaEncryption'. ($c, $k, $t, my $o) = ber_decnext $y; $c == C_UNV && !$k && $t == TY_OID or die "malformed key (expected oid)"; ber_decoid($o) eq "1.2.840.113549.1.1.1" or die "malformed key (wrong oid)"; ## Parameters; must be NULL. ($c, $k, $t, my $z) = ber_decnext $y; $c == C_UNV && !$k && $t == TY_NULL && $z eq "" or die "malformed key (expected null)"; ## End inner SEQUENCE. $y eq "" or die "malformed key (trailing junk)"; ## BIT STRING holding the actual public key data. I have no idea what ## they were thinking when they came up with this. ($c, $k, $t, my $u) = ber_decnext $x; $c == C_UNV && !$k && $t == TY_BITSTR or die "malformed key (expected bitstr)"; (my $n, $u) = unpack "C a*", $u; $n == 0 or die "malformed key (odd-length bitstr)"; ## Inner SEQUENCE. ($c, $k, $t, $y) = ber_decnext $u; $c == C_UNV && $k && $t == TY_SEQ or die "malformed key (expected seq)"; ## INTEGER holding the modulus. ($c, $k, $t, $n) = ber_decnext $y; $c == C_UNV && !$k && $t == TY_INT or die "malformed key (expected int)"; ## INTEGER holding the public exponent, which we don't care about. ($c, $k, $t, my $e) = ber_decnext $y; $c == C_UNV && !$k && $t == TY_INT or die "malformed key (expected int)"; ## Close the inner SEQUENCE, BIT STRING, outer SEQUENCE, and the ## top-level. $y eq "" or die "malformed key (trailing junk)"; $u eq "" or die "malformed key (trailing junk)"; $x eq "" or die "malformed key (trailing junk)"; $pub eq "" or die "malformed key (trailing junk)"; ## Extract the low bits of the modulus and encode them. length $n >= 10 or die "malformed key (too small)"; return lc encode_base32 substr($n, -10), ""; } ###-------------------------------------------------------------------------- ### Configuration. ## Conversion functions take two arguments, PARSEP and VAL. If PARSEP is ## truish, then VAL is a value read from the configuration file, and the ## function should parse it and return the Perl internal value. If PARSE is ## falsish, then do the reverse conversion. sub conf_token ($$) { my ($parsep, $val) = @_; ## Do-nothing conversion function for text. return $val; } sub conf_stamp ($$) { my ($parsep, $val) = @_; ## Convert ISO8601 zulu-time stamps to POSIX `time_t'. if ($parsep) { return parse_stamp $val; } else { return present_stamp $val; } } { ## Map between unit names and scales. my %unitmap; my @unitmap; for my $item ([7*24*60*60, qw{wk w wks week weeks}], [24*60*60, qw{dy d dys day days}], [60*60, qw{hr h hrs hour hours}], [60, qw{min m mins minute minutes}], [1, "", qw{s sec secs second seconds}]) { my ($scale, @names) = @$item; for my $name (@names) { $unitmap{$name} = $scale; } push @unitmap, [$scale, $names[0]]; } sub conf_duration ($$) { my ($parsep, $val) = @_; ## Convert time durations with units. if ($parsep) { my ($x, $u) = $val =~ m{^ \s* (\.\d+ | \d+ (?: \. \d*)?) \s* (| \S+) $}x or die "bad duration `$val'"; my $scale = $unitmap{$u} or die "bad duration `$val'"; return $x*$scale; } else { my $x = 1; my $u = "s"; UNIT: for my $item (@unitmap) { my ($scale, $unit) = @$item; if ($val >= $scale) { $x = $val/$scale; $u = $unit; last UNIT; } } return sprintf "%.4g %s", $x, $u; } } } sub read_config ($$) { my ($spec, $file) = @_; ## Read configuration from FILE, as described by SPEC. Return a hash ## mapping names to values. ## ## The SPEC is a hashref mapping configuration keys to [CONV, DFLT] pairs, ## where CONV is a conversion function (as described above) and DFLT is the ## default value. my %c; open my $f, "<", $file; LINE: while (<$f>) { chomp; next LINE unless /^\s*[^#]/; my ($k, $v) = m{^ \s* ([^=\s]+) \s* = (.*) $}x or die "bad assignment `$_'"; $v =~ s/^\s+//; $v =~ s/\s+$//; my $def = $spec->{$k} or die "unknown setting `$k'"; exists $c{$k} and die "duplicate setting `$k'"; $c{$k} = $def->[0](1, $v); } close $f; for my $k (keys %$spec) { if (exists $c{$k}) { } elsif ($spec->{$k}->@* > 1) { $c{$k} = $spec->{$k}[1]; } else { die "missing setting for `$k'"; } } return \%c; } sub write_config ($$$) { my ($spec, $file, $conf) = @_; ## Write configuration back FILE from CONF, as described by SPEC. See ## `read_config' for the format of SPEC. open my $f, ">", "$file.new"; KEY: for my $k (keys %$spec) { defined $conf->{$k} or next KEY; printf $f "%s = %s\n", $k, $spec->{$k}[0](0, $conf->{$k}); } close $f; rename "$file.new", $file; } ###-------------------------------------------------------------------------- ### Configuration. my %CONF_SPEC = ("ddns-zone" => [\&conf_token], "ddns-server" => [\&conf_token, undef], "ddns-key" => [\&conf_token], "ddns-ttl" => [\&conf_duration, 4*60*60], "dns-delay" => [\&conf_duration, 2*24*60*60], "instance" => [\&conf_token], "publish-uri" => [\&conf_token], "active-duration" => [\&conf_duration, 24*60*60], "cycle-period" => [\&conf_duration, 3*24*60*60], "mail-persistence" => [\&conf_duration, 7*24*60*60], "dns-persistence" => [\&conf_duration, 3*24*60*60]); our %C = %{read_config \%CONF_SPEC, "dkim-keys.conf"}; ###-------------------------------------------------------------------------- ### State model. ## There are six states that a key can be in. Keys always advance through ## these states in order. Except in `NEW' and `PUBLISH', the key's ## private-key file is reliably named `ST/TS.ID.priv', and this is the ## primary means to determine which state the key is actually in. ## ## * `NEW'. This is rather complicated, and there are multiple steps and ## a fiddly cleanup procedure. ## ## 1. Create `NEW/new.priv'. Cleanup: delete it. ## 2. Create `NEW/new.pub'. Determine key id. Cleanup: delete both. ## 3. Rename to `NEW/TS.ID.priv'. Cleanup: continue to ANNOUNCE. ## 4. Rename `MEW/new.pub' to `active/ID.pub'. (If it's not there, ## then this has been done already.) ## 5. Announce the key in the DNS. ## 6. Promote the key to ANNOUNCE: rename `NEW/TS.ID.priv' to ## `ANNOUNCE/TS.ID.priv'. ## ## * `ANNOUNCE'. The key has been announced in the DNS, and we're waiting ## for the announcement to propagate before we deploy the key. A ## placeholder HTML file is written to `publish/I/D.html'. Unless we're ## in catch-up mode, keys will remain in `ANNOUNCE' state for DNS-DELAY ## seconds. The timestamp is the time at which we expect the key to ## become active. ## ## * `DEPLOY'. The key is ready for use in the upcoming cycle; the private ## key file is in `active/ID.priv', and the key is named in the ## `dkim-keys.state' file. The timestamp is the time at which we expect ## the key to become active. The key will be active for ACTIVE-DURATION ## seconds. ## ## * `RETIRE'. The key has been deployed and replaced, and will no longer ## be used for signing. However, messages bearing its signatures are ## potentially still in flight, so the key is still visible in DNS. Keys ## will remain in `RETIRE' state for MAIL-PERSISTENCE seconds. The ## timestamp is the time at which we expect that the key can be ## withdrawn. ## ## * `WITHDRAW'. The key is no longer needed by verifiers because all ## messages signed under it have either been delivered or abandoned. The ## key is withdrawn from DNS, and we're waiting for the withdrawal to ## propagate. Keys remain in `WITHDRAW' state for DNS-PERSISTENCE ## seconds. The timestamp is the time at which we expect that the key ## can be published. ## ## * `PUBLISH'. The key is no longer available to verifiers, because it is ## no longer visible in DNS. It is therefore now safe to publish the ## private key. This is the final state, and we stop tracking keys in ## `PUBLISH' state. The `active/ID.priv' and `active/ID.pub' files are ## deleted; the placeholder `publish/I/D.html' is replaced with a file ## holding the actual key data. The private key file is deleted. ## Mapping between state names and indices. our @STNAME; our %STIX = (); for my $st (qw{ NEW ANNOUNCE DEPLOY RETIRE WITHDRAW PUBLISH }) { $STIX{$st} = scalar @STNAME; push @STNAME, $st; } ###-------------------------------------------------------------------------- ### Schedule objects. { package Scheduler; ## A `Scheduler' object keeps track of things to do in the future. Most ## notably, it accumulates DNS update tasks so that they can be performed ## in a single transaction, and a list of miscellaneous subs to run. ## ## Operations which create new files should be done immediately. ## Operations which delete files (or rename them -- such as committing ## state transitions by renaming the `.priv' file) should be deferred using ## `add_cleanup'. sub new { my ($pkg) = @_; ## Create and return a new `Scheduler'. return bless { dns_update => { }, cleanups => [] }, $pkg; } sub add_record { my ($me, $label, $type, $data) = @_; ## Add a DNS record for LABEL, with the given TYPE and DATA. ## ## This is collected as part of the single DNS update transaction. Any ## existing record for the LABEL and TYPE is deleted, which is probably ## terrible for general use, but works just fine in this program. $me->{dns_update}{$label}{$type} = $data; } sub delete_record { my ($me, $label, $type) = @_; ## Delete the DNS record(s) for LABEL with the given TYPE. $me->{dns_update}{$label}{$type} = undef; } sub add_cleanup { my ($me, $op) = @_; ## Arrange to run OP at the end of the program. push $me->{cleanups}->@*, $op; } sub execute { my ($me) = @_; ## Perform the scheduled activities. ## Do the DNS update. my $updates = ""; for my $label (keys $me->{dns_update}->%*) { for my $type (keys $me->{dns_update}{$label}->%*) { my $data = $me->{dns_update}{$label}{$type}; $updates .= "update delete $label IN $type\n"; if (!defined $data) { print "delete dns record $label $type\n"; } else { $updates .= "update add $label IN $type $data\n" if defined $data; print "create dns record $label $type $data\n"; } } } if ($updates eq "") { print "no dns updates\n"; } else { my @nsucmd = ("nsupdate"); push @nsucmd, "-k", $::C{"ddns-key"} if defined $::C{"ddns-key"}; ::running { @nsucmd = ("cat") if $ENV{"DKIM_KEYS_NODNS"} // 0; open my $f, "|-", @nsucmd; my $server = $::C{"ddns-server"}; my $ttl = $::C{"ddns-ttl"}; print $f "server $server\n" if defined $server; print $f "ttl $ttl\n" if defined $ttl; print $f $updates; print $f "send\n"; print $f "answer\n"; close $f } @nsucmd; } ## Run the deferred cleanup operations. for my $op ($me->{cleanups}->@*) { $op->(); } } } sub schedule_cleanup (&$) { my ($op, $sched) = @_; ## Get SCHED to run OP during its cleanup. ## ## This just has slightly nicer syntax than calling `SCHED->add_cleanup'. $sched->add_cleanup($op); } ###-------------------------------------------------------------------------- ### Key management. sub generate_key ($) { my ($file) = @_; ## Make a new RSA key and save it in PEM format in FILE. ## ## No particular effort is taken to ensure that FILE is updated atomically. my $oldmask = umask 0037; print "generate new key\n"; run "openssl", "genrsa", "-out", $file, "3072"; chmod 0640, $file; umask $oldmask; } sub extract_public_key ($$) { my ($pub, $priv) = @_; ## Store the public key corresponding to PRIV in the file PUB. ## ## No particular effort is taken to ensure that PUB is updated atomically. print "extract public key\n"; run "openssl", "rsa", "-pubout", "-in", $priv, "-out", $pub; return read_pem($pub, "PUBLIC KEY"); } { package Key; ## A `Key' object keeps track of a DKIM key's lifecycle stages. ## ## Key objects are blessed hashrefs with the following public slots. ## ## * `id' is the key's identifier, as a lowercase Base32 string. ## * `st' is the key's state, as an uppercase string. ## * `t' is the key's timestamp, as an integer count of seconds. ## ## It also has private slots. ## ## * `file0' is the filename holding the private key. ## * `file' is the filename that the private key /would/ have if the ## currently scheduled operations were executed. ## * `pub' is the key's public key, in binary BER format. ## External modules. use autodie; use strict; use Carp; use MIME::Base64; sub publish_components { my ($me) = @_; ## Split the key ID into pieces. This is how we map key IDs into the ## filesystem and URI space: if we return n pieces, that's n - 1 levels ## of directory, with leaves at the bottom. my $id = $me->{id}; return (substr($id, 0, 3), substr($id, 3, 5), substr($id, 8)); } sub publish_uri { my ($me) = @_; ## Return the URI at which the key will be published. my @cc = $me->publish_components; return $::C{"publish-uri"} . join("/", @cc) . ".html"; } sub publish_filename { my ($me) = @_; ## Return the filename at which the key publication HTML file is stored. my @cc = $me->publish_components; my $name = "publish"; for my $c (@cc) { ::maybe_mkdir $name, 0751; $name .= "/" . $c; } return $name . ".html"; } sub new { my ($pkg, $t) = @_; ## Return a new `Key' object with initial timestamp T. ## ## The `pub' slot is set on exit. ## Make the new key. ::maybe_mkdir "NEW", 0700; ::generate_key "NEW/new.priv"; ## Extract the public key and determine its id. my $pub = ::extract_public_key "NEW/new.pub", "NEW/new.priv"; my $id = ::ident $pub; ## Rename the private key. It won't go away now: we're committed to this ## one. print "commit to new key $id\n"; my $file = sprintf "NEW/%s.%s.priv", ::present_stamp($t), $id; rename "NEW/new.priv", $file; ## Report the public key as active. print "activated new public key $id\n"; ::maybe_mkdir "active"; rename "NEW/new.pub", "active/$id.pub"; ## It's now a proper key. my $me = $pkg->load($file); $me->{pub} = $pub; return $me; } sub load { my ($pkg, $file) = @_; ## Return a key object for the given FILE. We probably don't actually ## bother reading the file. ## Check that the file is sane. -r $file or confess "key file `$file' not found"; $file =~ m{^ (\w+) / (\d{4} - \d{2} - \d{2} T \d{2} : \d{2} : \d{2} Z) \. ([abcdefghijklmnopqrstuvwxyz234567]+) \.priv $}x or confess "bad key name `$file'"; ## Build the object.as the primary my $st = $1; my $tm = $2; my $id = $3; return bless { st => $st, id => $id, t => ::parse_stamp($tm), file => $file, file0 => $file }, $pkg; } sub set_state { my ($me, $sched, $newst, $newtime) = @_; ## Change the state of the key to be NEWST, with timestamp NEWTIME. ## Arrange to commit this state change by renaming the private key to ## `ST/TS.ID.priv'. return if $newst eq $me->{st} && $newtime eq $me->{t}; my $id = $me->{id}; printf "prepare key %s state %s %s -> %s %s\n", $id, $me->{st}, ::present_time($me->{t}), $newst, ::present_time($newtime); my $from = $me->{file}; my $to = $newst . "/" . ::present_stamp($newtime) . "." . $id . ".priv"; ::maybe_mkdir $newst; ::schedule_cleanup { print "key $id rename $from -> $to\n"; rename $from, $to; } $sched; $me->{st} = $newst; $me->{t} = $newtime; $me->{file} = $to; } sub announce { my ($me, $sched, $t_publish) = @_; ## Announce the public key in DNS as `ID.ZONE', and create a placeholder ## HTML file in `publish/I/D.html' explaining that the key will be ## published before T_PUBLISH. ## Initial preparation. print "announce new key\n"; my $id = $me->{id}; ## If we don't already have the public key then this must be a leftover ## key from a previous aborted run. Create the public key file and ## retrieve the key. Either way, we don't need it any more after this, ## so save the memory. my $pub = $me->{pub}; delete $me->{pub}; unless (defined $pub) { ::maybe_mkdir "active"; $pub = ::extract_public_key "active/$id.pub", $me->{file0}; } ## Prepare the record data. my $key = sprintf "v=DKIM1; s=email; t=s; k=rsa; h=sha256; " . "n=Not suitable for non-repudiation! " . "Private key revealed at %s by %s; " . "p=%s", $me->publish_uri, ::present_time($t_publish), encode_base64($pub, ""); ## A TXT record data consists of a sequence of strings of length at most ## 255 each. The split positions are not significant. Split the data ## into sufficiently small pieces, preferring to split at semantic ## boundaries. my @key = (); while (length $key > 255) { my $chunk = substr $key, 0, 255, ""; if ($chunk =~ m{^ (.* \;\s+) ([^\s;] [^;]*) $}x) { $chunk = $1; $key = $2 . $key; } push @key, $chunk; } push @key, $key; $key = join " ", map qq'"$_"', @key; ## Add the record. $sched->add_record($id . "." . $::C{"ddns-zone"}, "TXT", $key); } sub deploy { my ($me, $sched) = @_; ## Make the key suitable for deployment. Hard link the private key to ## `active/ID.priv'. my $id = $me->{id}; print "deploy private key $id\n"; ::maybe_mkdir "active"; ::maybe_unlink "active/$id.priv.new"; link $me->{file0}, "active/$id.priv.new"; rename "active/$id.priv.new", "active/$id.priv"; } sub withdraw { my ($me, $sched) = @_; ## Withdraw the key data from DNS so that legitimate reliers won't ## believe signatures any more. my $id = $me->{id}; my $zone = $::C{"ddns-zone"}; print "withdraw key $id from dns\n"; $sched->delete_record("$id.$zone", "TXT"); } sub delete { my ($me, $sched) = @_; ## Delete the key from everywhere except the publication tree. my $id = $me->{id}; print "delete key $id\n"; ::schedule_cleanup { print "delete published key $id\n"; ::maybe_unlink "active/$id.pub"; ::maybe_unlink "active/$id.priv"; unlink $me->{file}; } $sched; } } ###-------------------------------------------------------------------------- ### Publication archive. sub publication_time ($;$$) { my ($k, $newst, $newtime) = @_; ## Return the approximate publication time of key K. If NEWST and NEWTIME ## are given, then they override the key's state and timestamp. $newst //= $k->{st}; $newtime //= $k->{t}; defined (my $newix = $STIX{$newst}) or confess "unexpected state `$newst'"; my $t_publish = $newtime + $C{"cycle-period"}; if ($newix < $STIX{PUBLISH}) { $t_publish += $C{"dns-persistence"}; } if ($newix < $STIX{WITHDRAW}) { $t_publish += $C{"mail-persistence"}; } if ($newix < $STIX{RETIRE}) { $t_publish += $C{"active-duration"} + $C{"cycle-period"}; } return $t_publish; } sub publish_placeholder ($;$$) { my ($k, $newst, $newtime) = @_; ## Write a placeholder HTML file at the URI that key K's private key will ## be published later. If NEWST and NEWTIME are given, then the override ## the key's state and timestamp in the computation of the publication ## time. ## Determine the things we need to know. my $id = $k->{id}; my $inst = $C{"instance"}; my $file = $k->publish_filename; my $t_publish = publication_time $k, $newst, $newtime; my $th_publish = present_time $t_publish; ## Produce the placeholder file. print "publish placeholder for $id\n"; open my $f, ">", "$file.new"; print $f < $inst DKIM key $id

$inst DKIM key $id

This is a placeholder page. The private key $id is scheduled for publication on or before $th_publish. $inst DKIM private keys are published in order to make them unsuitable as evidence after the fact.

The public key is

EOF
  dump_file $f, "active/$id.pub";
  print $f <


EOF
  close $f;
  rename "$file.new", $file;
}

sub publish_key ($) {
  my ($k) = @_;
  ## Publish key K's private key.

  ## Determine the things we need to know.
  my $id = $k->{id}; my $inst = $C{"instance"};
  my $file = $k->publish_filename;

  ## Produce the publication file.
  print "publish private key for $id\n";
  open my $f, ">", "$file.new";
  print $f <


  $inst DKIM key $id


$inst DKIM key $id

The key $id was used as a key to authenticate that email passed through the $inst server. $inst DKIM keys are published after they are no longer in active use, to make them unsuitable as evidence after the fact.

The private and public keys are

EOF
  dump_file $f, "active/$id.priv";
  dump_file $f, "active/$id.pub";
  print $f <


EOF
  close $f;
  rename "$file.new", $file;
}

###--------------------------------------------------------------------------
### State transitions.

sub set_key_state ($$$$) {
  my ($k, $sched, $newst, $newtime) = @_;
  ## Advance K to state NEWST, and set its timestamp to NEWTIME.  Arrange for
  ## SCHED to complete the transition.

  ## Preliminary checking.
  my $oldst = $k->{st}; my $id = $k->{id};
  defined (my $ix = $STIX{$oldst})
    or confess "unexpected key state `$oldst'";
  defined (my $newix = $STIX{$newst})
    or confess "unexpected new state `$newst'";
  $ix <= $newix
    or confess "attempted state regression `$oldst' -> `$newst'";

  ## Advance the key through each intermediate state in turn.
  while ($ix < $newix) {
    $ix++; my $curst = $STNAME[$ix];
    print "advance key $id state $oldst -> $curst\n";
    if ($curst eq "ANNOUNCE") {
      $k->announce($sched, publication_time $k, $newst, $newtime);
      publish_placeholder $k, $newst, $newtime;
    }
    elsif ($curst eq "DEPLOY") { $k->deploy($sched); }
    elsif ($curst eq "RETIRE") { ; }
    elsif ($curst eq "WITHDRAW") { $k->withdraw($sched); }
    elsif ($curst eq "PUBLISH") { publish_key $k; }
    else { confess "unexpected state $curst" }
  }

  ## Finally commit the key in its new state.
  if ($newst eq "PUBLISH") { $k->delete($sched); }
  else { $k->set_state($sched, $newst, $newtime); }
}

sub keys_in_state ($) {
  my ($st) = @_;
  ## Return a list of the keys in state ST.

  my @k; my $d;
  eval { opendir $d, $st; };
  if ($@) {
    die if $@->errno != ENOENT;
  } else {
    FILE: for my $f (readdir $d) {
      next FILE if $f eq "." || $f eq "..";
      next FILE if $st eq "NEW" && ($f eq "new.priv" || $f eq "new.pub");
      push @k, Key->load("$st/$f");
    }
  }
  return @k;
}

###--------------------------------------------------------------------------
### Main program.

## Our first order of business is to ensure that there are keys lined up for
## the current deployment cycle.  The candidates are the keys which are
## currently in `NEW', `ANNOUNCE', and `DEPLOY' states.  We arrange to use
## these in ascending order of their current timestamps.  This might cause
## keys to become active earlier than before.

my @candidates;				# candidates for future deployment
my @to_retire;				# keys which are too old
my $cutoff = $NOW - $C{"active-duration"}; # threshold between the two
my $sched = Scheduler->new;		# a `Scheduler' instance

## Work through the `NEW' keys and advance them on to `ANNOUNCE'.  Pick up
## all of the unretired keys so that we can plan how to use them.

sub notice_candidate ($) {
  my ($k) = @_;
  ## Notice a key and add it to one of the lists above.  If K's deployment
  ## time window is entirely in the past then add it to `@to_retire';
  ## otherwise, add it to `@candidates'.

  if ($k->{t} < $cutoff) { push @to_retire, $k; }
  else { push @candidates, $k; }
}

for my $k (keys_in_state "NEW")
  { $k->announce($sched, publication_time $k); notice_candidate $k; }
for my $st (qw{ ANNOUNCE DEPLOY })
  { for my $k (keys_in_state $st) { notice_candidate $k; } }
@candidates = sort { $a->{t} <=> $b->{t} } @candidates;

## Determine the current start time of the oldest key we can deploy.  If this
## is earlier than now then things are chugging along OK and we continue with
## the current plan.  Otherwise, we need to arrange keys for immediate use.
my $t0 = @candidates && $candidates[0]{t} <= $NOW ? $candidates[0]{t} : $NOW;
my $nk = 0;

## Start producing the state file for the mail server.  Make sure that the
## current cycle is covered.
my $info = "";
my $t = $t0; my $t_limit = $NOW + $C{"cycle-period"};
while ($t < $t_limit) {
  my $k = @candidates ? shift @candidates : Key->new($t);
  set_key_state $k, $sched, "DEPLOY", $t; $t += $C{"active-duration"};
  $info .= sprintf "info.%d: k = %s u = %s tpub = \"%s\"\n",
    $nk, $k->{id}, $k->publish_uri, present_time publication_time $k;
  $nk++;
}

## Write the new state file.
maybe_mkdir "active";
open my $mcf, ">", "active/dkim-keys.state.new";
printf $mcf "### dkim-keys deployment state, from %s up to %s\n\n",
  present_time($t0), present_time($t);
printf $mcf "params: t0 = %d step = %d n = %d\n",
  $t0, $C{"active-duration"}, $nk;
print $mcf $info;
close $mcf; rename "active/dkim-keys.state.new", "active/dkim-keys.state";

## And now make sure there are enough new keys announced to cover the next
## cycle.
$t_limit = $NOW + 2*$C{"cycle-period"};
while ($t < $t_limit) {
  my $k = @candidates ? shift @candidates : Key->new($t);
  set_key_state $k, $sched, "ANNOUNCE", $t; $t += $C{"active-duration"};
}

## If there are keys to retire, then arrange that.
for my $k (@to_retire) {
  set_key_state $k, $sched, "RETIRE",
    $k->{t} + $C{"active-duration"} + $C{"mail-persistence"};
}

## If there are keys to withdraw, then arrange that too.
for my $k (keys_in_state "RETIRE") {
  if ($k->{t} <= $NOW)
    { set_key_state $k, $sched, "WITHDRAW", $NOW + $C{"dns-persistence"}; }
}

## And, finally, if there are keys to be published, then arrange that.
for my $k (keys_in_state "WITHDRAW")
  { if ($k->{t} <= $NOW) { set_key_state $k, $sched, "PUBLISH", -1; } }

## The planning is done.  It's now time to do all the things.
print "running cleanup actions\n";
$sched->execute;

###--------------------------------------------------------------------------
### Manual.

=head1 NAME

dkim-keys - manage short-lived DKIM keys

=head1 SYNOPSIS

B

=head1 DESCRIPTION

=head2 Background

DKIM, RFC6376, is a mechanism for authenticating email messages.  An
originating mail server signs each message that it sends using a private
signing key, and adds a header to the message containing the signature and a
reference to where the public verification key can be found in the DNS.
A receiving mail server can parse the header, retrieve the verification key,
verify the signature, and be convinced that the message at least passed
through the originating server.  This is intended to reduce the effectiveness
of forged email messages for fraud and spam.

A DKIM signature must cover email headers I the body, to prevent an
adversary from altering them in order to construct a forgery.  This creates
a problem which the designers of DKIM failed to foresee.  Messages bearing
DKIM signatures can remain verifiable long after delivery, providing
convincing evidence that a particular mail server transmitted a particular
message, and, moreover, at a particular time, and that was sent by a
particular user.  This may be undesirable, to say the least.

In 2020, Matthew Green wrote an article describing this problem on his
I blog, and suggesting a solution: that DKIM
signing keys should cycle rapidly, each key being used only for a short time,
say a day, and, once all messages bearing signatures from a particular key
have been delivered or abandoned, the I signing key should be
published.  Once this is done, the DKIM signature on an old message becomes
worthless as evidence of anything, since anyone with a little technical
ability can forge convincing-looking messages bearing a valid signature from
the key.

This is what B does.

=head2 Inovking B

The B doesn't usually take any command-line arguments.  It does
recognize B<--help> (B<-h>) and B<--version> (B<-v>) options, for
consistency's sake.  It reports an error if other command-line arguments are
given.

=head2 Operation

The B program works in the current directory.  It expects to find
a configuration file B.  It will create directories for its
own use as necessary.

=head2 State model

Each key has an I, notated I here.  For RSA keys, which are
the only kind currently implemented, this is the least significant 80 bits of
the modulus, encoded in lowercase Base32.  (This means that the identifier
can easily be determined from just the public key.)  There are no padding
characters because 80 is a multiple of five.  The key identifier is used in
the DKIM selector when the key is in use.

Keys advance through six states.

=over

=item *

A key in the B state has just been created, and nobody else knows
anything about it.  Under normal circumstances, keys immediately advance to
B, but the B state is separate for technical reasons.

=item *

A key in the B state has a record listed in the DNS, and we're
waiting for the DNS records to propagate before the key can be used.

=item *

A key in the B state is ready for use by a mail server to sign
outgoing messages.  There are usually multiple keys in this state so that the
mail server can cycle from one to the next at the appropriate time without
any further action from B.

=item *

A key in the B state has completed its time in service.  It may have
been used to sign outgoing messages which are still on their way to being
delivered or abandoned, so legitimate verifiers need access to the public
key, but it won't be used to sign new messages.

=item *

A key in the B state should no longer be of use to receiving
servers.  All messages signed using the key have either been delivered or
abandoned.  The public key record is withdrawn from the DNS, and we're
waiting for this withdrawal to propagate before the private key can be
published.  At this stage, a forgery made using the key might be accepted by
a receiving server which still has access to stale DNS records, so the
private key can't quite be published yet.

=item *

A key in the B state is published for all to see.  Nobody should
beleive it for anything, and nobody should have any reason to.

=back

=head2 Configuration file

The configuration syntax is simple and line-based.  Lines consisting only of
whitespace, and lines whose first whitespace character is C<#>, are ignored.
Other lines must be I of the form I B<=> I.  The
recognized keys and their values are as follows.

=over

=item B

A name for the instance of B.  This is used in the published HTML
files.  There is no default.

=item B

The base URL at which keys will be published.  The web server should be
configured to publish the contents of the B directory at this URL.
There is no default.

=item B

The DNS I in which the public keys will be listed.  A key with a given
I will be listed in a B record at IB<.>I.  In a simple
system, I might be B<_domainkey.example.org>, so the B
outgoing mail server need only set BIB<;> B in its
DKIM headers.  In a more complex case, the I might be referred to using
B for multiple domains.

There is no default for this setting.

=item B

The server to which DNS updates will be submitted.  By default, the source
server listed in the zone B record will be used.

=item B

The file containing the TKIP key to use for authenticating the DNS update.
There is no default for this setting.

=item B

The TTL to set on DKIM key records, as a duration (see below).  The default
is four hours.

=item B

The time required for a newly published DNS record to fully propagate, as a
duration (see below).  Conservatively, this will be the sum of the zone
refresh, expiry, and minimum-TTL times: a secondary server must have time to
try updating, give up, and expire the zone, and a downstream recursive
resolver must time out a previously cached negative result for the record,
before the record can be considered fully propagated.

This setting determines how long a key remains in the B state.  The
default is two days.

=item B

The time for which a key will be in active use signing outgoing messages, as
a duration (see below).  The default is one day.

=item B

The maximum interval between runs of B, as a duration (see below).
This determines the how many keys are queued up in the B state.  The
default is three days, with the expectation that the script will actually run
daily.

=item B

The time before which outgoing email messages can safely be expected to have
been delivered or abandoned, as a duration (see below).  This mostly depends
on the outgoing server configuration, but there can conceivably be additional
delay at the receiving end.

This setting determines how long a key remains in the B state.  The
default is one week.

=item B

The time required for a withdrawn DNS record to fully propagate, as a
duration (see below).  Conservatively, this will be the sum of the zone
refresh and expiry times, and the record TTL: a secondary server must have
time to try updating, give up, and expire the zone, and a downstream
recursive resolver must time out a previously cached record, before it can be
considered fully withdrawn.

This setting determines how long a key remains in the B state.  The
default is three days.

=back

A duration is a (possibly fractional) decimal number, followed by an optional
unit.

=over

=item *

B, B, B, B, B

=item *

B, B, B, B, B

=item *

B
, B, B, B, B =item * B, B, B, B, B =item * B, B, B, B, B =back If no unit is given, the default is seconds. =head2 Output The B directory contains public and private key files, and a B file for the mail server. BIB<.pub> contains the public key for the key with identifier I (in OpenSSL PEM format), and BIB<.priv> is the private key (a hard link to the IBIB<.>IB<.priv> file). The B file is formatted as follows. It contains blank lines and (sparse) comments beginning with C<#>. It contains a line =over B B I B I B I =back where I, I, and I are integers in decimal notation. It also contains lines =over BIB<:> B I B I B I =back where I is an integer, I is a key identifier, I is a url (not containing spaces), and I is an ISO8601 time stamp with explicit time zone offset, in double quotes. There will be exactly I B lines, one for each I from 0 (inclusive) up to but not including I. These lines may appear in any order. The B line identifies one of the B lines, as follows. I is a POSIX timestamp, counting nonleap seconds since the start of January 1970. I is the length of time, in seconds, for which a key is active (taken from the B configuration setting). If the current POSIX time is I, then the mail server should use the BI data, where I = floor((I - I)/I). If I, as computed in this way, is less than zero or greater than or equal to I, then the data is invalid: the mail server should report temporary failure. Specifically, if I is less than zero then the data is apparently from the future: this is most likely if the system clock has been stepped, and the situation can be corrected by running B again. If I is greater than or equal to I then the file is out-of-date: B has not run to completion sufficiently recently, or is otherwise broken. The B line fields are as follows. The I is the identifier of the signing key to use, and to include in the DKIM B selector tag; the actual private key will be BIB<.priv>. The I is the URL at which the private key will be published (derived from the key identifier and the B configuration setting) and I is a time by which publication should have occurred; the latter two items are intended to be included in a message header. Exim, for example, might be configured as follows. dkim_selector = \ ${lookup {params} lsearch \ {${lookup {${domain:$h_From:}} partial0-lsearch \ {/etc/mail/dkim-sign.conf} \ {/var/lib/dkim-keys/$value/active/dkim-keys.state}}} \ {${if and {{>= {$tod_epoch} {${extract {t0}{$value}}}} \ {< {$tod_epoch} \ {${eval:${extract {t0}{$value}} + \ ${extract {n}{$value}}*${extract {step}{$value}}}}}} \ {${lookup {info.${eval:($tod_epoch - ${extract {t0}{$value}})/ \ ${extract {step}{$value}}}} lsearch \ {${lookup {${domain:$h_From:}} partial0-lsearch \ {/etc/mail/dkim-sign.conf} \ {/var/lib/dkim-keys/$value/active/dkim-keys.state}}} \ {${extract {k}{$value}}}fail}} \ fail}}\ fail} dkim_private_key = \ ${lookup {${domain:$h_From:}} partial0-lsearch \ {/etc/mail/dkim-sign.conf} \ /var/lib/dkim-keys/$value/active/$dkim_selector.priv} =head2 Working state In the working directory, there is a subdirectory for each state. Private keys are stored (in OpenSSL PEM) format) in files named IBIB<.>IB<.priv> where I is the key's state, as listed above, in uppercase, I is a timestamp in ISO8601 `zulu' format, and I is the key's identifier, used as the DKIM selector. The meaning and value of the timestamp changes as the key advances from one state to the next: see the source code for details. =head1 SEE ALSO =over =item * D. Crocker, T. Hansen, M. Kucherawy, RFC6376: I, L. =item * Matthew Green, I, L =item * L. =back =head1 AUTHOR Mark Wooding, . =cut ###----- That's all, folks --------------------------------------------------