OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-identity commits / 8261f49

vault: a recovery secret sealed so it can be replicated
Creating an identity phone-first means minting a did:plc, and whoever holds its recovery material can rotate its keys for ever. That secret has to reach the owner's other devices, and every obvious channel is worse: retyping 24 words on a handset invites transcription errors, a screenshot lands in a cloud photo library, and shipping plaintext to "attested devices" makes device attestation exactly as strong as the root key it exists to protect. So: Argon2id + XChaCha20-Poly1305, parameters and salt carried IN the envelope because a handset and a workstation have different tolerances for a KDF and cost must be raisable later without stranding blobs sealed today. ⛔ THE PASSPHRASE IS NEVER STORED. The blob is designed to be replicated over the same substrate as everything else, so it will end up on disks we do not control and with anyone who later joins the identity. It is safe there only because it is useless without something that lives in a person's head. A future change that writes the passphrase to disk to smooth out unlocking turns this from a vault into an elaborate copy of the mnemonic. ⛔ BOUND TO THE DID, as AEAD additional data. Without it, one identity's vault could be served in place of another's and would open cleanly for whoever knows that passphrase. The caller states which identity it expects; relabelling the envelope does not help. Both cases are tested. ⛔ SEALING IS NOT REVOCATION. A device that once unlocked this knows the secret and keeps knowing it after removal — favela-android's Pair.jsx says the same of the signing key. Only rotating on the PLC log devalues a leaked secret (plc-directory.js rotateVerificationMethodOnChain). This module is how a NEW secret reaches the remaining devices; it is not what makes an old one harmless. That distinction is why the vault is being built first and rotation second, rather than being mistaken for it. A wrong passphrase, a tampered blob and the wrong identity all fail identically and deliberately: saying which one failed tells an attacker which half they got right. sodium-native and b4a are now DECLARED rather than used transitively — the same undeclared-dependency trap that broke favela-android's suite earlier today. 11 tests, 27 assertions, green under BOTH node and bare (the phone runs bare). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8261f499f70b1c0c4093f92770da5a7a7f21bca3
by plankatron <fleeky@gmail.com> · 2026-08-25T19:41:21.000Z

Changed files (3) +248 -1

lib/vault.js +143 -0
……@@ -0,0 +1,143 @@
1// vault.js — the recovery secret, encrypted so it can be replicated.
2//
3// WHAT THIS IS FOR. Creating an identity phone-first means minting a did:plc, and whoever holds its
4// recovery material can rotate its keys for ever. That secret has to reach the owner's other devices
5// somehow, and every obvious channel is worse than this one: retyping 24 words on a handset invites
6// transcription errors, a screenshot lands in a cloud photo library, and shipping it in plaintext to
7// "attested devices" makes device attestation exactly as strong as the root key it is meant to protect.
8//
9// ⛔ THE PASSPHRASE IS NEVER STORED, ANYWHERE. That is the whole security argument: the sealed blob is
10// designed to be replicated over the same P2P substrate as everything else, so it WILL end up on disks
11// we do not control and in the hands of anyone who later joins the identity. It is safe there only
12// because it is useless without something that lives in a person's head. If a future change writes the
13// passphrase to disk to "make unlock smoother", this file stops being a vault and becomes a very
14// elaborate copy of the mnemonic.
15//
16// ⛔ SEALING IS NOT REVOCATION. A device that once unlocked this vault knows the secret and will keep
17// knowing it after you remove the device — see app/Pair.jsx in favela-android, which says the same thing
18// about the signing key: removing a device stops it being ADMITTED, it cannot make it forget. The only
19// thing that devalues a leaked recovery secret is ROTATING the identity's keys on the PLC log (see
20// plc-directory.js rotateVerificationMethodOnChain). This module is how a NEW secret reaches the
21// remaining devices; it is not what makes an old one harmless.
22//
23// ⛔ BOUND TO THE DID. The DID goes in as AEAD additional data, so a blob sealed for one identity cannot
24// be silently served in place of another's. A vault that opens under the wrong identity is a vault that
25// can be swapped.
26//
27// Argon2id via sodium-native, XChaCha20-Poly1305 for the seal. Both parameters and salt travel INSIDE
28// the envelope: a phone and a workstation have very different tolerances for a KDF, and cost is a thing
29// we will want to raise later without stranding blobs sealed today.
30import sodium from 'sodium-native'
31import b4a from 'b4a'
32
33export const VAULT_VERSION = 1
34
35// Interactive memory (64 MiB) with moderate ops. A handset is the constrained case and this has to run
36// there without the OS killing the app; the ops cost carries the weight instead. Recorded per-envelope,
37// so raising either later leaves old blobs openable.
38const DEFAULT_OPSLIMIT = sodium.crypto_pwhash_OPSLIMIT_MODERATE
39const DEFAULT_MEMLIMIT = sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE
40
41const b64 = (buf) => b4a.toString(buf, 'base64')
42const unb64 = (s) => b4a.from(s, 'base64')
43
44function deriveKey (passphrase, salt, opslimit, memlimit) {
45 const key = b4a.alloc(sodium.crypto_aead_xchacha20poly1305_ietf_KEYBYTES)
46 // ⛔ NOT a plain hash. A passphrase people can remember is low-entropy by definition, so the only
47 // thing standing between a replicated blob and an offline guessing attack is the KDF's cost. Argon2id
48 // is memory-hard, which is what makes that cost real on a GPU too.
49 sodium.crypto_pwhash(
50 key,
51 b4a.from(String(passphrase), 'utf8'),
52 salt,
53 opslimit,
54 memlimit,
55 sodium.crypto_pwhash_ALG_ARGON2ID13
56 )
57 return key
58}
59
60/**
61 * Seal a recovery secret for one identity.
62 *
63 * @param {{secret: string, passphrase: string, did: string, opslimit?: number, memlimit?: number}} opts
64 * @returns {object} a JSON-safe envelope, safe to replicate
65 */
66export function sealVault ({ secret, passphrase, did, opslimit = DEFAULT_OPSLIMIT, memlimit = DEFAULT_MEMLIMIT } = {}) {
67 if (typeof secret !== 'string' || !secret) throw new Error('secret required')
68 // A vault with no passphrase is a file with extra steps, and would be worse than none: it would LOOK
69 // encrypted to everyone downstream, including to whoever decides where to replicate it.
70 if (typeof passphrase !== 'string' || !passphrase) throw new Error('passphrase required')
71 if (typeof did !== 'string' || !did.startsWith('did:')) throw new Error('did required')
72
73 const salt = b4a.alloc(sodium.crypto_pwhash_SALTBYTES)
74 sodium.randombytes_buf(salt)
75 const nonce = b4a.alloc(sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES)
76 sodium.randombytes_buf(nonce)
77
78 const key = deriveKey(passphrase, salt, opslimit, memlimit)
79 const message = b4a.from(secret, 'utf8')
80 const ct = b4a.alloc(message.length + sodium.crypto_aead_xchacha20poly1305_ietf_ABYTES)
81 sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(ct, message, b4a.from(did, 'utf8'), null, nonce, key)
82 sodium.sodium_memzero(key)
83 sodium.sodium_memzero(message)
84
85 return {
86 v: VAULT_VERSION,
87 did,
88 kdf: { alg: 'argon2id13', opslimit, memlimit, salt: b64(salt) },
89 aead: 'xchacha20poly1305-ietf',
90 nonce: b64(nonce),
91 ct: b64(ct)
92 }
93}
94
95/**
96 * Open a sealed vault. Throws on a wrong passphrase, a tampered blob, or the wrong identity — all three
97 * are the same AEAD failure and are deliberately NOT distinguished in the message: telling a caller
98 * which one failed tells an attacker which half they got right.
99 */
100export function openVault ({ envelope, passphrase, did } = {}) {
101 const e = envelope
102 if (!e || typeof e !== 'object') throw new Error('envelope required')
103 if (e.v !== VAULT_VERSION) throw new Error('unsupported vault version: ' + e.v)
104 if (e.aead !== 'xchacha20poly1305-ietf') throw new Error('unsupported aead: ' + e.aead)
105 if (!e.kdf || e.kdf.alg !== 'argon2id13') throw new Error('unsupported kdf: ' + (e.kdf && e.kdf.alg))
106 if (typeof passphrase !== 'string' || !passphrase) throw new Error('passphrase required')
107
108 // The caller says which identity it BELIEVES this vault is for, and the AEAD refuses if it disagrees.
109 // Defaulting to the envelope's own `did` would let a swapped blob authenticate itself.
110 const forDid = typeof did === 'string' && did ? did : e.did
111 if (typeof forDid !== 'string' || !forDid.startsWith('did:')) throw new Error('did required')
112
113 const salt = unb64(e.kdf.salt)
114 const nonce = unb64(e.nonce)
115 const ct = unb64(e.ct)
116 if (salt.length !== sodium.crypto_pwhash_SALTBYTES) throw new Error('malformed vault: salt')
117 if (nonce.length !== sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES) throw new Error('malformed vault: nonce')
118 if (ct.length < sodium.crypto_aead_xchacha20poly1305_ietf_ABYTES) throw new Error('malformed vault: ciphertext')
119
120 const key = deriveKey(passphrase, salt, e.kdf.opslimit, e.kdf.memlimit)
121 const out = b4a.alloc(ct.length - sodium.crypto_aead_xchacha20poly1305_ietf_ABYTES)
122 try {
123 sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(out, null, ct, b4a.from(forDid, 'utf8'), nonce, key)
124 } catch {
125 throw new Error('could not open the vault')
126 } finally {
127 sodium.sodium_memzero(key)
128 }
129 const secret = b4a.toString(out, 'utf8')
130 sodium.sodium_memzero(out)
131 return secret
132}
133
134/**
135 * Re-seal an already-open secret under a new passphrase.
136 *
137 * Separate from sealVault so the caller cannot accidentally change WHICH identity a vault is for while
138 * changing its passphrase — the did is carried over, not re-supplied.
139 */
140export function rekeyVault ({ envelope, passphrase, newPassphrase, did, ...rest } = {}) {
141 const secret = openVault({ envelope, passphrase, did })
142 return sealVault({ secret, passphrase: newPassphrase, did: (typeof did === 'string' && did) ? did : envelope.did, ...rest })
143}
package.json +3 -1
……@@ -14,9 +14,11 @@
1414 "@noble/hashes": "^1.5.0",
1515 "@scure/bip32": "^1.5.0",
1616 "@scure/bip39": "^1.4.0",
17 "b4a": "^1.6.7",
1718 "hypercore-crypto": "^3.0.0",
1819 "keet-identity-key": "^3.2.0",
19 "multiformats": "^13.3.0"
20 "multiformats": "^13.3.0",
21 "sodium-native": "^5.1.0"
2022 },
2123 "devDependencies": {
2224 "brittle": "^4.0.2"
test/vault.test.js +102 -0
……@@ -0,0 +1,102 @@
1// The recovery vault: a secret sealed so it can be REPLICATED without being readable.
2//
3// Every test here is a property the design depends on, not a happy path. The blob is meant to travel
4// over the same P2P substrate as everything else and end up on disks nobody controls — so "wrong
5// passphrase fails", "tampering fails" and "another identity's blob does not open" are the whole
6// security argument, and a regression in any of them is silent.
7import test from 'brittle'
8import { sealVault, openVault, rekeyVault, VAULT_VERSION } from '../lib/vault.js'
9
10const DID = 'did:plc:wj62ebbusln6gxo5azo6ogzo'
11const OTHER = 'did:plc:e2jevhuurrhlm36ufk7fklpg'
12const WORDS = 'abandon ability able about above absent absorb abstract absurd abuse access accident'
13const PASS = 'correct horse battery staple'
14
15// Argon2id at real cost is slow on purpose; these use the cheapest parameters sodium accepts so the
16// suite stays fast. Cost lives in the envelope precisely so it can differ per caller.
17const CHEAP = { opslimit: 2, memlimit: 67108864 }
18
19test('a sealed vault round-trips', (t) => {
20 const env = sealVault({ secret: WORDS, passphrase: PASS, did: DID, ...CHEAP })
21 t.is(env.v, VAULT_VERSION, 'the envelope is versioned')
22 t.is(openVault({ envelope: env, passphrase: PASS, did: DID }), WORDS, 'and opens to exactly what went in')
23})
24
25test('the secret never appears in the envelope', (t) => {
26 const env = sealVault({ secret: WORDS, passphrase: PASS, did: DID, ...CHEAP })
27 const json = JSON.stringify(env)
28 t.absent(json.includes(WORDS), 'not in the clear')
29 t.absent(json.includes('abandon'), 'not even the first word')
30 t.absent(json.includes(PASS), 'and the passphrase is not stored — that is the whole argument')
31})
32
33test('a wrong passphrase does not open it', (t) => {
34 const env = sealVault({ secret: WORDS, passphrase: PASS, did: DID, ...CHEAP })
35 t.exception(() => openVault({ envelope: env, passphrase: PASS + '!', did: DID }))
36})
37
38// ⛔ The blob is designed to be replicated, so it lands where anyone can edit it. AEAD is what makes an
39// edit a failure rather than a silently different secret.
40test('a tampered ciphertext does not open', (t) => {
41 const env = sealVault({ secret: WORDS, passphrase: PASS, did: DID, ...CHEAP })
42 const bytes = Buffer.from(env.ct, 'base64')
43 bytes[0] ^= 0xff
44 t.exception(() => openVault({ envelope: { ...env, ct: bytes.toString('base64') }, passphrase: PASS, did: DID }))
45})
46
47test('a tampered salt does not open', (t) => {
48 const env = sealVault({ secret: WORDS, passphrase: PASS, did: DID, ...CHEAP })
49 const salt = Buffer.from(env.kdf.salt, 'base64')
50 salt[0] ^= 0xff
51 t.exception(() => openVault({ envelope: { ...env, kdf: { ...env.kdf, salt: salt.toString('base64') } }, passphrase: PASS, did: DID }))
52})
53
54// ⛔ THE SWAP. Without binding, one identity's vault could be served in place of another's and would
55// open cleanly for whoever knows that passphrase — the DID as AEAD additional data is what refuses it.
56test('a vault sealed for one identity does not open as another', (t) => {
57 const env = sealVault({ secret: WORDS, passphrase: PASS, did: DID, ...CHEAP })
58 t.exception(() => openVault({ envelope: env, passphrase: PASS, did: OTHER }), 'the caller\'s expected did is enforced')
59 t.exception(() => openVault({ envelope: { ...env, did: OTHER }, passphrase: PASS }), 'and relabelling the envelope does not help')
60})
61
62// A vault with no passphrase would LOOK encrypted to everything downstream — including whatever decides
63// where to replicate it — which is worse than having none at all.
64test('sealing refuses to produce something that only looks encrypted', (t) => {
65 t.exception(() => sealVault({ secret: WORDS, passphrase: '', did: DID, ...CHEAP }), 'empty passphrase')
66 t.exception(() => sealVault({ secret: WORDS, did: DID, ...CHEAP }), 'missing passphrase')
67 t.exception(() => sealVault({ secret: '', passphrase: PASS, did: DID, ...CHEAP }), 'empty secret')
68 t.exception(() => sealVault({ secret: WORDS, passphrase: PASS, did: 'nope', ...CHEAP }), 'a non-did identity')
69})
70
71test('a malformed envelope is refused rather than misread', (t) => {
72 t.exception(() => openVault({ envelope: null, passphrase: PASS, did: DID }))
73 t.exception(() => openVault({ envelope: { v: 99 }, passphrase: PASS, did: DID }), 'a future version')
74 const env = sealVault({ secret: WORDS, passphrase: PASS, did: DID, ...CHEAP })
75 t.exception(() => openVault({ envelope: { ...env, aead: 'rot13' }, passphrase: PASS, did: DID }), 'an unknown aead')
76 t.exception(() => openVault({ envelope: { ...env, kdf: { ...env.kdf, alg: 'md5' } }, passphrase: PASS, did: DID }), 'an unknown kdf')
77 t.exception(() => openVault({ envelope: { ...env, nonce: 'AAAA' }, passphrase: PASS, did: DID }), 'a short nonce')
78})
79
80// Cost has to be raisable later without stranding blobs sealed today, which is why it travels in the
81// envelope rather than living as a constant in this file.
82test('the kdf parameters travel with the blob', (t) => {
83 const env = sealVault({ secret: WORDS, passphrase: PASS, did: DID, opslimit: 3, memlimit: 67108864 })
84 t.is(env.kdf.opslimit, 3, 'recorded')
85 t.is(env.kdf.memlimit, 67108864, 'both of them')
86 t.is(openVault({ envelope: env, passphrase: PASS, did: DID }), WORDS, 'and are what opening uses')
87})
88
89test('two seals of the same secret differ', (t) => {
90 const a = sealVault({ secret: WORDS, passphrase: PASS, did: DID, ...CHEAP })
91 const b = sealVault({ secret: WORDS, passphrase: PASS, did: DID, ...CHEAP })
92 t.not(a.ct, b.ct, 'fresh salt and nonce per seal — identical blobs would leak that nothing changed')
93 t.not(a.kdf.salt, b.kdf.salt)
94})
95
96test('rekey changes the passphrase without changing the identity', (t) => {
97 const env = sealVault({ secret: WORDS, passphrase: PASS, did: DID, ...CHEAP })
98 const next = rekeyVault({ envelope: env, passphrase: PASS, newPassphrase: 'a whole new thing', did: DID, ...CHEAP })
99 t.is(next.did, DID, 'still the same identity')
100 t.is(openVault({ envelope: next, passphrase: 'a whole new thing', did: DID }), WORDS, 'opens under the new passphrase')
101 t.exception(() => openVault({ envelope: next, passphrase: PASS, did: DID }), 'and no longer under the old one')
102})