Compare commits

..

1 Commits
main ... perl

Author SHA1 Message Date
Lucas fe858efc53 Reimplement in Perl 2022-04-19 02:09:39 +00:00
6 changed files with 422 additions and 155 deletions

View File

@ -19,7 +19,7 @@
chmod a+x $@ chmod a+x $@
P = cassh P = cassh
V = 2 V = 0
PREFIX = /usr/local PREFIX = /usr/local
MANPREFIX = ${PREFIX}/man MANPREFIX = ${PREFIX}/man

View File

@ -11,7 +11,7 @@
.\" along with this software. If not, see .\" along with this software. If not, see
.\" <http://creativecommons.org/publicdomain/zero/1.0/>. .\" <http://creativecommons.org/publicdomain/zero/1.0/>.
.\" .\"
.Dd April 20, 2022 .Dd April 07, 2022
.Dt CASSH-KEYFILE 1 .Dt CASSH-KEYFILE 1
.Os .Os
.Sh NAME .Sh NAME

View File

@ -27,7 +27,7 @@ fi
cassh_command=$2 cassh_command=$2
needs_agent=false needs_agent=false
case $cassh_command in case $cassh_command in
issue|revoke) issue)
needs_agent=true needs_agent=true
;; ;;
esac esac

68
cassh.1
View File

@ -11,7 +11,7 @@
.\" along with this software. If not, see .\" along with this software. If not, see
.\" <http://creativecommons.org/publicdomain/zero/1.0/>. .\" <http://creativecommons.org/publicdomain/zero/1.0/>.
.\" .\"
.Dd April 20, 2022 .Dd March 01, 2022
.Dt CASSH 1 .Dt CASSH 1
.Os .Os
.Sh NAME .Sh NAME
@ -30,19 +30,13 @@
.Bk -words .Bk -words
.Cm mkfile .Cm mkfile
.Ic authorized_keys .Ic authorized_keys
.Op options ... .Op options
.Ek .Ek
.Nm .Nm
.Bk -words .Bk -words
.Cm mkfile .Cm mkfile
.Ic known_hosts .Ic known_hosts
.Op hostnames ... .Op hostnames
.Ek
.Nm
.Bk -words
.Cm revoke
.Op Fl qv
.Ar
.Ek .Ek
.Sh DESCRIPTION .Sh DESCRIPTION
.Nm .Nm
@ -62,14 +56,9 @@ A Certification Authority directory consists of a
.Pa ./ca.pub .Pa ./ca.pub
file corresponding to the public key of it, a file corresponding to the public key of it, a
.Pa ./pubkeys/ .Pa ./pubkeys/
directory which holds the public keys to be signed, an optional directory which holds the public keys to be signed, and an optional
.Pa ./krl .Pa ./serial.txt
file corresponding to the last issued Key Revocation List, and optional file holding the current serial number for the issued certificates.
.Pa ./ca_serial.txt
and
.Pa ./krl_serial.txt
files corresponding to the current serial number for the issued certificates
and Key Revocation Lists.
.Pp .Pp
The following commands are available to The following commands are available to
.Nm : .Nm :
@ -95,15 +84,9 @@ The recognized tokens are:
A literal A literal
.Sq % . .Sq % .
.It \&%C .It \&%C
The Certification Authority private key comment field as reported by The Certification Authority private key comment.
.Xr ssh-add 1 ,
or the string
.Sq cassh
if there is no comment reported.
.It %f .It %f
The basename of the public key being signed, without The basename of the public key being signed.
.Sq .pub
suffix.
.El .El
.Pp .Pp
.Ar key_id .Ar key_id
@ -117,56 +100,37 @@ accepts the tokens %% and %f.
After token expansion, all recognized options are passed down to After token expansion, all recognized options are passed down to
.Xr ssh-keygen 1 .Xr ssh-keygen 1
process. process.
.It Cm mkfile Ic authorized_keys Op Ar options ... .It Cm mkfile Ic authorized_keys Op Ar options
Write an Write an
.Ic authorized_keys .Ic authorized_keys
file on standard output corresponding to the current Certification file on standard output corresponding to the current Certification
Authority. Authority.
.Ar options .Ar options
are concatenated with commas and copied verbatim to the output. is copied verbatim to the output, and
.Cm cert-authority .Cm cert-authority
is always added to the options list. is always added.
See See
.Xr sshd 8 AUTHORIZED_KEYS FILE FORMAT .Xr sshd 8 AUTHORIZED_KEYS FILE FORMAT
for details. for details.
.It Cm mkfile Ic known_hosts Op Ar hostnames ... .It Cm mkfile Ic known_hosts Op Ar hostnames
Write a Write a
.Ic known_hosts .Ic known_hosts
file on standard output corresponding to the current Certification file on standard output corresponding to the current Certification
Authority. Authority.
.Ar hostnames .Ar hostnames
are concatenated with commas and copied verbatim to the output. is copied verbatim to the output.
See See
.Xr sshd 8 SSH_KNOWN_HOSTS FILE FORMAT .Xr sshd 8 SSH_KNOWN_HOSTS FILE FORMAT
for details. 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 .El
.Sh FILES .Sh FILES
.Bl -tag -width MMMMMMMMMMMMMMMMMM -compact .Bl -tag -width MMMMMMMMMMMMMM -compact
.It Pa ./ca.pub .It Pa ./ca.pub
Certification Authority public key Certification Authority public key
.It Pa ./pubkeys/ .It Pa ./pubkeys/
Directory containing the public keys to be signed Directory containing the public keys to be signed
.It Pa ./krl .It Pa ./serial.txt
Key Revocation List Last issued serial
.It Pa ./ca_serial.txt
Last issued serial for certificates
.It Pa ./krl_serial.txt
Last issued serial for KRLs
.El .El
.Sh EXIT STATUS .Sh EXIT STATUS
.Ex -std .Ex -std

299
cassh.pl Normal file
View File

@ -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
# <http://creativecommons.org/publicdomain/zero/1.0/>.
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 <<EOF;
Usage:
$PROGNAME issue [-hqv] [-I key_id] [-n principals]
[-V validity_interval]
$PROGNAME mkfile authorized_keys [options ...]
$PROGNAME mkfile known_hosts [hostnames ...]
EOF
exit 1;
}
sub err (@)
{
say STDERR "$PROGNAME: @_";
exit 1;
}
sub slurp ($)
{
my $f = shift;
open(my $fh, "<", $f) or err "can't open $f";
local $/;
my $s = <$fh>;
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();

204
cassh.sh
View File

@ -17,9 +17,8 @@ usage()
Usage: Usage:
${0##*/} issue [-hqv] [-I key_id] [-n principals] ${0##*/} issue [-hqv] [-I key_id] [-n principals]
[-V validity_interval] [-V validity_interval]
${0##*/} mkfile authorized_keys [options ...] ${0##*/} mkfile authorized_keys [options]
${0##*/} mkfile known_hosts [hostnames ...] ${0##*/} mkfile known_hosts [hostnames]
${0##*/} revoke [-qv] file ...
EOF EOF
exit 1 exit 1
} }
@ -30,11 +29,29 @@ err()
exit 1 exit 1
} }
# Returns the comment from the loaded secret key in ssh-agent, if any is strip_leading_zeros()
# present.
get_ca_sk_comment_from_pk()
{ {
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 read -r pk_sz pk_fp pk_extra
_comment=$(ssh-add -l | while read -r sk_sz sk_fp sk_extra; do _comment=$(ssh-add -l | while read -r sk_sz sk_fp sk_extra; do
if [ "X$sk_fp" = "X$pk_fp" ]; then if [ "X$sk_fp" = "X$pk_fp" ]; then
@ -42,67 +59,57 @@ get_ca_sk_comment_from_pk()
break break
fi fi
done) done)
echo "${_comment:-}" echo "${_comment:-${pk_fp#*:}}"
} }
} }
format() _template_fmt()
{ {
_s=$1 _allowed_chars=$1
shift _char=$2
if [ "X$_char" = X% ]; then
echo %
return $?
fi
_cleanup=unset case $_char in
while [ $# -ge 2 ]; do [$_allowed_chars])
_k=$1 _v=$2 ;;
shift 2 *)
case $_k in return 1
[A-Za-z]) ;;
;; esac
*)
return 1 _v=$(eval echo '${_template_fmt_'"$_char"':-}')
;; if [ -z "$_v" ]; then
esac
eval "_token_${_k}=\$_v"
_cleanup=$_cleanup" _token_${_k}"
done
if [ $# -ne 0 ]; then
return 1 return 1
fi fi
echo "$_v"
}
template()
{
_allowed=$1
_s=$2
_out= _out=
while [ "${_s#*%}" != "$_s" ]; do while [ "${_s#*%}" != "$_s" ]; do
_t=${_s#*%} _t=${_s#*%}
_out=$_out${_s%"%"$_t} _out=$_out${_s%"%"$_t}
_s=$_t _s=$_t
_c=${_s%${_s#?}} _c=${_s%${_s#?}}
if [ -z "${_c:-}" ]; then _t=$(_template_fmt "$_allowed" "$_c")
if [ $? -ne 0 ]; then
return 1 return 1
elif [ X"${_c}" = X% ]; then
_out=$_out%
else
eval "_out=$_out\$_token_${_c}" || return 1
fi fi
_out=$_out$_t
_s=${_s#$_c} _s=${_s#$_c}
done done
_out=$_out$_s _out=$_out$_s
eval "$_cleanup"
echo "$_out"
}
strjoin()
{
_c=$1
shift
_out=
for _s; do
_out=${_out:+$_out$_c}$_s
done
echo "$_out" echo "$_out"
} }
@ -131,42 +138,64 @@ main_issue()
usage usage
fi 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 if [ ! -f "$PATH_CA_SERIAL" ]; then
echo 1 >"$PATH_CA_SERIAL" date -u +%Y%m%d000000000 >"$PATH_CA_SERIAL"
fi fi
read -r serial <"$PATH_CA_SERIAL" read -r serial <"$PATH_CA_SERIAL"
# Remove NNNNNNNNN suffix
if [ ! -d "$PATH_PUBKEYS_DIR" ]; then serial_date=${serial%?????????}
exit 0 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 fi
find "$PATH_PUBKEYS_DIR" -type f -name '*.pub' ! -name '*-cert.pub' | serial=$(printf "%s%09u\n" "$serial_date" "$serial_counter")
sort | {
ca_comment=$(get_ca_sk_comment_from_pk "$PATH_CA_PUB")
: ${ca_comment:=cassh}
_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 while read -r pk; do
pkname=${pk%.pub} pkname=${pk%.pub}
pkname=${pkname#$PATH_PUBKEYS_DIR/} 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" \ set -- -I "$id" -Us "$PATH_CA_PUB" \
$hflag $qflag $vflag \ $hflag $qflag $vflag \
-V "$validity_interval" -z "$serial" -V "$validity_interval" -z "$serial"
if $nflag; then if $nflag; then
principals=$(format "$principals_fmt" \ principals=$(template f "$principals_fmt")
f "$pkname")
ssh-keygen "$@" -n "$principals" "$pk" ssh-keygen "$@" -n "$principals" "$pk"
else else
ssh-keygen "$@" "$pk" ssh-keygen "$@" "$pk"
fi fi || rc=1
if [ $? -ne 0 ]; then
exit 1
fi
serial=$(($serial + 1)) serial_counter=$(($serial_counter + 1))
echo $serial >"$PATH_CA_SERIAL" 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 done
return $rc
} }
} }
@ -190,11 +219,19 @@ main_mkfile()
case $file in case $file in
authorized_keys) authorized_keys)
printf "%s " "$(strjoin , cert-authority "$@")" if [ $# -gt 1 ]; then
usage
fi
options=cert-authority${1:+,$1}
printf "%s " "$options"
;; ;;
known_hosts) known_hosts)
if [ $# -gt 0 ]; then if [ $# -gt 1 ]; then
printf "@cert-authority %s " "$(strjoin , "$@")" usage
fi
hostnames=${1:-}
if [ -n "$hostnames" ]; then
printf "@cert-authority %s " "$hostnames"
else else
printf "@cert-authority " printf "@cert-authority "
fi fi
@ -207,42 +244,10 @@ main_mkfile()
cat "$PATH_CA_PUB" 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 set -u
PATH_CA_PUB=./ca.pub PATH_CA_PUB=./ca.pub
PATH_CA_SERIAL=./ca_serial.txt PATH_CA_SERIAL=./serial.txt
PATH_KRL=./krl
PATH_KRL_SERIAL=./krl_serial.txt
PATH_PUBKEYS_DIR=./pubkeys PATH_PUBKEYS_DIR=./pubkeys
if [ $# -lt 1 ]; then if [ $# -lt 1 ]; then
@ -254,6 +259,5 @@ shift
case $cmd in case $cmd in
issue) main_issue "$@" ;; issue) main_issue "$@" ;;
mkfile) main_mkfile "$@" ;; mkfile) main_mkfile "$@" ;;
revoke) main_revoke "$@" ;;
*) usage ;; *) usage ;;
esac esac