Skip to content

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.


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).


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

  • 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.

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.


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 bytes

The 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.


  1. Generate fk and stream_salt, and one ephemeral X25519 pair per 0x04 stanza. Build the stanzas, then the meta block, then header_mac. Write the header.
  2. Fill a buffer of S bytes. 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 when L = 0) with flag 1.
  3. Keep KWP(active KEK, fk) for the catalogue, so a key rotation can re-wrap without touching stored objects. Zero fk and every derived key.
  1. 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.
  2. 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.
  3. 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.
  4. Derive header_key and verify header_mac in 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.
  5. Decrypt the meta block when one is present.
  6. Read the payload in chunks of S + 16 bytes 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 = 0 file.
  7. Release a segment’s plaintext only after its tag has verified.

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.

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.

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.


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.json holds the vectors. Every key, salt and ephemeral key is fixed. The plaintext of a vector is the first plaintextLength bytes 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 are flip (XOR 1 at an offset), set (a byte to a value), truncate, append, flipHeaderMac and flipMeta.
  • docs/specs/bmenc/reference/bmenc_ref.py is 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 need argon2-cffi; without it they are reported as skipped. Run it with python 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.