// The recovery vault: a secret sealed so it can be REPLICATED without being readable.
//
// Every test here is a property the design depends on, not a happy path. The blob is meant to travel
// over the same P2P substrate as everything else and end up on disks nobody controls — so "wrong
// passphrase fails", "tampering fails" and "another identity's blob does not open" are the whole
// security argument, and a regression in any of them is silent.
import test from 'brittle'
import { sealVault, openVault, rekeyVault, VAULT_VERSION } from '../lib/vault.js'
const DID = 'did:plc:wj62ebbusln6gxo5azo6ogzo'
const OTHER = 'did:plc:e2jevhuurrhlm36ufk7fklpg'
const WORDS = 'abandon ability able about above absent absorb abstract absurd abuse access accident'
const PASS = 'correct horse battery staple'
// Argon2id at real cost is slow on purpose; these use the cheapest parameters sodium accepts so the
// suite stays fast. Cost lives in the envelope precisely so it can differ per caller.
const CHEAP = { opslimit: 2, memlimit: 67108864 }
test('a sealed vault round-trips', (t) => {
const env = sealVault({ secret: WORDS, passphrase: PASS, did: DID, ...CHEAP })
t.is(env.v, VAULT_VERSION, 'the envelope is versioned')
t.is(openVault({ envelope: env, passphrase: PASS, did: DID }), WORDS, 'and opens to exactly what went in')
})
test('the secret never appears in the envelope', (t) => {
const env = sealVault({ secret: WORDS, passphrase: PASS, did: DID, ...CHEAP })
const json = JSON.stringify(env)
t.absent(json.includes(WORDS), 'not in the clear')
t.absent(json.includes('abandon'), 'not even the first word')
t.absent(json.includes(PASS), 'and the passphrase is not stored — that is the whole argument')
})
test('a wrong passphrase does not open it', (t) => {
const env = sealVault({ secret: WORDS, passphrase: PASS, did: DID, ...CHEAP })
t.exception(() => openVault({ envelope: env, passphrase: PASS + '!', did: DID }))
})
// ⛔ The blob is designed to be replicated, so it lands where anyone can edit it. AEAD is what makes an
// edit a failure rather than a silently different secret.
test('a tampered ciphertext does not open', (t) => {
const env = sealVault({ secret: WORDS, passphrase: PASS, did: DID, ...CHEAP })
const bytes = Buffer.from(env.ct, 'base64')
bytes[0] ^= 0xff
t.exception(() => openVault({ envelope: { ...env, ct: bytes.toString('base64') }, passphrase: PASS, did: DID }))
})
test('a tampered salt does not open', (t) => {
const env = sealVault({ secret: WORDS, passphrase: PASS, did: DID, ...CHEAP })
const salt = Buffer.from(env.kdf.salt, 'base64')
salt[0] ^= 0xff
t.exception(() => openVault({ envelope: { ...env, kdf: { ...env.kdf, salt: salt.toString('base64') } }, passphrase: PASS, did: DID }))
})
// ⛔ THE SWAP. Without binding, one identity's vault could be served in place of another's and would
// open cleanly for whoever knows that passphrase — the DID as AEAD additional data is what refuses it.
test('a vault sealed for one identity does not open as another', (t) => {
const env = sealVault({ secret: WORDS, passphrase: PASS, did: DID, ...CHEAP })
t.exception(() => openVault({ envelope: env, passphrase: PASS, did: OTHER }), 'the caller\'s expected did is enforced')
t.exception(() => openVault({ envelope: { ...env, did: OTHER }, passphrase: PASS }), 'and relabelling the envelope does not help')
})
// A vault with no passphrase would LOOK encrypted to everything downstream — including whatever decides
// where to replicate it — which is worse than having none at all.
test('sealing refuses to produce something that only looks encrypted', (t) => {
t.exception(() => sealVault({ secret: WORDS, passphrase: '', did: DID, ...CHEAP }), 'empty passphrase')
t.exception(() => sealVault({ secret: WORDS, did: DID, ...CHEAP }), 'missing passphrase')
t.exception(() => sealVault({ secret: '', passphrase: PASS, did: DID, ...CHEAP }), 'empty secret')
t.exception(() => sealVault({ secret: WORDS, passphrase: PASS, did: 'nope', ...CHEAP }), 'a non-did identity')
})
test('a malformed envelope is refused rather than misread', (t) => {
t.exception(() => openVault({ envelope: null, passphrase: PASS, did: DID }))
t.exception(() => openVault({ envelope: { v: 99 }, passphrase: PASS, did: DID }), 'a future version')
const env = sealVault({ secret: WORDS, passphrase: PASS, did: DID, ...CHEAP })
t.exception(() => openVault({ envelope: { ...env, aead: 'rot13' }, passphrase: PASS, did: DID }), 'an unknown aead')
t.exception(() => openVault({ envelope: { ...env, kdf: { ...env.kdf, alg: 'md5' } }, passphrase: PASS, did: DID }), 'an unknown kdf')
t.exception(() => openVault({ envelope: { ...env, nonce: 'AAAA' }, passphrase: PASS, did: DID }), 'a short nonce')
})
// Cost has to be raisable later without stranding blobs sealed today, which is why it travels in the
// envelope rather than living as a constant in this file.
test('the kdf parameters travel with the blob', (t) => {
const env = sealVault({ secret: WORDS, passphrase: PASS, did: DID, opslimit: 3, memlimit: 67108864 })
t.is(env.kdf.opslimit, 3, 'recorded')
t.is(env.kdf.memlimit, 67108864, 'both of them')
t.is(openVault({ envelope: env, passphrase: PASS, did: DID }), WORDS, 'and are what opening uses')
})
test('two seals of the same secret differ', (t) => {
const a = sealVault({ secret: WORDS, passphrase: PASS, did: DID, ...CHEAP })
const b = sealVault({ secret: WORDS, passphrase: PASS, did: DID, ...CHEAP })
t.not(a.ct, b.ct, 'fresh salt and nonce per seal — identical blobs would leak that nothing changed')
t.not(a.kdf.salt, b.kdf.salt)
})
test('rekey changes the passphrase without changing the identity', (t) => {
const env = sealVault({ secret: WORDS, passphrase: PASS, did: DID, ...CHEAP })
const next = rekeyVault({ envelope: env, passphrase: PASS, newPassphrase: 'a whole new thing', did: DID, ...CHEAP })
t.is(next.did, DID, 'still the same identity')
t.is(openVault({ envelope: next, passphrase: 'a whole new thing', did: DID }), WORDS, 'opens under the new passphrase')
t.exception(() => openVault({ envelope: next, passphrase: PASS, did: DID }), 'and no longer under the old one')
})