BMENC v1 encryption format
| Field | Value |
|---|---|
| Specification | BMENC v1 |
| Status | Published; the wire format is frozen at version = 0x01 |
| Source | Output formats & encryption §11, which this document is written from |
| Requirement | FR-ENC.2 (platform-key encryption), FR-ENC.13 (the format is published) |
| Vectors | vectors/v1/vectors.json |
| Reference decryptor | docs/specs/bmenc/reference/bmenc_ref.py |
| Golden files | product/tests/golden/bmenc/v1/ |
BMENC is the envelope BMP writes round an artifact when a plan asks for PlatformKey encryption. It exists so that a
backup can be decrypted by somebody who does not have BMP: the format is published, the vectors are published, and a
second implementation in another language is checked against them on every build.
It is modelled on age: one random key per file, a list of independent ways to unwrap that key, and a payload encrypted in segments so that a file of any size is read in constant memory and cannot be truncated without it being noticed.
Notation. Integers are unsigned and big-endian. ‖ is concatenation. HKDF is HKDF-SHA-256 (RFC 5869). KWP is
AES-256 key wrap with padding (RFC 5649). GCM is AES-256-GCM with a 12-byte nonce and a 16-byte tag. Strings are
ASCII without NUL. Byte offsets count from zero.
1. File layout
Section titled “1. File layout”file = header ‖ payload| Offset | Size | Field | Rule |
|---|---|---|---|
| 0 | 6 | magic |
42 4D 45 4E 43 00 — BMENC\0 |
| 6 | 1 | version |
0x01 |
| 7 | 1 | aead_id |
0x01 = AES-256-GCM. 0x02 is reserved for ChaCha20-Poly1305 and is not written by v1 |
| 8 | 1 | seg_log2 |
16 to 24; the plaintext segment size is 2^seg_log2. The default is 20 (1 MiB) |
| 9 | 1 | flags |
Bit 0: a META block is present. Every other bit MUST be zero |
| 10 | 2 | stanza_count |
1 to 32 |
| 12 | 4 | header_len |
The whole header including header_mac; 80 ≤ header_len ≤ 1 MiB |
| 16 | 32 | stream_salt |
From a CSPRNG, unique per file |
| 48 | var | stanzas |
stanza_count × ( type u8 ‖ body_len u16 ‖ body ) |
| … | var | meta |
Present when flags bit 0 is set: meta_len u32 (≤ 65,536) ‖ meta_ct |
header_len − 32 |
32 | header_mac |
HMAC-SHA-256(header_key, header[0 .. header_len − 32)) |
The payload follows immediately. Segment i starts at header_len + i · (2^seg_log2 + 16).
2. Stanzas
Section titled “2. Stanzas”Each stanza wraps the same 32-byte file key fk. wrapped is always the 40 bytes KWP produces for a 32-byte key.
| Type | Name | Body | Wrapping key |
|---|---|---|---|
0x01 |
KEK | kek_id 16 ‖ kek_version u32 ‖ wrapped 40 (60 B) |
The platform’s artifact key of that version. kek_id is a UUID in RFC 9562 byte order |
0x02 |
PASSWORD-ARGON2ID | salt 16 ‖ m_kib u32 ‖ t u32 ‖ p u8 ‖ wrapped 40 (65 B) |
Argon2id v1.3 over the UTF-8 of the NFC-normalised password, output 32 bytes. BMP writes m = 262,144 KiB, t = 3, p = 4 |
0x03 |
PASSWORD-PBKDF2-SHA256 | salt 16 ‖ iterations u32 ‖ wrapped 40 (60 B) |
PBKDF2-HMAC-SHA-256, at least 600,000 iterations. For deployments that must stay inside FIPS |
0x04 |
X25519 | eph_pub 32 ‖ wrapped 40 (72 B) |
s = X25519(eph_priv, R), rejecting an all-zero s; the wrapping key is HKDF(ikm = s, salt = eph_pub ‖ R, info = "BMENC/1/X25519", L = 32). A fresh ephemeral pair per stanza |
0x05, 0x06 |
reserved | — | Post-quantum hybrid; external KMS |
0x80–0xFF |
private | — | Never written by BMP 1.x |
A decoder MUST step over a stanza type it does not know — they are length-prefixed for exactly that reason — and
MUST reject a type it does know whose body_len is not the one in this table.
What BMP writes:
| Object | Stanzas |
|---|---|
A PlatformKey artifact and its file index |
0x01 + 0x04 when a recovery key exists |
| A manifest’s sealed section, and the platform’s self-backup | 0x01 + 0x04 |
| The KEK export inside a recovery kit | 0x02 alone, at m = 1,048,576 KiB, t = 4, p = 4 |
3. Key schedule
Section titled “3. Key schedule”fk— 32 bytes from a CSPRNG, one per file. Never reused, never stored unwrapped.header_key= HKDF(ikm =fk, salt =stream_salt, info ="BMENC/1/header", L = 32)payload_key= HKDF(ikm =fk, salt =stream_salt, info ="BMENC/1/payload"‖aead_id‖seg_log2, L = 32)meta_key= HKDF(ikm =fk, salt =stream_salt, info ="BMENC/1/meta", L = 32)
The payload key is bound to the AEAD id and the segment size, so a header edited to claim a different segment size cannot decrypt the payload it was attached to.
3.1 The META block
Section titled “3.1 The META block”meta_ct = GCM(meta_key, nonce = twelve zero bytes, aad = the header bytes preceding the meta block,
plaintext = UTF-8 JSON). The nonce may be fixed because meta_key is used exactly once.
The JSON says what the envelope holds and never says anything secret:
{"inner":"tar.zst","innerName":"nightly-web-etc-nginx-20260915t020000z.tar.zst","createdUtc":"2026-09-15T02:00:03Z","instanceId":"…","runId":"…","artifactId":"…"}Unknown members are ignored by a reader, so later releases may add them.
4. Payload
Section titled “4. Payload”Let S = 2^seg_log2 and let L be the plaintext length, which need not be known before the file is written.
n = max(1, ceil(L / S)). Segment i carries S bytes for i < n − 1; the last carries 1 to S bytes, or zero
bytes only when L = 0.
nonce_i = I2OSP(i, 11) ‖ (i == n − 1 ? 0x01 : 0x00)seg_ct_i = GCM-Encrypt(payload_key, nonce_i, pt_i, aad = ε) // len(pt_i) + 16 bytesThe associated data of a segment is empty by design. What the file is, is bound through the key derivation; where
the segment is and whether it is the last one is bound through the nonce. The payload is deliberately not bound to the
stanza list or to header_mac, so that rotating a key rewrites the header and leaves the payload alone.
This gives: constant memory; every segment authenticated before a byte of it is released; truncation, reordering, duplication and splicing all detected; and, because every file has its own key, no nonce is ever reused with a key.
A writer stops before 2³² segments — 4 PiB at 1 MiB segments.
5. Writing a file
Section titled “5. Writing a file”- Generate
fkandstream_salt, and one ephemeral X25519 pair per0x04stanza. Build the stanzas, then the meta block, thenheader_mac. Write the header. - Fill a buffer of
Sbytes. Emit a full buffer with flag 0 only once at least one more byte has arrived — a writer always holds one segment back. When there is no more input, emit whatever is buffered (a full segment, a partial one, or an empty one whenL = 0) with flag 1. - Keep KWP(active KEK,
fk) for the catalogue, so a key rotation can re-wrap without touching stored objects. Zerofkand every derived key.
6. Reading a file
Section titled “6. Reading a file”- Read 16 bytes. Check
magic,version = 1,aead_id = 1,seg_log2∈ 16..24, no undefined flag bits,stanza_count∈ 1..32,header_len∈ 80..1 MiB. Read the rest of the header. - Parse the stanzas inside the header’s bounds. Enforce these limits before running any key derivation:
m_kib≤ 4,194,304,t≤ 16,p≤ 16,iterations≤ 10,000,000,meta_len≤ 65,536. A stanza outside them ends the read; it is not a wrong key, it is a file asking for more work than a reader will do. - Try the stanzas for which a key was supplied, in the order KEK, X25519, password. A KWP failure means the wrong key for that stanza: move to the next one.
- Derive
header_keyand verifyheader_macin constant time. A failure here is fatal — the file key was right, so whatever is wrong was done to the header. No other stanza is tried. - Decrypt the meta block when one is present.
- Read the payload in chunks of
S + 16bytes with one byte of lookahead: a chunk that is not followed by anything is the last one and its nonce carries flag 1. Reject- any tag failure,
- a stream whose last chunk verifies only with flag 0 (it has been truncated, or its last segment was dropped),
- bytes after the final segment,
- a final chunk of exactly 16 bytes when
i > 0(an empty final segment), - a chunk shorter than 17 bytes, except the single chunk of an
L = 0file.
- Release a segment’s plaintext only after its tag has verified.
7. Ranged reads
Section titled “7. Ranged reads”For an object of size Z, n = ceil((Z − header_len) / (S + 16)) and the last index uses flag 1. A reader fetches the
header (16 bytes, then header_len), unwraps fk, and then reads only the segments it wants — by HTTP Range, or by
seeking in a file. This is what makes a parallel restore, a random-segment integrity check and single-file extraction
from an uncompressed .tar.bmenc possible. A compressed payload cannot be entered in the middle.
8. Versioning
Section titled “8. Versioning”version is 0x01. A reader rejects anything else. Inside v1, the only compatible changes are new stanza types (which
old readers step over) and new meta JSON members (which old readers ignore). A new flag bit, a new AEAD, a different
nonce or a different segment layout all require version = 0x02.
9. Overhead
Section titled “9. Overhead”A header is about 440 bytes with a KEK stanza, a recovery stanza and a meta block. The payload costs 16 bytes per segment — 0.0015 % at 1 MiB segments.
10. Conformance
Section titled “10. Conformance”An implementation conforms when it reads every positive vector, refuses every negative one, and produces byte-for-byte the same file from the same inputs.
vectors/v1/vectors.jsonholds the vectors. Every key, salt and ephemeral key is fixed. The plaintext of a vector is the firstplaintextLengthbytes of the sequence(i · 131) % 251.positive— the whole file as hex, with its SHA-256. Every key listed for a vector must open it.boundary— the lengths where the segmenting is decided (S − 1,S,S + 1,3S, and one at 1 MiB segments), pinned by SHA-256 rather than by hex because nobody should have to read three megabytes of a diff.negative— a byte-level change to a positive vector, with part of the message a reader must give. The changes areflip(XOR 1 at an offset),set(a byte to a value),truncate,append,flipHeaderMacandflipMeta.
docs/specs/bmenc/reference/bmenc_ref.pyis an independent reader, written from this document with pyca/cryptography. It decrypts every positive vector, rebuilds the KEK-only and boundary vectors from their inputs and compares the bytes, and refuses every negative vector. Argon2id vectors additionally needargon2-cffi; without it they are reported as skipped. Run it withpython docs/specs/bmenc/reference/bmenc_ref.py.product/tests/golden/bmenc/v1/<release>/holds real files written by each release. Every later release opens them again, which is the only way to know that an artifact written today can still be read (NFR-30).
The platform’s own conformance tests are BmencRoundTripTests, BmencNegativeVectorTests, BmencGoldenFileTests and
BmencPythonReferenceTests.