Référence en anglais
Cette page technique est disponible en anglais. Les guides d’utilisation et d’installation sont traduits en français.
feuristic QR backup format, version 1¶
Status: normative for feuristic v0.x. Product requirements: spec §3.3,
§8 (all of it), §10.3, §10.6, R13, R14. Design rationale:
docs/adr/0004-backup-envelope.md.
Implementation:
| Part | Module |
|---|---|
| Base45 (RFC 9285) | src/feuristic/core/base45.py |
| CRC-32 | src/feuristic/apps/backup/crc32.py |
| Envelope (§3–§5) | src/feuristic/apps/backup/envelope.py |
| QR frames (§6) | src/feuristic/apps/backup/frames.py |
| Provider interface (§7) | src/feuristic/apps/backup/provider.py |
| Export / restore (§8) | src/feuristic/apps/backup/export.py, restore.py |
| Import payloads (§9) | src/feuristic/apps/importer/payload.py, session.py |
| Test vector (§10) | tests/host/backup/test_vector.py |
The key words MUST, MUST NOT, SHOULD and MAY are used as in RFC 2119.
1. Overview¶
identity (provider dict) ──canonical CBOR──► plaintext
passphrase + salt ──PBKDF2-HMAC-SHA256 ×600 000──► 256-bit key
key + nonce + AAD(header) ──AES-256-GCM──► envelope = header ‖ ciphertext ‖ tag
envelope ──split, CRC-32, Base45──► "FB1:…" frames ──► one QR code each
- One envelope holds one identity (one OTP account, one passkey, one PIV slot, the OpenPGP key set, one AES key…). "Export complet" produces one envelope per identity, all encrypted with the same key (same salt) but each with its own random nonce and backup id, so every identity restores on its own and a lost image only loses that identity (ADR-0004).
- The format depends only on the passphrase and the data. Nothing is bound to the device, its master secret or its hardware revision, so a backup made on a T2B1 restores on a T3B1 and conversely (spec §8.3, R14).
- The format is distinct from, and not interchangeable with, Trezor wallet backups (spec §8.3).
2. Conventions¶
- Integers in binary layouts are unsigned big-endian.
- "Canonical CBOR" is the core deterministic encoding of RFC 8949 §4.2.1:
shortest-form integers and lengths, definite lengths only, map keys
sorted by the bytewise order of their encodings, no duplicate keys, no
tags, no floats, no simple values other than
false,true,null. Decoders MUST reject anything else, including trailing bytes. - CDDL follows RFC 8610.
3. Key derivation¶
3.1 Passphrase policy¶
The backup passphrase is distinct from the device PIN, is typed on the device with the two buttons, is never stored, and is never sent to the computer (spec §8.2).
- Length: 12 to 128 bytes.
- Alphabet: printable ASCII
0x20–0x7E(space allowed). The on-device keyboard offers only ASCII. Restricting the alphabet also means the same passphrase gives the same bytes on every device and firmware, with no Unicode normalisation question. - The passphrase bytes are used as-is: no trimming, no case folding, no normalisation.
An encoder MUST refuse a passphrase outside this policy. A v1 decoder MUST apply the same policy (a backup can only have been made with a conforming passphrase).
3.2 KDF¶
salt: 16 bytes from the strong RNG, drawn once per export session.cis fixed at 600 000 for format v1. A decoder MUST reject any other value (UnsupportedParameters), so a crafted backup cannot ask for an absurd iteration count. A future, stronger setting needs a new KDF identifier or a new format version.- The 32-byte PBKDF2 output is the AES-256 key, with no further step.
- Implementations run the derivation incrementally. feuristic does 4096
iterations per step and reports
progress(done, 600000)between steps, so the screen and USB stay responsive. On the device this takes several seconds.
4. Envelope¶
4.1 Binary layout¶
| Offset | Size | Field |
|---|---|---|
| 0 | 4 | magic 46 45 42 4B ("FEBK") |
| 4 | 1 | format version = 0x01 |
| 5 | 2 | H = length of the header map, 1 ≤ H ≤ 96 |
| 7 | H |
header: canonical CBOR map (§4.2) |
7+H |
n ≥ 1 |
ciphertext |
7+H+n |
16 | AES-GCM tag |
Total envelope length MUST be ≤ 65 536 bytes.
4.2 Header (CDDL)¶
header = {
1 => kind, ; 1 = identity export, 2 = part of a full export
2 => bstr .size 8, ; backup id (random, also carried by every frame)
3 => 1, ; KDF: 1 = PBKDF2-HMAC-SHA256
4 => 600000, ; PBKDF2 iteration count
5 => bstr .size 16, ; salt
6 => 1, ; AEAD: 1 = AES-256-GCM (96-bit nonce, 128-bit tag)
7 => bstr .size 12, ; nonce
}
kind = 1 / 2
All seven keys are required. Other keys are forbidden. In practice the
header is 55 bytes (H = 0x0037), so the fixed overhead of an envelope
is 7 + 55 + 16 = 78 bytes.
4.3 Encryption¶
AAD = envelope[0 : 7+H] ; magic ‖ version ‖ H ‖ header
ciphertext ‖ tag = AES-256-GCM-Encrypt(key, nonce, plaintext, AAD)
- The whole header is authenticated: magic, version, length, kind, backup id, KDF and AEAD identifiers, iteration count, salt and nonce.
- The nonce is 12 random bytes, drawn for every envelope. Within one export session the key is shared, and the nonces are independent random values (ADR-0004 §3 gives the collision bound).
- RNG draw order in feuristic (needed to reproduce §10): salt (once per session), then for each envelope the backup id (8), then the nonce (12).
4.4 Decryption: authenticate before parsing¶
- Check the length bound, the magic and the version (
UnsupportedVersionif the version is not 1). - Decode the header with a canonical, depth-1 CBOR decoder and check it against §4.2. The header is the only data read before authentication. It is short, bounded and has a fixed schema.
- Derive the key (§3.2). A key is cached per salt for the duration of one restore. A key is cached only after it has authenticated at least one envelope.
- Verify the tag with AES-GCM. On failure, report "wrong passphrase or
damaged backup" (
AuthenticationFailed). The two cases cannot be told apart. No plaintext is released and nothing is written. - Only then decode the plaintext (canonical CBOR, maximum depth 8,
bounded item count) and validate it strictly (§5). A plaintext that is
authenticated but not canonical, or that breaks the schema, MUST be
rejected (
PlaintextError).
5. Plaintext¶
5.1 CDDL¶
plaintext = identity-plaintext / full-plaintext
identity-plaintext = { ; header kind = 1
"v": 1,
"kind": "identity",
"items": [ item ], ; exactly one item
? "created": uint .le 4294967295,
}
full-plaintext = { ; header kind = 2
"v": 1,
"kind": "full",
"items": [ 1*16 item ], ; the v1 exporter writes exactly one
"seq": uint, ; 1 ≤ seq ≤ count: position in the export session
"count": uint, ; 1 ≤ count ≤ 1024: identities in the session
? "created": uint .le 4294967295,
}
item = {
"type": item-type, ; selects the provider (§7)
"v": 1..255, ; provider schema version
* tstr => any, ; provider fields (no floats, no tags)
}
item-type = tstr .regexp "[a-z][a-z0-9_-]{0,15}"
"kind"MUST match the header kind.- Unknown top-level keys are forbidden. Item keys MUST all be text strings.
"created"is informational (Unix seconds from the session clock when it is set) and is never used for a security decision.- Items nest at most 5 container levels below the item map, and one item holds at most 1024 CBOR data items. The exporter checks this, so every export can be restored.
5.2 Registered item types¶
type |
Application | Item schema |
|---|---|---|
otp |
TOTP/HOTP accounts | docs/protocols/backup-otp.md |
fido2 |
discoverable passkeys (+ their largeBlob data) | FIDO2 agent (to be published) |
openpgp |
OpenPGP Card key set | OpenPGP agent (to be published) |
piv |
one PIV slot (key + certificate) | PIV agent (to be published) |
aes |
one AES-256-GCM key | AES agent (to be published) |
example is used only by the test vector and has no provider.
6. QR frames¶
6.1 Frame text¶
frame = "FB1:" Base45(binary)
binary = backup_id (8) ‖ index (u16) ‖ total (u16) ‖ chunk (≥ 1 byte) ‖ crc32 (4)
backup_idis the envelope header's backup id. Frames of different envelopes are never mixed.indexis 1-based, with1 ≤ index ≤ total ≤ 999.chunkis the next slice of the envelope. Concatenating the chunks in index order gives the envelope exactly. Encoders balance the chunk sizes (the sizes differ by at most one byte), so all QR codes of a sequence have the same version.crc32is CRC-32/ISO-HDLC (zlib: poly0x04C11DB7reflected, init and xorout0xFFFFFFFF, check valueCBF43926) over all preceding bytes ofbinary, big-endian. It detects misreads only. Security comes from the GCM tag.Base45is RFC 9285 with strict decoding. Decoders reject characters outside the 45-character alphabet, a dangling single character, a triplet value above 65 535 and a final pair value above 255.- The prefix
FB1:and the Base45 alphabet both belong to the QR alphanumeric character set, so a frame is one alphanumeric-mode segment (5.5 bits per character). - Frame texts MAY contain spaces (Base45 digit 36), including a trailing space. QR decoders MUST NOT trim the payload. A trimmed frame fails the CRC or the Base45 check and is never misread.
- Decoders accept frame texts up to 1024 characters. Encoders emit at most 262.
6.2 Display profiles (128 × 64 OLED, ECC level M)¶
| Profile | Max QR version | Modules | Pixels/module | Max frame chars | Max binary | Chunk bytes |
|---|---|---|---|---|---|---|
dense (default) |
9 | 53 × 53 | 1 | 262 (v9-M alnum) | 172 | 156 |
large (large modules) |
3 | 29 × 29 | 2 | 61 (v3-M alnum) | 38 | 22 |
Frames needed for an envelope of L bytes: ceil(L / 156) (dense) or
ceil(L / 22) (large). Examples:
| Envelope | Dense | Large |
|---|---|---|
| 197 B (test vector, §10) | 2 | 9 |
| 300 B (typical passkey) | 2 | 14 |
| 2 500 B (PIV slot with certificate) | 17 | 114 |
| 65 536 B (maximum) | 421 | — (> 999) |
tests/host/backup/test_qr_capacity.py checks with an independent encoder
(segno) that every generated frame fits its profile, and that the profiles
are tight (one more character needs the next version). The quiet zone at 1
px/module is 5 modules (standard). At 2 px/module it is only about 1.5
modules: readability with ordinary phone cameras is acceptance test
R13 on real hardware and has not been demonstrated by the host
tests. A full export of many identities needs many images. This is
specified behaviour, not presented as fast or convenient (spec §8.2).
6.3 Reassembly rules¶
- Frames may arrive in any order and over several files.
- An exact duplicate (same id, index and chunk) is ignored.
- For one backup id, a frame with the same index and a different chunk is
rejected (
FrameConflict), as is a frame with a differenttotal(FrameTotalMismatch). A frame whose CRC fails is rejected (FrameChecksumError). A rejected frame never changes the state. - Generic format/parser bounds: ≤ 999 frames and ≤ 65 536 envelope bytes per backup. The host-oriented collection API defaults to 96 KiB and 192 sequences. Device reception uses the separate, published 12 KiB per-identity budget and one active sequence; see §8.3. These device budgets do not change format v1.
- Reassembly is refused while any index is missing (
FrameIncomplete, which lists the missing indices). A missing frame blocks the restore of that identity and modifies none of its active data.
7. Provider interface¶
Every backed-up application registers one provider at boot:
feuristic.apps.backup.provider.register(p). Registration order is export
order. get(type) raises UnsupportedItem for an unknown type, and all()
returns the providers.
| Member | Contract |
|---|---|
TYPE: str |
item type (§5.2), [a-z][a-z0-9_-]{0,15}, unique |
LABEL_KEY: str |
i18n key of the category name shown in prompts (optional) |
list_identities() -> [(uid, label)] |
every exportable identity. uid is 1–32 bytes and stable. label is ≤ 128 UTF-8 bytes |
export_identity(uid) -> dict |
complete item ("type", "v", fields, secrets included), canonical-CBOR-able, with no device-bound material. NotFound if the uid is gone |
validate(obj) |
strict validation of an authenticated item. Raises InvalidInput. The default implementation checks type/v. Called for every item before any write |
exists(obj) -> bool |
restoring obj would replace an existing identity (same natural key) |
describe(obj) -> (label, fingerprint) |
shown in the restore preview and the overwrite confirmation. fingerprint is hex (≤ 64 chars) or "" |
restore_identity(obj, overwrite) -> uid |
atomic write (all or nothing, power-loss safe). PolicyDenied if it exists and overwrite is false. StorageFull with nothing written. Restored FIDO2 credentials carry BE = BS = 1 |
on_exported(uid) |
called after the envelope is built and before its QR codes are returned for display (FIDO2 sets the WebAuthn backup-state flag BS). If it raises, no frame is returned |
Providers MAY subclass BackupProvider (recommended) or be duck-typed. A
provider without validate gets only the generic checks.
8. Procedures¶
8.1 Export ("Exporter une identité", "Export complet")¶
- The user starts the export from the device menu (never from the host, spec §10.10).
- DANGER confirmation. It shows the identity (or the number of identities) and the mandatory warning: anyone holding the QR images and the passphrase can recover the keys; exported keys are no longer non-exportable (spec §8.2). The device never claims that an exported key is non-exportable.
- Re-authentication hook (device PIN, spec §9).
- Passphrase entry, typed twice by the UI glue and compared in constant time, then checked against the policy (§3.1, up to 3 attempts).
- A random salt and one key derivation for the whole session.
- For each identity:
export_identity→ envelope (new backup id and nonce) → frames →on_exported→ display "QR i of n".
The optional re-authentication hook is a core API extension point. The device UI instead collects and confirms the passphrase first, then re-enters the actual device PIN inside its passphrase callback, comparing the two locally. It supplies no separate core hook, so there is one PIN check. All of this happens after the recovery warning and before derivation or identity export.
8.2 Restore¶
- The import session (§9) collects frames until every sequence is complete.
- All headers are parsed. A malformed header fails before any passphrase prompt.
- Envelopes are grouped by salt. Each group gets one passphrase entry and one key derivation. A wrong passphrase is reported and may be retried (3 attempts). When the attempts run out, the restore fails with storage unchanged.
- Every envelope is authenticated and decrypted, and every item is validated by its provider, before anything is written. If any envelope or item fails, nothing is written.
- If a full-export session is incomplete (
seqvalues missing out ofcount), a NORMAL confirmation is shown. - A SENSITIVE summary confirmation previews the identities. For a single identity it shows the name and fingerprint.
- For each item, if
exists, a specific DANGER overwrite confirmation shows its name and fingerprint. A refusal skips that identity only. Thenrestore_identityruns, atomically per identity. On the first failure the remaining items are not attempted, and identities already restored stay restored (spec §8.2 "atomique par identité"). - A report shows the identities restored, skipped, failed and not attempted.
8.3 Memory-bounded device restore (same v1 bytes)¶
The batch procedure above remains the host/test API restore.restore.
Firmware uses restore.StreamingRestore: it collects one identity's complete
sequence, releases the image decoder's workspace/bitmap/payload while keeping
MSC active, consumes that sequence's chunks, then authenticates and validates
every item of the current envelope before any of them is written. No
partial/unauthenticated identity is ever restored. Earlier confirmed identities
stay restored if a later independent identity is missing or damaged, as required
by per-identity atomicity. This is not a transaction over an entire full export.
The device receiver accepts one active sequence, ≤ 999 frames and ≤ 12 KiB of encrypted envelope data. Tests cover maximal native exports: OpenPGP with three RSA keys/auxiliary data (5127 B), FIDO with 64 associated largeBlob entries (4847 B), PIV/RSA with certificate (2500 B), HOTP (379 B) and AES (241 B). The native exporter emits one identity per envelope. A generic third-party Full envelope may contain up to 16 items/64 KiB under format v1; the generic parser supports that format, while device reception explicitly applies its smaller transport budget. Oversized/interleaved input is refused, never silently truncated or committed.
The user copies all frames of one identity, presses Continue and confirms it, then waits for the next-identity invitation before copying more. During prompts and derivation the MSC interface stays present but WRITE(10) returns NOT READY (04/07), with the unaccepted residue; the host must retry a refused file after resume. Done finishes the import and requests normal host eject. Do not copy a whole full-export image directory during a paused confirmation.
PackedReassembler preserves arbitrary frame order and exact-duplicate checks
using a reserved byte arena plus four index bytes per frame. It does not keep a
Python bytes object for every tiny QR chunk. StreamingRestore caches at most
four wipeable salt/key pairs, records accepted backup-id/SHA-256 pairs and
full-set sequence bitmaps, rejects conflicting reused ids/sequence numbers, and
never calls a partial set complete. Missing sets are visibly reported. Exact
restored/skipped counters and the last four receipt examples are retained,
preventing hundreds of long labels from accumulating beside decoder buffers.
A later failure reports those prior successes. Keys are wiped on close.
See memory evidence. None of these software checks replaces physical R04/R05/R13/R14 acceptance.
9. Import payloads and sessions¶
A QR found in an imported PNG/JPEG (spec §3.3) is classified as follows:
| Payload | Result |
|---|---|
starts with otpauth:// (scheme case-insensitive), ≤ 1024 bytes, all bytes in 0x20–0x7E |
OTP import |
starts with FB1:, ≤ 1024 bytes, the rest in the Base45 alphabet |
backup frame |
FB<1–3 digits>: with another version |
"unsupported backup version" |
| empty, > 2953 bytes, or anything else | "unsupported QR content" |
Session rules (ImportSession):
- It accepts any number of files without leaving the mode. Each file produces an event (accepted, duplicate or a specific rejection) with progress: received/total and the missing indices.
- The generic host collection API may collect several backup sequences and
becomes batch-ready only when all are complete. Device streaming reception
accepts one identity at a time (§8.3). Missing frames block that identity;
take_complete()cannot return an incomplete envelope. - At most one
otpauth://URI per session. A different second URI is rejected. An identical one counts as a duplicate. - Mixing: an
otpauth://image while backup frames are being collected, or a backup frame while an OTP import is pending, is rejected and leaves the state unchanged. The user finishes or cancels the current import, then starts a new session. - The session never writes storage. It returns an action (import an OTP URI, or restore these envelopes). After the action is done or declined, it is purged, which wipes the URI buffer. Then the glue asks for the SCSI eject. Cable removal or failure aborts and purges the session.
10. Test vector¶
Generated by tests/host/backup/test_vector.py with
feuristic.platform.testing.deterministic_rng() (SHA-256 counter mode,
seed "feuristic-tests") and the real 600 000 iterations. The test also
decrypts the vector with hashlib, cryptography and cbor2 only,
following this document.
Inputs:
passphrase = "correct horse battery staple" (28 bytes)
item = {"type": "example", "v": 1, "label": "Example identity",
"secret": h'000102…1f' (32 bytes)}
kind = identity (1), created = 1800000000
Values:
salt 778f41b9e5a960dacd70a61bbec357cd
key 147a98dfa786ba474adcba47ff2102b9dc3d15c819dbccee35995bf0098bdc8a
backup id ac25714e21b1883d
nonce 015808a7e62817a47192c63c
AAD / prefix + header (62 bytes)
4645424b 01 0037
a7 01 01 02 48 ac25714e21b1883d 03 01 04 1a 000927c0
05 50 778f41b9e5a960dacd70a61bbec357cd 06 01 07 4c 015808a7e62817a47192c63c
plaintext (119 bytes, canonical CBOR)
a4617601646b696e64686964656e74697479656974656d7381a46176016474797065676578
616d706c65656c6162656c704578616d706c65206964656e7469747966736563726574582000
0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f67637265617465
641a6b49d200
ciphertext (119 bytes)
cdd51a18a6b3aa566c3164c387a68f146aeb03d33c59e3b6c409161690152a662c717bf449dc
82ef56f59d7e6389452b0a1620e6de2744c6452f160b92bd3dcf857af29209cc8e7dbf36f02d
b9ade254132790ce54c434a196d537f417dc24bc604b23779ce8870b91d0a181516a992cd305
a8919f6ac2
tag 46127862388a5421a52aefc416b3b630
envelope AAD ‖ ciphertext ‖ tag (197 bytes)
Frames, dense profile (2 frames of 99 and 98 chunk bytes):
FB1:EYLQEEUB42AH100200Y*86H8V50R17W50:C0EYLQEEUB42AH4H0FN0900615AU075F+D8N1T:ACW/P/:KA5OM4B7Y0N-0T70A41F4TM:24GEX2P/0QKD3F3L1OLMUDAXCV6H/3IBNDYL0ES7JZSAZOTZ2U9I9G5$R57UF+D9ZJ0:0
FB1:EYLQEEUB42AH200200K*RPBUC2V0:F-FH%J5*Z2H8TH/4.2P*.5$K1P-NPAQJOFZKIV%PG+FO.65Z59:LDSA305Z3Q8 OBJK/.QR%UG$RT NKM9K5F$HT K1*GQUFGJLD0U58W0JIIFMDS%8%9FT67RSAR*K0EU6*2K1NUBDM8U
Frames, large profile (9 frames):
FB1:EYLQEEUB42AH100900Y*86H8V50R17W50:C0EYLQEEUB42AH4H0V$7V0E
FB1:EYLQEEUB42AH200900FN0900615AU075F+D8N1T:ACW/P/:KA5O1K5BE1
FB1:EYLQEEUB42AH300900M4B7Y0N-0T70A41F4TM:24GEX2P/0QKD3O%TYDK
FB1:EYLQEEUB42AH400900F3L1OLMUDAXCV6H/3IBNDYL0ES7JZSAZONP53H9
FB1:EYLQEEUB42AH500900TZ2U9I9G5$R57UF8F9*OGV:A/+JBQCMX8VAV80B
FB1:EYLQEEUB42AH600900HC1774 3SBV8QX8IZ2ZOIS 7F*G/TUXA1IMD5*L
FB1:EYLQEEUB42AH700900R0IZ7OFGUDLNPRS/I2ZDIAWAIT633JE3771B4HI
FB1:EYLQEEUB42AH800900X03:S4 7CYL4S$JB3HNJIZIK7DAHGJLUQ%Y4202
FB1:EYLQEEUB42AH900900/DL+6K9PO3F2YIC.LHIB4BJ5NZOGWMQ768Z683
11. Security considerations¶
- Stolen images, no passphrase: confidentiality rests on the passphrase and on 600 000 PBKDF2-SHA256 iterations per guess, with a 128-bit salt that defeats precomputation. A short or guessable passphrase is the main risk. The policy enforces only a minimum length, and the UI SHOULD recommend several random words.
- Images and passphrase both held: keys are recoverable by design (spec §8.2). The device says so before every export and never calls such keys non-exportable. FIDO2 sets the BS flag.
- Altered or forged backup: every byte is authenticated (header as AAD, body by GCM). Without the passphrase an attacker cannot produce an envelope that restores. Someone who has the passphrase can only make backups the owner could also make. They are still strictly validated, and an overwrite always needs a specific confirmation.
- Replay of an older backup of the same identity is possible (inherent to backups). The overwrite confirmation shows the fingerprint. Restoring an older HOTP counter is handled by the OTP provider, which never moves a counter backwards for the same secret.
- Hostile QR content: classification and parsing are bounded and strict, run unprivileged, and never interpret data as commands. The plaintext parser runs only on authenticated data.
- Secrets in RAM: derived keys and the OTP URI are kept in
bytearrayand wiped. Decrypted plaintext objects are immutable on MicroPython and cannot be wiped deterministically (known limitation).
12. Versioning¶
- The magic
FEBKand the version byte identify the envelope format. The frame prefixFB1:identifies the frame format. Both are shown to the user as "Backup format v1" (spec §8.3). - A decoder MUST reject unknown versions, KDF/AEAD identifiers and iteration counts with a clear "unsupported version/parameters" message. It MUST NOT guess.
- Item schemas are versioned independently with the
"v"field of each item.