// The vault must actually protect the root (hyper-social #47).
//
// WHY. `encrypted: true` on a hyperident vault used to mean:
// deriveKey = sha256(passphrase || salt) — ONE round, no work factor
// encryptData = data[i] ^ key[i % 32] — repeating-key XOR, unauthenticated
//
// Against a BIP39 mnemonic that is not protection. The plaintext is lowercase words from a PUBLIC
// 2048-word list, space-separated, every byte in a narrow range, ~150-200 bytes against a 32-byte
// key — five or six repetitions of the keystream. That is textbook repeating-key cryptanalysis: the
// passphrase is not even needed. And a single SHA-256 makes guessing the passphrase billions/sec on
// a GPU anyway.
//
// This matters beyond the file itself: the whole case for backing a root up across your own devices
// rests on "a stolen device yields a locked vault". That premise is only as true as this code.
//
// These tests assert the PROPERTIES, not the implementation, so they survive a future algorithm
// change. Run: brittle test/vault-crypto.test.js
import test from 'brittle'
import { CryptoManager } from '../lib/crypto.js'
const PASS = 'correct horse battery staple'
const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
test('vault - round-trips a mnemonic', async (t) => {
const salt = CryptoManager.generateSalt()
const box = CryptoManager.encryptSecret(Buffer.from(MNEMONIC, 'utf8'), PASS, salt)
const out = CryptoManager.decryptSecret(box, PASS, salt)
t.is(out.toString('utf8'), MNEMONIC, 'decrypts back to the same mnemonic')
})
test('vault - a WRONG passphrase is REJECTED, not silently garbage', async (t) => {
// The old XOR scheme returned plausible-looking bytes for any key, so "wrong passphrase" was only
// ever inferred downstream when IdentityKey.from() happened to choke on the result. An unauthenticated
// cipher cannot tell you the key was wrong — it just hands you a different plaintext.
const salt = CryptoManager.generateSalt()
const box = CryptoManager.encryptSecret(Buffer.from(MNEMONIC, 'utf8'), PASS, salt)
t.exception(() => CryptoManager.decryptSecret(box, 'not the passphrase', salt),
'a wrong passphrase throws rather than returning garbage')
})
test('vault - tampered ciphertext is REJECTED (authenticated)', async (t) => {
const salt = CryptoManager.generateSalt()
const box = CryptoManager.encryptSecret(Buffer.from(MNEMONIC, 'utf8'), PASS, salt)
const flipped = Buffer.from(box)
flipped[flipped.length - 1] ^= 0x01
t.exception(() => CryptoManager.decryptSecret(flipped, PASS, salt),
'a single flipped bit is detected — the old scheme was malleable with no MAC')
})
test('vault - ciphertext carries no repeating-key structure', async (t) => {
// The concrete break on the old scheme: ct XOR pt recovers the keystream, and with a repeating key
// the recovered stream repeats at the key length. Assert it does NOT.
const salt = CryptoManager.generateSalt()
const pt = Buffer.from(MNEMONIC, 'utf8')
const box = CryptoManager.encryptSecret(pt, PASS, salt)
const ct = box.subarray(box.length - pt.length) // whatever tail aligns with the plaintext length
let repeats = 0
for (let i = 0; i + 32 < Math.min(ct.length, pt.length); i++) {
if ((ct[i] ^ pt[i]) === (ct[i + 32] ^ pt[i + 32])) repeats++
}
const span = Math.max(1, Math.min(ct.length, pt.length) - 32)
t.ok(repeats / span < 0.5, `no 32-byte keystream repetition (matched ${repeats}/${span})`)
})
test('vault - same secret and passphrase produce DIFFERENT ciphertexts', async (t) => {
const salt = CryptoManager.generateSalt()
const a = CryptoManager.encryptSecret(Buffer.from(MNEMONIC, 'utf8'), PASS, salt)
const b = CryptoManager.encryptSecret(Buffer.from(MNEMONIC, 'utf8'), PASS, salt)
t.not(a.toString('hex'), b.toString('hex'), 'a fresh nonce per encryption — identical output would leak equality')
t.is(CryptoManager.decryptSecret(b, PASS, salt).toString('utf8'), MNEMONIC, 'and both still decrypt')
})
test('vault - the KDF has a real work factor', async (t) => {
// Not a timing assertion (flaky), a parameter assertion: the scheme must EXPOSE its cost so it can be
// raised later, and it must not be a bare hash.
const params = CryptoManager.kdfParams()
t.is(params.kdf, 'argon2id', 'memory-hard KDF, not a single SHA-256')
t.ok(params.opslimit >= 2, 'a real ops cost')
t.ok(params.memlimit >= 64 * 1024 * 1024, 'a real memory cost')
t.is(params.saltBytes, 16, 'salt sized for the KDF that consumes it')
})