diff options
Diffstat (limited to '')
-rwxr-xr-x | scripts/add-key | 144 | ||||
-rwxr-xr-x | scripts/check-dm-acl | 23 | ||||
-rwxr-xr-x | scripts/chk_expiry | 69 | ||||
-rwxr-xr-x | scripts/clean-keydir | 146 | ||||
-rwxr-xr-x | scripts/explode-keyring | 38 | ||||
-rwxr-xr-x | scripts/gpg-diff | 204 | ||||
-rwxr-xr-x | scripts/mail_expired.rb | 69 | ||||
-rwxr-xr-x | scripts/move-key | 145 | ||||
-rwxr-xr-x | scripts/parse-email | 199 | ||||
-rwxr-xr-x | scripts/parse-git-changelog | 245 | ||||
-rwxr-xr-x | scripts/parse-gpg-update | 52 | ||||
-rwxr-xr-x | scripts/process-rt | 667 | ||||
-rwxr-xr-x | scripts/pull-updates | 90 | ||||
-rwxr-xr-x | scripts/replace-key | 178 | ||||
-rwxr-xr-x | scripts/revoke-key | 52 | ||||
-rwxr-xr-x | scripts/update-key | 95 | ||||
-rwxr-xr-x | scripts/update-keyrings | 405 | ||||
-rwxr-xr-x | scripts/update-ldap | 82 |
18 files changed, 2903 insertions, 0 deletions
diff --git a/scripts/add-key b/scripts/add-key new file mode 100755 index 0000000..313719f --- /dev/null +++ b/scripts/add-key @@ -0,0 +1,144 @@ +#!/bin/bash + +# Copyright (c) 2008 Jonathan McDowell <noodles@earth.li> +# GNU GPL; v2 or later +# Adds a new key to a keyring directory + +set -e + +if [ -z "$1" ] || [ -z "$2" ]; then + echo "Usage: add-key keyfile dir" >&2 + echo "Or: add-key fingerprint dir" >&2 + exit 1 +fi + +# avoid gnupg touching ~/.gnupg +GNUPGHOME=$(mktemp -d -t jetring.XXXXXXXX) +export GNUPGHOME +trap cleanup exit +cleanup () { + rm -rf "$GNUPGHOME" +} + +if echo -n "$1" | egrep -q '^[[:xdigit:]]{40}$'; then + fpr=$1 + keyserver=${KEYSERVER:=pool.sks-keyservers.net} + keyfile=$(mktemp -p $GNUPGHOME newkyXXXXXX) + echo "Retrieving key $fpr from keyserver $keyserver" + gpg --keyserver $keyserver --recv-key "$fpr" + gpg --export "$fpr" > $keyfile +else + keyfile=$(readlink -f "$1") # gpg works better with absolute keyring paths +fi +keydir="$2" + +basename=$(basename "$keyfile") +date=`date -R` + +if [ -f $keyfile ]; then + keyid=$(gpg --with-colons --keyid long --options /dev/null --no-auto-check-trustdb < $keyfile | grep '^pub' | cut -d : -f 5) +else + keyid=${1: -16:16} +fi + +for keyring in *-pgp/ *-gpg/; do + if [ -e $keyring/0x$keyid ]; then + echo "0x$keyid already exists in $keyring - existing key or error." + exit 1 + fi +done + +# Check we have our keyrings available for checking the signatures +if [ ! -e output/keyrings/debian-keyring.gpg ]; then + make +fi + +if [ -f $keyfile ]; then + gpg --quiet --import $keyfile +else + gpg --quiet --keyserver the.earth.li --recv-key $1 || true + gpg --quiet --keyserver pgp.mit.edu --recv-key $1 || true + gpg --quiet --keyserver keyserver.ubuntu.com --recv-key $1 || true + gpg --quiet --keyserver the.earth.li --send-key $1 +fi +gpg --keyring output/keyrings/debian-keyring.gpg \ + --keyring output/keyrings/debian-nonupload.gpg --check-sigs \ + --with-fingerprint --keyid-format 0xlong 0x$keyid | \ +sensible-pager + +echo "We want signatures from at least two other DDs." +echo "If this is a key transition, we also want a signature from the DD's old key." +echo "Are you sure you want to update this key? (y/n)" +read n + +if ( echo $keydir | egrep -q '^(\./)?debian-keyring-gpg/?$' ); then + dest=DD +elif ( echo $keydir | egrep -q '^(\./)?debian-nonupload-gpg/?$' ); then + dest=DN +elif ( echo $keydir | egrep -q '^(\./)?debian-maintainers-gpg/?$' ); then + dest=DM +fi + +if [ "x$n" = "xy" -o "x$n" = "xY" ]; then + gpg --no-auto-check-trustdb --options /dev/null \ + --keyring output/keyrings/debian-keyring.gpg \ + --keyring output/keyrings/debian-nonupload.gpg \ + --keyring output/keyrings/debian-maintainers.gpg \ + --export-options export-clean,no-export-attributes \ + --export $keyid > $keydir/0x$keyid + git add $keydir/0x$keyid + echo -n "Enter full name of new key: " + read name + echo -n 'RT issue ID this change closes, if any: ' + read rtid + if [ "$dest" = DD -o "$dest" = DN ]; then + echo -n "Enter Debian login of new key: " + read login + echo "0x$keyid $name <$login>" >> keyids + sort keyids > keyids.$$ && mv keyids.$$ keyids + git add keyids + fi + + log="Add new $dest key 0x${fpr:24:16} ($name) (RT #$rtid)" + VERSION=$(head -1 debian/changelog | awk '{print $2}' | sed 's/[\(\)]//g') + RELEASE=$(head -1 debian/changelog | awk '{print $3}' | sed 's/;$//') + case $RELEASE in + UNRELEASED) + dch --multimaint-merge -D UNRELEASED -a "$log" + ;; + unstable) + NEWVER=$(date +%Y.%m.xx) + if [ "$VERSION" = "$NEWVER" ] + then + echo '* Warning: New version and previous released version are' + echo " the same: $VERSION. This should not be so!" + echo ' Check debian/changelog' + fi + dch -D UNRELEASED -v $NEWVER "$log" + ;; + *) + echo "Last release $VERSION for unknown distribution «$RELEASE»." + echo "Not calling dch, do it manually." + ;; + esac + git add debian/changelog + + cat > git-commit-template <<EOF +$log + +Action: add +Subject: $name +Username: $login +Role: $dest +Key: $fpr +Key-type: +RT-Ticket: $rtid +Request-signed-by: +Key-certified-by: +Details: +EOF + +else + echo "Not adding key." + exit 1 +fi diff --git a/scripts/check-dm-acl b/scripts/check-dm-acl new file mode 100755 index 0000000..e886975 --- /dev/null +++ b/scripts/check-dm-acl @@ -0,0 +1,23 @@ +#!/bin/bash +# +# Copyright (c) 2016 Jonathan McDowell <noodles@earth.li> +# GNU GPL; v2 or later +# +# Grabs the DM ACL list from ftp-master and ensures that all the +# active keys are still present. Outputs the last commit involving +# a missing key and indicates if it has been moved to the DD keyring. +# + +for fp in $(curl -s https://ftp-master.debian.org/dm.txt | awk '/^Fingerprint: / { print $2 }'); do + keyid=${fp:24} + if [ ! -e debian-maintainers-gpg/0x$keyid -a \ + ! -e debian-nonupload-gpg/0x$keyid ]; then + if [ -e debian-keyring-gpg/0x$keyid ]; then + echo "0x$keyid moved to DD keyring." + else + echo "0x$keyid is missing." + fi + git log --format=oneline -n 1 -- \ + debian-maintainers-gpg/0x$keyid + fi +done diff --git a/scripts/chk_expiry b/scripts/chk_expiry new file mode 100755 index 0000000..2d6b020 --- /dev/null +++ b/scripts/chk_expiry @@ -0,0 +1,69 @@ +#!/usr/bin/perl +use strict; +use Date::Calc qw(Today Delta_Days Add_Delta_YM); + +my (%conf); +%conf = (keyrings => ['debian-keyring.gpg', 'debian-nonupload.gpg', + 'debian-maintainers.gpg'], + basedir => 'output/keyrings', + cmd => 'gpg --no-default-keyring --keyring %s/%s --list-key|grep expire[ds]:' +# basedir => '/tmp', +# cmd => 'cat %s/%s' + ); + +for my $keyring (@{$conf{keyrings}}) { + my ($keys, @expired, @nextmonth, @threemonths); + $keys = {}; + print "============================================================\n"; + print "Processing keyring: $keyring\n\n"; + for my $line (query_keyring($keyring)) { + my ($key, $y, $m, $d); + unless ($line =~ m![ps]ub\s+\d+[RDg]/ + ([\dABCDEF]{8}) + \s.+expire[ds]:\s + (\d{4})-(\d{2})-(\d{2})!x) { + warn "Unrecognized: $line"; + next; + } + ($key, $y, $m, $d) = ($1, $2, $3, $4); + $keys->{$key} = [$y, $m, $d]; + } + print "\nAlready expired keys:\n"; + report($keys, [Today()]); + print "\nKeys expiring soon (one month from today):\n"; + report($keys, [Add_Delta_YM(Today(),0,1)], [Today()]); + print "\nKeys expiring after a month but within three months:\n"; + report($keys, [Add_Delta_YM(Today(),0,1)], [Add_Delta_YM(Today(),0,3)]); +} + +sub query_keyring { + my ($keyring, $cmd); + $keyring = shift; + $cmd = sprintf($conf{cmd}, $conf{basedir}, $keyring); + return `$cmd`; +} + +# Called with three parameters: +# - $keys: Hash keyed by keyid, with the expiry date in [y,m,d] form as its +# value +# - $before: [y,m,d] form. Keys expiring before this date will be reported +# - $limit: Optional, [y,m,d] form. Keys expiring before this date will be +# ignored. +sub report { + my ($keys, $before, $limit, %res); + $keys = shift; + $before = shift; + $limit = shift; + for my $key (keys %$keys) { + next if Delta_Days(@{$keys->{$key}}, @{$before}) < 0; + next if $limit and Delta_Days(@{$keys->{$key}}, @{$limit}) > 0; + $res{$key} = {expiry => $keys->{$key}, + days_to_exp => Delta_Days(Today, @{$keys->{$key}}) }; + } + + foreach my $key (sort {$res{$a}{days_to_exp} <=> $res{$b}{days_to_exp}} + keys %res) { + printf("%s: %s (%s days)\n", $key, join('-', @{$res{$key}{expiry}}), + $res{$key}{days_to_exp}); + } +} diff --git a/scripts/clean-keydir b/scripts/clean-keydir new file mode 100755 index 0000000..bd61432 --- /dev/null +++ b/scripts/clean-keydir @@ -0,0 +1,146 @@ +#!/bin/bash + +# Copyright (c) 2012 Jonathan McDowell <noodles@earth.li>, +# 2019 Daniel Kahn Gillmor <dkg@fifthhorseman.net> +# GNU GPL; v2 or later +# Given a key directory, prune, clean, or minimize the keys + +# "prune" just does basic cleanup on the file, without getting rid of +# any third-party signatures. + +set -e + +if [ -z "$1" ] || [ -z "$2" ]; then + cat >&2 <<EOF +Usage: $0 [prune|launder|clean|minimal] dir + prune: remove invalid parts + launder: invoke GnuPG's merge logic to trim the key + clean: prune and drop non-debian third-party certifications + minimal: prune and remove *all* third-party certifications +EOF + exit 1 +fi + +declare -a GPGOPTIONS=(--batch + --no-tty + --quiet + --no-options + --homedir=/dev/null + --trust-model=always + --fixed-list-mode + --with-colons + --export-options=no-export-attributes + ) + + +if [ "$1" == prune ]; then + GPGOPTIONS+=(--no-keyring + --import-options=import-export + ) +elif [ "$1" == launder ]; then + # we are going to do something very ugly... + # see https://dev.gnupg.org/T4421 + : pass +elif [ "$1" == clean ]; then + # we need to include all the known keys so that we keep the + # interlocking signatures + make + GPGOPTIONS+=(--no-default-keyring + --import-options=import-export,import-clean + --export-options=export-clean + --keyring "$(readlink -f output/keyrings/debian-keyring.gpg)" + --keyring "$(readlink -f output/keyrings/debian-nonupload.gpg)" + --keyring "$(readlink -f output/keyrings/debian-maintainers.gpg)" + --keyring "$(readlink -f output/keyrings/debian-role-keys.gpg)" + --keyring "$(readlink -f output/keyrings/emeritus-keyring.gpg)" + ) +elif [ "$1" == minimal ]; then + GPGOPTIONS+=(--no-keyring + --import-options=import-export,import-minimal + --export-options=export-minimal + ) +else + echo "Must specify prune, launder, clean or minimal; not $1" >&2 + exit 1 +fi + +if [ ! -d "$2" ]; then + printf '%s is not a directory' "$2" >&2 + exit 1 +fi + +# takes name of transferable public key file as $1, emits the laundered key to file named $2 +launder_tpk() { + local interim="$(mktemp -d interim.XXXXXXX)" + local success=false + local key="$1" + local output="$2" + mkdir -p -m 0700 "$interim/gpg" "$interim/split" + cat > "$interim/gpg/gpg.conf" <<EOF +batch +no-tty +quiet +no-options +trust-model always +fixed-list-mode +with-colons +export-options no-export-attributes +EOF + if gpg --homedir "$interim/gpg" --import-options=import-minimal --status-file "$interim/status" --import < "$key" && + fpr="$(awk '{ if ($1 == "[GNUPG:]" && $2 == "IMPORT_OK" && $3 == "1") { print $4 } }' < "$interim/status")" && + [ -n "$fpr" ] && + gpg --homedir "$interim/gpg" --export | (cd "$interim/split" && gpgsplit) && + gpg --homedir "$interim/gpg" --delete-key "$fpr"; then + local pub="$interim/split/000001-006.public_key" + local uid=$(ls "$interim/split/"*.user_id | head -n1) + local sig=$(printf '%s/split/%06d-002.sig' "$interim" $(( "$(echo "${uid##$interim/split/}" | sed -e 's_^0*__' -e 's_-.*$__')" + 1 )) ) + if [ -r "$pub" ] && [ -r "$uid" ] && [ -r "$sig" ]; then + if cat "$pub" "$uid" "$sig" | gpg --homedir "$interim/gpg" --import && + gpg --homedir "$interim/gpg" --import < "$key" && + gpg --homedir "$interim/gpg" --output "$output" --export "$fpr"; then + success=true + else + printf 'Merging failed for %s (fpr: %s)\n' "$key" "$fpr" >&2 + fi + else + printf 'Could not find minimal TPK for %s (fpr: %s)\n' "$key" "$fpr" >&2 + fi + else + printf 'failed to do initial import of %s\n' "$key" >&2 + fi + rm -rf "$interim" + [ $success = true ] +} + +cd "$2" +for key in 0x*; do + success=false + if [ "$1" == launder ]; then + if launder_tpk "$key" "$key.new"; then + success=true + fi + else + if gpg "${GPGOPTIONS[@]}" --output "$key.new" --import "$key"; then + success=true + fi + fi + if [ $success = true ] && [ -s $key.new ]; then + OLDSIZE=$(stat -c "%s" "$key") + NEWSIZE=$(stat -c "%s" "$key.new") + if [ $OLDSIZE -gt $NEWSIZE ]; then + echo "Cleaning $key [$OLDSIZE] -> [$NEWSIZE]" + mv "$key.new" "$key" + elif [ $OLDSIZE -eq $NEWSIZE ] && ! cmp --quiet "$key" "$key.new" ; then + printf "Packets were reordered in $key" + if [ "$1" == launder ]; then + echo " (but ignoring while doing launder: https://dev.gnupg.org/T4422)" + else + mv "$key.new" "$key" + echo + fi + fi + fi + [ -e "$key.new" ] && rm "$key.new" +done + +exit 0 diff --git a/scripts/explode-keyring b/scripts/explode-keyring new file mode 100755 index 0000000..d6362ac --- /dev/null +++ b/scripts/explode-keyring @@ -0,0 +1,38 @@ +#!/bin/sh + +# Copyright (c) 2008 Jonathan McDowell <noodles@earth.li> +# GNU GPL; v2 or later +# Converts a keyring into individual keys. +# Taken from jetring-explode, Copyright 2007 Joey Hess <joeyh@debian.org> + +set -e + +if [ -z "$1" ] || [ -z "$2" ]; then + echo "Usage: explode-keyring keyring dir" >&2 + exit 1 +fi + +# avoid gnupg touching ~/.gnupg +GNUPGHOME=$(mktemp -d -t jetring.XXXXXXXX) +export GNUPGHOME +trap cleanup exit +cleanup () { + rm -rf "$GNUPGHOME" +} + +keyring=$(readlink -f "$1") # gpg works better with absolute keyring paths +changesetdir="$2" + +basename=$(basename "$keyring") +date=`date -R` + +mkdir -p "$changesetdir" + +for key in $(gpg --no-auto-check-trustdb --options /dev/null --no-default-keyring --keyring "$keyring" --list-keys --keyid-format long | grep '^pub' | sed -e 's!.*/!!' -e 's/ .*//'); do + out="$changesetdir/0x$key" + echo "$out" + gpg --no-auto-check-trustdb --options /dev/null \ + --no-default-keyring --keyring "$keyring" \ + --export-options no-export-attributes \ + --export "$key" > "$out" +done diff --git a/scripts/gpg-diff b/scripts/gpg-diff new file mode 100755 index 0000000..29f45ca --- /dev/null +++ b/scripts/gpg-diff @@ -0,0 +1,204 @@ +#!/usr/bin/perl -w + +# Copyright (c) 2007 Anthony Towns +# GNU GPL; v2 or later +# Gives an overview of what changed between two keyrings + +# Take from jetring-diff and modified to be suitable for git. +# Copyright (c) 2007 Jonathan McDowell <noodles@earth.li> + +use strict; +use Cwd q{abs_path}; +use File::Temp qw(tempdir); +use warnings; +use strict; + +if (@ARGV != 2 and @ARGV != 7) { + die "usage: gpg-diff old.gpg new.gpg | path old.gpg old-hex old-mode ". + "new.gpg new-hex new-mode\n"; +} + +# avoid gnupg touching ~/.gnupg +$ENV{GNUPGHOME}=tempdir("jetring.XXXXXXXXXX", TMPDIR => 1, CLEANUP => 1); + +my ($l, $r); + +if (@ARGV == 7) { + # Print a diff style header + print "gpg-diff a/$ARGV[0] b/$ARGV[4]\n"; + print "--- a/$ARGV[0]\n"; + print "+++ b/$ARGV[4]\n"; + print "\n"; + + if ($ARGV[4] eq '/dev/null') { + print "Key deleted\n"; + exit 0; + } + + $l = parse_keyring($ARGV[1]); + $r = parse_keyring($ARGV[4]); +} else { + $l = parse_keyring(shift); + $r = parse_keyring(shift); +} + +foreach my $id (sort keys %{$l}) { + if (not exists $r->{$id}) { + summary("-", @{$l->{$id}}); + } + else { + my $diff=0; + my @out; + + my %rpackets = map { comparable($_->{'details'}) => $_ } + @{$r->{$id}}; + my %lpackets = map { comparable($_->{'details'}) => 1 } + @{$l->{$id}}; + + foreach my $packet (@{$l->{$id}}) { + if (defined($rpackets{comparable($packet->{'details'})})) { + push @out, " ".outformat($packet->{'details'}); + push @out, comparesigs(\$diff, $packet->{'sigs'}, + $rpackets{comparable($packet->{'details'})}->{'sigs'}); + } else { + push @out, "-".outformat($packet->{'details'}); + $diff = 1; + } + } + + foreach my $packet (@{$r->{$id}}) { + if (! $lpackets{comparable($packet->{'details'})}) { + push @out, "+".outformat($packet->{'details'}); + $diff = 1; + } + } + + print @out if $diff; + } +} +foreach my $id (sort keys %{$r}) { + if (not exists $l->{$id}) { + summary("+", @{$r->{$id}}); + } +} + +sub parse_keyring { + my $k=shift; + + $k=abs_path($k); # annoying gpg.. + my $cache=$k.".cache"; + + my $cached=0; + my $kmtime=(stat($k))[9]; + if (-e $cache) { + my $cmtime=(stat($cache))[9]; + if ($kmtime == $cmtime) { + open(DUMP, $cache) || die "$cache: $!"; + $cached=1; + } + } + if (! $cached) { + open(DUMP, "gpg --options /dev/null --no-default-keyring ". + "--no-auto-check-trustdb --keyring $k --list-sigs ". + "--fixed-list-mode --with-colons -q |") + or die "couldn't dump keyring $k: $!"; +# Disable caching for the moment +# if (! open(CACHE, ">$cache")) { +# print STDERR "warning: cannot write cache $cache\n"; + $cache=undef; +# } + } + my %keys; + my $id; + my $packet; + while (<DUMP>) { + if (! $cached && defined $cache) { + print CACHE $_; + } + chomp; + + my @fields=split(":", $_); + $fields[5]="-"; # ignore creation date, varies + next if $fields[0] eq 'tru'; + if ($fields[0] eq 'pub') { + $id=$fields[4]; + } + if ($fields[0] ne 'sig' && $fields[0] ne 'rev') { + if (defined($packet)) { + push @{$keys{$id}}, $packet; + undef $packet; + } + $packet->{'details'} = \@fields; + } else { + if (! defined $id or !defined($packet)) { + die "parse error: $_"; + next; + } + push @{$packet->{'sigs'}}, \@fields; + } + } + push @{$keys{$id}}, $packet; + close DUMP; + + if (defined $cache) { + close CACHE; + utime($kmtime, $kmtime, $cache) || + print STDERR "warning: failed setting cache time: $!"; + } + + return \%keys; +} + +sub summary { + my $prefix=shift; + + foreach my $record (@_) { + if (ref $record eq 'HASH') { + summary($prefix, $record->{$_}) + foreach reverse sort keys %$record; + } + else { + if ($record->[0] eq 'pub' || $record->[0] eq 'uid') { + print "$prefix".outformat($record); + } + } + } +} + +sub outformat { + return join(":", @{shift()})."\n"; +} + +sub comparable { + my @record=@{shift()}; + if ($record[0] eq 'sig') { + # Displayed user ids for sigs vary, so compare different + # ones the same. The user-id is what matters. + $record[9]=""; + } + return join(":", @record); +} + +sub comparesigs { + my $diff = shift; + my $l = shift; + my $r = shift; + my %lseen = map { comparable($_) => 1 } @{$l}; + my %rseen = map { comparable($_) => 1 } @{$r}; + my @out; + + foreach my $record (@{$l}) { + if (! $rseen{comparable($record)}) { + push @out, "-".outformat($record); + ${$diff} = 1; + } + } + foreach my $record (@{$r}) { + if (! $lseen{comparable($record)}) { + push @out, "+".outformat($record); + ${$diff} = 1; + } + } + + return @out; +} diff --git a/scripts/mail_expired.rb b/scripts/mail_expired.rb new file mode 100755 index 0000000..f0124c7 --- /dev/null +++ b/scripts/mail_expired.rb @@ -0,0 +1,69 @@ +#!/usr/bin/ruby +# coding: utf-8 +require 'yaml' + +ARGV[0] == '--send' or + raise RuntimeError, 'This will send many mails! Are you sure? Tell me to "--send"' + +%w(debian-keyring-gpg debian-maintainers-gpg debian-nonupload-gpg .git).each do |ck| + Dir.exists?(ck) or raise RuntimeError, 'Please run this script from the base keyring-maint git tree' +end + +data = `make test`.split(/\n/).select {|lin| lin=~/expired on/} +keys = {} +expired_keys = {'keyring' => [], + 'maintainers' => [], + 'nonupload' => []} + +File.open('keyids','r') do |f| + f.readlines.each do |l| + l=~/^(0x[\dABCDEF]+) (.+) <(.+)>$/; keys[$1] = [$2,$3] + end +end + +data.each do |l| + l=~/debian-(\w+).gpg:\s*(0x[\dABCDEF]+) expired on (.+) \(\)\s*$/ + persondata = keys[$2] || ['NM (?)', nil] + expired_keys[$1] << {:key => $2, :name => persondata[0], :login => persondata[1], :date => $3} +end + +proposed_exp = (Time.now + 2*365*86400).strftime '%a %d %b %Y %I:%M:%S %p %Z' +expired_keys.each do |keyring, exp_k| + next if keyring == 'maintainers' + exp_k.each do |key| + IO.popen('mutt %s@debian.org -c keyring-maint@debian.org -s "Expired key in Debian -- %s (since %s)" -H -' % + [key[:login], key[:key], key[:date]], 'w') do |f| + + f.puts 'From: Debian Keyring Maintainers <keyring-maint@debian.org> + +Hello %s <%s@debian.org>, + +According to our records, your key «%s», part of the Debian +%s keyring, is expired since %s — Which means you will +be, among other issues, not able to perform package uploads or vote in GRs! + +Please review your key\'s expiry date setting it to a sensible date in +the future, and send it to our HKP server: + + $ gpg --edit-key %s + (...) + gpg> expire + (...) + Key is valid for? (0) 2y + Key expires at %s + Is this correct? (y/N) y + (...) + gpg> save + $ gpg --keyserver keyring.debian.org --send-key %s + +And we will include it in our next keyring push (due towards the end +of each month). + +Thanks, + + - Gunnar Wolf + on behalf of the keyring maintenance team +' % [ key[:name], key[:login], key[:key], keyring, key[:date], key[:key], proposed_exp, key[:key] ] + end + end +end diff --git a/scripts/move-key b/scripts/move-key new file mode 100755 index 0000000..2c52c93 --- /dev/null +++ b/scripts/move-key @@ -0,0 +1,145 @@ +#!/bin/sh + +# Copyright (c) 2013 Gunnar Wolf <gwolf@debian.org>, +# Based on 2008 Jonathan McDowell <noodles@earth.li> +# GNU GPL; v2 or later +# Moves an existing key to another keyring directory + +set -e + +if [ -z "$1" ] || [ -z "$2" ]; then + echo "Usage: move-key keyid dir" >&2 + exit 1 +fi + +key=$1 +destdir=$(readlink -f $2) + +# avoid gnupg touching ~/.gnupg +GNUPGHOME=$(mktemp -d -t jetring.XXXXXXXX) +export GNUPGHOME +trap cleanup exit +cleanup () { + rm -rf "$GNUPGHOME" +} + +keyfp="<fixme>" +if [ $(echo -n $key|wc -c) -eq 16 ]; then + key='0x'$(echo $key|tr a-z A-Z) +elif [ $(echo -n $key|wc -c) -eq 40 ] ; then + keyfp=$key + key='0x'$(echo -n $key | cut -b 25-) +fi + +if [ ! -d "$destdir" ] || echo "$destdir"|grep -q -- '-gpg/?$'; then + echo "Error: $destdir is not a valid keyring directory" >& 2 + exit 1 +fi + +for dir in *-gpg/; do + if [ -f $dir/$key ]; then + keyfile=$(readlink -f "$dir/$key") + srcdir=$(readlink -f $dir) + break + fi +done + +if [ "$srcdir" = "$destdir" ]; then + echo "Source and destination directories are the same: $srcdir" >& 2 + exit 1 +fi + +if [ -z "$keyfile" ]; then + echo "Requested key '$key' not found" + exit 1 +fi + +keyuser=$(gpg --with-colons --keyid long --options /dev/null --no-auto-check-trustdb < $keyfile| grep '^pub' | cut -d : -f 10) + +echo "" +echo "About to move key $key ($keyuser)" +echo " FROM $srcdir" +echo " TO $destdir" +echo "Are you sure you want to update this key? (y/n)" +read n + +if [ "x$n" = "xy" -o "x$n" = "xY" ]; then + add_to_keyid="" + echo -n "Enter full name of new key's owner: " + read name + echo -n 'RT issue ID this change closes, if any: ' + read rtid + + if ( echo $destdir | egrep -q 'debian-keyring-gpg/?$' ); then + log="Add new DD key $key ($name) (RT #$rtid)" + add_to_keyid=yes + dest=DD + action=add + elif ( echo $destdir | egrep -q 'debian-nonupload-gpg/?$' ); then + log="Add new nonuploading key $key ($name) (RT #$rtid)" + add_to_keyid=yes + dest=DN + action=add + elif ( echo $destdir | egrep -q 'debian-maintainer-gpg/?$' ); then + log="Add new DM key $key ($name) (RT #$rtid)" + dest=DM + action=add + elif ( echo $destdir | egrep -q 'emeritus-keyring-gpg/?$' ); then + log="Move $key to emeritus ($name) (RT #$rtid)" + action=remove + fi + + git mv $keyfile $destdir + VERSION=$(head -1 debian/changelog | awk '{print $2}' | sed 's/[\(\)]//g') + RELEASE=$(head -1 debian/changelog | awk '{print $3}' | sed 's/;$//') + case $RELEASE in + UNRELEASED) + dch --multimaint-merge -D UNRELEASED -a "$log" + ;; + unstable) + NEWVER=$(date +%Y.%m.xx) + if [ "$VERSION" = "$NEWVER" ] + then + echo '* Warning: New version and previous released version are' + echo " the same: $VERSION. This should not be so!" + echo ' Check debian/changelog' + fi + dch -D UNRELEASED -v $NEWVER "$log" + ;; + *) + echo "Last release $VERSION for unknown distribution «$RELEASE»." + echo "Not calling dch, do it manually." + ;; + esac + git add debian/changelog + + if [ ! -z "$add_to_keyid" ]; then + if oldkey=$(grep $key keyids); then + echo "Key already present in the keyids file:" + echo $oldkey + else + echo -n "Enter Debian login of new key: " + read login + echo "$key $name <$login>" >> keyids + sort keyids > keyids.$$ && mv keyids.$$ keyids + git add keyids + fi + fi + + cat > git-commit-template <<EOF +$log + +Action: $action +Subject: $name +Username: $login +Role: $dest +Key: $keyfp +Key-type: +RT-Ticket: $rtid +Request-signed-by: +Details: +Notes: Move from <src> keyring +EOF +else + echo "Not moving key." +fi diff --git a/scripts/parse-email b/scripts/parse-email new file mode 100755 index 0000000..bf457b9 --- /dev/null +++ b/scripts/parse-email @@ -0,0 +1,199 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +from __future__ import print_function +import datetime +import email +import os.path +import re +import subprocess +import sys + +debug = False + +roles = { + 'DM': 'debian-maintainers-gpg', + 'DD': 'debian-keyring-gpg', + 'DN': 'debian-nonupload-gpg' +} + + +def do_dch(logmsg): + release = "unknown" + with open('debian/changelog', 'r') as f: + line = f.readline() + m = re.match("debian-keyring \((.*)\) (.*); urgency=", line) + version = m.group(1) + release = m.group(2) + if release == "UNRELEASED": + if debug: + print('dch --multimaint-merge -D UNRELEASED -a "' + logmsg + '"') + else: + subprocess.call(['dch', '--multimaint-merge', '-D', 'UNRELEASED', + '-a', logmsg]) + elif release == "unstable": + newver = datetime.date.today().strftime("%Y.%m.xx") + if newver == version: + print(' * Warning: New version and previous released version are ') + print(' the same: ' + newver + '. This should not be so!') + print(' Check debian/changelog') + if debug: + print('dch -D UNRELEASED -v ' + newver + ' "' + logmsg + '"') + else: + subprocess.call(['dch', '-D', 'UNRELEASED', '-v', newver, logmsg]) + else: + print("Unknown changelog release: " + release) + + if not debug: + subprocess.call(['git', 'add', 'debian/changelog']) + + +def do_git_template(logmsg, state): + with open('git-commit-template', 'w') as f: + f.write(logmsg) + f.write('\n\n') + f.write("Action: add\n") + f.write("Subject: " + state['name'] + "\n") + if 'username' in state: + f.write("Username: " + state['username'] + "\n") + f.write("Role: " + state['role'] + "\n") + f.write("Key: " + state['keyid'] + "\n") + f.write("Key-type: " + state['keytype'] + "\n") + f.write("RT-Ticket: " + state['rt'] + "\n") + f.write("Request-signed-by: \n") + f.write("Key-certified-by: \n") + if 'details' in state: + f.write("Details: " + state['details'] + "\n") + if state['role'] == 'DM' and 'agreement' in state: + f.write("Advocates:\n") + for a in state['advocates']: + f.write(" " + a + "\n") + f.write("Agreement: " + state['agreement'] + "\n") + f.write("BTS: " + state['bts'] + "\n") + + +# Change to our basedir, assuming we're in <basedir>/scripts/<us> +basedir = os.path.dirname(os.path.dirname(os.path.realpath(sys.argv[0]))) +os.chdir(basedir) + +inadvocates = False +state = {} +state['advocates'] = [] + +for line in sys.stdin: + line = line.rstrip() + + if inadvocates: + if line[:9] == 'KeyCheck:': + inadvocates = False + else: + line = line.lstrip() + state['advocates'].append(line) + continue + + m = re.match("X-RT-Ticket: rt.debian.org #(.*)$", line) + if m: + state['rt'] = m.group(1) + continue + + m = re.match(" please add key ID (.*)$", line) + if m: + state['keyid'] = m.group(1) + state['role'] = 'DM' + state['move'] = False + continue + + m = re.match("Please make (.*) \(currently ([^ ]*) ([^ ]*)", line) + if m: + state['name'] = m.group(1) + state['keytype'] = '<FIXME>' + state['move'] = (not ((m.group(2) == 'NOT') or (m.group(2) != "'Debian") or (m.group(3) == "Contributor')"))) or m.group(3) == 'DM)' + m = re.match(".*a non-uploading", line) + if m: + state['role'] = 'DN' + else: + state['role'] = 'DD' + continue + + m = re.match("^Agreement: (.*)", line) + if m: + state['agreement'] = m.group(1) + continue + + m = re.match("^BTS: (.*)$", line) + if m: + state['bts'] = m.group(1) + continue + + m = re.match("^Comment: (Please add|Add) (.*) <", line) + if m: + state['name'] = m.group(2) + continue + + m = re.match("^Advocates:(=20)?$", line) + if m: + inadvocates = True + continue + + m = re.match(" pub (.....)/", line) + if m: + state['keytype'] = m.group(1) + continue + + m = re.match(" Key fingerprint:\s+(.*)", line) + if m: + state['keyid'] = m.group(1) + continue + + m = re.match(" Target keyring:\s+(Deb.*)", line) + if m: + if (m.group(1) == 'Debian Maintainer' or + m.group(1) == 'Debian Maintainer, with guest account'): + state['role'] = 'DM' + state['move'] = False + elif m.group(1) == 'Debian Developer, uploading': + state['role'] = 'DD' + elif m.group(1) == 'Debian Developer, non-uploading': + state['role'] = 'DN' + else: + state['role'] = "UNKNOWN" + continue + + m = re.match(" (Account|Username):\s+(.*)", line) + if m: + state['username'] = m.group(2) + continue + + m = re.match(" Details:\s+(http.*)", line) + if m: + state['details'] = m.group(1) + continue + +if 'role' not in state: + print('Did not find recognised keyring related email.') + sys.exit(1) + +if not debug: + if state['move']: + if os.path.exists(roles['DM'] + '/0x' + state['keyid'][24:]): + res = subprocess.call(["scripts/move-key", state['keyid'], + roles[state['role']]], stdin=open('/dev/tty')) + elif os.path.exists(roles['DN'] + '/0x' + state['keyid'][24:]): + res = subprocess.call(["scripts/move-key", state['keyid'], + roles[state['role']]], stdin=open('/dev/tty')) + else: + print('Trying to move non-existent key from DM keyring.') + sys.exit(1) + else: + res = subprocess.call(["scripts/add-key", state['keyid'], + roles[state['role']]], stdin=open('/dev/tty')) + if res: + print('Failed to add key.') + sys.exit(1) + +logmsg = ("Add new " + state['role'] + " key 0x" + state['keyid'][24:] + " (" + + state['name'] + ") (RT #" + state['rt'] + ")") + +#if not state['move']: +# do_dch(logmsg) +do_git_template(logmsg, state) diff --git a/scripts/parse-git-changelog b/scripts/parse-git-changelog new file mode 100755 index 0000000..d74e83b --- /dev/null +++ b/scripts/parse-git-changelog @@ -0,0 +1,245 @@ +#!/usr/bin/python3 + +from __future__ import print_function +import email +import re +import sys + +def role_is_dd(role): + """ + Check if a role is a DD role + """ + return role.startswith("DD") or role.startswith("DN") + +class DakOutput(object): + """ + Output parsed record for dak + """ + def __init__(self, pathname): + self.out = open(pathname, 'w') + self.out.write("Archive: ftp.debian.org\n") + self.out.write("Uploader: Jonathan McDowell <noodles@earth.li>\n") + self.out.write("Cc: keyring-maint@debian.org\n") + + def close(self): + self.out.close() + + def write(self, state, operation): + if operation['action'] == 'remove': + if 'rt-ticket' in operation: + if not role_is_dd(operation['role']): + self.out.write("\nAction: dm-remove\n" + + "Fingerprint: " + operation['key'] + "\n" + + "Reason: RT #" + operation['rt-ticket'] + + ", keyring commit " + state['commit'] + "\n") + elif operation['action'] == 'replace': + if not role_is_dd(operation['role']): + self.out.write("\nAction: dm-migrate\n" + + "From: " + operation['old-key'] + "\n" + + "To: " + operation['new-key'] + "\n" + + "Reason: RT #" + operation['rt-ticket'] + + ", keyring commit " + state['commit'] + "\n") + elif (operation['action'] == 'add' and + role_is_dd(operation['role']) and + operation['notes'] == 'Move from DM keyring'): + if 'rt-ticket' in operation: + self.out.write("\nAction: dm-remove\n" + + "Fingerprint: " + operation['key'] + "\n" + + "Reason: Moved to DD keyring, RT #" + + operation['rt-ticket'] + ", keyring commit " + + state['commit'] + "\n") + + +class RTOutput(object): + """ + Output parsed records for RT + """ + def __init__(self, pathname): + self.out = open(pathname, 'w') + + def close(self): + self.out.close() + + def write(self, state, operation): + if operation['action'] == 'add': + if 'rt-ticket' in operation: + self.out.write("# Commit " + state['commit'] + "\n") + if role_is_dd(operation['role']): + self.out.write("rt edit ticket/" + operation['rt-ticket'] + + " set queue=DSA\n") + elif operation['role'] == 'DM': + self.out.write("rt correspond -s resolved -m " + + "'This key has now been added to the active DM keyring.' " + + operation['rt-ticket'] + "\n") + else: + self.out.write("rt correspond -s resolved -m " + + "'This key has now been added to the " + + operation['role'] + " keyring.' " + + operation['rt-ticket'] + "\n") + elif operation['action'] == 'remove': + if 'rt-ticket' in operation: + self.out.write("# Commit " + state['commit'] + "\n") + if role_is_dd(operation['role']): + self.out.write("rt edit ticket/" + operation['rt-ticket'] + + " set queue=DSA\n") + else: + self.out.write("rt edit ticket/" + operation['rt-ticket'] + + " set queue=Keyring\n" + + "rt correspond -s resolved -m "+ + "'This key has now been removed from the active DM keyring.' " + + operation['rt-ticket'] + "\n") + elif operation['action'] == 'replace': + self.out.write("# Commit " + state['commit'] + "\n") + if role_is_dd(operation['role']): + self.out.write("rt edit ticket/" + operation['rt-ticket'] + + " set queue=Keyring\n" + + "rt correspond -s resolved -m " + + "'Your key has been replaced in the active keyring and LDAP updated with the new fingerprint.' " + + operation['rt-ticket'] + "\n") + else: + self.out.write("rt edit ticket/" + operation['rt-ticket'] + + " set queue=Keyring\n" + + "rt correspond -s resolved -m "+ + "'Your key has been replaced in the active DM keyring.' " + + operation['rt-ticket'] + "\n") + + +class LDAPOutput(object): + """ + Output parsed records for LDAP + """ + def __init__(self, pathname): + self.out = open(pathname, 'w') + + def close(self): + self.out.close() + + def write(self, state, operation): + if operation['action'] == 'replace': + if role_is_dd(operation['role']): + self.out.write(operation['username'] + " " + operation['old-key'] + " ") + self.out.write(operation['new-key'] + "\n") + + +class Parser(object): + def __init__(self): + self.seenrt = {} + + def do_operation(self, state): + operation = email.message_from_string(state['message']) + + if not 'action' in operation: + print("NOTE : " + state['commit'] + " (" + state['summary'] + ") has no action") + return None + + if operation['role'] == 'role': + # At present we don't do anything with role keys + return None + + if 'rt-ticket' in operation and operation['rt-ticket'] in self.seenrt: + print("ERROR: RT " + operation['rt-ticket'] + " used in " + + self.seenrt[operation['rt-ticket']] + " and " + + state['commit']) + else: + self.seenrt[operation['rt-ticket']] = state['commit'] + + if operation['action'] == 'add': + if 'rt-ticket' in operation: + if operation['role'] == 'DM': + try: + bts = operation['BTS'].strip() + bts = re.sub(r'https?://bugs.debian.org/(\d+)', + r'\1-done@bugs.debian.org', bts) + print("NOTE : Mail " + bts + " (new DM).") + except AttributeError: + print('NOTE : DM add for RT ticket %s lacks a BTS ticket.' % operation['RT-Ticket']) + return operation + else: + print("TODO : Add with no RT ticket") + return None + elif operation['action'] == 'remove': + if 'rt-ticket' in operation: + return operation + else: + if 'username' in operation: + username = operation['username'] + elif 'key' in operation: + username = operation['key'] + elif 'old-key' in operation: + username = operation['old-key'] + elif 'subject' in operation: + username = operation['subject'] + print("TODO : Removal for " + username + " without RT ticket.") + return None + elif operation['action'] == 'replace': + if role_is_dd(operation['role']): + if not 'username' in operation: + operation['Username'] = 'FIXME' + return operation + else: + return operation + else: + print("Error: Unknown action " + operation['action']) + return None + + def main(self): + state = {} + opcount = 0 + dak = DakOutput("dak-update") + rt = RTOutput("rt-update") + ldap = LDAPOutput("ldap-update") + + for line in sys.stdin: + line = line.rstrip() + + # Catch the start of a commit + m = re.match("commit (.*)$", line) + if m: + if 'message' in state: + operation = self.do_operation(state) + if operation: + dak.write(state, operation) + rt.write(state, operation) + ldap.write(state, operation) + opcount += 1 + elif 'commit' in state: + if re.match("Import changes sent to keyring", state['summary']): + pass + elif re.match("Update changelog", state['summary']): + pass + else: + print("NOTE : " + state['commit'] + " (" + state['summary'] + ") is not an action.") + state = {} + state['commit'] = m.group(1) + + if not re.match(" ", line): + continue + + line = line[4:] + if not 'inaction' in state: + if not 'summary' in state: + state['summary'] = line + elif re.match("[a-zA-Z]*: ", line): + state['inaction'] = True + state['message'] = line + "\n" + else: + state['message'] += line + "\n" + + # Process the last commit, if applicable + if 'message' in state: + operation = self.do_operation(state) + if operation: + dak.write(state, operation) + rt.write(state, operation) + ldap.write(state, operation) + opcount += 1 + + ldap.close() + rt.close() + dak.close() + + print("Processed " + str(opcount) + " operations.") + +if __name__ == '__main__': + parser = Parser() + parser.main() diff --git a/scripts/parse-gpg-update b/scripts/parse-gpg-update new file mode 100755 index 0000000..844083e --- /dev/null +++ b/scripts/parse-gpg-update @@ -0,0 +1,52 @@ +#!/usr/bin/perl + +use strict; +use warnings; + +use DB_File; + +my %ident; + +if ($#ARGV != 0 and $#ARGV != 1) { + print "Must supply key id.\n"; + exit 1; +} + +open KEYIDS, "<keyids" or die "Can't open keyids file: $!"; +while (<KEYIDS>) { + chomp; + /^0x([^ ]*) (.*)/; + $ident{$1} = $2; +} +close KEYIDS; + +$ARGV[0] =~ s/0x//; + +my $keyid = $ARGV[0]; +my $user; +if (! defined($ident{$ARGV[0]})) { + if ($#ARGV == 1) { + $user = $ARGV[1] . " [DM]"; + } else { + $user = "UNKNOWN (DM?)"; + } +} else { + $user = $ident{$ARGV[0]}; +} + +my ($uids, $subs, $sigs) = (0, 0, 0); +while (<STDIN>) { + if (/new subkeys: (\d+)$/) { + $subs = $1; + } elsif (/new user IDs: (\d+)$/) { + $uids = $1; + } elsif (/new signatures: (\d+)$/) { + $sigs = $1; + } +} + +print "0x$keyid $user"; +print " uid:$uids" if ($uids > 0); +print " sub:$subs" if ($subs > 0); +print " sig:$sigs" if ($sigs > 0); +print "\n"; diff --git a/scripts/process-rt b/scripts/process-rt new file mode 100755 index 0000000..87d5285 --- /dev/null +++ b/scripts/process-rt @@ -0,0 +1,667 @@ +#!/usr/bin/python3 + +# Copyright (c) 2017-2018 Jonathan McDowell <noodles@earth.li> +# GNU GPL; v2 or later +# +# Process RT tickets for keyring-maint@debian as raised by the +# nm.debian.org web interface + +# Semi-helpful gpgme/Python examples: +# https://pypkg.com/pypi/gpg/f/examples/ + +import datetime +import gpg +import io +import os +import pprint +import re +import requests +import subprocess +import sys +from urllib.parse import urlencode + +debug = False +RT_BASE_URL = 'https://rt.debian.org/REST/1.0/' +KEYSERVERS = ['keyserver.ubuntu.com:11371', 'the.earth.li:11371', + 'pool.sks-keyservers.net:11371' ] +DAM = ['enrico', 'joerg', 'jmw'] +FD = DAM + ['noodles', 'mattia', 'peb', 'santiago', 'tobi', 'hartmans', + 'stuart', 'olasd'] + +SIG_DAYS_WARN = 45 +DATE_FORMAT = "%d/%m/%Y %H:%M:%S" + +# Try to find the keyring base directory, assuming we live in /scripts/ +basedir = os.path.dirname(os.path.dirname(os.path.realpath(sys.argv[0]))) + '/' +if os.path.exists(basedir + 'debian-keyring-gpg'): + KEYRING_BASE_DIR = basedir +else: + print("Can't find keyring directory.") + sys.exit(-1) +GNUPG_HOME = KEYRING_BASE_DIR + 'gpghome/' + +# Maps roles to keyring directories +role2keyring = { + 'DM': 'debian-maintainers-gpg', + 'DD': 'debian-keyring-gpg', + 'DN': 'debian-nonupload-gpg', + 'emeritus': 'emeritus-keyring-gpg', +} + +# The keys must match nm2:backend/const.py:ALL_STATUS +desc2role = { + 'Debian Developer, uploading': 'DD', + 'Debian Developer, non-uploading': 'DN', + 'Debian Maintainer': 'DM', + 'Debian Maintainer, with guest account': 'DM', + 'Debian Contributor': 'DC', + 'Debian Contributor, with guest account': 'DC', + 'Debian Developer, emeritus': 'emeritus', + 'Debian Developer, removed': 'removed', +} + +fp_regex = ("[0-9A-F]{4} [0-9A-F]{4} [0-9A-F]{4} [0-9A-F]{4} [0-9A-F]{4} " + + "[0-9A-F]{4} [0-9A-F]{4} [0-9A-F]{4} [0-9A-F]{4} [0-9A-F]{4}") +fp_regex_nospc = "[0-9A-F]{40}" + +# Global keyid to username/name dict +keyids = {} + + +def get_gpg_ctx(do_import=False): + """ Setup (if necessary) and return a GnuPG context + + Checks if the private GnuPG home directory already exists. If not, + creates it and imports the DD + DN keyrings into it. Also configures + GnuPG to do clean imports (i.e. only include signatures that can be + verified). + """ + if not os.path.isdir(GNUPG_HOME): + os.makedirs(GNUPG_HOME) + do_import = True + + c = gpg.Context() + + c.set_engine_info(gpg.constants.protocol.OpenPGP, + home_dir=GNUPG_HOME) + + if do_import: + for keyring in ['debian-keyring', 'debian-nonupload']: + keyfile = KEYRING_BASE_DIR + 'output/keyrings/' + keyring + '.gpg' + if not os.path.exists(keyfile): + raise RuntimeError(keyfile + " does not exist. " + + "Need to run 'make'?") + keys = gpg.Data(file=keyfile) + c.op_import(keys) + + with open(GNUPG_HOME + 'gpg.conf', 'w') as f: + f.write('import-options import-clean\n') + + return c + + +def fetch_key(ctx, fpr): + """Fetches the supplied fingerprint from a public keyserver + + Does an HKP lookup for the supplied fingerprint, then imports it into + the current keyring. Then does an export (picking up any cleaning done + by the import) and returns the binary key data. + + Note this function does not remove the key from the GnuPG keyring. If + the key is destined for the DM keyring, or subsequently not to be added, + it must be removed by the caller using delete_key(). + """ + gotkey = False + for keyserver in KEYSERVERS: + url = "http://{server}/pks/lookup?{query}".format( + server=keyserver, + query=urlencode({ + "op": "get", + "search": "0x" + fpr, + "exact": "on", + })) + res = requests.get(url) + keytext = [] + for line in res.text.splitlines(): + if line == "-----BEGIN PGP PUBLIC KEY BLOCK-----": + gotkey = True + if gotkey: + keytext.append(line) + if line == "-----END PGP PUBLIC KEY BLOCK-----": + break + if gotkey: + break + + if not gotkey: + raise RuntimeError('Failed to fetch key') + + key = gpg.Data(string="\n".join(keytext)) + ctx.op_import(key) + + key = gpg.Data() + ctx.op_export(fpr, 0, key) + key.seek(0, os.SEEK_SET) + keydata = key.read() + + return keydata + + +def delete_key(ctx, fpr): + """Delete the key matching fpr from the GnuPG keyring""" + keys = list(ctx.keylist(fpr)) + for k in keys: + ctx.op_delete(k, True) + + +def get_keyinfo(ctx, fpr, needsigs=2): + ctx.set_keylist_mode(gpg.constants.keylist.mode.SIGS) + key = ctx.get_key(fpr) + for subkey in key.subkeys: + if subkey.fpr == fpr: + keytype = str(subkey.length) + if subkey.pubkey_algo == gpg.constants.pk.RSA: + keytype += 'R' + elif subkey.pubkey_algo == gpg.constants.pk.DSA: + keytype += 'D' + elif subkey.pubkey_algo == gpg.constants.pk.ECC: + keytype += 'E' + elif subkey.pubkey_algo == gpg.constants.pk.EDDSA: + keytype += 'E' + elif subkey.pubkey_algo == gpg.constants.pk.ELG: + keytype += 'g' + else: + keytype += '?' + + sigs = {} + for uid in key.uids: + # print(uid.name, uid.email) + if not uid.revoked: + for sig in uid.signatures: + if sig.keyid in keyids: + sigs[keyids[sig.keyid]['username']] = 1 + # else: + # print("Skipping unknown ID " + sig.keyid) + + if len(sigs) < needsigs: + print('\nWARNING: Insufficient key signatures, check endorsements\n') + + certs = None + for sig in sorted(sigs.keys()): + if certs: + certs += ', ' + sig + else: + certs = sig + + return (keytype, certs) + + +def read_keyids(): + """Read the keyids file into a dict allowing a username/name mapping""" + with open(KEYRING_BASE_DIR + 'keyids', 'r') as f: + dds = f.readlines() + for dd in dds: + keyid = dd[2:18] + name = dd[19:dd.find('<') - 1] + username = dd[dd.find('<') + 1:] + username = username[:username.find('>')] + keyids[keyid] = { + 'name': name, + 'username': username, + } + + +def write_keyids(): + """Write the sorted keyids username/name dict out to the keyids file""" + with open(KEYRING_BASE_DIR + 'keyids', 'w') as f: + for key in sorted(keyids.keys()): + f.write("0x{} {} <{}>\n".format(key, + keyids[key]['name'], + keyids[key]['username'])) + + +def get_rt_auth(): + """Attempt to locate a valid set of RT login details + + Look for, and return, a set of RT login details. Uses RT_USER/RT_PASS + from the environment, failing back to ~/.rtrc if either is not set. + """ + rt_user = None + rt_pass = None + + if 'RT_USER' in os.environ: + rt_user = os.environ['RT_USER'] + if 'RT_PASS' in os.environ: + rt_pass = os.environ['RT_PASS'] + + if not rt_user or not rt_pass: + with open(os.environ['HOME'] + '/.rtrc', 'r') as f: + for line in f: + if not rt_user and line.startswith('user '): + rt_user = line[5:].strip() + elif not rt_pass and line.startswith('passwd '): + rt_pass = line[7:].strip() + + return (rt_user, rt_pass) + + +def fetch_ticket(rtid): + (rt_user, rt_pass) = get_rt_auth() + + args = { + 'params': { + 'user': rt_user, + 'pass': rt_pass, + } + } + + print("Fetching ticket " + str(rtid)) + + # res = requests.post(RT_BASE_URL + 'ticket/' + str(rtid) + '/show', + # **args) + # Look for "Owner: Nobody" or "Owner: noodles" + # res = requests.post(RT_BASE_URL + 'ticket/' + str(rtid) + '/edit', + # **args) + # "content" variable = "Owner: noodles" + + res = requests.post(RT_BASE_URL + 'ticket/' + str(rtid) + '/attachments', + **args) + + # Validate the RT result + res_lines = res.text.splitlines() + ver, status, text = res_lines[0].split(None, 2) + + if int(status) != 200: + print("RT status code is not 200", res_lines) + return + + attachments = [] + inattachments = False + text = None + signature = None + + for line in res_lines[2:]: + if inattachments: + m = re.match(' +(\d+):', line) + if m: + print('Attachment found, ' + m.group(1)) + attachments.append(int(m.group(1))) + else: + inattachments = False + else: + m = re.match('Attachments: (\d+):', line) + if m: + print('Attachment found, ' + m.group(1)) + attachments.append(int(m.group(1))) + inattachments = True + + for attachment in attachments: + res = requests.post(RT_BASE_URL + 'ticket/' + str(rtid) + + '/attachments/' + str(attachment), + **args) + + # Validate the RT result + res_lines = res.text.splitlines() + ver, status, text = res_lines[0].split(None, 2) + + if int(status) != 200: + print("RT status code is not 200", res_lines) + + incontent = False + message = [] + for line in res_lines[2:]: + if line.startswith('Content: '): + incontent = True + message.append(line[9:]) + elif incontent and line.startswith(' '): + message.append(line[9:]) + elif incontent: + incontent = False + + with get_gpg_ctx() as c: + sig = None + try: + text, result = c.verify(io.BytesIO( + "\n".join(message).encode('utf-8'))) + sig = result.signatures[0] + except gpg.errors.GPGMEError: + text = None + continue + except gpg.errors.BadSignatures as e: + # If a request is signed by multiple keys (such as old + new + # for a replacement) the important thing is one of the + # signatures is valid. + for s in e.results[1].signatures: + if s.status == 0: + text = e.results[0] + sig = s + break + else: + print("Bad signature from", s.fpr) + if sig: + key = c.get_key(sig.fpr) + # print(result.signatures[0].__str__()) + for subkey in key.subkeys: + if subkey.fpr[24:] in keyids: + signature = keyids[subkey.fpr[24:]]['username'] + print("Good signature from " + signature + " (" + + sig.fpr + ")") + + sig_date = datetime.datetime.fromtimestamp(sig.timestamp) + now = datetime.datetime.now() + if (now - sig_date) >= datetime.timedelta(days=SIG_DAYS_WARN): + print(( + "WARNING: Old GPG signature ({formatted_sig_date}, " + "today is {formatted_now}), please verify validity." + ).format( + formatted_sig_date=sig_date.strftime(DATE_FORMAT), + formatted_now=now.strftime(DATE_FORMAT), + )) + break + else: + print("Couldn't verify message.") + + return (signature, text) + + +def parse_ticket(text): + state = {} + + for line in text.decode().split('\n'): + if line.startswith(' Key fingerprint: '): + if line[20:] != 'None': + state['keyid'] = line[20:] + elif line.startswith(' Username: '): + state['username'] = line[20:] + elif line.startswith(' uid: '): + state['username'] = line[20:] + elif line.startswith(' Details: '): + state['details'] = line[20:] + elif line.startswith(' First name: '): + state['first'] = line[20:] + elif line.startswith(' Middle name: '): + state['middle'] = line[20:] + elif line.startswith(' Last name: '): + state['last'] = line[20:] + elif line.startswith(' Current status: '): + if line[20:] not in desc2role: + print('Unknown current status: ' + line[20:]) + else: + state['current'] = desc2role[line[20:]] + elif line.startswith(' Target keyring: '): + if line[20:] not in desc2role: + print('Unknown destination status: ' + line[20:]) + else: + state['dest'] = desc2role[line[20:]] + elif 'details' not in state: + # Try to see if we have a fingerprint on the line and if + # it might be a replacement request + m = re.search(fp_regex, line) + if not m: + m = re.search(fp_regex_nospc, line) + if m: + fp = m.group(0).replace(' ', '') + if fp[24:] in keyids: + state['oldkeyid'] = fp + else: + state['keyid'] = fp + + + # Based on the current + target statuses work out if this is an add or + # remove. + if 'dest' not in state: + # Assume it's a replacement. + for role in role2keyring: + if os.path.exists(role2keyring[role] + '/0x' + + state['oldkeyid'][24:]): + state['role'] = role + break + if 'role' not in state: + state['role'] = 'DD' + state['action'] = 'replace' + state['subject'] = keyids[state['oldkeyid'][24:]]['name'] + state['username'] = keyids[state['oldkeyid'][24:]]['username'] + elif state['dest'] in ['DD', 'DM', 'DN']: + state['action'] = 'add' + state['role'] = state['dest'] + elif state['dest'] in ['DC', 'emeritus', 'removed']: + state['action'] = 'remove' + if 'current' in state: + state['role'] = state['current'] + else: + # Assume DD -> removed as a fall back + state['role'] = 'DD' + + # Collapse first/middle/last to a single name field + if 'first' in state and state['first'] != '-': + state['subject'] = state['first'] + if 'middle' in state and state['middle'] != '-': + if 'subject' in state: + state['subject'] += ' ' + state['middle'] + else: + state['subject'] = state['middle'] + if 'last' in state and state['last'] != '-': + if 'subject' in state: + state['subject'] += ' ' + state['last'] + else: + state['subject'] = state['last'] + + for field in ['details', 'username']: + if field in state and state[field] == '-': + del state[field] + + # Get the key length + type, plus signatures from other DDs + if 'keyid' in state: + with get_gpg_ctx() as c: + if state['action'] in ('add', 'replace') or state['role'] == 'DM': + state['keydata'] = fetch_key(c, state['keyid']) + # If it's a removal we don't need to check signature count. For + # DM we relax the number of signatures, otherwise we use the + # default. + if state['action'] == 'remove': + keyinfo = get_keyinfo(c, state['keyid'], 0) + elif state['role'] == 'DM': + keyinfo = get_keyinfo(c, state['keyid'], 1) + else: + keyinfo = get_keyinfo(c, state['keyid']) + state['keytype'] = keyinfo[0] + state['certs'] = keyinfo[1] + + if 'oldkeyid' in state: + with get_gpg_ctx() as c: + fetch_key(c, state['oldkeyid']) + keyinfo = get_keyinfo(c, state['oldkeyid'], 0) + state['oldkeytype'] = keyinfo[0] + + return state + + +def do_action(state): + if state['action'] == 'remove': + if 'keyid' not in state: + # When inactive DDs with no valid keys are retired, + # there's nothing for us to do + print('-!- Nothing for keyring-maint to do, please reasign ticket') + print('-!- to DSA for account removal.') + sys.exit(-1) + if 'dest' in state and state['dest'] == 'emeritus': + subprocess.call(['git', 'mv', role2keyring[state['role']] + '/0x' + + state['keyid'][24:], + 'emeritus-keyring-gpg/']) + state['logmsg'] = ('Move 0x' + state['keyid'][24:] + + ' (' + state['subject'] + ') to ' + + 'emeritus (RT #' + state['rtid'] + ')') + else: + subprocess.call(['git', 'rm', role2keyring[state['role']] + '/0x' + + state['keyid'][24:]]) + state['logmsg'] = ('Remove 0x' + state['keyid'][24:] + + ' (' + state['subject'] + ')' + + ' (RT #' + state['rtid'] + ')') + if state['role'] in ['DD', 'DN']: + with get_gpg_ctx() as c: + delete_key(c, state['keyid']) + elif state['action'] == 'add': + state['logmsg'] = ('Add new ' + state['dest'] + ' key 0x' + + state['keyid'][24:] + ' (' + state['subject'] + + ') (RT #' + state['rtid'] + ')') + # See if it's just a move from a different keyring + if state['current'] in role2keyring: + subprocess.call(['git', 'mv', + role2keyring[state['current']] + '/0x' + + state['keyid'][24:], + role2keyring[state['dest']]]) + state['notes'] = 'Move from ' + state['current'] + ' keyring' + else: + keyfile = role2keyring[state['dest']] + '/0x' + state['keyid'][24:] + with open(keyfile, 'wb') as f: + f.write(state['keydata']) + subprocess.call(['git', 'add', keyfile]) + + # We don't keep DM keys in the our working keyring + if state['dest'] == 'DM': + with get_gpg_ctx() as c: + delete_key(c, state['keyid']) + keyids[state['keyid'][24:]] = { + 'name': state['subject'], + 'username': state['username'], + } + write_keyids() + subprocess.call(['git', 'add', 'keyids']) + elif state['action'] == 'replace': + state['logmsg'] = ('Replace 0x' + state['oldkeyid'][24:] + ' with 0x' + + state['keyid'][24:] + ' (' + state['subject'] + + ') (RT #' + state['rtid'] + ')') + + keyfile = role2keyring[state['role']] + '/0x' + state['keyid'][24:] + with open(keyfile, 'wb') as f: + f.write(state['keydata']) + subprocess.call(['git', 'add', keyfile]) + subprocess.call(['git', 'rm', role2keyring[state['role']] + '/0x' + + state['oldkeyid'][24:]]) + + # Remove the replaced key + with get_gpg_ctx() as c: + delete_key(c, state['oldkeyid']) + + keyids[state['keyid'][24:]] = { + 'name': state['subject'], + 'username': state['username'], + } + write_keyids() + subprocess.call(['git', 'add', 'keyids']) + else: + print("Don't know how to handle action: " + state['action']) + + +def do_dch(state): + release = "unknown" + with open('debian/changelog', 'r') as f: + line = f.readline() + m = re.match("debian-keyring \((.*)\) (.*); urgency=", line) + version = m.group(1) + release = m.group(2) + if release == "UNRELEASED": + if debug: + print('dch --multimaint-merge -D UNRELEASED -a "' + + state['logmsg'] + '"') + else: + subprocess.call(['dch', '--multimaint-merge', '-D', 'UNRELEASED', + '-a', state['logmsg']]) + elif release == "unstable": + newver = datetime.date.today().strftime("%Y.%m.xx") + if newver == version: + print(' * Warning: New version and previous released version are ') + print(' the same: ' + newver + '. This should not be so!') + print(' Check debian/changelog') + if debug: + print('dch -D UNRELEASED -v ' + newver + ' "' + state['logmsg'] + + '"') + else: + subprocess.call(['dch', '-D', 'UNRELEASED', '-v', newver, + state['logmsg']]) + else: + print("Unknown changelog release: " + release) + + if not debug: + subprocess.call(['git', 'add', 'debian/changelog']) + + +def do_git_template(state): + with open('git-commit-template', 'w') as f: + f.write(state['logmsg']) + f.write('\n\n') + f.write("Action: " + state['action'] + "\n") + f.write("Subject: " + state['subject'] + "\n") + if 'username' in state: + f.write("Username: " + state['username'] + "\n") + f.write("Role: " + state['role'] + "\n") + if state['action'] == 'replace': + f.write("Old-key: " + state['oldkeyid'] + "\n") + f.write("Old-key-type: " + state['oldkeytype'] + "\n") + f.write("New-key: " + state['keyid'] + "\n") + f.write("New-key-type: " + state['keytype'] + "\n") + else: + f.write("Key: " + state['keyid'] + "\n") + f.write("Key-type: " + state['keytype'] + "\n") + f.write("RT-Ticket: " + state['rtid'] + "\n") + f.write("Request-signed-by: " + state['requester'] + "\n") + if state['action'] != 'remove' and state['certs'] != None: + prefix = 'Key-certified-by: ' + prefixlen = len(prefix) + certs = state['certs'] + while (len(certs) + prefixlen) > 72: + last = certs.rfind(',', 0, 72 - prefixlen) + 1 + f.write(prefix + certs[:last] + "\n") + certs = certs[last:] + prefix = ' ' + prefixlen = 1 + f.write(prefix + certs + "\n") + if 'details' in state: + f.write("Details: " + state['details'] + "\n") + if state['role'] == 'DM' and 'agreement' in state: + f.write("Advocates:\n") + for a in state['advocates']: + f.write(" " + a + "\n") + f.write("Agreement: " + state['agreement'] + "\n") + f.write("BTS: " + state['bts'] + "\n") + if 'notes' in state: + f.write('Notes: ' + state['notes'] + '\n') + + +if len(sys.argv) < 2: + print('Must supply RT ticket to process.') + sys.exit(-1) + +# Change to the keyring dir so that git etc work +os.chdir(KEYRING_BASE_DIR) + +read_keyids() +(requester, ticket) = fetch_ticket(sys.argv[1]) +if ticket is None: + print('No signature on ticket.') + sys.exit(-1) +if requester is None: + print('Signature from unknown key.') + sys.exit(-1) +state = parse_ticket(ticket) +state['rtid'] = sys.argv[1] +state['requester'] = requester +if state['action'] == 'add': + if requester not in FD: + print('Signature for add must come from Front Desk.') + sys.exit(-1) +# Output our state for confirmation, without the keydata +pp = pprint.PrettyPrinter() +s = dict(state) +if 'keydata' in s: + del s['keydata'] +pp.pprint(s) +proceed = input("Do you wish to proceed? [y/n]: ") +if proceed.lower() not in ['y', 'yes']: + print("Aborting.") + # If this was an add we need to make sure the key isn't in our keyring + if state['action'] in ['add', 'replace']: + delete_key(get_gpg_ctx(), state['keyid']) + sys.exit(-1) +do_action(state) +do_dch(state) +do_git_template(state) diff --git a/scripts/pull-updates b/scripts/pull-updates new file mode 100755 index 0000000..1d49e08 --- /dev/null +++ b/scripts/pull-updates @@ -0,0 +1,90 @@ +#!/bin/sh + +if [ -z "$1" ]; then + echo "Usage: pull-updates keyring [dir | keyring]" >&2 + exit 1 +fi + +# avoid gnupg touching ~/.gnupg +GNUPGHOME=$(mktemp -d -t jetring.XXXXXXXX) +export GNUPGHOME +cat > "$GNUPGHOME"/gpg.conf <<EOF +keyid-format 0xlong +import-options import-clean,merge-only +export-options export-clean,no-export-attributes +no-auto-check-trustdb +no-autostart +quiet +EOF +trap cleanup exit +cleanup () { + rm -rf "$GNUPGHOME" +} + +if [ ! -e output/keyrings/debian-keyring.gpg ]; then + echo "Keyrings don't appear to be built. Run make?" + exit 1 +fi + +# Build a set of keyrings +cat output/keyrings/debian-keyring.gpg output/keyrings/debian-nonupload.gpg \ + output/keyrings/debian-maintainers.gpg > $GNUPGHOME/pubring.gpg + +mkdir updates/ +if [ ! -z "$2" -a -d "$2" ]; then + # Old style with directory as second parameter + scripts/explode-keyring $1 updates +else + # New style. Keyrings all the way. + touch update-keyring.gpg + echo Exploding keyrings + for keyring in $*; do + scripts/explode-keyring $keyring updates + cd updates + for i in 0x*; do + if [ ! -e ../debian-*-gpg/$i ]; then + echo $i no longer exists, removing. + rm $i + elif cmp -s $i ../debian-*-gpg/$i; then + echo $i matches old key version, removing. + rm $i + fi + done + cat 0x* >> ../update-keyring.gpg + rm 0x* + cd .. + done + echo Importing updates + gpg --import update-keyring.gpg + echo Exploding keyring + for key in $(gpg --list-keys --with-colons < update-keyring.gpg | awk -F: '/^pub/ {print $5}'); do + gpg --export 0x$key > updates/0x$key + done + rm update-keyring.gpg +fi + +cd updates +for i in 0x*; do + if [ ! -e ../debian-*-gpg/$i ]; then + echo $i no longer exists, removing. + rm $i + elif cmp -s $i ../debian-*-gpg/$i; then + echo $i matches old key version, removing. + rm $i + fi +done + +echo Updated keys are: +ls + +cd .. + +for i in updates/0x*; do + if [ -f $i ]; then + scripts/update-key --no-clean $i \ + $(dirname debian-*-gpg/$(basename $i)) + rm $i + fi +done + +rmdir updates/ diff --git a/scripts/replace-key b/scripts/replace-key new file mode 100755 index 0000000..caad499 --- /dev/null +++ b/scripts/replace-key @@ -0,0 +1,178 @@ +#!/bin/bash + +# Copyright (c) 2014 Gunnar Wolf <gwolf@debian.org>, +# Based on 2008 Jonathan McDowell <noodles@earth.li> +# GNU GPL; v2 or later +# Replaces an existing key with a new one in its same keyring directory + +set -e + +if [ -z "$1" ] || [ -z "$2" ]; then + echo "Usage: replace-key oldkeyid newkeyid" >&2 + exit 1 +fi + +scriptdir=`dirname $0` +oldkey=$1 +newkey=$2 + +# avoid gnupg touching ~/.gnupg +GNUPGHOME=$(mktemp -d -t jetring.XXXXXXXX) +export GNUPGHOME +cat > "$GNUPGHOME"/gpg.conf <<EOF +keyid-format 0xlong +keyserver pgpkeys.eu +no-autostart +EOF +trap cleanup exit +cleanup () { + rm -rf "$GNUPGHOME" +} + +newkeytemp=`mktemp -t newkey.XXXXXXXXX` +gpgconf --launch dirmngr +gpg --recv-key "$newkey" +gpg --no-auto-check-trustdb --options /dev/null \ + --keyring output/keyrings/debian-keyring.gpg \ + --keyring output/keyrings/debian-nonupload.gpg \ + --keyring output/keyrings/debian-maintainers.gpg \ + --export-options export-clean,no-export-attributes \ + --export "$newkey" > $newkeytemp + +# strip leading 0x from fingerprints +oldkey=${oldkey##0x} +newkey=${newkey##0x} + +if [ $(echo -n $oldkey|wc -c) -eq 16 ]; then + key='0x'$(echo $oldkey|tr a-z A-Z) +elif [ $(echo -n $oldkey|wc -c) -eq 40 ] ; then + key='0x'$(echo -n $oldkey | cut -b 25-) +else + echo "Please supply either a long keyid or a full fingerprint for the old key." + exit 1 +fi + +for dir in *-gpg/; do + if [ -f $dir/$key ]; then + oldkeyfile=$(readlink -f "$dir/$key") + keydir=$(readlink -f $dir) + keyring=`basename $keydir` + break + fi +done + +if [ -z "$oldkeyfile" -o -z "$keydir" ]; then + echo "Requested key '$oldkey' not found (looked for '*-gpg/$key')" + exit 1 +fi + +oldkeyfp=$(gpg --with-colons --fingerprint --no-auto-check-trustdb --no-default-keyring --keyring $oldkeyfile| grep '^fpr' | cut -d : -f 10) +newkeyfp=$(gpg --with-colons --fingerprint --no-auto-check-trustdb --no-default-keyring --keyring $newkeytemp| grep '^fpr' | cut -d : -f 10) + +oldkeydata=$(gpg --with-colons --keyid long --options /dev/null --no-auto-check-trustdb < $oldkeyfile|grep '^pub') +newkeydata=$(gpg --with-colons --keyid long --options /dev/null --no-auto-check-trustdb < $newkeytemp|grep '^pub') +oldkeyuser=$(echo $oldkeydata | cut -d : -f 10) +newkeyuser=$(echo $newkeydata | cut -d : -f 10) +oldkeylen=$(echo $oldkeydata | cut -d : -f 3) +newkeylen=$(echo $newkeydata | cut -d : -f 3) +oldkeyalg=$(echo $oldkeydata | cut -d : -f 4) +if [ "$oldkeyalg" == "1" ]; then + oldkeyalg='R' +elif [ "$oldkeyalg" == "17" ]; then + oldkeyalg='D' +elif [ "$oldkeyalg" == "22" ]; then + oldkeyalg='E' +else + oldkeyalg='UNK' +fi +newkeyalg=$(echo $newkeydata | cut -d : -f 4) +if [ "$newkeyalg" == "1" ]; then + newkeyalg='R' +elif [ "$newkeyalg" == "17" ]; then + newkeyalg='D' +elif [ "$oldkeyalg" == "22" ]; then + oldkeyalg='E' +else + newkeyalg='UNK' +fi +echo $oldkeydata + +echo "" +echo "About to replace key $oldkey ($oldkeyuser)" +echo " with NEW key $newkey ($newkeyuser)" +echo " in the $keyring keyring." +echo "Are you sure you want to update this key? (y/n)" +read n + +if [ "x$n" = "xy" -o "x$n" = "xY" ]; then + destkeyring="$keyring" + if ! $scriptdir/add-key $newkeytemp $destkeyring ; then + echo "add-key failed" + exit 1 + fi + + if [ "$keyring" = "debian-keyring-gpg" -o "$keyring" = "debian-nonupload-gpg" ]; then + name=`grep $newkey keyids | sed 's/^[^ ]* //'|sed s/\<.*//` + account=`grep $newkey keyids | sed 's/.*\<//'|sed s/\>$//` + if [ "$keyring" = "debian-nonupload-gpg" ]; then + role='DD-NU' + else + role='DD' + fi + elif [ "$keyring" = "debian-maintainers-gpg" ]; then + echo -n "Enter full name of new key: " + read name + role='DM' + else + echo "*** Key to be replaced is of a strange type (not DD, NonUplDD, DM)" + echo " Be sure you are doing the right thing before committing. Double-check" + echo " the log message, as it will most likely not be correct." + name="Unknown" + fi + echo -n 'RT issue ID this change closes, if any: ' + read rtid + name=$(echo $name | sed -r 's/^ *(.*) *$/\1/') + + log="Replace 0x$oldkey with 0x$newkey ($name) (RT #$rtid)" + + git rm $oldkeyfile + VERSION=$(head -1 debian/changelog | awk '{print $2}' | sed 's/[\(\)]//g') + RELEASE=$(head -1 debian/changelog | awk '{print $3}' | sed 's/;$//') + case $RELEASE in + UNRELEASED) + dch --multimaint-merge -D UNRELEASED -a "$log" + ;; + unstable) + NEWVER=$(date +%Y.%m.xx) + if [ "$VERSION" = "$NEWVER" ] + then + echo '* Warning: New version and previous released version are' + echo " the same: $VERSION. This should not be so!" + echo ' Check debian/changelog' + fi + dch -D UNRELEASED -v $NEWVER "$log" + ;; + *) + echo "Last release $VERSION for unknown distribution «$RELEASE»." + echo "Not calling dch, do it manually." + ;; + esac + git add debian/changelog + + cat > git-commit-template <<EOF +$log + +Action: replace +Subject: $name +Username: $account +Role: $role +Old-key: $oldkeyfp +Old-key-type: $oldkeylen$oldkeyalg +New-key: $newkeyfp +New-key-type: $newkeylen$newkeyalg +RT-Ticket: $rtid +Request-signed-by: \$oldkey +New-key-certified-by: \$oldkey, +EOF + +fi diff --git a/scripts/revoke-key b/scripts/revoke-key new file mode 100755 index 0000000..db0c37c --- /dev/null +++ b/scripts/revoke-key @@ -0,0 +1,52 @@ +#!/bin/sh + +# Copyright (c) 2008 Jonathan McDowell <noodles@earth.li> +# GNU GPL; v2 or later +# Imports a standalone revocation certificate + +set -e + +if [ -z "$1" ] || [ -z "$2" ]; then + echo "Usage: revoke-key revocationcertfile dir" >&2 + exit 1 +fi + +# avoid gnupg touching ~/.gnupg +GNUPGHOME=$(mktemp -d -t jetring.XXXXXXXX) +export GNUPGHOME +trap cleanup exit +cleanup () { + rm -rf "$GNUPGHOME" +} + +revfile=$(readlink -f "$1") # gpg works better with absolute keyring paths +keydir="$2" + +basename=$(basename "$revfile") +date=`date -R` + +keyid=$(gpg --with-colons --keyid long --options /dev/null --no-auto-check-trustdb < $keyfile | grep '^pub' | cut -d : -f 5) + +if [ ! -e $keydir/0x$keyid ]; then + echo "0x$keyid isn't already in $keydir - new key or error." + exit 1 +fi + +gpg --import $keydir/0x$keyid +gpg --import $revfile +gpg --no-auto-check-trustdb --options /dev/null \ + --export-options export-minimal,no-export-attributes \ + --export $keyid > $GNUPGHOME/0x$keyid + +echo "Running gpg-diff:" +scripts/gpg-diff $keydir/0x$keyid $GNUPGHOME/0x$keyid + +echo "Are you sure you want to update this key? (y/n)" +read n + +if [ "x$n" = "xy" -o "x$n" = "xY" ]; then + mv $GNUPGHOME/0x$keyid $keydir/0x$keyid + echo "Updated key." +else + echo "Not updating key." +fi diff --git a/scripts/update-key b/scripts/update-key new file mode 100755 index 0000000..5ca2a9c --- /dev/null +++ b/scripts/update-key @@ -0,0 +1,95 @@ +#!/bin/sh + +# Copyright (c) 2008 Jonathan McDowell <noodles@earth.li> +# GNU GPL; v2 or later +# Updates an existing key in a keyring dir + +set -e + +if [ "x$1" = "x--no-clean" ]; then + NOCLEAN=1 + shift +else + NOCLEAN=0 +fi + + +if [ -z "$1" ] || [ -z "$2" ]; then + echo "Usage: update-key keyfile dir" >&2 + exit 1 +fi + +# avoid gnupg touching ~/.gnupg +GNUPGHOME=$(mktemp -d -t jetring.XXXXXXXX) +export GNUPGHOME +cat > "$GNUPGHOME"/gpg.conf <<EOF +keyid-format 0xlong +no-auto-check-trustdb +no-autostart +EOF + +if [ "$NOCLEAN" = "0" ]; then + echo "import-options import-clean" >> "$GNUPGHOME"/gpg.conf + echo "export-options export-clean,no-export-attributes" >> "$GNUPGHOME"/gpg.conf + cat output/keyrings/debian-keyring.gpg \ + output/keyrings/debian-nonupload.gpg \ + output/keyrings/debian-maintainers.gpg > $GNUPGHOME/pubring.gpg +fi + +trap cleanup exit +cleanup () { + rm -rf "$GNUPGHOME" +} + +keyfile=$(readlink -f "$1") # gpg works better with absolute keyring paths +keydir="$2" + +basename=$(basename "$keyfile") +date=`date -R` + +keyid=$(gpg --quiet --with-colons < $keyfile | grep '^pub' | head -n 1 | cut -d : -f 5) +name=$(gpg --quiet --with-colons < $keyfile | grep '^uid' | head -n 1 | cut -d : -f 10 | sed -e 's/ <.*//') + +if [ ! -e $keydir/0x$keyid ]; then + echo "0x$keyid isn't already in $keydir - new key or error." + exit 1 +fi + +gpg --quiet --import $keydir/0x$keyid +summary=$(gpg --import $keyfile 2>&1 | \ + scripts/parse-gpg-update 0x$keyid "$name") +gpg --export $keyid > $GNUPGHOME/0x$keyid + +case "$summary" in + *:*) + # Something changed + ;; + *) + # Nothing changed, exit + echo "No changes to $summary" + exit +esac + +if cmp -s $GNUPGHOME/0x$keyid $keydir/0x$keyid; then + echo "No changes to 0x$keyid" + exit +fi + +echo "Running gpg-diff:" +( + echo $summary + echo + scripts/gpg-diff $keydir/0x$keyid $GNUPGHOME/0x$keyid +) | sensible-pager + +echo "Are you sure you want to update this key? (y/n)" +read n + +if [ "x$n" = "xy" -o "x$n" = "xY" ]; then + mv $GNUPGHOME/0x$keyid $keydir/0x$keyid + git add $keydir/0x$keyid + echo "Updated key." + echo $summary >> update.log +else + echo "Not updating key." +fi diff --git a/scripts/update-keyrings b/scripts/update-keyrings new file mode 100755 index 0000000..8e5ed6f --- /dev/null +++ b/scripts/update-keyrings @@ -0,0 +1,405 @@ +#!/usr/bin/python3 + +# Authors: Daniel Kahn Gillmor <dkg@fifthhorseman.net>, +# Gunnar Wolf <gwolf@debian.org>, +# Jonathan McDowell <noodles@earth.li> +# License: Parts from dkg are GPLv3+ + +import os +from os import path +import socket +from subprocess import run, Popen, PIPE +from shutil import chown, copy, copyfile, rmtree +from distutils.dir_util import copy_tree +import sys +import tempfile +import hashlib +import codecs +from multiprocessing.pool import ThreadPool +from typing import List, Tuple, Optional +import datetime +from email.utils import parseaddr + + +def check_environ(should_run_on: str = 'kaufmann.debian.org') -> None: + '''Make sure that we are running where we expect to run + + The expectation is to run on kaufmann.debian.org, but this can be + bypassed for testing with the RUNANYWAY environment variable. + ''' + if not (os.environ.get('RUNANYWAY', False) or + socket.getfqdn(socket.gethostname()) == should_run_on): + raise Exception(''' +This script is meant to be run in %s +You can still run it if you are sure by setting +$RUNANYWAY to a nonempty value. + ''' % (should_run_on)) + + +def wkd_localpart(incoming: bytes) -> str: + '''Z-base32 the localpart of an e-mail address + + https://tools.ietf.org/html/draft-koch-openpgp-webkey-service-08#section-3.1 + describes why this is needed. + + See https://tools.ietf.org/html/rfc6189#section-5.1.6 for a + description of the z-base32 scheme. + ''' + zb32 = "ybndrfg8ejkmcpqxot1uwisza345h769" + + b = hashlib.sha1(incoming).digest() + ret = "" + assert(len(b) * 8 == 160) + for i in range(0, 160, 5): + byte = i // 8 + offset = i - byte * 8 + # offset | bits remaining in k+1 | right-shift k+1 + # 3 | 0 | x + # 4 | 1 | 7 + # 5 | 2 | 6 + # 6 | 3 | 5 + # 7 | 4 | 4 + if offset < 4: + n = (b[byte] >> (3 - offset)) + else: + n = (b[byte] << (offset - 3)) + (b[byte + 1] >> (11 - offset)) + + ret += zb32[n & 0b11111] + return ret + + +def getdomainlocalpart(line: bytes, domain: bytes) -> Optional[bytes]: + 'Get the localpart of the e-mail address of a GnuPG user ID line matching DOMAIN' + if line.startswith(b'uid:'): + uid = line.split(b':')[9] + uid = uid.decode() + _name, addr = parseaddr(uid) # if parsing fails, this returns ('', '') + localpart, _at, thisdomain = addr.rpartition('@') + if thisdomain == domain.decode(): + return localpart.lower().encode() + return None + + +def gpgbase(keyrings: List[str]) -> List[str]: + 'Return the standard set of options to invoke gpg in an automated way' + return ['gpg', '--batch', '--no-options', '--with-colons', + '--no-default-keyring', + '--homedir=/dev/null', '--trust-model=always', + '--fixed-list-mode'] + list(map(lambda k: '--keyring=' + k, keyrings)) + + +def emit_wkd_and_return_dane(localpart: bytes, domain: str, keyrings: List[str]) -> bytes: + '''For a given address, emit the WKD file, and return the DANE OPENPGKEY record + + These are handled differently because we want to generate a + single, reproducible zonefile for the DNS, while we are generating + a tree of files for WKD. + + The caller will assemble all of the OPENPGPKEY records into a + single zonefile. + ''' + wkdstr = wkd_localpart(localpart) + # what do we do if this local part is not a proper encoding? + addr = codecs.decode(localpart) + '@' + domain + cmd = gpgbase(keyrings) + ['--output', + path.join('openpgpkey', domain, 'hu', wkdstr), + '--export-options', + 'export-clean', + '--export-filter', + 'keep-uid=mbox=' + addr, + '--export', + '<' + addr + '>'] + run(cmd, check=True) + cmd = gpgbase(keyrings) + ['--export-options', 'export-dane,export-clean', + '--export-filter', 'keep-uid=mbox=' + addr, + '--export', '<' + addr + '>'] + dane = run(cmd, stdout=PIPE, check=True) + return dane.stdout + + +def build_wkd_and_dane(domain: str, keyrings: List[str]) -> None: + 'Publish WKD and DANE OPENPGPKEY for all domain-relevant OpenPGP certificates' + if not path.isdir('openpgpkey'): + os.mkdir('openpgpkey') + os.mkdir(path.join('openpgpkey', domain)) + os.mkdir(path.join('openpgpkey', domain, 'hu')) + + # FIXME: deal with IDN: + bytedomain = codecs.encode(domain) + + lister = Popen(gpgbase(keyrings) + + ['--list-keys', '@' + domain], stdout=PIPE) + + localparts = set( + map(lambda x: getdomainlocalpart(x, bytedomain), lister.stdout)) + localparts.discard(None) + + dane_map = {} + + def runner(x: bytes) -> Tuple[bytes, bytes]: + return (x, emit_wkd_and_return_dane(x, domain, keyrings)) + + def add_to_zone(res: Tuple[bytes, bytes]) -> None: + dane_map[res[0]] = res[1] + + pool = ThreadPool(None) + for localpart in localparts: + pool.apply_async(runner, (localpart,), {}, add_to_zone) + + pool.close() + pool.join() + # make the policy file: + policyfile = open(path.join('openpgpkey', domain, 'policy'), 'wb') + del policyfile + # write out the zonefile all at once, ordered by the localpart + with open(path.join('_openpgpkey.' + domain + '.zone'), 'wb') as zonefile: + when = datetime.datetime.now() + # FIXME: inspect serial number from existing zonefile -- + # update serial number if it was from the same day + serial = 0 + zonefile.write(openpgpkey_zonefile_header(when, serial)) + for local in sorted(dane_map.keys()): + zonefile.write(dane_map[local]) + + +def fix_perms(path: str) -> None: + '''Fix the permissions of a given directory + + Ensures all files/directories are owned and writeable by the keyring group. + Additionally they must be readable by all and directories executable. + ''' + try: + chown(path, group="keyring") + os.chmod(path, 0o775) + except: + pass + for root, dirs, files in os.walk(path): + for cur in dirs: + try: + chown(os.path.join(root, cur), group="keyring") + except: + pass + try: + os.chmod(os.path.join(root, cur), 0o775) + except: + pass + for cur in files: + try: + chown(os.path.join(root, cur), group="keyring") + except: + pass + try: + os.chmod(os.path.join(root, cur), 0o664) + except: + pass + + +def publish(srcdir: str, + where: str = None) -> None: + '''Verify new keyrings in srcdir; if ok, then publish at where. + + Verification ensures that the new keyrings are signed-off by a + member of debian's keyring-maint team. + + Publication consists of verifying the keyrings, placing them where + onak can find them, produce a zonefile for OPENPGPKEY DANE + records, and a tree of files for WKD. + + ''' + if where is None: + prefix = os.environ.get('PREFIX', '/srv/keyring.debian.org') + else: + prefix = where + pendingdir = path.join(prefix, 'pending-updates') + hkpdir = path.join(prefix, 'keyrings-new') + outputdir = path.join(prefix, 'pub') + for direc in [srcdir, pendingdir, hkpdir, outputdir]: + if not path.isdir(direc): + raise Exception("%s is not a directory" % (direc)) + srcdir = path.realpath(srcdir) + sha512fname = path.join(srcdir, 'sha512sums.txt') + if not path.exists(sha512fname): + raise Exception('sha512sums.txt not found in %s' % (srcdir)) + placeholder = path.join(srcdir, 'keyrings', '.placeholder') + if path.exists(placeholder): + os.unlink(placeholder) + # gpgv needs the keyring in the filesystem, not just a file + # descriptor (https://dev.gnupg.org/T4608) + with tempfile.NamedTemporaryFile() as maint_keyring: + maint_keyring.write(keyring_maint_keys()) + gpgvcall = [ + 'gpgv', + '--enable-special-filenames', + '--keyring', + maint_keyring.name, + '--output', + '-', + sha512fname] + gpgvout = run(gpgvcall, stderr=PIPE, stdout=PIPE) + if gpgvout.returncode != 0: + raise Exception("gpg verification failed:\n%s" % + (codecs.decode(gpgvout.stderr))) + os.chdir(srcdir) + files_to_check = set( + path.join('keyrings', x + '.gpg') for x in [ + 'debian-keyring', + 'debian-maintainers', + 'debian-nonupload', + 'debian-role-keys', + 'emeritus-keyring']) + unexpected_files = set() + for line in filter(lambda x: x, codecs.decode(gpgvout.stdout).split('\n')): + (indigest, fname) = line.split() + with open(fname, 'rb') as f: + data = f.read() + digest = hashlib.new('sha512', data=data).hexdigest() + if digest != indigest: + raise Exception( + 'mismatched digest for %s.\nWanted: %s\nGot: %s' % + (fname, indigest, digest)) + if fname in files_to_check: + files_to_check.remove(fname) + else: + unexpected_files.add(fname) + if files_to_check: + raise Exception('No sha512 digest found for: %s' % (files_to_check)) + if unexpected_files: + print( + 'unexpected files (maybe add them to files_to_check):', + unexpected_files) + + keyrings = ['keyring', 'maintainers', 'nonupload'] + for kname in keyrings: + kfile = path.join(pendingdir, 'debian-%s.gpg' % (kname)) + if path.exists(kfile): + raise Exception( + 'Unhandled pending updates.\nKeyrings in %s should be dealt with and removed' % + (pendingdir)) + + for kname in keyrings: + kfile = path.join(hkpdir, 'debian-%s.gpg' % (kname)) + copy(kfile, pendingdir) + + print('Updating active keyrings.') + copy_tree(srcdir, outputdir) + fix_perms(outputdir) + print('Updating HKP keyrings.') + for kname in keyrings: + kfile = path.join(srcdir, 'keyrings', 'debian-%s.gpg' % (kname)) + dst = os.path.join(hkpdir, os.path.basename(kfile)) + copyfile(kfile, dst) + print('Publishing WKD and DANE data (may take a few minutes).') + with tempfile.TemporaryDirectory(prefix='pub_staging_', dir=prefix) as wkd_staging: + os.chdir(wkd_staging) + + def dkeyring(name: str) -> str: + return path.join(srcdir, 'keyrings', 'debian-' + name + '.gpg') + build_wkd_and_dane('debian.org', + [dkeyring(x) for x in [ + 'nonupload', + 'keyring', + 'role-keys']]) + wkd_deploy_path = path.join(prefix, 'openpgpkey') + # not quite an atomic move: + if path.isdir(wkd_deploy_path): + os.rename(wkd_deploy_path, 'openpgpkey.old') + os.rename('openpgpkey', wkd_deploy_path) + fix_perms(wkd_deploy_path) + os.rename( + '_openpgpkey.debian.org.zone', + path.join(prefix, '_openpgpkey.debian.org.zone')) + fix_perms(path.join(prefix, '_openpgpkey.debian.org.zone')) + os.chdir(srcdir) + run(['static-update-component', 'openpgpkey.debian.org'], check=True) + run(['sudo', 'service', 'bind9', 'reload'], check=True) + + +def openpgpkey_zonefile_header(timestamp: datetime.datetime = None, sequence: int = 0) -> bytes: + '''Return static DNS RRs for _openpgpkey.debian.org + + These records were suggested by the Debian System Administration + (DSA) team. + ''' + if timestamp is None: + timestamp = datetime.datetime.now() + return b'''; zonefile for OPENPGPKEY records (RFC 7929) for debian.org +_openpgpkey.debian.org. 3600 IN SOA kaufmann.debian.org. hostmaster.debian.org. ( + %d%02d ; serial + 1800 ; refresh (30 minutes) + 600 ; retry (10 minutes) + 1814400 ; expire (3 weeks) + 600 ; minimum (10 minutes) + ) +_openpgpkey.debian.org. 28800 IN NS sec2.rcode0.net. +_openpgpkey.debian.org. 28800 IN NS nsp.dnsnode.net. +_openpgpkey.debian.org. 28800 IN NS sec1.rcode0.net. + +''' % (int(timestamp.strftime('%Y%m%d')), sequence) + + +def keyring_maint_keys() -> bytes: + '''Extract keyring-maint keys from the local system keyrings. + +On DSA-managed hosts, /srv/keyring.debian.org/keyrings is more recent +and up-to-date so we prefer it. On other hosts that have the +debian-keyring package installed, we can fall back to it. + ''' + keyring_locations = [ + '/srv/keyring.debian.org/keyrings', + '/usr/share/keyrings'] + keyrings = ['debian-keyring.gpg', 'debian-nonupload.gpg'] + keyring_maint_uids = ['Jonathan McDowell <noodles@earth.li>', + 'William John Sullivan <johns@debian.org>', + 'Gunnar Wolf <gwolf@debian.org>', + 'Daniel Kahn Gillmor <dkg@debian.org>'] + keyring_files = None + for loc in keyring_locations: + possible_keyrings = [path.join(loc, k) for k in keyrings] + if path.isdir(loc) and all( + map(lambda k: path.exists(k), possible_keyrings)): + keyring_files = possible_keyrings + break + + if keyring_files is None: + raise Exception( + "Could not find keyrings to extract keyring-maint keys") + + gpgcmd = ['gpg', + '--batch', + '--homedir', + '/dev/null', + '--no-options', + '--no-default-keyring', + '--export-options', + 'export-minimal'] + for k in keyring_files: + gpgcmd += ['--keyring', k] + gpgcmd += ['--export'] + gpgcmd += ['=' + u for u in keyring_maint_uids] + + return run(gpgcmd, stdout=PIPE, check=True).stdout + + +if __name__ == '__main__': + if len(sys.argv) < 2: + raise Exception('Must provide directory containing new keyrings.') + elif len(sys.argv) > 2: + sys.argv.pop(0) + subcommand = sys.argv.pop(0) + if subcommand != 'build-wkd': + raise Exception("do not know this subcommand: %s" % (subcommand)) + if len(sys.argv): + domain = sys.argv.pop(0) + else: + domain = 'debian.org' + if len(sys.argv): + keys = sys.argv + else: + keys = ['/usr/share/keyrings/debian-nonupload.gpg', + '/usr/share/keyrings/debian-keyring.gpg', + '/usr/share/keyrings/debian-role-keys.gpg'] + build_wkd_and_dane(domain, keys) + else: + # standard update-keyrings + check_environ() + publish(sys.argv[1]) diff --git a/scripts/update-ldap b/scripts/update-ldap new file mode 100755 index 0000000..02b9b0f --- /dev/null +++ b/scripts/update-ldap @@ -0,0 +1,82 @@ +#!/usr/bin/python3 + +# Author: Daniel Kahn Gillmor <dkg@fifthhorseman.net> +# Date: 2014-08-30 +# License: GPLv3+ + +# For doing keyring-maint tasks with debian LDAP + +import ldap +import getpass +import sys + +if sys.argv.__len__() != 2 or sys.argv[1] in ['-h', '--help', 'help']: + print('''Usage: update-ldap <FILENAME> + +FILENAME should be a simple text file, utf8 encoded, where each line +is a key replacement with three fields separated by a single space: + +uid oldfpr newfpr +''') + exit(1) + + + +class debldap: + def __init__(self): + self.l = ldap.initialize("ldaps://db.debian.org") + + def auth(self, uid, password): + self.l.simple_bind_s("uid={uid},ou=users,dc=debian,dc=org".format(uid=uid),password) + + def changefpr(self, uid, oldfpr, newfpr): + dn = "uid={uid},ou=users,dc=debian,dc=org".format(uid=uid) + objs = self.l.search_s(dn, ldap.SCOPE_SUBTREE, "objectclass=*") + if not objs: + raise BaseException("No objects found matching {dn}".format(dn=dn)) + for o in objs: + if o[0] != dn: + raise BaseException("Weird/unexpected dn {new} (expected {old})".format(new=o[0], old=dn)) + fprs = o[1]['keyFingerPrint'] + if fprs != [oldfpr.encode('ascii')]: + raise BaseException("old fingerprint was {found}, but we expected {oldfpr}".format(found=fprs, oldfpr=oldfpr)) + self.l.modify_s(dn, [(ldap.MOD_REPLACE, 'keyFingerPrint', [newfpr.encode('ascii')])]) + + + +f = open(sys.argv[1]) + +x = debldap() +username = getpass.getuser() + +try: + passwd = getpass.getpass('Debian LDAP password for {user}: '.format(user=username)) + x.auth(username, passwd) + bound = True +except BaseException as e: + print("Failed to authenticate: {m}".format(m=e.message)) + exit(1) + +errors = [] +lineno = 0 +successes = 0 +for line in f: + lineno += 1 + user = '<unknown>' + try: + data = line.strip('\n').split(' ') + if data.__len__() != 3: + raise BaseException("ignoring malformed line: {line}\n".format(lineno=lineno, line=line)) + user = data[0] + x.changefpr(user, data[1], data[2]) + successes += 1 + except BaseException as e: + print("{lineno}: {user}: {message}".format(lineno=lineno, user=user,message=str(e.message).strip())) + errors.append((lineno, e)) + + +print("{errors} errors, {successes} successfully processed".format(successes=successes, errors=errors.__len__())) + +if errors: + exit(1) + |