SSH File Signatures
SSH keys can be used to sign files!
Unfortunately this is a pretty recent change to the openssh tooling, so it is not
supported by golang.org/x/crypto/ssh yet.
This document explains how it works at a high level.
Keys
SSH keys are usually split into public and private files, named id_rsa.pub
and
id_rsa
, respectively.
These files are encoded and formatted a little differently than other signing keys.
Public Keys
These are typically in the "known hosts" format.
This looks something like:
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDw0ZWP4zZLELSJVenQTQsrFJVBnoP64KTg/UWRU6qOb8HEOdtHJDOyTmo9dvN/yJoTFtWAfQEjaTsMVJzTD0gOk6ncTsp0BUtgXawSCfEUiv7v+2VgSVbUfAv/NL+HEGSCdcORnansIyrZaHwAjR3ei3O+pRWvgjRj3pOH1rWGrxaC5IbsELYzS/HvwAG/uwcxgBv4POvaq6eCEHVbqRjIYjjoYsC+c24sgSQxOyXvDS7j2z9TPHPvepDhVr9y6xnnqhLqZEWmidRrbb35aYkVLJxmGTFy/JW1cewyU2Jb3+sKQOiOwL7DAB39tRyec2ed+EHh6QLW4pcMnoXsWuPyi+G595HiUYmIlqXJ5JPo0Cv/rOJrmWSFceWiDjC/SeODp/AcK0EsN/p3wOp6ac7EzAz9Npri0vwSQX4MUYlya/olKiKCx5GIhTZtXioREPd8v4osx2VrVyDxKX99PVVbxw1FXSe4u+PuOawJzUA4vW41mxUY9zoAsb/fvoNPtrrT9HfC+7Pg6ryBdz+445M8Atc8YjjLeYXkTXWD6KMielRzBFFoIwIgi0bMotq3iQ9IwjQSXPMDQLb+UPg8xqsgRsX3wvyZzdBhxO4Bdomv7JYmySysaGgliHktU8qRse1lpDIXMovPtowywcKL4U3seDKrq7saVO0qdsLavy1o0w== lorenc.d@gmail.com
These can be parsed with ParseKnownHosts
, NOT ParsePublicKey
.
In addition to the key material itself, this can contain the algorithm (ssh-rsa
here) and a comment
(lorenc.d@gmail.com) here.
Private Keys
These are stored in an "armored" PEM format, resembling PGP or x509 keys:
-----BEGIN SSH PRIVATE KEY-----
<base64 encoded key here>
-----END SSH PRIVATE KEY-----
These can be parsed correctly with ParsePrivateKey.
The wire format is relatively standard.
- Bytes are laid out in order.
- Fixed-length fields are laid out at the proper offset with the specified length.
- Strings are stored with the size as a prefix.
Signature
These can be generated and validated from the command line with the ssh-keygen -Y
set of commands:
sign
, verify
, and check-novalidate
.
To work with them in Go is a little tricker.
The signature is stored using a struct packed using the openssh
wire format.
The data that is used in the signing function is also packed in another struct before it is signed.
Signatures are formatted on disk in a PEM-encoded format.
The header is -----BEGIN SSH SIGNATURE-----
, and the end is -----BEGIN SSH SIGNATURE-----
.
The signature contents are base64-encoded.
The signature contents are wrapped with extra metadata, then encoded as a struct using the
openssh
wire format.
That struct is defined here.
In Go:
type wrappedSig struct {
MagicHeader [6]byte
Version uint32
PublicKey string
Namespace string
Reserved string
HashAlgorithm string
Signature string
}
The PublicKey
and Signature
fields are also stored as openssh-wire-formatted structs.
The MagicHeader
is SSHSIG
.
The Version
is 1.
The Namespace
is file
(for this use-case).
Reserved
must be empty.
Go can already parse the PublicKey
and Signature
fields,
and the Signature
struct contains a Blob
with the signature data.
Signed Message
In addition to these wrappers, the message to be signed is wrapped with some metadata before
it is passed to the signing function.
That wrapper is defined here.
And in Go:
type messageWrapper struct {
Namespace string
Reserved string
HashAlgorithm string
Hash string
}
So, the data must first be hashed, then packed in this struct and encoded in the
openssh wire format.
Then, this resulting data is signed using the desired signature function.
The Namespace
field must be file
(for this usecase).
The Reserved
field must be empty.
The output of this signature function (and the hash) becomes the Signature.Blob
value, which gets wire-encoded, wrapped, wire-encoded and finally pem-encoded.