Cryptography
This page describes the primitives as they're implemented. open-secret uses hybrid constructions throughout: every asymmetric operation combines a post-quantum algorithm with a classical one, and a verifier requires both.
Algorithm matrix
| Concern | Algorithm |
|---|---|
| Key encapsulation (KEM) | ML-KEM-768 (FIPS 203) ‖ X25519 |
| Signatures | ML-DSA-65 (FIPS 204) ‖ Ed25519 |
| Symmetric AEAD | XChaCha20-Poly1305 (24-byte nonce) |
| Key derivation | HKDF-SHA-256 |
| Password-based key wrap | Argon2id → HKDF-SHA-256 |
| Session tokens | PASETO v4.local (authenticated encryption with a server-only key) |
All randomness comes from the platform CSPRNG (crypto.getRandomValues in the browser, crypto/rand in Go). There is no use of Math.random or math/rand anywhere in a security path.
Hybrid: both halves, always
Every wire-visible public key, signature, and KEM ciphertext carries both a post-quantum half and a classical half. A verifier accepts only if both halves validate; a KEM derives its shared secret from both encapsulations combined. Breaking the scheme requires breaking ML-DSA-65 and Ed25519 (signatures), or ML-KEM-768 and X25519 (KEM), so a future break of either family alone does not sink it.
The two halves are concatenated with an explicit length-prefixed layout ([len(pq)] [pq] [len(classical)] [classical]), and the Go and TypeScript implementations agree byte-for-byte.
Per-(version, device) envelopes
Entry content is never encrypted "to the vault", it's encrypted once per recipient device, for each version:
Encapsulate to the device
The writer runs the hybrid KEM against the recipient device's public key, producing a KEM ciphertext and a shared secret.
Derive a fresh AEAD key
HKDF-SHA-256 over the shared secret, with the salt bound to the KEM ciphertext and a domain-separation info tag, yields a one-use AEAD key.
Seal with key-committing AEAD
XChaCha20-Poly1305 with a fresh 24-byte nonce encrypts the content, and the authenticated data commits to the key and KEM ciphertext.
Key-committing AEAD
Plain AEAD authenticates the message but not which key opened it. open-secret binds SHA-256(key ‖ KEM-ciphertext) into the authenticated data, so a ciphertext can be opened under exactly one key. This closes the partitioning-oracle class of attacks (Len–Grubbs–Ristenpart, 2021) that bite naive multi-recipient AEAD.
The unlock vault (keys at rest)
Your device's private keys are stored wrapped under your unlock password, never in the clear:
- The password (NFC-normalized, minimum 8 codepoints) is stretched with Argon2id, time cost 2, 128 MiB memory, parallelism 1, with a random 16-byte salt.
- The Argon2id output is run through HKDF-SHA-256 under an unlock-specific domain tag to derive the AEAD key.
- The keys are sealed with XChaCha20-Poly1305; the Argon2 parameters and salt are bound into the authenticated data, so an attacker can't shave the cost down by editing the stored parameters and still open the blob.
Because this is all local, unlocking works offline and the server can neither perform nor reset it.
Integrity of versions
Every entry version is signed (hybrid) over entry_id ‖ version_ulid ‖ content_hash ‖ outline_hash. content_hash covers the full encrypted detail blob; outline_hash covers only the outline so the list view can be verified independently. A client verifies that signature against the attestation-rooted trusted-device set before trusting the content, so a server that tampers with or swaps a version is detected, online or from cache.
Domain separation
Every distinct cryptographic use has its own info-tag / domain string (envelope encryption, unlock-vault KDF, device attestation, …), and both sides share the same constants with parity tests. This keeps a key or signature derived for one purpose from ever being valid for another.