diff --git a/Makefile b/Makefile index 2cd3f04..09e943b 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,7 @@ chmod a+x $@ P = cassh -V = 2 +V = 0 PREFIX = /usr/local MANPREFIX = ${PREFIX}/man diff --git a/cassh-keyfile.1 b/cassh-keyfile.1 index 570d2d2..bd1e777 100644 --- a/cassh-keyfile.1 +++ b/cassh-keyfile.1 @@ -11,7 +11,7 @@ .\" along with this software. If not, see .\" . .\" -.Dd April 20, 2022 +.Dd April 07, 2022 .Dt CASSH-KEYFILE 1 .Os .Sh NAME diff --git a/cassh-keyfile.sh b/cassh-keyfile.sh index 5094b8e..4490a09 100644 --- a/cassh-keyfile.sh +++ b/cassh-keyfile.sh @@ -27,7 +27,7 @@ fi cassh_command=$2 needs_agent=false case $cassh_command in -issue|revoke) +issue) needs_agent=true ;; esac diff --git a/cassh.1 b/cassh.1 index 24529ac..aa4400d 100644 --- a/cassh.1 +++ b/cassh.1 @@ -11,7 +11,7 @@ .\" along with this software. If not, see .\" . .\" -.Dd April 20, 2022 +.Dd March 01, 2022 .Dt CASSH 1 .Os .Sh NAME @@ -30,19 +30,13 @@ .Bk -words .Cm mkfile .Ic authorized_keys -.Op options ... +.Op options .Ek .Nm .Bk -words .Cm mkfile .Ic known_hosts -.Op hostnames ... -.Ek -.Nm -.Bk -words -.Cm revoke -.Op Fl qv -.Ar +.Op hostnames .Ek .Sh DESCRIPTION .Nm @@ -62,14 +56,9 @@ A Certification Authority directory consists of a .Pa ./ca.pub file corresponding to the public key of it, a .Pa ./pubkeys/ -directory which holds the public keys to be signed, an optional -.Pa ./krl -file corresponding to the last issued Key Revocation List, and optional -.Pa ./ca_serial.txt -and -.Pa ./krl_serial.txt -files corresponding to the current serial number for the issued certificates -and Key Revocation Lists. +directory which holds the public keys to be signed, and an optional +.Pa ./serial.txt +file holding the current serial number for the issued certificates. .Pp The following commands are available to .Nm : @@ -95,15 +84,9 @@ The recognized tokens are: A literal .Sq % . .It \&%C -The Certification Authority private key comment field as reported by -.Xr ssh-add 1 , -or the string -.Sq cassh -if there is no comment reported. +The Certification Authority private key comment. .It %f -The basename of the public key being signed, without -.Sq .pub -suffix. +The basename of the public key being signed. .El .Pp .Ar key_id @@ -117,56 +100,37 @@ accepts the tokens %% and %f. After token expansion, all recognized options are passed down to .Xr ssh-keygen 1 process. -.It Cm mkfile Ic authorized_keys Op Ar options ... +.It Cm mkfile Ic authorized_keys Op Ar options Write an .Ic authorized_keys file on standard output corresponding to the current Certification Authority. .Ar options -are concatenated with commas and copied verbatim to the output. +is copied verbatim to the output, and .Cm cert-authority -is always added to the options list. +is always added. See .Xr sshd 8 AUTHORIZED_KEYS FILE FORMAT for details. -.It Cm mkfile Ic known_hosts Op Ar hostnames ... +.It Cm mkfile Ic known_hosts Op Ar hostnames Write a .Ic known_hosts file on standard output corresponding to the current Certification Authority. .Ar hostnames -are concatenated with commas and copied verbatim to the output. +is copied verbatim to the output. See .Xr sshd 8 SSH_KNOWN_HOSTS FILE FORMAT for details. -.It Cm revoke Oo Fl qv Oc Ar -Generates a Key Revocation List for the current Certification Authority. -All recognized options are passed down to -.Xr ssh-keygen 1 -process. -See -.Xr ssh-keygen 1 KEY REVOCATION LISTS -for details on the file format for input files. -If -.Pa ./krl -exists, -.Cm revoke -will update. -.Pa ./krl -can be synced back with the input files by first removing it. .El .Sh FILES -.Bl -tag -width MMMMMMMMMMMMMMMMMM -compact +.Bl -tag -width MMMMMMMMMMMMMM -compact .It Pa ./ca.pub Certification Authority public key .It Pa ./pubkeys/ Directory containing the public keys to be signed -.It Pa ./krl -Key Revocation List -.It Pa ./ca_serial.txt -Last issued serial for certificates -.It Pa ./krl_serial.txt -Last issued serial for KRLs +.It Pa ./serial.txt +Last issued serial .El .Sh EXIT STATUS .Ex -std diff --git a/cassh.pl b/cassh.pl new file mode 100644 index 0000000..e5b558e --- /dev/null +++ b/cassh.pl @@ -0,0 +1,299 @@ +#!/usr/bin/env perl +# cassh - Manager for an OpenSSH Certification Authority +# +# Written in 2022 by Lucas +# +# To the extent possible under law, the author(s) have dedicated all +# copyright and related and neighboring rights to this software to the +# public domain worldwide. This software is distributed without any +# warranty. +# You should have received a copy of the CC0 Public Domain Dedication +# along with this software. If not, see +# . + +use v5.14; +use strict; +use warnings; + +package SerialFile v1.0.0; + +use overload '0+' => \&value; + +sub new ($$) +{ + my $class = shift; + my $self = {}; + bless $self, $class; + return $self->_init(@_); +} + +sub _init ($$) +{ + my ($self, $file) = @_; + + my $mode = -f $file ? "+<" : ">"; + open(my $fh, $mode, $file) or die "can't open $file: $!"; + my @lines = <$fh>; + + my $counter; + if (@lines == 0) { + $counter = 0; + } elsif (@lines == 1 && $lines[0] =~ /^(\d+)$/) { + $counter = $1; + } else { + return undef; + } + + $self->{_fh} = $fh; + $self->{_counter} = $counter; + + return $self; +} + +sub inc ($;$) +{ + use integer; + my ($self, $inc) = @_; + $self->{_counter} += $inc // 1; + return $self; +} + +sub value ($) +{ + my $self = shift; + return $self->{_counter}; +} + +sub commit ($) +{ + my $self = shift; + + truncate($self->{_fh}, 0) or return 0; + seek($self->{_fh}, 0, 0) or return 0; + + return say {$self->{_fh}} $self->value(); +} + +sub DESTROY ($) +{ + local ($., $@, $!, $^E, $?); + my $self = shift; + close($self->{_fh}); +} + +package FormatToken v1.0.0; + +use List::Util qw(all); + +sub new ($) +{ + return bless {_tokens => {"%" => "%"}}, shift; +} + +sub register ($;%) +{ + my ($self, %pairs) = @_; + for (keys %pairs) { + die "can't register $_" if !/^[A-Za-z]$/; + $self->{_tokens}->{$_} = $pairs{$_}; + } + return $self; +} + +sub deregister ($;%) +{ + my ($self, %pairs) = @_; + delete $self->{_tokens}->{$_} for (keys %pairs); + return $self; +} + +sub format ($$) +{ + my ($self, $in) = @_; + die "can't format \"$in\"" unless + all {defined $self->{_tokens}->{$_}} ($in =~ /%([A-Za-z%])/g); + return $in =~ s/%([A-Za-z%])/$self->{_tokens}->{$1}/egr; +} + +package main; + +use Getopt::Long qw(:config posix_default bundling no_ignore_case); + +my $PATH_CA_PUB = "./ca.pub"; +my $PATH_CA_SERIAL = "./serial.txt"; +my $PATH_PUBKEYS_DIR = "./pubkeys"; + +my %COMMANDS = ( + issue => \&main_issue, + mkfile => \&main_mkfile, +); + +my $PROGNAME = ($0 =~ s,.*/,,r); + +sub usage () +{ + print STDERR <; + close($fh); + + return $s; +} + +# Returns comment from the ssh-agent if any is returned, otherwise it +# returns the public key's fingerprint. +sub get_ca_sk_comment_from_pk ($) +{ + my $f = shift; + + my ($fh, @ssh_keygen_lines, @ssh_add_lines); + + open($fh, "-|", "ssh-keygen", "-l", "-f", $f) or err "can't fork: $!"; + @ssh_keygen_lines = <$fh>; + close($fh); + + open($fh, "-|", "ssh-add", "-l") or err "can't fork: $!"; + @ssh_add_lines = <$fh>; + close($fh); + + my $comment; + OUTER: foreach my $ssh_keygen_line (@ssh_keygen_lines) { + chomp($ssh_keygen_line); + my @ssh_keygen_parts = split(/ /, $ssh_keygen_line, 3); + foreach my $ssh_add_line (@ssh_add_lines) { + chomp($ssh_add_line); + my @ssh_add_parts = split(/ /, $ssh_add_line, 3); + if ($ssh_keygen_parts[1] eq $ssh_add_parts[1]) { + my $s = $ssh_add_parts[2]; + $comment = substr($s, 0, rindex($s, " ")); + last OUTER; + } + } + } + + return $comment; +} + +sub get_pubkeys_files ($) +{ + my $d = shift; + + opendir(my $dh, "$d") or err "can't open directory $d"; + my @files = map {"$d/$_"} + grep {-f "$d/$_" && /\.pub$/ && !/-cert\.pub/} readdir($dh); + closedir($dh); + + return @files; +} + + +sub main_issue () +{ + my ($host, $key_id_fmt, $principals_fmt, $quiet, $verbose, + $validity_interval); + + $validity_interval = "always:forever"; + $key_id_fmt = "%C/%f"; + $verbose = 0; + GetOptions( + "h" => \$host, + "I=s" => \$key_id_fmt, + "n=s" => \$principals_fmt, + "q" => \$quiet, + "v+" => \$verbose, + "V=s" => \$validity_interval, + ) or usage; + + usage if @ARGV != 0; + + my ($hflag, $qflag, $vflag); + $hflag = "-h" if defined($host); + $qflag = "-q" if defined($quiet); + $vflag = "-" . ("v" x $verbose) if $verbose > 0; + + my @files = get_pubkeys_files($PATH_PUBKEYS_DIR); + exit 0 if @files == 0; + + my $formatter = FormatToken->new(); + my $ca_sk_comment = get_ca_sk_comment_from_pk($PATH_CA_PUB) // "cassh"; + $formatter->register(C => $ca_sk_comment); + + my $serial = SerialFile->new($PATH_CA_SERIAL); + + # Doing individual calls to ssh-keygen allows for using the filename as + # a token in principals and key_id format. + foreach my $file (@files) { + $formatter->register(f => $file =~ s,.*/(.*)\.pub$,$1,r); + + my ($key_id, $principals); + $key_id = $formatter->format($key_id_fmt); + $principals = $formatter->format($principals_fmt) if + defined($principals_fmt); + + my @cmdopts = ("-I", $key_id, "-U", "-s", $PATH_CA_PUB, + "-V", $validity_interval, "-z", $serial); + push(@cmdopts, "-h") if defined($host); + push(@cmdopts, $qflag) if defined($qflag); + push(@cmdopts, $vflag) if defined($vflag); + push(@cmdopts, "-n", $principals) if defined($principals); + + system("ssh-keygen", @cmdopts, $file); + if ($? == -1) { + err "ssh-keygen: $!"; + } elsif ($? != 0) { + exit $? >> 8; + } + + $serial->inc()->commit() or err "can't save serial"; + } +} + +sub main_mkfile () +{ + usage if @ARGV < 1; + my $file = shift @ARGV; + + err "no $PATH_CA_PUB found" if ! -f $PATH_CA_PUB; + + if ($file eq "authorized_keys") { + print join(" ", join(",", "cert-authority", @ARGV), + slurp($PATH_CA_PUB)); + } elsif ($file eq "known_hosts") { + print join(" ", grep {$_ ne ""} ('@cert-authority', + join(",", @ARGV), slurp($PATH_CA_PUB))); + } else { + err "unknown file $file"; + } +} + +sub main () +{ + usage if @ARGV < 1; + + my $cmd = shift @ARGV; + + usage if !defined($COMMANDS{$cmd}); + $COMMANDS{$cmd}->(); +} + +main(); diff --git a/cassh.sh b/cassh.sh index c1456c8..209b2b7 100644 --- a/cassh.sh +++ b/cassh.sh @@ -17,9 +17,8 @@ usage() Usage: ${0##*/} issue [-hqv] [-I key_id] [-n principals] [-V validity_interval] - ${0##*/} mkfile authorized_keys [options ...] - ${0##*/} mkfile known_hosts [hostnames ...] - ${0##*/} revoke [-qv] file ... + ${0##*/} mkfile authorized_keys [options] + ${0##*/} mkfile known_hosts [hostnames] EOF exit 1 } @@ -30,11 +29,29 @@ err() exit 1 } -# Returns the comment from the loaded secret key in ssh-agent, if any is -# present. -get_ca_sk_comment_from_pk() +strip_leading_zeros() { - ssh-keygen -lf "$1" 2>/dev/null | { + _s=$1 + if [ -z "$_s" ]; then + return + fi + while [ X"${_s#0}" != X"$_s" ]; do + _s=${_s#0} + done + echo "${_s:-0}" +} + +strcmp() +{ + _r=$(expr "X$1" "$2" "X$3") + [ "${_r:-0}" -eq 1 ] +} + +# Returns comment from the ssh-agent if any is returned, otherwise it +# returns the public key's fingerprint. +get_ca_comment_from_sk() +{ + ssh-keygen -lf "$1" | { read -r pk_sz pk_fp pk_extra _comment=$(ssh-add -l | while read -r sk_sz sk_fp sk_extra; do if [ "X$sk_fp" = "X$pk_fp" ]; then @@ -42,67 +59,57 @@ get_ca_sk_comment_from_pk() break fi done) - echo "${_comment:-}" + echo "${_comment:-${pk_fp#*:}}" } } -format() +_template_fmt() { - _s=$1 - shift + _allowed_chars=$1 + _char=$2 + if [ "X$_char" = X% ]; then + echo % + return $? + fi - _cleanup=unset - while [ $# -ge 2 ]; do - _k=$1 _v=$2 - shift 2 - case $_k in - [A-Za-z]) - ;; - *) - return 1 - ;; - esac - eval "_token_${_k}=\$_v" - _cleanup=$_cleanup" _token_${_k}" - done - if [ $# -ne 0 ]; then + case $_char in + [$_allowed_chars]) + ;; + *) + return 1 + ;; + esac + + _v=$(eval echo '${_template_fmt_'"$_char"':-}') + if [ -z "$_v" ]; then return 1 fi + echo "$_v" +} + +template() +{ + _allowed=$1 + _s=$2 _out= + while [ "${_s#*%}" != "$_s" ]; do _t=${_s#*%} _out=$_out${_s%"%"$_t} _s=$_t _c=${_s%${_s#?}} - if [ -z "${_c:-}" ]; then + _t=$(_template_fmt "$_allowed" "$_c") + if [ $? -ne 0 ]; then return 1 - elif [ X"${_c}" = X% ]; then - _out=$_out% - else - eval "_out=$_out\$_token_${_c}" || return 1 fi + _out=$_out$_t _s=${_s#$_c} done _out=$_out$_s - eval "$_cleanup" - - echo "$_out" -} - -strjoin() -{ - _c=$1 - shift - - _out= - for _s; do - _out=${_out:+$_out$_c}$_s - done - echo "$_out" } @@ -131,42 +138,64 @@ main_issue() usage fi + if [ ! -f "$PATH_CA_PUB" ]; then + err "no $PATH_CA_PUB found" + fi + if ! ssh-add $qflag $vflag -T "$PATH_CA_PUB"; then + err "can't use CA key" + fi + if [ ! -d "$PATH_PUBKEYS_DIR/" ]; then + err "no pubkeys directory found" + fi + if [ ! -f "$PATH_CA_SERIAL" ]; then - echo 1 >"$PATH_CA_SERIAL" + date -u +%Y%m%d000000000 >"$PATH_CA_SERIAL" fi read -r serial <"$PATH_CA_SERIAL" - - if [ ! -d "$PATH_PUBKEYS_DIR" ]; then - exit 0 + # Remove NNNNNNNNN suffix + serial_date=${serial%?????????} + current_date=$(date -u +%Y%m%d) + if strcmp "$current_date" ">" "$serial_date"; then + serial_date=$current_date + serial_counter=0 + else + # Remove YYYYmmdd prefix and leading + serial_counter=$(strip_leading_zeros "${serial#????????}") fi - find "$PATH_PUBKEYS_DIR" -type f -name '*.pub' ! -name '*-cert.pub' | - sort | { - ca_comment=$(get_ca_sk_comment_from_pk "$PATH_CA_PUB") - : ${ca_comment:=cassh} + serial=$(printf "%s%09u\n" "$serial_date" "$serial_counter") + _template_fmt_C=$(get_ca_comment_from_sk "$PATH_CA_PUB") + find "$PATH_PUBKEYS_DIR/" -type f -name '*.pub' ! -name '*-cert.pub' | { + rc=0 while read -r pk; do pkname=${pk%.pub} pkname=${pkname#$PATH_PUBKEYS_DIR/} + _template_fmt_f=$pkname - id=$(format "$key_id_fmt" C "$ca_comment" f "$pkname") + id=$(template Cf "$key_id_fmt") set -- -I "$id" -Us "$PATH_CA_PUB" \ $hflag $qflag $vflag \ -V "$validity_interval" -z "$serial" if $nflag; then - principals=$(format "$principals_fmt" \ - f "$pkname") + principals=$(template f "$principals_fmt") ssh-keygen "$@" -n "$principals" "$pk" else ssh-keygen "$@" "$pk" - fi - if [ $? -ne 0 ]; then - exit 1 - fi + fi || rc=1 - serial=$(($serial + 1)) - echo $serial >"$PATH_CA_SERIAL" + serial_counter=$(($serial_counter + 1)) + if [ $serial_counter -ge 1000000000 ]; then + err "can't issue more certificates today" + fi + serial=$(printf "%s%09u\n" "$serial_date" \ + "$serial_counter" | tee "$PATH_CA_SERIAL") + + if [ $rc -ne 0 ]; then + break + fi done + return $rc } } @@ -190,11 +219,19 @@ main_mkfile() case $file in authorized_keys) - printf "%s " "$(strjoin , cert-authority "$@")" + if [ $# -gt 1 ]; then + usage + fi + options=cert-authority${1:+,$1} + printf "%s " "$options" ;; known_hosts) - if [ $# -gt 0 ]; then - printf "@cert-authority %s " "$(strjoin , "$@")" + if [ $# -gt 1 ]; then + usage + fi + hostnames=${1:-} + if [ -n "$hostnames" ]; then + printf "@cert-authority %s " "$hostnames" else printf "@cert-authority " fi @@ -207,42 +244,10 @@ main_mkfile() cat "$PATH_CA_PUB" } -main_revoke() -{ - qflag= - vflag= - while getopts fqv flag; do - case $flag in - q) qflag=-q ;; - v) vflag=${vflag:--}v ;; - *) usage ;; - esac - done - shift $(($OPTIND - 1)) - - if [ ! -f "$PATH_KRL_SERIAL" ]; then - echo 1 >"$PATH_KRL_SERIAL" - fi - read -r serial <"$PATH_KRL_SERIAL" - - uflag= - if [ -f "$PATH_KRL" ]; then - uflag=-u - fi - - ssh-keygen -kf "$PATH_KRL" -Us "$PATH_CA_PUB" -z "$serial" \ - $qflag $vflag $uflag "$@" || exit 1 - - serial=$(($serial + 1)) - echo $serial >"$PATH_KRL_SERIAL" -} - set -u PATH_CA_PUB=./ca.pub -PATH_CA_SERIAL=./ca_serial.txt -PATH_KRL=./krl -PATH_KRL_SERIAL=./krl_serial.txt +PATH_CA_SERIAL=./serial.txt PATH_PUBKEYS_DIR=./pubkeys if [ $# -lt 1 ]; then @@ -254,6 +259,5 @@ shift case $cmd in issue) main_issue "$@" ;; mkfile) main_mkfile "$@" ;; -revoke) main_revoke "$@" ;; *) usage ;; esac