hare-ssh.md (32420B)
- ---
- title: Implementing an SSH agent in Hare
- author: Drew DeVault
- date: 2022-05-09
- ---
- *Cross-posted from [the Hare blog](https://harelang.org/blog/2022-05-09-hare-ssh/)*
- In the process of writing an SSH agent for [Himitsu], I needed to implement many
- SSH primitives from the ground up in Hare, now available via [hare-ssh]. Today,
- I'm going to show you how it works!
- [Himitsu]: https://sr.ht/~sircmpwn/himitsu
- [hare-ssh]: https://sr.ht/~sircmpwn/hare-ssh
- **Important**: This blog post deals with cryptography-related code. The code
- you're going to see today is incomplete, unaudited, and largely hasn't even seen
- any code review. Let me begin with a quote from the "crypto" module's
- documentation in the Hare standard library:
- > Cryptography is a difficult, high-risk domain of programming. The life and
- > well-being of your users may depend on your ability to implement cryptographic
- > applications with due care. Please carefully read all of the documentation,
- > double-check your work, and seek second opinions and independent review of
- > your code. Our documentation and API design aims to prevent easy mistakes from
- > being made, but it is no substitute for a good background in applied
- > cryptography.
- Do your due diligence before repurposing anything you see here.
- ## Decoding SSH private keys
- Technically, you do not need to deal with OpenSSH private keys when implementing
- an SSH agent. However, my particular use-case includes dealing with this format,
- so I started here. Unlike much of SSH, the OpenSSH private key format (i.e. the
- format of the file at ~/.ssh/id_ed25519) is, well, private. It's not
- documented and I had to get most of the details from reverse-engineering the
- OpenSSH C code. The main area of interest is sshkey.c. I'll spare you from
- reading it yourself and just explain how it works.
- First of all, let's just consider what an SSH private key looks like:
- ```
- -----BEGIN OPENSSH PRIVATE KEY-----
- b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABDTIm/zSI
- 7zeHAs4rIXaOD1AAAAEAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIE7qq/pMk9VrRupn
- 9j4/tNHclJnKgJAE1pfUecRNT1fAAAAAoEcx6mnJmFlYXx1eYztw6SZ5yuL6T1LWfj+bpg
- 7zNQBoqJW1j+Q17PUMtXj9wDDOQx+6OE7JT/RrK3Vltp4oXmFI4FgsYbE9RbNXSC2xvLaX
- fplmx+eAOir9UTZGTIbOGy1cVho8LzDLLo4WiGYbpxtIvkJE72f0YdTm8RrNVkLlAy7ayV
- uFcoq1JBrjIAa7UtqIr9SG8b76ALJZb9jPc3A=
- -----END OPENSSH PRIVATE KEY-----
- ```
- We can immediately tell that this is a [PEM] file ([RFC 7468]). The first step
- to read this file was to implement a decoder for the PEM format, which has been
- on our to-do list for a while now, and is also needed for many other use-cases.
- Similar to many other formats provided in the standard library, you can call
- [pem::newdecoder] to create a PEM decoder for an arbitrary I/O source, returning
- the decoder state on the stack. We can then call [pem::next] to find the next
- PEM header (`-----BEGIN...`), which returns a decoder for that specific PEM blob
- (this design accommodates PEM files which have several PEM segments concatenated
- together, or intersperse other data in the file alongside the PEM bits. This is
- common for other PEM use-cases). With this secondary decoder, we can simply read
- from it like any other I/O source and it decodes the base64-encoded data and
- returns it to us as bytes.
- [PEM]: https://docs.harelang.org/encoding/pem
- [RFC 7468]: https://www.rfc-editor.org/rfc/rfc7468
- [pem::newdecoder]: https://docs.harelang.org/encoding/pem#newdecoder
- [pem::next]: https://docs.harelang.org/encoding/pem#next
- Based on this, we can examine the contents of this key with a simple program.
- ```hare
- use encoding::hex;
- use encoding::pem;
- use fmt;
- use io;
- use os;
- export fn main() void = {
- const dec = pem::newdecoder(os::stdin);
- defer pem::finish(&dec);
- for (true) {
- const reader = match (pem::next(&dec)) {
- case let reader: (str, pem::pemdecoder) =>
- yield reader;
- case io::EOF =>
- break;
- };
- const name = reader.0, stream = reader.1;
- defer io::close(&stream)!;
- fmt::printfln("PEM data '{}':", name)!;
- const bytes = io::drain(&stream)!;
- defer free(bytes);
- hex::dump(os::stdout, bytes)!;
- };
- };
- ```
- Running this program on our sample key yields the following:
- ```
- PEM data 'OPENSSH PRIVATE KEY':
- 00000000 6f 70 65 6e 73 73 68 2d 6b 65 79 2d 76 31 00 00 |openssh-key-v1..|
- 00000010 00 00 0a 61 65 73 32 35 36 2d 63 74 72 00 00 00 |...aes256-ctr...|
- 00000020 06 62 63 72 79 70 74 00 00 00 18 00 00 00 10 d3 |.bcrypt.........|
- 00000030 22 6f f3 48 8e f3 78 70 2c e2 b2 17 68 e0 f5 00 |"o.H..xp,...h...|
- 00000040 00 00 10 00 00 00 01 00 00 00 33 00 00 00 0b 73 |..........3....s|
- 00000050 73 68 2d 65 64 32 35 35 31 39 00 00 00 20 4e ea |sh-ed25519... N.|
- 00000060 ab fa 4c 93 d5 6b 46 ea 67 f6 3e 3f b4 d1 dc 94 |..L..kF.g.>?....|
- 00000070 99 ca 80 90 04 d6 97 d4 79 c4 4d 4f 57 c0 00 00 |........y.MOW...|
- 00000080 00 a0 47 31 ea 69 c9 98 59 58 5f 1d 5e 63 3b 70 |..G1.i..YX_.^c;p|
- 00000090 e9 26 79 ca e2 fa 4f 52 d6 7e 3f 9b a6 0e f3 35 |.&y...OR.~?....5|
- 000000a0 00 68 a8 95 b5 8f e4 35 ec f5 0c b5 78 fd c0 30 |.h.....5....x..0|
- 000000b0 ce 43 1f ba 38 4e c9 4f f4 6b 2b 75 65 b6 9e 28 |.C..8N.O.k+ue..(|
- 000000c0 5e 61 48 e0 58 2c 61 b1 3d 45 b3 57 48 2d b1 bc |^aH.X,a.=E.WH-..|
- 000000d0 b6 97 7e 99 66 c7 e7 80 3a 2a fd 51 36 46 4c 86 |..~.f...:*.Q6FL.|
- 000000e0 ce 1b 2d 5c 56 1a 3c 2f 30 cb 2e 8e 16 88 66 1b |..-\V.</0.....f.|
- 000000f0 a7 1b 48 be 42 44 ef 67 f4 61 d4 e6 f1 1a cd 56 |..H.BD.g.a.....V|
- 00000100 42 e5 03 2e da c9 5b 85 72 8a b5 24 1a e3 20 06 |B.....[.r..$.. .|
- 00000110 bb 52 da 88 af d4 86 f1 be fa 00 b2 59 6f d8 cf |.R..........Yo..|
- 00000120 73 70 |sp|
- ```
- OpenSSH private keys begin with a magic string, "openssh-key-v1\0", which we can
- see here. Following this are a number of binary encoded fields which are
- represented in a manner similar to the SSH wire protocol, most often as strings
- prefixed by their length, encoded as a 32-bit big-endian integer. In order, the
- fields present here are:
- - Cipher name (aes256-ctr)
- - KDF name (bcrypt)
- - KDF data
- - Public key data
- - Private key data (plus padding)
- We parse this information like so:
- ```hare
- export type sshprivkey = struct {
- cipher: str,
- kdfname: str,
- kdf: []u8,
- pubkey: []u8,
- privkey: []u8,
- };
- export fn decodesshprivate(in: io::handle) (sshprivkey | error) = {
- const pem = pem::newdecoder(in);
- const dec = match (pem::next(&pem)?) {
- case io::EOF =>
- return invalid;
- case let dec: (str, pem::pemdecoder) =>
- if (dec.0 != "OPENSSH PRIVATE KEY") {
- return invalid;
- };
- yield dec.1;
- };
- let magicbuf: [15]u8 = [0...];
- match (io::readall(&dec, magicbuf)?) {
- case size => void;
- case io::EOF =>
- return invalid;
- };
- if (!bytes::equal(magicbuf, strings::toutf8(magic))) {
- return invalid;
- };
- let key = sshprivkey { ... };
- key.cipher = readstr(&dec)?;
- key.kdfname = readstr(&dec)?;
- key.kdf = readslice(&dec)?;
- let buf: [4]u8 = [0...];
- match (io::readall(&dec, buf)?) {
- case size => void;
- case io::EOF =>
- return invalid;
- };
- const nkey = endian::begetu32(buf);
- if (nkey != 1) {
- // OpenSSH currently hard-codes the number of keys to 1
- return invalid;
- };
- key.pubkey = readslice(&dec)?;
- key.privkey = readslice(&dec)?;
- // Add padding bytes
- append(key.privkey, io::drain(&dec)?...);
- return key;
- };
- ```
- However, to get at the actual private key — so that we can do
- cryptographic operations with it — we first have to decrypt this inner
- data. Those three fields — cipher name, KDF name, and KDF data — are
- our hint. In essence, this data is encrypted by OpenSSH by using a variant of
- [bcrypt] as a [key derivation function], which turns your password (plus a salt)
- into a symmetric encryption key. Then it uses [AES 256] in [CTR mode] with this
- symmetric key to encrypt the private key data. With the benefit of hindsight, I
- might question these primitives, but that's what they use so we'll have to work
- with it.
- [bcrypt]: https://en.wikipedia.org/wiki/Bcrypt
- [key derivation function]: https://en.wikipedia.org/wiki/Key_derivation_function
- [AES 256]: https://en.wikipedia.org/wiki/Advanced_Encryption_Standard
- [CTR mode]: https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation#Counter_(CTR)
- Prior to starting this work, Hare already had support for [AES] and [CTR],
- though they gained some upgrades during the course of this work, since using an
- interface for real-world code is the best way to evaluate its design. This
- leaves us to implement bcrypt.
- [AES]: https://docs.harelang.org/crypto/aes
- [CTR]: https://docs.harelang.org/crypto/cipher#ctr
- bcrypt is a password hashing algorithm invented by OpenBSD based on the
- [Blowfish] cipher, and it is pretty badly designed. However, Blowfish was fairly
- straightforward to implement. I'll spare you the details, but here's the
- [documentation](https://docs.harelang.org/crypto/blowfish) and
- [implementation](https://git.sr.ht/~sircmpwn/hare/tree/master/crypto/blowfish)
- for your consideration. I also implemented the standard bcrypt hash at
- [crypto::bcrypt], whose implementation is
- [here](https://git.sr.ht/~sircmpwn/hare/tree/master/crypto/bcrypt) (for now).
- This isn't especially relevant for us, however, since OpenSSH uses a modified
- form of bcrypt as a key derivation function.
- [Blowfish]: https://en.wikipedia.org/wiki/Blowfish_(cipher)
- [crypto::bcrypt]: https://docs.harelang.org/crypto/bcrypt
- The [implementation](https://git.sr.ht/~sircmpwn/hare-ssh/tree/master/item/format/ssh/bcrypt.ha)
- the bcrypt KDF in Hare is fairly straightforward. To write it, I referenced
- OpenSSH portable's vendored OpenBSD implementation at
- `openbsd-compat/bcrypt_pbkdf.c`, as well as the Go implementation in
- `golang.org/x/crypto`. Then, with these primitives done, we can implement the
- actual key decryption.
- First, not all keys are encrypted with a passphrase, so a simple function tells
- us if this step is required:
- ```hare
- // Returns true if this private key is encrypted with a passphrase.
- export fn isencrypted(key: *sshprivkey) bool = {
- return key.kdfname != "none";
- };
- ```
- The "decrypt" function is used to perform the actual decryption. It begins by
- finding the symmetric key, like so:
- ```hare
- export fn decrypt(key: *sshprivkey, pass: []u8) (void | error) = {
- assert(isencrypted(key));
- const cipher = getcipher(key.cipher)?;
- let ckey: []u8 = alloc([0...], cipher.keylen + cipher.ivlen);
- defer {
- bytes::zero(ckey);
- free(ckey);
- };
- let kdfbuf = bufio::fixed(key.kdf, io::mode::READ);
- switch (key.kdfname) {
- case "bcrypt" =>
- const salt = readslice(&kdfbuf)?;
- defer free(salt);
- const rounds = readu32(&kdfbuf)?;
- bcrypt_pbkdf(ckey, pass, salt, rounds);
- case =>
- return badcipher;
- };
- ```
- The "KDF data" field I mentioned earlier uses a format private to each KDF mode,
- though at the present time the only supported KDF is this bcrypt one. In this
- case, it serves as the salt. The "getcipher" function returns some data from a
- [static table of supported ciphers][ciphers], which provides us with the
- required size of the cipher's key and IV parameters. We allocate sufficient
- space to store these, create a [bufio reader][bufio::fixed] from the KDF field,
- read out the salt and hashing rounds, and hand all of this over to the bcrypt
- function to produce our symmetric key (and I/V) in the "ckey" variable.
- [ciphers]: https://git.sr.ht/~sircmpwn/hare-ssh/tree/master/item/format/ssh/cipher.ha
- [bufio::fixed]: https://docs.harelang.org/bufio#fixed
- We may then use these parameters to decrypt the private key area.
- ```hare
- let secretbuf = bufio::fixed(key.privkey, io::mode::READ);
- const cipher = cipher.init(&secretbuf,
- ckey[..cipher.keylen], ckey[cipher.keylen..]);
- defer cipher_free(cipher);
- let buf: []u8 = alloc([0...], len(key.privkey));
- defer free(buf);
- io::readall(cipher, buf)!;
- const a = endian::begetu32(buf[..4]);
- const b = endian::begetu32(buf[4..8]);
- if (a != b) {
- return badpass;
- };
- key.privkey[..] = buf[..];
- free(key.kdf);
- free(key.kdfname);
- free(key.cipher);
- key.kdfname = strings::dup("none");
- key.cipher = strings::dup("none");
- key.kdf = [];
- };
- ```
- The "cipher.init" function is an abstraction that allows us to support more
- ciphers in the future. For this particular cipher mode, it's implemented fairly
- simply:
- ```hare
- type aes256ctr = struct {
- st: cipher::ctr_stream,
- block: aes::ct64_block,
- buf: [aes::CTR_BUFSIZE]u8,
- };
- fn aes256ctr_init(handle: io::handle, key: []u8, iv: []u8) *io::stream = {
- let state = alloc(aes256ctr {
- block = aes::ct64(),
- ...
- });
- aes::ct64_init(&state.block, key);
- state.st = cipher::ctr(handle, &state.block, iv, state.buf);
- return state;
- };
- ```
- Within this private key data section, once decrypted, are several fields. First
- is a random 32-bit integer which is written twice — comparing that these
- are equal to one another allows us to verify the user's password. Once verified,
- we overwrite the private data field in the key structure with the decrypted
- data, and update the cipher and KDF information to indicate that the key is
- unencrypted. We could decrypt it directly into the existing private key buffer,
- without allocating a second buffer, but this would overwrite the encrypted data
- with garbage if the password was wrong — you'd have to decode the key all
- over again if the user wants to try again.
- So, what does this private key blob look like once decrypted? The hare-ssh
- repository includes a little program at `cmd/sshkey` which dumps all of the
- information stored in an SSH key, and it provides us with this peek at the
- private data:
- ```
- 00000000 fb 15 e6 16 fb 15 e6 16 00 00 00 0b 73 73 68 2d |............ssh-|
- 00000010 65 64 32 35 35 31 39 00 00 00 20 4e ea ab fa 4c |ed25519... N...L|
- 00000020 93 d5 6b 46 ea 67 f6 3e 3f b4 d1 dc 94 99 ca 80 |..kF.g.>?.......|
- 00000030 90 04 d6 97 d4 79 c4 4d 4f 57 c0 00 00 00 40 17 |.....y.MOW....@.|
- 00000040 bf 87 74 0b 2a 74 d5 29 d0 14 10 3f 04 5d 88 c6 |..t.*t.)...?.]..|
- 00000050 32 fa 21 9c e9 97 b0 5a e7 7e 5c 02 72 35 72 4e |2.!....Z.~\.r5rN|
- 00000060 ea ab fa 4c 93 d5 6b 46 ea 67 f6 3e 3f b4 d1 dc |...L..kF.g.>?...|
- 00000070 94 99 ca 80 90 04 d6 97 d4 79 c4 4d 4f 57 c0 00 |.........y.MOW..|
- 00000080 00 00 0e 73 69 72 63 6d 70 77 6e 40 74 61 69 67 |...sircmpwn@taig|
- 00000090 61 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f |a...............|
- ```
- We can see upfront these two 32-bit verification numbers I mentioned, and
- following this are several fields in a similar format to earlier —
- length-prefixed strings. The fields are:
- - Key type ("ssh-ed25519" in this case)
- - Public key (in a format specific to each key type)
- - Private key (in a format specific to each key type)
- - Comment
- - Padding up to the cipher's block size (16)
- This is a little bit weird in my opinion — the public key field is
- redundant with the unencrypted data in this file, and the comment field is
- probably not so secret as to demand encryption. I think these are just
- consequences of the file format being private to OpenSSH's implementation; not
- much thought has gone into it and implementation details (like the ability to
- call the same "dump private key" function here as OpenSSH uses elsewhere) have
- probably leaked through.
- We can decode this data with the following Hare code:
- ```hare
- export fn decodeprivate(src: *sshprivkey) (key | error) = {
- assert(!isencrypted(src));
- const buf = bufio::fixed(src.privkey, io::mode::READ);
- let verify: [8]u8 = [0...];
- io::read(&buf, verify)!;
- const a = endian::begetu32(verify[..4]);
- const b = endian::begetu32(verify[4..8]);
- if (a != b) {
- return badpass;
- };
- const keytype = readstr(&buf)?;
- defer free(keytype);
- switch (keytype) {
- case "ssh-ed25519" =>
- let key = ed25519key { ... };
- decode_ed25519_sk(&key, &buf)?;
- return key;
- case =>
- // TODO: Support additional key types
- return badcipher;
- };
- };
- // An ed25519 key pair.
- export type ed25519key = struct {
- pkey: ed25519::publickey,
- skey: ed25519::privatekey,
- comment: str,
- };
- fn decode_ed25519_pk(key: *ed25519key, buf: io::handle) (void | error) = {
- const l = readu32(buf)?;
- if (l != ed25519::PUBLICKEYSZ) {
- return invalid;
- };
- io::readall(buf, key.pkey)?;
- };
- fn decode_ed25519_sk(key: *ed25519key, buf: io::handle) (void | error) = {
- decode_ed25519_pk(key, buf)?;
- const l = readu32(buf)?;
- if (l != ed25519::PRIVATEKEYSZ) {
- return invalid;
- };
- io::readall(buf, key.skey)?;
- // Sanity check
- const pkey = ed25519::skey_getpublic(&key.skey);
- if (!bytes::equal(pkey, key.pkey)) {
- return invalid;
- };
- key.comment = readstr(buf)?;
- };
- ```
- Fairly straightforward! Finally, we have extracted the actual private key from
- the file. For this SSH key, in base64, the cryptographic keys are:
- ```
- Public key: Tuqr+kyT1WtG6mf2Pj+00dyUmcqAkATWl9R5xE1PV8A=
- Private key: F7+HdAsqdNUp0BQQPwRdiMYy+iGc6ZewWud+XAJyNXJO6qv6TJPVa0bqZ/Y+P7TR3JSZyoCQBNaX1HnETU9XwA==
- ```
- ## Signing and verification with ed25519
- Using these private keys, implementing signatures and signature verification are
- pretty straightforward. We can stop reading the OpenSSH code at this point
- — [RFC 8709] standardizes this format for ed25519 signatures.
- [RFC 8709]: https://datatracker.ietf.org/doc/html/rfc8709
- ```hare
- use crypto::ed25519;
- use io;
- // Signs a message using the provided key, writing the message signature in the
- // SSH format to the provided sink.
- export fn sign(
- sink: io::handle,
- key: *key,
- msg: []u8,
- ) (void | io::error) = {
- const signature = ed25519::sign(&key.skey, msg);
- writestr(sink, "ssh-ed25519")?;
- writeslice(sink, signature)?;
- };
- // Reads an SSH wire signature from the provided I/O handle and verifies that it
- // is a valid signature for the given message and key. If valid, void is
- // returned; otherwise [[badsig]] is returned.
- export fn verify(
- source: io::handle,
- key: *key,
- msg: []u8,
- ) (void | error) = {
- const sigtype = readstr(source)?;
- defer free(sigtype);
- if (sigtype != keytype(key)) {
- return badsig;
- };
- const sig = readslice(source)?;
- defer free(sig);
- assert(sigtype == "ssh-ed25519"); // TODO: other key types
- if (len(sig) != ed25519::SIGNATURESZ) {
- return badsig;
- };
- const sig = sig: *[*]u8: *[ed25519::SIGNATURESZ]u8;
- if (!ed25519::verify(&key.pkey, msg, sig)) {
- return badsig;
- };
- };
- ```
- This implementation writes and reads signatures in the SSH wire format, which is
- generally how they will be most useful in this context. This code will be
- expanded in the future with additional keys, such as RSA, once the necessary
- primitives are implemented for Hare's standard library.
- ## The SSH agent protocol
- The agent protocol is also standardized (albeit in draft form), so we refer to
- [draft-miller-ssh-agent-11] from this point onwards. It's fairly
- straightforward. The agent communicates over an unspecified protocol (Unix
- sockets in practice) by sending messages in the SSH wire format, which, again,
- mainly comes in the form of strings prefixed by their 32-bit length in network
- order.
- [draft-miller-ssh-agent-11]: https://tools.ietf.org/id/draft-miller-ssh-agent-11.html
- The first step for implementing net::ssh::agent starts with adding types for all
- of the data structures and enums for all of the constants, which you can find in
- [types.ha]. Each message begins with its length, then a message type (one byte)
- and a message payload; the structure of the latter varies with the message type.
- [types.ha]: https://git.sr.ht/~sircmpwn/hare-ssh/tree/master/item/net/ssh/agent/types.ha
- I started to approach this by writing some functions which, given a byte buffer
- that contains an SSH agent message, either parses it or asks for more data.
- ```hare
- export fn parse(msg: []u8) (message | size | invalid) = {
- if (len(msg) < 5) {
- return 5 - len(msg);
- };
- const ln = endian::begetu32(msg[..4]);
- if (len(msg) < 4 + ln) {
- return 4 + ln - len(msg);
- };
- const mtype = msg[4];
- const buf = bufio::fixed(msg[5..], io::mode::READ);
- switch (mtype) {
- case messagetype::REQUEST_IDENTITIES =>
- return request_identities;
- case messagetype::SIGN_REQUEST =>
- return parse_sign_request(&buf)?;
- case messagetype::ADD_IDENTITY =>
- return parse_add_identity(&buf)?;
- // ...trimmed for brevity, and also because it's full of TODOs...
- case =>
- return invalid.
- };
- };
- ```
- Each individual message payload includes its own parser, except for some
- messages (such as `REQUEST_IDENTITIES`), which have no payload. Here's what the
- parser for `SIGN_REQUEST` looks like:
- ```hare
- fn parse_sign_request(src: io::handle) (sign_request | invalid) = {
- return sign_request {
- key = readslice(src)?,
- data = readslice(src)?,
- flags = readu32(src)?: sigflag,
- };
- };
- ```
- Pretty straightforward! A more complex one is `ADD_IDENTITY`:
- ```hare
- fn parse_add_identity(src: io::handle) (add_identity | invalid) = {
- const keytype = readstr(src)?;
- // TODO: Support more key types
- const key: ssh::key = switch (keytype) {
- case "ssh-ed25519" =>
- let key = ssh::ed25519key { ... };
- const npub = readu32(src)?;
- if (npub != len(key.pkey)) {
- return invalid;
- };
- io::readall(src, key.pkey)!;
- const npriv = readu32(src)?;
- if (npriv != len(key.skey)) {
- return invalid;
- };
- io::readall(src, key.skey)!;
- yield key;
- case =>
- return invalid;
- };
- return add_identity {
- keytype = keytype,
- key = key,
- comment = readstr(src)?,
- };
- };
- ```
- One thing I'm not thrilled with in this code is memory management. In Hare,
- libraries like this one are not supposed to allocate memory if they can get away
- with it, and if they must, they should do it as conservatively as possible. This
- implementation does a lot of its own allocations, which is unfortunate. I might
- refactor it in the future to avoid this. A more subtle issue here is the memory
- leaks on errors — each of the readslice/readstr functions allocates data
- for its return value, but if they return an error, the ? operator will return
- immediately without freeing them. This is a known problem with Hare's language
- design, and while we have some ideas for addressing it, we have not completed
- any of them yet. This is one of a small number of goals for Hare which will
- likely require language changes prior to 1.0.
- We have a [little bit more code](https://git.sr.ht/~sircmpwn/hare-ssh/tree/master/item/net/ssh/agent/agent.ha)
- in net::ssh::agent, which you can check out if you like, but this covers most of
- it — time to move onto the daemon implementation.
- ## Completing our SSH agent
- The [ssh-agent] command in the hare-ssh tree is a simple (and non-production)
- implementation of an SSH agent based on this work. Let's go over its code to see
- how this all comes together to make it work.
- [ssh-agent]: https://git.sr.ht/~sircmpwn/hare-ssh/tree/master/item/cmd/ssh-agent/main.ha
- First, we set up a Unix socket, and somewhere to store our application state.
- ```hare
- let running: bool = true;
- type identity = struct {
- comment: str,
- privkey: ssh::key,
- pubkey: []u8,
- };
- type state = struct {
- identities: []identity,
- };
- export fn main() void = {
- let state = state { ... };
- const sockpath = "./socket";
- const listener = unix::listen(sockpath)!;
- defer {
- net::shutdown(listener);
- os::remove(sockpath)!;
- };
- os::chmod(sockpath, 0o700)!;
- log::printfln("Listening at {}", sockpath);
- ```
- We also need a main loop, but we need to clean up that Unix socket when we
- terminate, so we'll also set up some signal handlers.
- ```hare
- signal::handle(signal::SIGINT, &handle_signal);
- signal::handle(signal::SIGTERM, &handle_signal);
- for (running) {
- // ...stay tuned...
- };
- for (let i = 0z; i < len(state.identities); i += 1) {
- const ident = state.identities[i];
- ssh::key_finish(&ident.privkey);
- free(ident.pubkey);
- free(ident.comment);
- };
- log::printfln("Terminated.");
- };
- // ...elsewhere...
- fn handle_signal(sig: int, info: *signal::siginfo, ucontext: *void) void = {
- running = false;
- };
- ```
- The actual clean-up is handled by our "defer" statement at the start of "main".
- The semantics of signal handling on Unix are complex (and bad), and beyond the
- scope of this post, so hopefully you already grok them. Our stdlib [provides
- docs](https://docs.harelang.org/unix/signal), if you care to learn more, but
- also includes this warning:
- > Signal handling is stupidly complicated and easy to get wrong. The standard
- > library makes little effort to help you deal with this. Consult your local man
- > pages, particularly signal-safety(7) on Linux, and perhaps a local priest as
- > well. We advise you to get out of the signal handler as soon as possible, for
- > example via the "self-pipe trick".
- We also provide signalfds on platforms that support them (such as Linux), which
- is less fraught with issues. Good luck.
- Next: the main loop. This code accepts new clients, prepares an agent for them,
- and hands them off to a second function:
- ```hare
- const client = match (net::accept(listener)) {
- case errors::interrupted =>
- continue;
- case let err: net::error =>
- log::fatalf("Error: accept: {}", net::strerror(err));
- case let fd: io::file =>
- yield fd;
- };
- const agent = agent::new(client);
- defer agent::agent_finish(&agent);
- run(&state, &agent);
- ```
- This is a really simple event loop for a network daemon, and comes with one
- major limitation: no support for serving multiple clients connecting at once. If
- you're curious what a more robust network daemon looks like in Hare, consult the
- [Himitsu] code.
- [Himitsu]: https://git.sr.ht/~sircmpwn/himitsu/tree/master/item/cmd/himitsud/socket.ha
- The "run" function simply reads SSH agent commands and processes them, until the
- client disconnects.
- ```hare
- fn run(state: *state, agent: *agent::agent) void = {
- for (true) {
- const msg = match (agent::readmsg(agent)) {
- case (io::EOF | agent::error) =>
- break;
- case void =>
- continue;
- case let msg: agent::message =>
- yield msg;
- };
- defer agent::message_finish(&msg);
- const res = match (msg) {
- case agent::request_identities =>
- yield handle_req_ident(state, agent);
- case let msg: agent::add_identity =>
- yield handle_add_ident(state, &msg, agent);
- case let msg: agent::sign_request =>
- yield handle_sign_request(state, &msg, agent);
- case agent::extension =>
- const answer: agent::message = agent::extension_failure;
- agent::writemsg(agent, &answer)!;
- case => abort();
- };
- match (res) {
- case void => yield;
- case agent::error => abort();
- };
- };
- };
- ```
- Again, this is non-production code, and, among other things, is missing good
- error handling. The handlers for each message are fairly straightforward,
- however. Here's the handler for `REQUEST_IDENTITIES`:
- ```hare
- fn handle_req_ident(
- state: *state,
- agent: *agent::agent,
- ) (void | agent::error) = {
- let idents: agent::identities_answer = [];
- defer free(idents);
- for (let i = 0z; i < len(state.identities); i += 1) {
- const ident = &state.identities[i];
- append(idents, agent::identity {
- pubkey = ident.pubkey,
- comment = ident.comment,
- });
- };
- const answer: agent::message = idents;
- agent::writemsg(agent, &answer)!;
- };
- ```
- The first one to do something interesting is `ADD_IDENTITY`, which allows the
- user to supply SSH private keys to the agent to work with:
- ```hare
- fn handle_add_ident(
- state: *state,
- msg: *agent::add_identity,
- agent: *agent::agent,
- ) (void | agent::error) = {
- let sink = bufio::dynamic(io::mode::WRITE);
- ssh::encode_pubkey(&sink, &msg.key)!;
- append(state.identities, identity {
- comment = strings::dup(msg.comment),
- privkey = msg.key,
- pubkey = bufio::buffer(&sink),
- });
- const answer: agent::message = agent::agent_success;
- agent::writemsg(agent, &answer)?;
- log::printfln("Added key {}", msg.comment);
- };
- ```
- With these two messages, we can start to get the agent to do something
- relatively interesting: accepting and listing keys.
- ```
- $ hare run cmd/ssh-agent/
- [2022-05-09 17:39:12] Listening at ./socket
- ^Z[1]+ Stopped hare run cmd/ssh-agent/
- $ bg
- [1] hare run cmd/ssh-agent/
- $ export SSH_AUTH_SOCK=./socket
- $ ssh-add -l
- The agent has no identities.
- $ ssh-add ~/.ssh/id_ed25519
- Enter passphrase for /home/sircmpwn/.ssh/id_ed25519:
- Identity added: /home/sircmpwn/.ssh/id_ed25519 (sircmpwn@homura)
- 2022-05-09 17:39:31] Added key sircmpwn@homura
- $ ssh-add -l
- 256 SHA256:kPr5ZKTNE54TRHGSaanhcQYiJ56zSgcpKeLZw4/myEI sircmpwn@homura (ED25519)
- ```
- With the last message handler, we can upgrade from something "interesting" to
- something "useful":
- ```hare
- fn handle_sign_request(
- state: *state,
- msg: *agent::sign_request,
- agent: *agent::agent,
- ) (void | agent::error) = {
- let key: nullable *identity = null;
- for (let i = 0z; i < len(state.identities); i += 1) {
- let ident = &state.identities[i];
- if (bytes::equal(ident.pubkey, msg.key)) {
- key = ident;
- break;
- };
- };
- const key = match (key) {
- case let key: *identity =>
- yield key;
- case null =>
- const answer: agent::message = agent::agent_failure;
- agent::writemsg(agent, &answer)?;
- return;
- };
- let buf = bufio::dynamic(io::mode::WRITE);
- defer io::close(&buf)!;
- ssh::sign(&buf, &key.privkey, msg.data)!;
- const answer: agent::message = agent::sign_response {
- signature = bufio::buffer(&buf),
- };
- agent::writemsg(agent, &answer)?;
- log::printfln("Signed challenge with key {}", key.comment);
- };
- ```
- For performance reasons, it may be better to use a hash map in a production Hare
- program (and, as many commenters will be sure to point out, Hare does not
- provide a built-in hash map or generics). We select the desired key with a
- linear search, sign the provided payload, and return the signature to the
- client. Finally, the big pay-off:
- ```
- $ ssh git@git.sr.ht
- [2022-05-09 17:41:42] Signed challenge with key sircmpwn@homura
- PTY allocation request failed on channel 0
- Hi sircmpwn! You've successfully authenticated, but I do not provide an interactive shell. Bye!
- Connection to git.sr.ht closed.
- ```
- ## Incorporating it into Himitsu
- [Himitsu] was the motivation for all of this work, and I have yet to properly
- introduce it to the public. I will go into detail later, but in essence, Himitsu
- is a key-value store that stores some keys in plaintext and some keys encrypted,
- and acts as a more general form of a password manager. One of the things it can
- do (at least as of this week) is store your SSH private keys and act as an SSH
- agent, via a helper called [himitsu-ssh]. The user can import their private key
- from OpenSSH's private key format via the "hissh-import" tool, and then the
- "hissh-agent" daemon provides agent functionality via the Himitsu key store.
- [himitsu-ssh]: https://git.sr.ht/~sircmpwn/himitsu-ssh
- The user can import their SSH key like so:
- ```
- $ hissh-import < ~/.ssh/id_ed25519
- Enter SSH key passphrase:
- key proto=ssh type=ssh-ed25519 pkey=pF7SljE25sVLdWvInO4gfqpJbbjxI6j+tIUcNWzVTHU= skey! comment=sircmpwn@homura
- # Query the key store for keys matching proto=ssh:
- $ hiq proto=ssh
- proto=ssh type=ssh-ed25519 pkey=pF7SljE25sVLdWvInO4gfqpJbbjxI6j+tIUcNWzVTHU= skey! comment=sircmpwn@homura
- ```
- Then, when running the agent:
- <video src="https://mirror.drewdevault.com/hissh-agent.webm" controls muted></video>
- (Yes, I know that the GUI has issues. I slapped it together in C in an afternoon
- and it needs a lot of work. [Help wanted!][hiprompt-gtk])
- [hiprompt-gtk]: https://git.sr.ht/~sircmpwn/hiprompt-gtk
- Ta-da!
- ## What's next?
- I accomplished my main goal, which was getting my SSH setup working with
- Himitsu. The next steps for expanding hare-ssh are:
- 1. Expanding the supported key types and ciphers (RSA, DSA, etc), which first
- requires implementing the primitives in the standard library
- 2. Implement the SSH connection protocol, which requires primitives like ECDH in
- the standard library. Some required primitives, like ChaCha, are already
- supported.
- 3. Improve the design of the networking code. hare-ssh is one of a very small
- number of network-facing Hare libraries, and it's treading new design ground
- here.
- SSH is a relatively small target for a cryptography implementation to aim for.
- I'm looking forward to using it as a testbed for our cryptographic suite. If
- you're interested in helping with any of these, [please get in
- touch][community]! If you're curious about Hare in general, check out the
- [language introduction][intro] to get started. Good luck!
- [intro]: https://harelang.org/tutorials/introduction/
- [community]: https://harelang.org/community/