OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-identity / test / vault.test.js

103 lines · javascript ref:
// 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')
})