Designing an Identity Manager
There are people who find scrambling bits and prime number arithmetic exciting, if that’s you, consider a career in cryptography. For the rest of us, this stuff is boring. But, in the case of cryptographic applications, boring is exactly what you want.
Specifically, I’m talking about Uno’s identity and security core. Everything else about Uno is abundantly exciting and you should check it out 🎉
From a security angle, “boring” means:
- use off the shelf standard battle-tested cryptographic primitives
- from community verified and expert audited projects
- implemented in memory-safe languages
- applied in simple, obvious, and intended ways.
So that’s what we did for Uno’s core identity and security layer.
Yeah, right. It'd be nice if it were that easy, but we're serious about good security and thin promises aren't that. Instead, I’m going to walk through exactly what design choices we made so you can understand, audit, and critique our implementation yourself. And of course, the source code to our rust reference implementation is freely available if source dives are more your style.
Cryptographic Identity Foundation
Every Uno user generates 256 bits of entropy drawn from their operating system’s cryptographically secure pseudo random number generator (CSPRNG). This constitutes a user’s identity seed. Fundamentally, everything a user needs is derived from these 256 bits. These bits are what our application helps the user store and protect, shard and socially escrow, and synchronize across devices. Like a DeFi/Web3 “wallet”, a user is a single seed.
At rest, a user’s identity seed is always encrypted using the system’s hardware device backed cryptographic primitives e.g. Apple’s secure enclave, or generically a trusted platform module. Unlike a traditional password manager, Uno does not ask the user to remember a password. Consequently, our application is designed to be used only on devices that have hardware security modules.
A philosophical design principle that is key in understanding our direction is ambient authority. Generally, we leverage the systems our software runs in to determine whether access to credentials should be allowed. To think about it another way, our primary goal is to provide a well built utility that works in tandem with the operating system (rather than treating it as an adversary) to assist uses in practicing good credential and identity management hygiene and to provide redundancy in the event to hardware failure. We’re not specifically designing to protect users from malware. Historically ambient authority on devices has been poor but we believe with the introduction and proliferation of HSMs and biometric authentication, as well as the shift to platforms now considering security to be a core requirement, there are finally good platform primitives in place to work with.
From the seed entropy we derive use-case specific keys and secrets because it’s bad to cross your cryptographic streams.
We derive keys using Blake3. Blake3 is an efficient 3-in-1 hash function, key derivation function, and extendable output function (XOF) which we find very convenient. Should Blake3 prove weak or broken upon future cryptanalysis, HKDF is a perfectly conservative (albeit computationally wasteful) alternative.
It is important to note that in the case of key derivation we are not hashing passwords. Therefore, it is not important (or desired) to use a password-based KDF like
pbkdf2, etc. The resulting keys we derive are secrets and should never be transmitted publicly. In other words, our usage of a KDF does not exist to "protect" the root seed. It exists to derive equally secret sub-keys from a root key in service of good cryptographic hygiene.
Each user needs a set of Ed25519 signing keys, X25519 asymmetric encryption keys, and a private symmetric vault encryption key. When deriving sub-keys, it is imperative that an application-scoped, use-case specific context be used. We use an application tag "uno seed" plus " " plus the specific use case (e.g. "identity keypair", or "encryption secret").
While it is technically possible to use the same Ed25519 signing keys for X25519 encryption if you’re very careful, the algorithms were designed assuming independent keys and it makes cryptographers uneasy any time materials for one purpose are used for another.
Key derivation simply looks like:
let subkey = blake3::derive_key("context string", &seed);
The most important concept is that these sub-keys are deterministic but also cryptographically independent from the main seed and can be used freely for their own purposes without worrying about cross pollination.
With the three derived keys a user can:
- encrypt documents that should only be accessible by the same user, e.g. their digital “vault”,
- sign messages to other components of our system, including other users, and
- establish a shared encryption key with another party in order to exchange secret messages.
Together, these actions plus our relevant domain logic compose into the foundation for a secure, cryptographically independent, application layer that does not depend on the security of the any networking, transport, or storage systems except during the explicitly out-of-band key-exchange segment.
One of the core features we offer users is credential document management. Colloquially, we save your logins. Of course we do a lot more than that, but these sensitive credentials need to be protected which is why we go through all the trouble.
We use ChaCha20-Poly1305 as our authenticated encryption with additional data (AEAD) primitive. We generate a new random 12-byte nonce for every vault encryption operation and prepend it to the resulting cipher text when storing it. The cipher is keyed using the symmetric encryption key derived from a user’s seed.
An AEAD is commonly known as a sealed box in cryptography libraries.
Yes, we are aware of the XChaCha20 (extended nonce) variant and would love to use it. However it just isn’t there yet in terms of implementation support in some of the cryptographic libraries we depend on. Also we are nowhere near the message volume that would risk nonce reuse so it’s really not a problem anyway. Migrating to an updated cipher construction, while logistically annoying, is fundamentally a simple decrypt using the old cipher and encrypt using the new type of operation.
A user’s vault is just a JSON document the structure of which is not relevant right now. One thing to point out is that we encrypt and decrypt the entire vault, not individual key paths. The entire vault is an opaque encrypted blob to anything without the key.
One more tidbit, all our encrypt and decrypt operations use independent additional data strings to bind arbitrary cipher-text to specific use cases. This often overlooked step is important to ensure that an adversary can’t pull out valid encrypted blobs from one part of the application and use them in another spot.
That’s really all there is to the vault right now. Of course we expect to build in more sophistication over time (perhaps occasional and/or automated rekeying, perhaps exposing some of the non-sensitive structure to the server application to help manage distributed data synchronization, etc.) but fundamentally an Uno vault is just an encrypted document that, once decrypted, you can read and write using
The second key usage is document signing. Documents may be messages to other users or http requests to the API backend. We use the Ed25519 signature scheme for document verification. Signing documents is perhaps the most widely used cryptographic primitive and there isn’t much extra to add here regarding our application of Ed25519. We simply construct a user’s private key from the derived bytes, and from that compute the public key. Signing is a primitive operation that an Ed25519 private key can perform and results in an EdDSA signature.
let private_key = ed25519_dalek::SecretKey::from_bytes(&subkey_signing); let keypair = private_key.into(); let signature = keypair.sign(message);
It is important to note that we use the public signing key for user identity purposes. This key is essentially a user’s database ID, except we don’t have a central database. It is a somewhat arbitrary selection since we could use the X25519 public key or derive an independent id, but since every message in our application is signed it’s proven rather consistent to expect that components needing to verify messages and index user operations have the user’s unique public signing key at hand. After all, the point of a public key is to uniquely identify some entity.
Our identity manager is social. I cover the motivation behind that in the Replacing passwords with people post because there’s a whole can of worms to unpack. For the scope of this overview just know that it’s important to be able to securely communicate with other Uno users in a way that the server cannot observe. The use case is not like a traditional secure messenger app designed to allow journalists to report on whistler-blowers without the prying eye of nation-sates, that’s best left to the secure messenger domain. But, rather, in our domain simply used for privately coordinating certain actions related to managing your identity like informing others about updates to your recovery shares, or sending someone your Netflix password.
The algorithm used to implement our encrypted messages is X25519 Elliptic Curve Diffie-Hellman. The Uno public key exchange is also the topic of another essay. For now assume two users have acquired each others’ public X25519 keys. Once public keys are exchanged the two peers can compute a static symmetric ECDH shared key.
let private_key = x25519_dalek::StaticSecret::from(&subkey_encryption); let peer_pubkey = x25519_dalek::PublicKey::from(&peer_pubkey_bytes); let shared_secret = private_key.diffie_hellman(&peer_pubkey);
We could use the static key to directly encrypt messages, but it’s better form to derive per-message keys off the static key so that compromise of any single message’s key does not compromise the integrity of other messages, past or future. To this end every message has an ID (we use a UUID). After each client computes their shared static DH key, we
let per_message_key = blake3::derive_key("message uuid", &shared_secret);
and use the resulting 32 bytes as the per-message encryption key which we use with our standard AEAD and authenticate with a context unique to the message’s use case.
The messaging protocol itself deserves some in-depth coverage at some point, but for now rest assured all messages are signed by the sender and encrypted for the recipient. Signatures are verified before the message is further processed.
I notice you dozed off. Don’t worry I’m not offended, that’s a good sign. It means we’re not doing anything interesting and simply using bog standard cryptographic primitives in unassuming ways. If you didn’t nod off, please [join our discord] and let us know. We are always looking for ways to improve, even especially on the boring stuff.