Design of Mundane
Mundane has the following design goals, in this order:
- To be difficult to misuse
- To be ergonomic
- To be performant
This document describes both the overall philosophy, and also specific design patterns used to achieve these goals
Cryptography is famously subtle and easy to get wrong. And when cryptography is done wrong, the results can be catastrophic.
Experience has shown that most programmers, not being familiar with the subtleties of cryptography, will unknowingly misuse cryptographic libraries if they are allowed. This is one example of a broader trend - that it's difficult to get something right if getting it wrong doesn't affect whether your program runs. It's the same reason that error handling code is so often buggy in otherwise well-written programs. Misuse of cryptography, like buggy error handling, is unlikely to show up in tests, and unlikely to affect the correctness of a program under normal conditions. But unlike error handling, most programmers aren't familiar with the requirements of using cryptography securely, and getting it wrong can be absolutely fatal.
Given this realization, Mundane takes the approach of giving the programmer the fewest degrees of freedom possible. Doing the right thing should be easy and feel natural. Doing the wrong thing should feel difficult and ideally be entirely impossible. This philosophy motivates the design patterns which are explored in the next section.
Do work for the user
Some cryptographic operations have setup phases, require generating random values, computing key schedules, etc. While many cryptographic APIs split these into multiple steps that must be performed by the user in the right order, prefer APIs which perform all setup steps on behalf of the user. This reduces the opportunities for the user to make a mistake.
For example, the scrypt password-based key derivation function takes a salt.
When generating a key from a new password, the salt should always be randomly
generated anew. Instead of taking the salt as an argument, as many cryptographic
APIs do, we generate the salt as part of the generation function -
scrypt_generate - so that the user is not given the opportunity to improperly
As another example, while scrypt can be used as a general-purpose key derivation function, we expose it specifically for password verification. Thus, instead of having the API expose the ability to take a password and a salt and generate a hash, leaving it up to the user to verify that the calculated hash matches the expected one, our API takes a password, a salt, and a hash, computes the new hash, checks it against the expected one, and returns a boolean. Not only does this ensure that the comparison is not accidentally skipped, it also allows us to ensure that the comparison is performed using a constant-time comparison function, which is a subtle detail that is often overlooked by users.
All cryptographic operations have data associated with them. A hash function takes a byte sequence and outputs a digest. A signature functions takes a private key and a digest, and outputs a signature.
These types of data usually have strict definitions of what operations are valid or secure to perform on them. The Rust type system provides a powerful mechanism to enforce that users cannot use these cryptographic objects other than as intended.
The easiest way to restrict the set of allowed operations on a type is to make
it opaque - that is, to create a struct with private fields, and provide only
the minimum set of methods or trait implementations needed. For example, instead
of representing a SHA-256 digest as a
[u8; 32], we represent it as an opaque
Sha256Digest, which has a private
[u8; 32] field.
Given such an opaque type, we can be judicious about methods or trait
implementations to provide. For example, a SHA-256 digest should be comparable
for equality with other SHA-256 digests, and so
for itself. However, comparing with digests from other hash functions is not a
cryptographically meaningful operation, so it doesn't implement
Eq for other
types of digests. Similarly, the
EcdsaSignature type provides only a
constructor and a getter, as no other operations (including comparison between
signatures) are meaningful.
It will usually be necessary to allow the user to access a non-opaque
representation (such as a byte array for hash digests). However, it is
sufficient to only provide constructors and getters. For example, the
trait provides a
from_bytes(bytes: [u8; Self::DIGEST_LEN]) -> Self
constructor, and a
bytes(&self) -> [u8; Self::DIGEST_LEN] getter, but does not
provide, for example,
bytes_mut(&mut self) -> &mut [u8; Self::DIGEST_LEN].
Use distinct types even if they have the same operations
Sometimes, distinct cryptographic objects will have the same representations and operations allowed on them. For example, both RIPEMD-160 and SHA-1 produce 20-byte digests. Even if this happens, distinct Rust types should still be used. Since it is never valid to compare a RIPEMD-160 digest to a SHA-1 digest, representing them with the same Rust type would allow operations that are not valid. Representing them with distinct Rust types ensures that they are not spuriously used together in an invalid or insecure way.
Use the most restrictive type
At some point, it will be necessary to accept or provide a non-opaque representation so that input can be gathered from the outside world or output to it. When this happens, use the most restrictive type in order to avoid having to perform validation at runtime.
For example, hash digests have a fixed length. Thus, their constructors accept (and their getters produce) fixed-length byte arrays. If, instead, variable-length byte slices were used, it would be necessary a) to document the length requirement, b) to validate the length during construction, and panic if it failed, and c) to promise to the user to always produce slices of a particular length. By using fixed-length arrays, we allow the type system to guarantee that input will always be valid - so we don't need to perform any validation - and to guarantee that the output will always conform to what is documented - so the user doesn't need to simply trust our documentation.
Put as much as possible in the type system
If a distinction exists between two cryptographic objects, always try to encode that distinction in the type system if possible (of course, don't go over board; ergonomics and other considerations are important too).
For example, most cryptography libraries provide a single elliptic curve private
key type. However, elliptic curve keys have a curve parameter, and two keys over
different curves are not interchangable - they might as well be completely
different cryptosystems. Thus, we provide a
PCurve trait which is implemented
by various curve types, and our private key type,
EcKey is parametrized on
such a type -
Error return values from cryptographic functions are an infamous source of vulnerabilities. As with any system, the error handling logic of a program is often the last consideration of a programmer, and is rarely exercised in tests. More so than most systems, however, failure to properly handle errors from cryptographic functions can easily lead to catastrophic vulnerabilities.
In order to avoid an opportunity for error values to be misused, we follow the following design guidelines:
- Always use the Rust
Resulttype to report errors. This may be a no-brainer, but it means that, if the user wants to extract the return value from a function, they must handle errors (at the very least, by calling
expect, and thus panicking on failure). This contrasts with a language like C, in which failing to check status return codes is easy and a common source of bugs.
- Collapse the distinction between "verification failed" and "verification encountered an error." When verifying cryptographic objects like comparing digests, verifying signatures, etc, there can sometimes be errors that are not the same as a verification failure. For example, a verification routine might fail to allocate memory, or it might fail to parse an encoded signature. Instead of having a verification routine provide three possible return values (verification succeeded, verification failed, or error encountered) we collapse the last two into a single one - verification failed. That way, the user is never given the opportunity to try to make subtle error-handling decisions that might lead to them mistakenly accepting an invalid signature as valid.
- If an error requires particularly subtle error-handling, prefer panicking or
aborting the process. When cryptographic operations fail in a way that would
require reporting an error to the user (in other words, there's no valid
non-error interpretation like in the case of signature verification), and
handling that failure is particularly error-prone, it may be justified to make
the function's API infallible, and instead panic or abort the process on
error. BoringSSL famously does this when failing to read randomness (e.g.,
/dev/urandom), as this has historically been a source of vulnerabilities.
Rust has two attributes which will cause compiler warnings in user code if elements of the Mundane API are misused.
#[must_use]attribute on a function causes a compiler warning if a user calls the function and discards the result. We put this attribute on all functions which return values, as it is always suspicious to call a side-effect-free function and ignore its result (most of our functions which return values are side-effect free). This is especially useful in cases like signature or hash verification, where failure to check the return value from a function could lead to a catastrophic vulnerability (the iOS
goto failTLS verification bug is a famous example of this).
#[must_use]on a type will cause a compiler warning if a user ever calls a function which returns that type and discards the result.
#[deprecated]attribute causes a warning whenever an item is imported or used anywhere in code. We make liberal use of this attribute for our insecure, legacy-only operations like SHA-1. See
CONTRIBUTING.mdfor more details.