logo

drewdevault.com

[mirror] blog and personal website of Drew DeVault git clone https://hacktivis.me/git/mirror/drewdevault.com.git
commit: 04d97d9e89926ee785aae78a0e433f2867c103b9
parent b3b5e839121d2389236f085272c37a43c72888b3
Author: Drew DeVault <sir@cmpwn.com>
Date:   Mon,  9 May 2022 18:26:04 +0200

hare-ssh

Diffstat:

M.build.yml7+++----
Acontent/blog/hare-ssh.md938+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 941 insertions(+), 4 deletions(-)

diff --git a/.build.yml b/.build.yml @@ -5,7 +5,6 @@ packages: sources: - https://git.sr.ht/~sircmpwn/drewdevault.com - https://git.sr.ht/~sircmpwn/openring - - https://git.sr.ht/~sircmpwn/hugo secrets: - 160a72cf-34d6-47b7-928b-c13b42b4d4f6 triggers: @@ -14,9 +13,9 @@ triggers: to: Drew DeVault <sir@cmpwn.com> tasks: - hugo: | - cd hugo - go build --tags extended - sudo cp hugo /usr/local/bin/ + curl -O https://mirror.drewdevault.com/hugo + chmod +x hugo + sudo mv hugo /usr/local/bin/ - openring: | cd openring go build -o openring diff --git a/content/blog/hare-ssh.md b/content/blog/hare-ssh.md @@ -0,0 +1,938 @@ +--- +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 &mdash; so that we can do +cryptographic operations with it &mdash; we first have to decrypt this inner +data. Those three fields &mdash; cipher name, KDF name, and KDF data &mdash; 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 &mdash; 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 &mdash; 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 &mdash; +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 &mdash; 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 +&mdash; [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-01] 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-01]: https://tools.ietf.org/id/draft-miller-ssh-agent-01.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 &mdash; 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 &mdash; 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/