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