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 $@
P = cassh
V = 2
V = 0
PREFIX = /usr/local
MANPREFIX = ${PREFIX}/man

View File

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

View File

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

68
cassh.1
View File

@ -11,7 +11,7 @@
.\" along with this software. If not, see
.\" <http://creativecommons.org/publicdomain/zero/1.0/>.
.\"
.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

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:
${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