| … | … | @@ -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. | |
| 30 | import sodium from 'sodium-native' | |
| 31 | import b4a from 'b4a' | |
| 32 | ||
| 33 | export 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. | |
| 38 | const DEFAULT_OPSLIMIT = sodium.crypto_pwhash_OPSLIMIT_MODERATE | |
| 39 | const DEFAULT_MEMLIMIT = sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE | |
| 40 | ||
| 41 | const b64 = (buf) => b4a.toString(buf, 'base64') | |
| 42 | const unb64 = (s) => b4a.from(s, 'base64') | |
| 43 | ||
| 44 | function 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 | */ | |
| 66 | export 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 | */ | |
| 100 | export 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 | */ | |
| 140 | export 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 | } |
| … | … | @@ -14,9 +14,11 @@ |
| 14 | 14 | "@noble/hashes": "^1.5.0", |
| 15 | 15 | "@scure/bip32": "^1.5.0", |
| 16 | 16 | "@scure/bip39": "^1.4.0", |
| 17 | "b4a": "^1.6.7", | |
| 17 | 18 | "hypercore-crypto": "^3.0.0", |
| 18 | 19 | "keet-identity-key": "^3.2.0", |
| 19 | "multiformats": "^13.3.0" | |
| 20 | "multiformats": "^13.3.0", | |
| 21 | "sodium-native": "^5.1.0" | |
| 20 | 22 | }, |
| 21 | 23 | "devDependencies": { |
| 22 | 24 | "brittle": "^4.0.2" |
| … | … | @@ -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. | |
| 7 | import test from 'brittle' | |
| 8 | import { sealVault, openVault, rekeyVault, VAULT_VERSION } from '../lib/vault.js' | |
| 9 | ||
| 10 | const DID = 'did:plc:wj62ebbusln6gxo5azo6ogzo' | |
| 11 | const OTHER = 'did:plc:e2jevhuurrhlm36ufk7fklpg' | |
| 12 | const WORDS = 'abandon ability able about above absent absorb abstract absurd abuse access accident' | |
| 13 | const 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. | |
| 17 | const CHEAP = { opslimit: 2, memlimit: 67108864 } | |
| 18 | ||
| 19 | test('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 | ||
| 25 | test('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 | ||
| 33 | test('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. | |
| 40 | test('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 | ||
| 47 | test('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. | |
| 56 | test('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. | |
| 64 | test('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 | ||
| 71 | test('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. | |
| 82 | test('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 | ||
| 89 | test('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 | ||
| 96 | test('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 | }) |