This commit is contained in:
Denis Knauf 2022-02-27 11:25:21 +01:00
commit e5a7448c5f
5 changed files with 725 additions and 0 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
*~
.*\.sw[opn]
ca
id_*
*.pub

45
Makefile Normal file
View file

@ -0,0 +1,45 @@
USER := sshca
HOME := /srv/sshca
BINDIR := $(HOME)/bin
DATADIR := $(HOME)/.local/ssh-ca
PKIDIR := $(DATADIR)/pubs
CONFDIR := $(HOME)/.config/ssh-ca
CONFFILE := $(CONFDIR)/ssh-ca.conf
all: ssh-ca.conf help
help:
@echo "Depends on ruby"
@echo "Run `make install` for installation."
@echo "If you want to define some default, delete ssh-ca.conf and run `make USER=sshca HOME=/srv/sshca`"
@echo "You have to install the user manually:"
@echo " useradd -H /srv/sshca sshca"
@echo "And needed gems:"
@echo " gem install activesupport"
install: $(BINDIR)/ssh-ca $(CONFFILE) $(PKIDIR) $(HOME)/.ssh/authorized_keys $(DATADIR)/serial
$(HOME):
useradd --system --no-user-group --shell /bin/sh --create-home --home-dir /srv/sshca sshca
$(BINDIR): $(HOME)
install -o $(USER) -m 0755 -d $@
$(BINDIR)/ssh-ca: ssh-ca $(BINDIR)
install -o $(USER) -m 0755 $^ $@
$(CONFFILE): ssh-ca.conf $(CONFDIR)
if ! test -f $@; then install -o $(USER) -m 0600 $^ $@; fi
$(CONFDIR) $(DATADIR) $(HOME)/.ssh: $(HOME)
install -o $(USER) -m 0700 -d $@
$(HOME)/.ssh/authorized_keys: $(HOME)/.ssh
umask 0177; touch $@; chmod 0600 $@; chown $(USER) $@
$(DATADIR)/serial: $(DATADIR)
if ! test -f $@; then echo '0' > $@; chown $(USER) $@; chmod 0600 $@; fi
$(DATADIR)/ca: $(DATADIR)
if ! test -f $@; then ssh-keygen -t ed25519 -C "CA" -N '' -f $@; chown $(USER) $@ $@.pub; chmod 0400 $@ $@.pub; fi
.PHONY: all help install

42
README.adoc Normal file
View file

@ -0,0 +1,42 @@
Manual installation
===================
* Create user sshca:
+
----
useradd --system --no-user-group --shell /bin/sh --create-home --home-dir /srv/sshca sshca
----
* Create directories:
+
----
install -o sshca -m 0700 -d ~sshca/bin ~sshca/.local ~sshca/.local/sshca ~sshca/.local/sshca/pubs
----
* Copy `ssh-ca` script:
+
----
install -o sshca -m 0700 -t ~sshca/bin ssh-ca
----
* Create `authorized_keys`:
+
----
touch emptyfile
install -o sshca -m 0700 emptyfile ~sshca/.ssh/authorized_keys
rm emptyfile
----
* Create serial-file:
+
----
echo 0 > serial
install -o sshca -m 0600 serial ~sshca/.local/sshca
rm serial
----
* Create CA (no password):
+
----
ssh-keygen -t ed25519 -C "CA" -N '' -f ~sshca/.local/sshca/ca
----

616
ssh-ca Executable file
View file

@ -0,0 +1,616 @@
#!/usr/bin/env ruby
require 'time'
require 'pathname'
require 'shellwords'
require 'getoptlong'
require 'active_support/time'
require 'json'
require 'fcntl'
class Die <Exception
end
module Kernel
def popen *a, **o, &e
a = a.flatten.compact.map &:to_s
IO.popen a, **o, &e
end
def die str
raise Die, str
end
end
module FileHelper
attr_reader :file
def exist?() @file.file? end
def read() @file.read end
def to_s() @file.to_s end
def basename() @file.basename end
def dirname() @file.dirname end
alias :to_path :file
def open *a, **o, &e
if block_given?
@file.open *a, **o, &e
else
@file.open *a, **o
end
end
end
class PublicKey
Types = %w[sk-ecdsa-sha2-nistp256@openssh.com ecdsa-sha2-nistp256 ecdsa-sha2-nistp384 ecdsa-sha2-nistp521 sk-ssh-ed25519@openssh.com ssh-ed25519 ssh-dss ssh-rsa]
RE = %r{
(?<type> (?:#{Types.map{|t|Regexp.quote t}.join '|'}))
\ +
(?<pubkey> [a-zA-Z0-9/+]+ ) # base64 encoded
\ +
(?<comment> .*? ) # we use it as ident
\ *
}x
Re = /\A#{RE}\z/
attr :type, :key
attr_accessor :comment
def self.parse line
m = Re.match line
return nil if m.nil?
self.new m[:type], m[:pubkey], m[:comment]
end
def initialize type, key, comment
die "Unknown PubKey type <#{type}>" unless Types.include?( type)
die "Not a (base64-encoded) PubKey <#{key}>" if %r<[^a-zA-Z0-9/+]> =~ key
@type, @key, @comment = type, key, comment
end
def to_s
"#{@type} #{@key} #{@comment}"
end
def == other_pub
other_pub.kind_of?( PublicKey) and @type == other_pub.type and @key == other_pub.key
end
end
class AuthorizedKeys
LINERE = %r{
(?:(?<options>
(?:[a-z-]+ (?:=\"[^"]*\")?) # one option
(?:, (?:[a-z-]+ (?: = \"[^"]*\")?) )* # , more options
)\ )?
#{PublicKey::RE}
}x
LineRe = /\A#{LINERE}\z/
attr_reader :file
def self.parse line
if m = LineRe.match( line)
return PublicKey.new( m[:type], m[:pubkey], m[:comment]), m[:options]
end
end
def initialize file
@file = file
end
def each_line &block
@file.each_line &block
end
def each_parsed_line &block
return to_enum(__method__) unless block_given?
@file.each_line do |l|
case l.chomp!
when /\A\s*# *(.*)\z/
yield :comment, l, $1
when /\A\s*$/
yield :empty, l
else
pk, o = self.class.parse( l)
if pk
yield :pub, l, o, pk
else
yield :unknown, l
end
end
end
end
def each &block
each_parsed_line {|t, *a| yield *a if :pub == t }
end
def get ident
each do |line, opts, pk|
return opts, pk if ident === pk.comment
end
nil
end
alias :[] :get
def add! options, pub
@file.open 'a+' do |f|
f.puts "#{options} #{pub}"
end
end
def delete! ident
tmp = Pathname.new "#{@file}.tmp.#{Process.pid}"
ret = 0
tmp.open 'w' do |f|
self.each_parsed_line do |t, l, *a|
if :pub == t and a[1].comment == ident
ret += 1
else
f.puts l
end
end
end
if 0 == ret
tmp.unlink
else
@file.unlink
tmp.rename @file
end
ret
end
end
class PublicKeyFile
include FileHelper
attr_reader :type, :ident
def initialize file, type, ident
@file, @type, @ident = file, type, ident
end
def prepare authkeysfile, ident
opts, entry = authkeysfile[ident]
die "PubKey not found in authorized_keys: #{ident}" unless entry
if exist?
entry == @file.read.chomp
else
@file.open( 'a+') {|f| f.puts entry }
entry == PublicKey.parse( @file.read.chomp)
end
rescue Errno::ENOENT
die "PubKey unusable"
end
end
class CertifcateFile
include FileHelper
attr_reader :metadata
def initialize file
@file = file
end
def parse
r = {}
popen %w[ssh-keygen -L -f], @file do |f|
mode = nil
f.each_line do |l|
case l.chomp
when /^ Valid: from ([^ ][^ ]*) to ([^ ][^ ]*)$/
r[:valid] = Time.parse( $1) ... Time.parse( $2)
when /^ Type: ([^ ]+) ([^ ]+) certificate$/
r[:type] = [$1, $2]
when /^ Public key: ([^ ]+) (.*)$/
r[:pubkey]= [$1, $2]
when /^ Signing CA: ([^ ]+) (.*)$/
r[:signca]= [$1, $2]
when /^ Key ID: (.*)$/
r[:keyid] = $1.sub /\A"?(.*?)"?\z/, '\1'
when /^ Serial: (\d+)$/
r[:serial]= $1.to_i
when /^ Principals: \(node\)$/
r[:principals] = []
when /^ Principals:\s*$/
mode = :principals
r[:principals] = []
when /^ Extensions: \(none\)$/
r[:extensions] = []
when /^ Extensions:\s*$/
mode = :extensions
r[:extensions] = []
when /^ Critical Options: \(none\)$/
r[:critopts] = []
when /^ Critical Options:\s*$/
mode = :critopts
r[:critopts] = []
when /^ ([^ ].*)$/
r[mode].push $1
else
end
end
end
@metadata = r
end
def [] key
(@metadata || parse)[key]
end
alias to_hash []
def valid_at? ts
exist? and self[:valid].include?( ts)
end
def create pubfile, type:, valid:, serial:, cafile:, info:, principals:, pubsdir:
#sign_user_pub opts
err =
popen( %w[ssh-keygen -V], "#{valid.begin.strftime '%Y%m%d'}:#{valid.end.strftime '%Y%m%d'}",
(:host == type ? '-h' : nil), '-s', cafile,
'-P', '', '-z', serial, '-I', info, '-n', principals.join(','), pubfile,
err: %i[child out]
) {|l| l.read }
die "Creating certificate failed [#{$?.inspect}]: #{err}" unless 0 == $?.exitstatus
serialkeyfile = pubsdir + 'serials' + serial.to_s
serialkeyfile.open( 'a+') {|f| f.write self.read }
self
end
end
class SerialFile
include FileHelper
def initialize file
@file = file
end
def read
open 'a+' do |fh|
fh.read.to_i
end
end
alias to_i read
alias get read
def increment!
open 'a+' do |fh|
(1+fh.read.to_i).tap do |val|
fh.rewind
fh.truncate 0
fh.print val
end
end
end
alias inc! increment!
end
class Lock
attr_reader :path, :file
LOCK_T = [Fcntl::F_WRLCK, IO::SEEK_SET, 0, 0, 0].pack('s_s_q_q_q_')
UNLOCK_T = [Fcntl::F_UNLCK, IO::SEEK_SET, 0, 0, 0].pack('s_s_q_q_q_')
def initialize path
@path = path
@file = @path.open File::CREAT|File::RDWR, 0600
end
def exclusive!() die "Unable to get lock." unless @file.fcntl Fcntl::F_SETLKW, LOCK_T end
def release!() die "Unable to release lock." unless @file.fcntl Fcntl::F_SETLKW, UNLOCK_T end
def exclusive &block
exclusive!
yield
ensure
release!
end
end
module REs
HOSTNAME = /[a-zA-Z](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?/
Hostname = /\A#{HOSTNAME}\z/
DOMAINNAME = /#{HOSTNAME}(?:\.#{HOSTNAME})*\.?/
Domainname = /\A#{DOMAINNAME}\z/
USERNAME = /[a-zA-Z0-9_]+/
Username = /\A#{USERNAME}\z/
USERIDENT = /#{USERNAME}@#{DOMAINNAME}/
Userident = /\A#{USERIDENT}\z/
# https://stackoverflow.com/a/17871737
OCTET = /1?[0-9]?[0-9] | 2[0-4][0-9] | 25[0-5]/x
IPV4ADDR = /#{OCTET}\.#{OCTET}\.#{OCTET}\.#{OCTET}/
IPv4Addr = /\A#{IPV4ADDR}\z/
IPV6SEG = /[0-9a-fA-Z]{1,4}/
IPV6ADDR = %r<
(?:#{IPV6SEG}:){7,7}#{IPV6SEG}| # 1:2:3:4:5:6:7:8
(?:#{IPV6SEG}:){1,7}:| # 1:: 1:2:3:4:5:6:7::
(?:#{IPV6SEG}:){1,6}:#{IPV6SEG}| # 1::8 1:2:3:4:5:6::8 1:2:3:4:5:6::8
(?:#{IPV6SEG}:){1,5}(?::#{IPV6SEG}){1,2}| # 1::7:8 1:2:3:4:5::7:8 1:2:3:4:5::8
(?:#{IPV6SEG}:){1,4}(?::#{IPV6SEG}){1,3}| # 1::6:7:8 1:2:3:4::6:7:8 1:2:3:4::8
(?:#{IPV6SEG}:){1,3}(?::#{IPV6SEG}){1,4}| # 1::5:6:7:8 1:2:3::5:6:7:8 1:2:3::8
(?:#{IPV6SEG}:){1,2}(?::#{IPV6SEG}){1,5}| # 1::4:5:6:7:8 1:2::4:5:6:7:8 1:2::8
#{IPV6SEG}:(?:(?::#{IPV6SEG}){1,6})| # 1::3:4:5:6:7:8 1::3:4:5:6:7:8 1::8
:(?:(?::#{IPV6SEG}){1,7}|:)| # ::2:3:4:5:6:7:8 ::2:3:4:5:6:7:8 ::8 ::
fe80:(?::#{IPV6SEG}){0,4}%[0-9a-zA-Z]{1,}| # fe80::7:8%eth0 fe80::7:8%1 (link-local IPv6 addresses with zone index)
::(?:ffff(?::0{1,4}){0,1}:){0,1}IPV4ADDR| # ::255.255.255.255 ::ffff:255.255.255.255 ::ffff:0:255.255.255.255 (IPv4-mapped IPv6 addresses and IPv4-translated addresses)
(?:#{IPV6SEG}:){1,4}:IPV4ADDR # 2001:db8:3:4::192.0.2.33 64:ff9b::192.0.2.33 (IPv4-Embedded IPv6 Address)
>x
IPv6Addr = /\A#{IPV6ADDR}\z/
IPADDR = /#{IPV6ADDR}|#{IPV4ADDR}/
IPAddr = /\A#{IPADDR}\z/
end
class Main
attr_reader :admin, :type, :ident, :principals, :info, :expire_in, :gracetime
alias :admin? :admin
def initialize *argv
die "2..4 arguments (type ident [principals [info]]) expected. "+
"Provided count of arguments: #{argv.length}" unless (2..4) === argv.length
type, @ident, @principals, @info = argv[0..3]
@admin, @type, @expire_in, @gracetime = false, type.to_sym, 12.weeks, 1.week
prepare
files
end
def prepare
case @type
when :host
die "Invalid ident: #{@ident}" unless REs::Domainname =~ @ident
@expire_in, @gracetime = 360.days, 30.days
@info ||= "host: #{@ident}"
@principals =
([@ident] +
@principals.split( ',').map do |e|
if /:/ =~ e or /\A[0-9.]+\z/ =~ e or /\A[^.]+\z/ =~ e
e
else
e = e.gsub /\.?\z/, ''
[e, "#{e}."]
end
end
).flatten.compact.map do |e|
if /\A\[/ =~ e
e
else
[e, "[#{e}]:19"]
end
end.flatten.compact.uniq
when :user
die "Invalid ident: #{@ident}" unless REs::Userident =~ @ident
@info ||= "user: #{@ident}"
@principals = @principals.split ','
when :admin
die "Invalid ident: #{@ident}" unless REs::Userident =~ @ident
@info ||= "admin: #{@ident}"
@type, @admin = :user, true
@principals = @principals.split ','
else
die "Invalid type: #{@type}"
end
end
attr_reader :lock, :pubsdir, :pubfile, :certfile, :cafile, :capubfile, :serialfile, :authkeysfile
def files
@config =
begin
JSON.parse Pathname.new( "~/.config/ssh-ca.conf").read
rescue Errno::ENOENT
{}
rescue JSON::JSONError
die "500, Internal server error"
end
@lock = Lock.new Pathname.new( @config[:lockfile] || "/run/lock/ssh-ca.lock")
@storage = Pathname.new( @config[:storage] || "~/.local/sshca")
@pubsdir =
if @config[:pubsdir] then Pathname.new @config[:pubsdir]
else @storage + 'pubs'
end.expand_path
@pubfile = PublicKeyFile.new @pubsdir + "#{@type}-#{@ident}.pub", @type, @ident
@certfile = CertifcateFile.new @pubsdir + "#{@type}-#{@ident}-cert.pub"
@cafile = @storage + "ca"
@capubfile = @storage + "ca.pub"
@serialfile = SerialFile.new @storage + "serial"
@authkeysfile = AuthorizedKeys.new Pathname.new( @config[:authorized_keys_file] || "~/.ssh/authorized_keys").expand_path
@binfile = @config[:command] || __FILE__
end
def help msg=nil
STDERR.puts msg if msg
conn = "#{ENV['LOGNAME']}@#{`hostname -f`}".chomp
STDERR.puts <<-EOHELP.gsub( /^\s*#(.*)$/, admin? ? ":\\1" : "").sub(/\n+/, "\n").gsub( /^\s*:/, '')
:Usage: ssh #{conn} renew [force] [show]
: ssh #{conn} show
: ssh #{conn} help
: ssh #{conn} jsonl
# ssh #{conn} keylist
# ssh #{conn} keyimport host|user|admin IDENT PRINCIPALS*
# ssh #{conn} keydelete IDENT
:
:[h]elp
:[r]enew Renews (if expires in graceperiod) or creates (if not existing) certificate
: [f]orce forces renewal, also if valid for graceperiod
:[s]how Shows current certificate
:jsonl Expect commands via STDIN as JSONL and responses via STDOUT in JSONL
: Request format: [0, CMD, ARGS...]
: Response format: [SCODE, BODY]
: SCODEs are like HTTP.
#keylist Lists installed keys
#keyimport host|user|admin IDENT PRINCIPALS*
# Import new key as host (a host-key) or user|admin (a user-key)
# Users and hosts are allowed to renew there keys.
# Admins are allowed to administrate keys (import/delete).
#keydelete IDENT
# Deletes key of this IDENT
:
:possible CMD:
:<renew[, {<force: true|false>}]> => <RENEWED, DATA, CONTENT>
:<get> => <DATA, CONTENT>
:
: RENEWED indicates if file was renewed (true|false).
: CONTENT is the content of the certfile (String).
: DATA is the parsed file (ssh-keygen -L) (Hash).
EOHELP
exit 1
end
def show argv
die "Unexpected addition parameters: #{argv.shelljoin}" unless 1 == argv.length
die "No certfile, yet: #{@certfile.basename}" unless @certfile.exist?
lock.exclusive do
puts @certfile.read
end
end
def renew argv
begin
unexpected = argv[1..-1] - %w[s show f force]
die "Unknown argument: #{unexpected.shelljoin}" unless unexpected.empty?
end
show = ! (argv[1..-1] & %w[s show]).empty?
force = ! (argv[1..-1] & %w[f force]).empty?
lock.exclusive do
@pubfile.prepare @authkeysfile, ident
die "Pubfile or pub in authorized_keys for #{ident} not found." unless @pubfile.exist?
if !force && @certfile.exist? && @certfile.valid_at?( @gracetime.from_now) &&
@certfile[:principals].sort == principals.sort
STDERR.print "Certificate [#{@certfile[:serial]}|#{@certfile[:keyid]}] "+
"valid between #{@certfile[:valid]} as #{@certfile[:principals].sort.join ', '}\n"
STDERR.flush
STDOUT.print "#{@certfile.read.chomp}\n" if show
exit 0
end
STDERR.puts "Renew certificate for #{ident} as #{principals.sort.join ', '}"
@certfile.create @pubfile, type: type, valid: -5.minutes.ago..@expire_in.from_now,
serial: @serialfile.increment!, cafile: @cafile, info: info,
principals: principals, pubsdir: @pubsdir
puts @certfile.read if show
end
end
def jsonl
STDIN.each_line do |line|
begin
line = JSON.parse line, symbolize_names: true
cmd, args = line[0].to_sym, line[1..-1]
case cmd
when :renew
die "renew expects no or a Hash as args." unless
(0..1).include?( args.length) && args.kind_of?( Hash)
args ||= {}
args[:force] = !!args[:force]
lock.exclusive do
@pubfile.prepare authkeyfile, ident
die "Pubfile or pub in authorized_keys for #{ident} not found." unless
@pubfile.exist?
if !args[:force] && @certfile.valid_at?( @gracetime.from_now)
STDOUT.puts [200, false, @certfile.metadata, @certfile.read].to_json
end
@certfile.create @pubfile, type: type, pubsdir: @pubsdir,
valid: -5.minutes.ago..@expire_in.from_now, serial: @serialfile.increment!,
cafile: @cafile, info: info, principals: principals
STDOUT.puts [200, true, @certfile.metadata, @certfile.read].to_json
end
when :get
lock.exclusive do
STDOUT.puts [200, @certfile.metadata, @certfile.read].to_json
end
else
die "Unknown command."
end
rescue Object
STDOUT.puts [500, $!.class.name, $!.to_s].to_json
end
end
end
def keyimport argv
help "Unknown command: #{argv[0]}" unless admin
type, ident, *principals = argv[1..-1].map(&:downcase)
case type
when 'host'
die "Ident must be a domainname / hostname" unless REs::Domainname =~ ident
principals.each do |princ|
case princ
when REs::Domainname then # ok
when REs::IPv6Addr then # ok
when REs::IPv4Addr then # ok
else die "Principals must be domainnames or IP-addresses, not <#{princ}>"
end
end
when 'user', 'admin'
die "Ident must be user@hostname (lower cased)" unless REs::Userident =~ ident
else
die "Type of key expected (host, user, admin) instead of <#{type}>."
end
str = STDIN.readline.chomp
pk, _ = PublicKey.parse( str)
die "Cannot parse public key <#{str}>." unless pk
pk.comment = ident
lock.exclusive do
@authkeysfile.add! "restrict,command=\"#{__FILE__} #{type} #{ident} #{principals.join ','}\"", pk
end
end
def keydelete argv
help "Unknown command: #{argv[0]}" unless admin
ident = argv[1]
lock.exclusive do
@authkeysfile.delete! ident
end
end
def keylist argv
help "Unknown command: #{argv[0]}" unless admin
lock.exclusive do
@authkeysfile.each_line {|l| STDOUT.print l }
end
end
def exec cmd
help unless cmd
die "Unallowed char in arguments [\\0]" if cmd.include? 0.chr
argv = cmd.shellsplit
case argv[0]
when *%w[h help] then help
when *%w[s show] then show argv
when *%w[r renew] then renew argv
when 'jsonl' then jsonl argv
when 'keyimport' then keyimport argv
when 'keydelete' then keydelete argv
when 'keylist' then keylist argv
else help "Unknown command: #{argv[0]}"
end
end
end
#################################################
begin
main = Main.new *ARGV
main.exec ENV['SSH_ORIGINAL_COMMAND']
rescue SystemExit
raise
rescue Die
STDERR.puts $!
exit 1
rescue Object
STDERR.puts "#$! (#{$!.class})", *$!.backtrace
exit 2
end

16
ssh-ca.conf Normal file
View file

@ -0,0 +1,16 @@
{
"!": [
"comments are keys beginning with #, ! are for documentation.",
"configs are letters-only like \"lockfile\": \"/run/lock/ssh-ca.lock\""
],
"!lockfile": [
"ssh-ca manipulates your authorized_keys and other files.",
"To prevent concurrent access and damages to your file, it uses a lock."
],
"#lockfile": "/run/lock/ssh-ca.lock",
"#storage": "~/.local/sshca",
"#pubsdir": "~/.local/sshca/pub",
"#authorized_keys_file": "~/.ssh/authorized_keys",
"#command": "~/bin/ssh-ca"
}