When talking about high-level application cryptography APIs I usually hear mentioned libsodium, Tink, pyca/cryptography, and NaCl.
One of these things is not like the others! The value NaCl had 10 years ago was that it was an opinionated library at a time when all cryptography libraries were choose-your-own-adventure toolkits, but its APIs are not high-level, and even its constructions are unsafe by today’s standards.
NaCl refers to a set of APIs implemented in C by an old library published at nacl.cr.yp.to. No one really uses the original library1, partially because it was a pain to build and package, but two of its constructions got ported to a number of languages, including Go: box and secretbox.2
secretbox is for encrypting a message with a symmetric key, and it looks like this in Go.
func Seal(out, message []byte, nonce *[24]byte, key *[32]byte) []byte func Open(out, box []byte, nonce *[24]byte, key *[32]byte) ([]byte, bool)
It’s nothing else than XSalsa20Poly1305, and you could use any other AEAD with large nonces like straight XChaCha20Poly1305 in the exact same way. (It also lacks the convenient ChaCha20Poly1305 twist of skipping the leftovers of the first ChaCha20 block after generating the Poly1305 key, so the ciphertext starts on a block boundary.)
It’s not even a good high-level AEAD API, because it leaves nonce management to the application, when it should just generate it randomly (192 bits is enough to do so safely) and prepend it to the ciphertext. The Go AEAD interface does this wrong, too, and I have seen so many developers struggling with where to store the nonce. (The answer is to prepend it to the ciphertext.) Some protocols do require controlling nonces (the TLS record layer, for example), for good reason, but they should be served by a lower-level API than what is provided to applications. secretbox was a missing opportunity here.
box is for encrypting a message with asymmetric keys, and it looks like this in Go.
func Seal(out, message []byte, nonce *[24]byte, peersPublicKey, privateKey *[32]byte) []byte func Open(out, box []byte, nonce *[24]byte, peersPublicKey, privateKey *[32]byte) ([]byte, bool)
The first thing you might notice, after the fact that again we’re asking the application for a nonce we could have randomized for them, is that sealing a message also requires a private key and opening one also requires a public key. That’s because box is static Diffie-Hellman between two long-term key pairs: the sender and the receiver. Having a stable sending key is uncommon enough that libsodium introduced an anonymous variant which generates an ephemeral sending key pair for each message, and long-term secrets should be avoided rather than encouraged.
The presence of a sending key might make you think the message is signed by it3, but it’s not. box provides only authentication4, meaning that the recipient can change the message, too, and it will look the same as if the sender sent it. This is supposed to provide repudiability, a property I never really saw the value of.
Worse, there is actually nothing to distinguish a sender→recipient box from a recipient→sender one. Any third-party can take a box you sent and reflect it back to you, and it will look like it came from the recipient.
For example, if you have a protocol where two parties exchange boxes every hour to confirm everything is fine, like this…
A → B: box(pubB, privA, "One o'clock and all is well")
B → A: box(pubA, privB, "One o'clock and all is well")
… then a MitM can just take the “A → B” message and send it back to A, even if B was captured by a bear and a fox, and it will look fine to A.
The original NaCl docs suggest a scheme to protect against this, even if they don’t mention the attack and focus more on avoiding nonce reuse, which again would not be a problem if they were randomized.5
the lexicographically smaller public key can use nonce 1 for its first message to the other key, nonce 3 for its second message, nonce 5 for its third message, etc., while the lexicographically larger public key uses nonce 2 for its first message to the other key, nonce 4 for its second message, nonce 6 for its third message, etc
Note that for this trick to protect you against reflection attacks, you have to not only use it as your nonce generation scheme, but also to verify all the incoming nonces.
Now, I don’t know about you, but I wouldn’t call this a high-level API.
I mostly focused on the construction here because again no one uses the library itself from 2008–2011, but I feel like the C API still deserves a honorable mention, because… excuse me, what!?
crypto_box()
takes a pointer to 32 bytes before the message, and stores the ciphertext 16 bytes after the destination pointer, the first 16 bytes being overwritten with zeros.crypto_box_open()
takes a pointer to 16 bytes before the ciphertext and stores the message 32 bytes after the destination pointer, overwriting the first 32 bytes with zeros.
(The quote above is from the libsodium docs because the NaCl ones were even too hard to quote.)
Those prefix bytes apparently must be zeroes or the behavior is undefined, they must be counted in the length parameters, and the two different constants are conveniently named crypto_box_BOXZEROBYTES
and crypto_box_ZEROBYTES
.
libsodium added replacement _easy
APIs, and mentions in its docs that “the original NaCl crypto_box API is also supported, albeit not recommended”. Well, yeah.
In Dispatch #4, I mention that OpenSSH encrypts the FIDO2 key handle when you encrypt a security key-backed SSH key, and I was not sure if you could actually rely on it being necessary to use the key. tialaramex on HN pointed out that WebAuthN does guarantee that: the key handle has to either include at least 100 bits of entropy, or be the encrypted key material.
There’s a wrinkle in that OpenSSH implements FIDO2, not WebAuthN, but most hardware tokens implement both. I appreciated this line, which means my shtick is getting across.
You’d presumably hate both specifications, because they drag in (and rely upon) registries for a bunch of technically unnecessary stuff and not even for the practical engineering reason I’ve excused in my sister posts to this thread.
Finally, I really need you all to read this thread about the festering misogyny in InfoSec, and reflect on how we all enable it by maintaining professional relationships with the people who harass our colleagues and drive them out of the industry.
Harriman State Park is pretty great.
A lot of people rightfully use libsodium by Frank Denis, which started as a portable NaCl fork, but added a number of APIs, replaced some of the egregiously bad ones, and most importantly provides the most impressive set of cryptography library docs. ↩
golang.org/x/crypto/nacl also implements the auth and sign APIs, but hardly anyone uses them. auth is just HMAC, and is imported by 10 packages. sign is just Ed25519, is imported by 20 packages, and was not even implemented by the original library. For comparison, box and secretbox are imported by 500-700 packages. ↩
You’ll see the operation of signing and asymmetrically encrypting a message called “signcryption”. It has its own name because it’s hard to construct: you can’t just compose signing and encryption. If you encrypt a message and then sign it, anyone can strip the signature, sign it themselves, and pretend it’s from them even if they can’t decrypt it; if you sign first and then encrypt, the recipient can decrypt it, keep the signature, and re-encrypt it to a new recipient the sender didn’t mean to reach. ↩
“Authentication” is an unfortunately overloaded term: in a symmetric context it means the ciphertext is not malleable (like, an AEAD instead of AES-CTR) and it’s absolutely table stakes; in an asymmetric context it means this sort of “only people with a certain key can send you a message”. As if the authenticated vs. signed distinction wasn’t confusing enough. ↩
Soatok pointed out on Twitter that libsodium solved all this much more elegantly in their key exchange API, where they ask peers to identify as server and client, and then generate separate s→c and c→s keys on both sides. ↩