Document type: Format specification Version: 1 Status: Draft Date: 2026-04-08 Project: recrypt
This document specifies the KeyMaterial v1 binary format: the fixed 96-byte plaintext bundle that the recrypt project encrypts under a PRE (proxy recryption) public key as part of its hybrid encryption scheme. It is paired with the OpenFHE BFV lattice PRE backend and the XChaCha20 stream cipher used for bulk file encryption.
This format is independent of the recrypt wire protocol — it lives inside a PRE ciphertext, never appears as a CBOR envelope, and is visible only to the holder of the PRE secret key after successful PRE decryption.
KeyMaterial is the bundle of secrets that a recrypt encryptor produces for a single file and PRE-encrypts so that only the intended recipient (and, after recryption, any subsequent recipients) can recover them. It contains exactly the data the recipient needs to decrypt and verify a recrypt-encrypted file:
- The XChaCha20 symmetric key.
- The XChaCha20 nonce.
- A Blake3 hash of the original plaintext (for post-decryption integrity checking).
- The original plaintext size.
KeyMaterial does not contain any routing information, recipient identifier, format version of the wrapping envelope, or any other metadata. It is the minimum sufficient bundle for "decrypt and verify a single file."
KeyMaterial v1 is sized to fit exactly within one OpenFHE BFV plaintext slot under recrypt's chosen BFV parameters. Those parameters yield a maximum plaintext size of 96 bytes per slot.
OpenFHE's BFV scheme is a leveled fully-homomorphic encryption scheme based on the Ring Learning With Errors (RLWE) problem. Recrypt uses BFV in its proxy recryption mode, which transforms a ciphertext encrypted under key A into one encrypted under key B without revealing the plaintext to the proxy.
For recrypt's purposes, BFV is used as a post-quantum public-key encapsulation mechanism, not as a homomorphic compute substrate. The "leveled" property is irrelevant; we only ever encrypt KeyMaterial once and decrypt it once per recipient. Recryption is the one operation we do between encrypt and decrypt, and that operation does not consume noise budget the way arithmetic homomorphic operations would.
The 96-byte plaintext capacity is a property of the chosen BFV parameters. Different BFV parameter sets yield different plaintext capacities; KeyMaterial v1 assumes 96 bytes specifically. A future KeyMaterial v2 paired with different parameters could use a different size, but all current implementations MUST treat KeyMaterial as exactly 96 bytes.
The post-quantum security claim of recrypt's hybrid scheme rests on the BFV layer — a quantum adversary capable of breaking ECDH or factoring would still need to break Ring-LWE to recover KeyMaterial. The XChaCha20 layer underneath is symmetric and already considered post-quantum-secure (Grover's algorithm provides only a quadratic speedup, leaving ChaCha20 effectively at 128-bit post-quantum security with a 256-bit key).
KeyMaterial v1 is exactly 96 bytes, organized as:
Offset Length Field Type Notes
─────── ─────── ─────────────── ────── ────────────────────────────
[0] 1 version u8 MUST be 1
[1] 32 symmetric_key bytes XChaCha20 256-bit key
[33] 24 nonce bytes XChaCha20 192-bit nonce
[57] 32 plaintext_hash bytes Blake3 of original plaintext
[89] 7 plaintext_size u56le Original plaintext byte count
Total: 96 bytes.
The first byte is a version discriminator. Recrypt implementations MUST verify this byte before interpreting the remaining 95 bytes. An unknown version MUST be rejected with a clear error and MUST NOT cause the implementation to attempt to parse the remaining bytes under any other version's layout.
A single unsigned byte. Value 0x01 denotes KeyMaterial v1. All
other values are either reserved for future versions or invalid;
implementations MUST reject any value other than the version(s)
they are built to recognize.
The 256-bit XChaCha20 symmetric key used to encrypt the file contents. Encoded as 32 raw bytes — no length prefix, no padding, no encoding.
This key MUST be drawn from a cryptographically secure random source (a CSPRNG seeded from the operating system's entropy pool). Reuse of a key across files is forbidden — every encrypted file gets a fresh symmetric key.
The 192-bit XChaCha20 nonce. Encoded as 24 raw bytes.
The 192-bit nonce length is a property of XChaCha20 (as opposed to the 96-bit nonce of plain ChaCha20) and is large enough that random nonce generation is birthday-safe up to ~2^96 messages under the same key. Since recrypt uses a fresh symmetric key per file, nonce collision is irrelevant in practice — but implementations SHOULD draw the nonce randomly anyway as a defense in depth against accidental key reuse.
The Blake3 hash of the file's original plaintext, computed before XChaCha20 encryption. Encoded as 32 raw bytes.
This is the integrity gate for KeyMaterial. After PRE-decrypting the wrapped key and XChaCha20-decrypting the file ciphertext, the recipient computes Blake3 over the recovered plaintext and compares to this field. A mismatch indicates either:
- The wrapped key has been tampered with (extremely unlikely given the PRE encryption around it, but the check is the post-hoc verification).
- The wrapped key was substituted for one belonging to a different file (the "wrong wrapped-key" confusion attack).
- The file ciphertext has been tampered with in a way that wasn't caught by the Bao tree hash check earlier in the decryption flow.
The plaintext_hash field is the cryptographic anchor that lets recrypt's wrapped-key envelopes ship without their own signatures. A maliciously-modified wrapped-key cannot pass this check unless the attacker knows the original plaintext, which would defeat the purpose of attacking the wrapped key in the first place.
The byte count of the original plaintext, encoded as an unsigned
56-bit little-endian integer. The maximum representable value is
2^56 - 1 ≈ 72 petabytes.
To encode: take the low 7 bytes of the plaintext size's u64 little-endian representation. To decode: zero-extend the high byte and interpret as u64 little-endian.
Encoders MUST reject any plaintext size larger than 2^56 - 1 with a clear error. In practice this is unreachable: recrypt's streaming encrypt path enforces a much smaller limit (currently 1 TiB = 2^40 bytes), well below the u56 cap.
The size is used after decryption as a fast-fail truncation check: if the recovered plaintext is shorter or longer than this value, decryption returns an error. This is a redundant check (the plaintext_hash would also catch truncation) but it provides faster feedback and a more specific error message.
All multi-byte integer fields are little-endian. There is one
such field (plaintext_size).
KeyMaterial is exactly 96 bytes with no padding, no trailing unused bytes, and no version-specific reserved space at the end. A future v2 may use the bytes [1..96] differently, but v1 uses all 95 of them.
KeyMaterial sits inside a fixed cryptographic plaintext slot. The chosen BFV parameters yield 96 bytes of plaintext capacity. The three 32-byte fields (symmetric_key, plaintext_hash, and combined nonce + size making up another 32 bytes) consume 88 bytes by themselves; even the most aggressive integer-keyed dCBOR framing adds enough overhead to push the encoding past 96 bytes.
A version byte and a custom layout are 95 bytes more efficient than that, while preserving the only property worth preserving (forward compatibility via the version byte).
The rest of the recrypt wire format is dCBOR. KeyMaterial is the one exception. The exception is justified because KeyMaterial:
- Lives inside a fixed-size cryptographic plaintext slot where bytes are non-negotiable.
- Has no third-party extension story (the recrypt project controls every encoder and decoder).
- Is never inspected by any tool that doesn't first hold the recipient's PRE secret key.
- Has no need for self-description because the wrapping envelope's type field already disambiguates it.
The version byte costs one byte from the budget. Three options were considered for where to take that byte from:
- Steal from
plaintext_size(u64 → u56). Chosen. - Drop
plaintext_sizefrom the bundle entirely. - Use some other field's bits.
Option 1 was chosen because the cap of 72 PB is wildly larger than any realistic file (recrypt's enforced limit is 1 TiB), so the u56 reduction has no practical cost. Option 2 would have removed a useful fast-fail check and required the decryption flow to trust the file envelope's size assertion (which is elidable for privacy reasons). Option 3 would have fragmented the encoding.
KeyMaterial v1 is the format we ship today. There is no plan to ship v2 imminently. The version byte exists so that the first time we want to change KeyMaterial, the migration path exists without requiring a coordinated flag day across every encrypted file in storage.
Concrete things a v2 might do:
- Support a different bulk cipher (e.g., AES-256-GCM as an alternative to XChaCha20).
- Support a key derivation function for deriving per-chunk subkeys.
- Support a different PRE backend with a different plaintext capacity.
- Add a key-rotation epoch field.
None of these are concrete needs today. The version byte is 1 byte of insurance against the cost of changing our minds.
A KeyMaterial encoder MUST:
- Produce exactly 96 bytes.
- Set byte 0 to the version it is implementing (1 for v1).
- Pack
symmetric_key,nonce, andplaintext_hashas raw bytes at the specified offsets. - Encode
plaintext_sizeas little-endian u56 in bytes [89..96]. - Reject any
plaintext_sizevalue greater than 2^56 - 1. - Draw the symmetric_key and nonce from a CSPRNG (for v1; future versions may permit derivation).
A KeyMaterial decoder MUST:
- Reject any input that is not exactly 96 bytes.
- Read byte 0 as the version and reject any version it does not recognize.
- Reconstruct
plaintext_sizeby zero-extending bytes [89..96] to a u64. - Treat all 96 bytes as confidential — they are key material and zeroizing them on drop is RECOMMENDED.
A KeyMaterial consumer (the decryption path) MUST:
- Use
symmetric_keyandnonceto XChaCha20-decrypt the file ciphertext. - Compute Blake3 over the recovered plaintext and compare to
plaintext_hash. - Verify the recovered plaintext length equals
plaintext_size. - Reject the entire decryption if either check fails.
KeyMaterial is the integrity anchor for recrypt's wrapped-key envelopes. A wrapped-key envelope is unsigned at the envelope level (see wire-protocol.md §3.2.1); its integrity is established by:
- The PRE encryption itself — only the recipient with the correct PRE secret key can decrypt it.
- The plaintext_hash check after XChaCha20 decryption — the recovered plaintext must hash to the value committed to inside KeyMaterial.
Together these defeat:
- Wrapped-key substitution. An attacker who substitutes a wrapped-key from a different file produces KeyMaterial that decrypts the file ciphertext to garbage that fails the hash check.
- Wrapped-key tampering. An attacker who modifies the PRE ciphertext bytes either fails PRE decryption or produces garbage KeyMaterial.
- Truncation. The plaintext_size check catches truncation before the full hash check, with a more specific error.
- Confidentiality of the KeyMaterial bytes themselves. If an attacker can read the post-PRE-decryption plaintext (e.g., by compromising the recipient's process memory), they have the symmetric key and nonce and can decrypt any file encrypted with that wrapped-key. KeyMaterial is high-value material; treat it accordingly.
- The PRE secret key. KeyMaterial is irrelevant if the recipient's PRE secret key is compromised — the attacker decrypts the wrapped-key the same way the recipient does.
- The original plaintext. If the attacker has the plaintext,
they can compute the same
plaintext_hashand produce a KeyMaterial that the recipient will accept as valid for that plaintext. (This is a meaningless attack — the attacker already has what they wanted.)
The symmetric_key and nonce MUST be unique per file. Recrypt
generates them fresh per encryption operation; implementations
MUST NOT reuse a (symmetric_key, nonce) pair across files.
If the same (symmetric_key, nonce) pair is ever reused, the
XOR of the two ciphertexts equals the XOR of the two plaintexts,
which catastrophically breaks XChaCha20's confidentiality. The
192-bit nonce length makes accidental collision negligible under
normal random generation, but the single-use rule for
symmetric_key is what makes this safe in practice.
Implementations SHOULD zero KeyMaterial bytes on drop, especially
the symmetric_key field. The recrypt reference implementation
uses the zeroize crate for this purpose. Failing to zero key
material is not a protocol error but is a defense-in-depth
issue for any implementation hosted in an environment where
process memory might be observable.
KeyMaterial's verification operations (the plaintext_hash comparison and the plaintext_size comparison) SHOULD use constant-time equality functions. Variable-time comparison creates a timing oracle that, in principle, could let an attacker probe which prefix of a candidate plaintext matches. The leakage is small and the attack model unrealistic for most deployments, but constant-time comparison is cheap and there is no reason to use the variable-time form.
KeyMaterial's confidentiality against a quantum adversary depends entirely on the security of the OpenFHE BFV layer that wraps it. BFV is based on the Ring Learning With Errors problem, which is believed to be hard for both classical and quantum adversaries. Recrypt uses BFV parameters chosen to provide approximately 128-bit post-quantum security.
XChaCha20 itself is not "post-quantum" in the sense of being designed against quantum adversaries — it predates the post- quantum standardization effort — but its 256-bit key length provides ~128 bits of effective security against Grover's algorithm, which is the best known quantum attack on symmetric ciphers. This matches BFV's classical/quantum security level.
The integrity anchor (Blake3) is similarly post-quantum-secure at ~128 bits against Grover-style preimage attacks on the 256-bit hash output.
The complete claim is therefore: a quantum adversary capable of breaking 128-bit symmetric primitives could break recrypt's KeyMaterial confidentiality, but so could they break essentially any post-quantum scheme of comparable parameters. Recrypt does not claim higher post-quantum security than its weakest component.
We do not have a concrete plan for v2. Possibilities being considered for the long term:
- Cipher agility. A v2 might add a 1-byte cipher identifier and let the bulk cipher be either XChaCha20 or AES-256-GCM, depending on the recipient's hardware preferences.
- Larger plaintext capacity. If we tune BFV parameters to yield a larger plaintext slot, v2 could use the extra bytes for a key-derivation salt or per-chunk subkeys.
- Different hash function. If Blake3 is ever superseded for post-quantum reasons (there is no current reason to expect this), v2 could swap in the replacement.
This document is specified in part so that implementations in other languages (Swift, TypeScript, Python) can produce byte-compatible KeyMaterial v1 bundles and interoperate with the Rust reference implementation. The format is small and unambiguous; a conformant implementation should fit in well under 100 lines of code in any language.
Implementations should test interoperability against the reference implementation's known-answer test vectors (see §8).
TODO before stable. The vectors below are placeholders. They need to be generated against the recrypt reference implementation and verified to produce byte-identical output from any conformant implementation.
Inputs:
symmetric_key: 0000000000000000000000000000000000000000000000000000000000000000
nonce: 000000000000000000000000000000000000000000000000
plaintext_hash: af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262 ; blake3("")
plaintext_size: 0
Encoded (96 bytes hex):
TODO — produce from reference implementation
Inputs:
symmetric_key: TODO (random 32 bytes)
nonce: TODO (random 24 bytes)
plaintext_hash: TODO (Blake3 of a known 1 MB plaintext)
plaintext_size: 1048576
Encoded:
TODO
Inputs:
symmetric_key: TODO
nonce: TODO
plaintext_hash: TODO
plaintext_size: 72057594037927935 ; (2^56 - 1)
Encoded:
TODO
Inputs:
plaintext_size: 72057594037927936 ; (2^56)
Expected: encoder error "plaintext_size exceeds u56 max"
Encoded (first byte 0x02, otherwise valid v1 layout):
02 [...95 bytes of valid v1 fields...]
Expected: decoder error "Unknown KeyMaterial version: 2"
- recrypt project README
- recrypt wire protocol §3.3 — how
KeyMaterial is wrapped in
recrypt.pre-wrapped-keyenvelopes - XChaCha20+Bao AEAD specification — the bulk encryption construction that uses KeyMaterial
- recrypt threat model — security analysis including KeyMaterial's role
- Blake3 specification
- draft-irtf-cfrg-xchacha — XChaCha20 specification
- OpenFHE — the BFV implementation recrypt uses
- Lyubashevsky, Peikert, Regev: "On Ideal Lattices and Learning with Errors over Rings" — RLWE foundation
- Reference implementation:
crates/recrypt-core/src/hybrid/keymaterial.rs