Backup and transfer formats
Three QR payloads, all Uniform Resources
(UR, BCR-2020-005) with Bytewords minimal
bodies, plus SeedSigner’s SeedQR. The UR encoder, the fountain code and the
decoder are src/lib/qr/ur.ts, byte-for-byte compatible with the reference
(test/vectors/ur.json, including BCR-2020-005’s published ur:bytes/1-9 part).
A payload whose single-part UR is at most 500 characters is shown as one QR.
A longer one is shown as an animated multi-part UR (ur:<type>/<seq>-<len>/…,
120-byte fragments, a frame every 300 ms): parts 1…len are the plain
fragments, later parts are fountain-coded XOR mixes, so a scanner that drops
frames still completes. QR codes carry the UR uppercased (alphanumeric mode),
error correction L. All CBOR below is canonical (definite lengths, integer map
keys in ascending order); every map has key 1 = version (1).
ur:lattice-vault — encrypted wallet backup
Section titled “ur:lattice-vault — encrypted wallet backup”The vault’s contents re-encrypted with the wallet password, in the keystore’s
own scheme (src/lib/crypto/keystore.ts):
| key | value |
|---|---|
| 1 | version: 1 |
| 2 | KDF: 1 = Argon2id, 2 = PBKDF2-HMAC-SHA256 |
| 3 | KDF parameters: Argon2id [memoryKiB, iterations, parallelism] (today [19456, 2, 1]); PBKDF2 [iterations] (600000) |
| 4 | salt (16 bytes) |
| 5 | AES-256-GCM IV (12 bytes) |
| 6 | AES-256-GCM ciphertext + 16-byte tag |
The 32-byte AES key is the KDF output over the password and salt. The
plaintext is UTF-8 JSON: { mnemonic: string | null, hd: [{ index, label }], imported: [{ priv: hex, label }], active, nodeCookies? }. nodeCookies (the
operator-port cookies of paired nodes) is present only when the user opts in.
A decoder refuses KDF costs above Argon2id m = 64 MiB, t = 4, p = 1 or
PBKDF2 2M iterations, and validates every decrypted field.
Saved as a file it is a text file whose non-comment line is the single-part UR. Printed, it is the plain fragments (1…len) as a grid; scanning all of them, in any order, completes it.
ur:lattice-pair — a receiver’s one-time pairing offer
Section titled “ur:lattice-pair — a receiver’s one-time pairing offer”| key | value |
|---|---|
| 1 | version: 1 |
| 2 | session id (8 random bytes) |
| 3 | receiver’s one-time X25519 public key R (32 bytes) |
| 4 | expiry, Unix seconds (now + 300) |
ur:lattice-transfer — the sealed transfer
Section titled “ur:lattice-transfer — the sealed transfer”| key | value |
|---|---|
| 1 | version: 1 |
| 2 | session id (from the offer) |
| 3 | sender’s one-time X25519 public key S (32 bytes) |
| 4 | AES-256-GCM IV (12 bytes) |
| 5 | AES-256-GCM ciphertext + tag |
transcript = "lattice-transfer-v1" ‖ 0x01 ‖ sid ‖ R ‖ Sshared = X25519(s, R) = X25519(r, S) (all-zero refused)salt = SHA-256(transcript)key = HKDF-SHA256(shared, salt, "lattice-transfer-v1 key", 32)sas = HKDF-SHA256(shared, salt, "lattice-transfer-v1 sas", 4) as big-endian uint32 mod 1 000 000, six digitsciphertext = AES-256-GCM(key, iv, plaintext, aad = transcript)The plaintext is a ur:lattice-vault CBOR (above, without node cookies),
encrypted with the sending wallet’s password. The receiver’s secret r lives
only in its signer, is used for one open attempt (good or bad) and is
discarded; an offer past its expiry is refused by both sides.
Both screens show the six-digit code; the receiver imports only after the user confirms they match, and then needs the sending wallet’s password. The code is 20 bits, so an active attacker who can swap both QR codes could search for a colliding one; the password layer is why that only ever yields what an encrypted backup QR would (an Argon2id-protected blob), never the keys. The ECDH layer keeps the transfer unreadable to anyone who merely films it.
SeedQR (SeedSigner)
Section titled “SeedQR (SeedSigner)”For the recovery phrase only (12 or 24 English words), per SeedSigner’s spec: Standard is each word’s 0-based BIP39 index as four digits (numeric mode; 25×25 for 12 words, 29×29 for 24); Compact is the raw entropy, 16 or 32 bytes (byte mode; 21×21 / 25×25). Error correction L. Tested against the spec’s four vectors. The QR is rendered to SVG inside the signer after the password is re-entered; the phrase itself never reaches the page.
Source: selfhold-wallet/docs/qr-formats.md at fce4540d077f