#!/usr/bin/perl -wT # # $Id: manage-authkeys.pl,v 1.28 2003/01/13 05:28:42 jmates Exp $ # # Copyright (c) 2002-2003, Jeremy Mates. This script is free # software; you can redistribute it and/or modify it under the same # terms as Perl itself. # # Run perldoc(1) on this file for additional documentation. # ###################################################################### # # REQUIREMENTS require 5; use strict; # clean up env for taint mode ("perldoc perlsec" for more information) sub BEGIN { delete @ENV{qw(IFS CDPATH ENV BASH_ENV)}; $ENV{'PATH'} = '/bin:/usr/bin:/usr/local/bin'; } ###################################################################### # # MODULES use Carp; # better error reporting use Getopt::Std; # command line option processing use Digest::MD5 qw(md5); # Perl interface to the MD5 Algorithm use Fcntl qw(:DEFAULT :flock); # file locking use Sys::Syslog; # interface to the UNIX syslog(3) calls ###################################################################### # # VARIABLES my $VERSION; ($VERSION = '$Revision: 1.28 $ ') =~ s/[^0-9.]//g; my (%opts); # path to authorized_keys file. OpenSSH >= 3.0 should merge old # authorized_keys2 files into authorized_keys. my $auth_key_file = "~/.ssh/authorized_keys"; # to untaint filename with my $auth_key_file_chars = qr"^([\w\s.~/-]+)$"; # filehandle of auth_key_file my $akfh; # where auth keys are stored in memory (Data::Dumper it for format) my $data; # registered commands my %command = ( 'list' => \&do_list, 'write' => \&do_write, 'delete' => \&do_delete, 'add' => \&do_add, 'dump' => \&do_dump, 'alter' => \&do_alter ); ###################################################################### # # MAIN # parse command-line options getopts('h?f:l:aiqL', \%opts); help() if exists $opts{'h'} or exists $opts{'?'}; $auth_key_file = $opts{'f'} if exists $opts{'f'}; # do ~ expansion on filename $auth_key_file =~ s{ ^ ~ ( [^/]* ) } { $1 ? (getpwnam($1))[7] : ( $ENV{HOME} || $ENV{LOGDIR} || (getpwuid($>))[7] ) }ex; # untaint filename if ($auth_key_file =~ $auth_key_file_chars) { $auth_key_file = $1; } else { die "Invalid characters in filename.\n"; } # open with locking auth_key_file unless (exists $opts{'L'}) { open $akfh, "+< $auth_key_file" or remark('alert', "problem opening authorized key file: $!") and die; unless (flock $akfh, LOCK_EX | LOCK_NB) { remark('notice', "waiting for write lock on $auth_key_file"); flock $akfh, LOCK_EX or remark('alert', "write lock on $auth_key_file failed: $!") and die; } } else { open $akfh, "+< $auth_key_file" or remark('alert', "problem opening authorized key file: $!") and die; } # read in authorized keys to data structure while (<$akfh>) { # see sshd(8) for expected format of auth_key_file next if m/^#/ or m/^\s*$/; chomp; my $values = parse_authkey($_); next unless defined $values; if (exists $data->{$values->{'hash'}}) { remark( 'warning', "skipping duplicate key (hash:", $values->{'hash'}, ") at $auth_key_file line $." ); next; } $data->{$values->{'hash'}} = $values; } # clear out arguments unless we're reading commands from there @ARGV = () unless exists $opts{'a'}; # read commands from STDIN if specified if (exists $opts{'i'}) { my @tmp; chomp(@tmp = ); push @ARGV, @tmp; } # and flag the help text if nothing from STDIN help() unless @ARGV; # look for commands and deal with them for (@ARGV) { next if m/^#/ or m/^\s*$/; # TODO: -a will not work with following, as tricky to get # command line arguments with tabs in them on unix... my @work = split /\t/; my $cmd = shift @work; unless (exists $command{$cmd}) { remark('notice', "skipping unknown command: $cmd"); next; } $command{$cmd}(@work); } unless (exists $opts{'L'}) { flock $akfh, LOCK_UN or remark('notice', "problem unlocking $auth_key_file: $!"); } close $akfh or remark('notice', "problem closing $auth_key_file: $!"); exit; ###################################################################### # # SUBROUTINES # shows some info about known keys sub do_list { push @_, "all" unless @_; my @keys = get_hashids(@_); remark('info', "listing", scalar @keys, "keys"); for (@keys) { print join ("\t", $data->{$_}->{'hash'}, $data->{$_}->{'type'}, $data->{$_}->{'comment'}, authkey_options2str($data->{$_}->{'options'})), "\n"; } } # literal display of requested keys in memory sub do_dump { push @_, "all" unless @_; my @keys = get_hashids(@_); remark('info', "dumping", scalar @keys, "keys"); for (@keys) { print data2keystring($data->{$_}), "\n"; } } # merges keys into auth_key_file sub do_write { seek $akfh, 0, 0 or remark('alert', "could not seek on $auth_key_file: $!") and die; truncate $akfh, 0 or remark('alert', "could not truncate $auth_key_file: $!") and die; remark('info', 'writing', scalar keys %$data, "keys to $auth_key_file"); for (keys %$data) { print $akfh, data2keystring($data->{$_}), "\n"; } } sub do_delete { my @evict = get_hashids(@_); remark('info', "deleting", scalar @evict, "keys: @evict"); delete @$data{@evict}; } # takes command arguments of "standard" form, and returns a list # of matching hash id's for the keys in question sub get_hashids { return keys %$data if grep { $_ eq "all" } @_; # otherwise... my (@keys, @tmpkeys); @tmpkeys = keys %$data; for (@_) { my ($cmd, $arg) = split /:/, $_, 2; push @keys, grep { $data->{$_}->{$cmd} eq $arg } @tmpkeys; } return @keys; } # adds specified key(s) into memory (replaces preexistant keys by hash) sub do_add { my @added; for (@_) { my $values = parse_authkey($_); next unless defined $values; push @added, $values->{'hash'}; if (exists $data->{$values->{'hash'}}) { remark('notice', 'replacing key', $values->{'hash'}); } $data->{$values->{'hash'}} = $values; } remark('info', 'adding', scalar @added, "keys: @added"); } # alters specified key(s) in memory (comments or options ONLY!) sub do_alter { my $keyspec = shift; my @keys = get_hashids($keyspec); my %updates; for (@_) { my ($cmd, $arg) = /^(comment|options):(.*)/; $updates{$cmd} = $arg; } if (exists $updates{'options'}) { $updates{'options'} = parse_authkey_options($updates{'options'}); } remark('info', 'altering', scalar @keys, "keys: @keys"); for (@keys) { @{$data->{$_}}{keys %updates} = values %updates; } } # takes scalar containing key, tries to return hash reference with # various bits of info expanded out sub parse_authkey { my $authkey = shift; my (%values, @options); # TODO: improve parser to get around boundary cases! # e.g.: command="mumble ssh-rsa AAAA.... zot" ssh-rsa AAAA.... # or even: ssh-rsa AAAA... # try to pull apart key so we can do a checksum on the # important bits (i.e. not the optional option and comment fields) my @elements = split /\s/, $authkey; for my $i (0 .. $#elements) { if ( $elements[$i] =~ m/^ssh-(?:rsa|dss)$/ and $elements[$i + 1] =~ m/^AAAA/) { $values{'type'} = ($elements[$i] =~ m/rsa$/) ? 'rsa' : 'dsa'; $values{'goodbit'} = join " ", @elements[$i .. $i + 1]; $values{'comment'} = join " ", @elements[$i + 2 .. $#elements]; last; } elsif ($elements[$i] =~ m/^\d+$/ and $elements[$i + 1] =~ m/^\d+$/ and $elements[$i + 2] =~ m/^\d+$/) { $values{'type'} = 'rsa1'; $values{'goodbit'} = join " ", @elements[$i .. $i + 2]; $values{'comment'} = join " ", @elements[$i + 3 .. $#elements]; last; } push @options, $elements[$i]; } unless (exists $values{'goodbit'}) { remark('notice', "could not parse key from: $authkey"); return; } $values{'options'} = parse_authkey_options("@options"); $values{'hash'} = sprintf "%08x", unpack "N", md5($values{'goodbit'}); return \%values; } # takes scalar containing auth_key_file options # returns hash reference # # TODO: fixup to handle 'mumble="asdf, zot",foo' issues sub parse_authkey_options { my $options = shift; my $ak_opts; for (split /,/, $options) { my ($k, $v) = split /=/; $ak_opts->{$k} = $v; } return $ak_opts; } # takes hash ref representing key, returns string suitable for # authorized keys file use sub data2keystring { my $href = shift; my $ks = ''; if (exists $href->{'options'}) { $ks .= authkey_options2str($href->{'options'}) . ' '; } $ks .= $href->{'goodbit'}; if (exists $href->{'comment'}) { $ks .= ' ' . $href->{'comment'}; } return $ks; } # takes hash ref, returns scalar format of options sub authkey_options2str { return join ",", map { $_[0]->{$_} ? $_ . '=' . $_[0]->{$_} : $_ } sort keys %{$_[0]}; } # generic log handler sub remark { my ($facility, $priority, $message); $facility = 'user'; if (@_ > 1) { $priority = shift; $message = "@_"; } else { $priority = 'info'; $message = "@_"; } return 1 if exists $opts{'q'} and $priority eq 'info'; if (exists $opts{'l'}) { if ($opts{'l'} eq "syslog") { openlog($0, 'cons', $facility); syslog($priority, $message); closelog(); } elsif ($opts{'l'} eq "logger") { system "logger", '-p', $facility . '.' . $priority, '-t', $0, $message; } else { die "alert: unknown log handler: ", $opts{'l'}; } } else { warn $priority, ": ", $message, "\n"; } return 1; } # a generic help blarb sub help { print <<"HELP"; Usage: $0 [opts] Command interface to OpenSSH authorized_keys file. Options for version $VERSION: -h/-? Display this message -f ff Use specified authorized_keys file instead of default. -L Do not lock authorized keys file (default is to flock). -a Allow commands on the command line. -i Allow commands on STDIN. -l xx Log to target xx (default: STDERR, also logger, syslog). -q Hide informational messages. Run perldoc(1) on this script for additional documentation. HELP exit; } ###################################################################### # # DOCUMENTATION =head1 NAME manage-authkeys.pl - command interface to OpenSSH authorized_keys file =head1 SYNOPSIS Get a listing of keys in the default authorized_keys file: $ manage-authkeys.pl -a list =head1 DESCRIPTION =head2 Overview Provides a scriptable means of listing, altering, and deleting keys out of an OpenSSH C<~/.ssh/authorized_keys> file. This script is intended to be talked to by other scripts which present a better interface to humans. This script is a first draft prototype, and may need a rewrite to support additional functionality (like support for management of authorized keys for other ssh products). =head2 Normal Usage $ manage-authkeys.pl [options] [commands] See L<"COMMANDS"> for details on the command syntax. See L<"OPTIONS"> for details on the command line switches supported. =head1 COMMANDS Commands allow scriptable interaction with the authorized_keys file. Multiple commands may be supplied on either the command line or on STDIN. Arguments to commands are separated by the tab character. =over 4 =item B I [I ...] Shows what temporary keyfiles are available, plus some general data about the public key in question. Will list all available keys if no additional arguments are supplied. The syntax of keymatch statements is either C to match all keys, or a colon-separated element consisting of the internal hash key and an exact value to match, e.g. C or C. =item B I [I ...] Lists keys as exist in memory. =item B I [I ...] Deletes the specified keys from memory. Delete requires a keymatch statement to do anything. =item B I Adds the specified key(s) into memory. The literal key must not contain tab characters. =item B I [comment:foo] [options:bar] Will alter the comment or options fields of the specified key(s). Leave the argument to comment or options blank to clear the item in question. This allows key options and comments to be updated without doing an awkward delete/add command cycle. =item B Overwrites the authorized_keys file with the keys in memory (or empties the file should none be in memory). =back =head1 OPTIONS This script currently supports the following command line switches: =over 4 =item B<-h>, B<-?> Prints a brief usage note about the script. =item B<-f> I Use the specified authorized_keys file instead of the default. Default is ~/.ssh/authorized_keys, unless changed in the script. =item B<-L> Do not lock the C file. By default, the file will be locked using flock. =item B<-a> Allow commands on the command line. =item B<-i> Allow commands on STDIN. If nothing is found on STDIN, the script will stall, waiting for input. One (or both) of B<-a> or B<-i> must be specified for the script to do anything. =item B<-l> I Log to I. Default is to log to STDERR, other options are C (via L) or C (system logger(1) utility). =item B<-q> Quiet mode: do not emit informational messages. =back =head1 EXAMPLES List only SSH1 keys with a BSD-compatible echo(1): $ echo -e "list\ttype:rsa1" | manage-authkeys.pl -i Delete all keys: $ (echo -e "delete\tall"; echo write) | manage-authkeys.pl -i For more information on generating authorized keys, see: http://cfm.gs.washington.edu/security/ssh/client-pkauth/ =head1 ENVIRONMENT Developed for OpenSSH (as of version 3.4); should work on any system with perl 5 and the required modules (e.g. L). Will most likely not work with other SSH server software. =head1 BUGS =head2 Reporting Bugs Newer versions of this script may be available from: http://sial.org/code/perl/ If the bug is in the latest version, send a report to the author. Patches that fix problems or add new features are welcome. =head2 Known Issues Commands expecting arguments use tabs to separate the parameters. Tabs are difficult to input on the command line, so STDIN is advised for commands that require arguments. Comments or blank lines in the authorized_keys file will be deleted following an extract-write cycle. The key parsing routine is loose, and will be messed up by options containing what look like the keys inside a C or similar option. Additionally, option parsing will be messed up if there are commas inside the same style of quoted blocks. =head1 TODO Some sort of ACL/preferences file to limit what commands a remote agent has available to it? Better error handling? Get user-side script written, so can figure out error handling across a SSH channel. Option to optionally log what keys were altered or not (as key list could potentially be long)? =head1 SEE ALSO perl(1), ssh-keygen(1) =head1 AUTHOR Jeremy Mates, http://sial.org/contact/ =head1 COPYRIGHT Copyright (c) 2002-2003, Jeremy Mates. This script is free software; you can redistribute it and/or modify it under the same terms as Perl itself. =head1 VERSION $Id: manage-authkeys.pl,v 1.28 2003/01/13 05:28:42 jmates Exp $ =cut