OverviewFilesCommitsIssuesDocsReleasesPeers

hyperident commits / c17d9cd

0.2.6: vault encryption is argon2id + secretbox, not XOR
`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, recoverable by classic repeating-key cryptanalysis WITHOUT the passphrase. The single SHA-256 made guessing the passphrase cheap regardless. And with no MAC it was malleable, so a wrong passphrase returned plausible garbage instead of an error (hyper-social #47). This matters beyond the file: the case for backing a root up across your own devices rests on "a stolen device yields a locked vault". That premise was only as true as this code. Now: deriveVaultKey argon2id (crypto_pwhash), parameters RECORDED per-vault encryptSecret XSalsa20-Poly1305 (crypto_secretbox_easy), fresh nonce per call decryptSecret THROWS on a wrong passphrase or a flipped bit INTERACTIVE (64 MB, 2 ops) not MODERATE (256 MB): the phone is a first-class target -- favela runs as a FULL NODE on Android (#36) -- and a 256 MB argon2 allocation during unlock is a real risk there. opslimit/memlimit are stored in the vault, so raising the cost later is a per-vault decision, not a migration. The weak primitives are REMOVED, not deprecated. Leaving callable broken crypto beside the fixed version is how it gets reused. A vault with no `kdf` field predates this and is refused with a message saying so -- not "Invalid passphrase", which would blame the operator for our bug -- and advising that the secret be treated as exposed rather than merely re-encrypted. Blast radius: none. All three fleet vaults are `encrypted: false`, so there is no ciphertext to migrate. Tests written first and confirmed red (encryptSecret did not exist): vault-crypto 6/6, 10/10 asserts -- round-trip, wrong-passphrase REJECTED, tamper detected, no repeating-key structure, fresh nonce, KDF cost crypto 9/9, 25/25 -- the old "double XOR returns the original" assertion was a test OF the weakness and is deliberately not replaced identity-manager 41/41, 115/115 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
c17d9cd91dcbbab42aa7816d5d6b99d37dc7137f
by plankatron <fleeky@gmail.com> · 2026-08-17T23:11:48.000Z

Changed files (5) +222 -111

lib/crypto.js +66 -42
……@@ -4,6 +4,7 @@
44 */
55
66import crypto from 'node:crypto'
7import sodium from 'sodium-native'
78
89/**
910 * CryptoManager class for encryption, decryption, and key derivation
……@@ -23,57 +24,80 @@ export class CryptoManager {
2324 * @param {Buffer} salt - 32-byte salt
2425 * @returns {Buffer} 32-byte derived key
2526 */
26 static deriveKey(passphrase, salt) {
27 if (typeof passphrase !== 'string') {
28 throw new Error('Passphrase must be a string')
29 }
30 if (!Buffer.isBuffer(salt) || salt.length !== 32) {
31 throw new Error('Salt must be a 32-byte Buffer')
27 // ── vault secret encryption ────────────────────────────────────────────────────────────────────
28 //
29 // This replaces a scheme that was sha256(passphrase||salt) + repeating-key XOR, unauthenticated
30 // (hyper-social #47). Against a BIP39 mnemonic that was not protection: the plaintext is lowercase
31 // words from a PUBLIC 2048-word list, ~150-200 bytes against a 32-byte key, so the keystream repeated
32 // five or six times and was recoverable WITHOUT the passphrase. A single SHA-256 made guessing the
33 // passphrase cheap regardless.
34 //
35 // The old deriveKey/encryptData/decryptData are REMOVED rather than deprecated. Leaving callable
36 // broken crypto beside the fixed version is how it gets reused.
37 //
38 // Parameters are returned rather than hardcoded at the call site so a vault records the cost it was
39 // written with, and the cost can be raised later without breaking existing vaults.
40 //
41 // INTERACTIVE (64 MB, 2 ops) not MODERATE (256 MB): the phone is a first-class target — favela runs
42 // as a FULL NODE on Android (#36) with a measured storage budget already in the hundreds of MB — and
43 // a 256 MB argon2 allocation during unlock is a real risk there. The memlimit is recorded per-vault,
44 // so raising it later is a per-vault decision, not a migration.
45 static kdfParams () {
46 return {
47 kdf: 'argon2id',
48 alg: sodium.crypto_pwhash_ALG_ARGON2ID13,
49 opslimit: sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE,
50 memlimit: sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE,
51 saltBytes: sodium.crypto_pwhash_SALTBYTES
3252 }
33
34 // Implement SHA256-based key derivation: sha256(passphrase + salt)
35 const passphraseBuffer = Buffer.from(passphrase, 'utf8')
36 const combined = Buffer.concat([passphraseBuffer, salt])
37 return crypto.createHash('sha256').update(combined).digest()
3853 }
3954
40 /**
41 * Encrypt data using XOR with derived key
42 * @param {Buffer} data - Data to encrypt
43 * @param {Buffer} key - 32-byte encryption key
44 * @returns {Buffer} Encrypted data
45 */
46 static encryptData(data, key) {
47 if (!Buffer.isBuffer(data)) {
48 throw new Error('Data must be a Buffer')
49 }
50 if (!Buffer.isBuffer(key) || key.length !== 32) {
51 throw new Error('Key must be a 32-byte Buffer')
55 // Memory-hard KDF. `salt` may be longer than the KDF consumes (generateSalt returns 32 for backward
56 // compatibility with existing callers); the first SALTBYTES are used.
57 static deriveVaultKey (passphrase, salt, params = CryptoManager.kdfParams()) {
58 if (typeof passphrase !== 'string' || passphrase.length === 0) throw new Error('Passphrase must be a non-empty string')
59 if (!Buffer.isBuffer(salt) || salt.length < sodium.crypto_pwhash_SALTBYTES) {
60 throw new Error('Salt must be a Buffer of at least ' + sodium.crypto_pwhash_SALTBYTES + ' bytes')
5261 }
62 const key = Buffer.alloc(sodium.crypto_secretbox_KEYBYTES)
63 sodium.crypto_pwhash(
64 key,
65 Buffer.from(passphrase, 'utf8'),
66 salt.subarray(0, sodium.crypto_pwhash_SALTBYTES),
67 params.opslimit, params.memlimit, params.alg
68 )
69 return key
70 }
5371
54 const encrypted = Buffer.alloc(data.length)
55 for (let i = 0; i < data.length; i++) {
56 encrypted[i] = data[i] ^ key[i % key.length]
57 }
58 return encrypted
72 // XSalsa20-Poly1305. Returns nonce || ciphertext+MAC, so the box is self-contained and a fresh nonce
73 // is used per call — encrypting the same secret twice must not produce identical bytes.
74 static encryptSecret (data, passphrase, salt, params = CryptoManager.kdfParams()) {
75 if (!Buffer.isBuffer(data)) throw new Error('Data must be a Buffer')
76 const key = CryptoManager.deriveVaultKey(passphrase, salt, params)
77 const nonce = Buffer.alloc(sodium.crypto_secretbox_NONCEBYTES)
78 sodium.randombytes_buf(nonce)
79 const ct = Buffer.alloc(data.length + sodium.crypto_secretbox_MACBYTES)
80 sodium.crypto_secretbox_easy(ct, data, nonce, key)
81 sodium.sodium_memzero(key)
82 return Buffer.concat([nonce, ct])
5983 }
6084
61 /**
62 * Decrypt data using XOR with derived key
63 * @param {Buffer} encryptedData - Data to decrypt
64 * @param {Buffer} key - 32-byte encryption key
65 * @returns {Buffer} Decrypted data
66 */
67 static decryptData(encryptedData, key) {
68 // XOR decryption is the same as encryption for XOR cipher
69 return this.encryptData(encryptedData, key)
85 // THROWS on a wrong passphrase or tampered bytes. The old scheme could not: an unauthenticated cipher
86 // returns a different plaintext for a different key and cannot tell you which happened.
87 static decryptSecret (box, passphrase, salt, params = CryptoManager.kdfParams()) {
88 if (!Buffer.isBuffer(box) || box.length <= sodium.crypto_secretbox_NONCEBYTES + sodium.crypto_secretbox_MACBYTES) {
89 throw new Error('Ciphertext is too short to be a valid vault box')
90 }
91 const nonce = box.subarray(0, sodium.crypto_secretbox_NONCEBYTES)
92 const ct = box.subarray(sodium.crypto_secretbox_NONCEBYTES)
93 const key = CryptoManager.deriveVaultKey(passphrase, salt, params)
94 const out = Buffer.alloc(ct.length - sodium.crypto_secretbox_MACBYTES)
95 const ok = sodium.crypto_secretbox_open_easy(out, ct, nonce, key)
96 sodium.sodium_memzero(key)
97 if (!ok) throw new Error('vault: wrong passphrase, or the vault is corrupt')
98 return out
7099 }
71100
72 /**
73 * Hash data using SHA256
74 * @param {Buffer} data - Data to hash
75 * @returns {Buffer} 32-byte hash
76 */
77101 static hash(data) {
78102 if (!Buffer.isBuffer(data)) {
79103 throw new Error('Data must be a Buffer')
lib/identity-manager.js +29 -12
……@@ -81,13 +81,17 @@ export class IdentityManager {
8181 }
8282
8383 if (passphrase) {
84 // Encrypt the mnemonic with passphrase
84 // argon2id + XSalsa20-Poly1305 (hyper-social #47). The KDF cost is RECORDED in the vault, not
85 // assumed at read time, so it can be raised later without stranding vaults written today.
8586 const salt = CryptoManager.generateSalt()
86 const key = CryptoManager.deriveKey(passphrase, salt)
87 const encryptedMnemonic = CryptoManager.encryptData(Buffer.from(mnemonic, 'utf8'), key)
88
89 identityData.mnemonic = encryptedMnemonic.toString('hex')
87 const params = CryptoManager.kdfParams()
88 const box = CryptoManager.encryptSecret(Buffer.from(mnemonic, 'utf8'), passphrase, salt, params)
89
90 identityData.mnemonic = box.toString('hex')
9091 identityData.salt = salt.toString('hex')
92 identityData.kdf = params.kdf
93 identityData.opslimit = params.opslimit
94 identityData.memlimit = params.memlimit
9195 } else {
9296 // Store mnemonic in plain text
9397 identityData.mnemonic = mnemonic
……@@ -132,15 +136,28 @@ export class IdentityManager {
132136 throw new Error('Identity is encrypted but no passphrase provided')
133137 }
134138
135 // Decrypt the mnemonic
139 // A vault with no `kdf` field predates the argon2id/secretbox fix (#47) and is XOR-"encrypted".
140 // Say so plainly instead of reporting "Invalid passphrase": the passphrase is not the problem,
141 // and that secret should be treated as exposed and re-minted, not merely re-encrypted.
142 if (!identityData.kdf) {
143 throw new Error(
144 'This vault was written with the pre-#47 XOR scheme, which does not protect it. ' +
145 'Recover the mnemonic with an older hyperident, then re-create the vault. ' +
146 'Treat the secret as exposed if the file ever left this machine.'
147 )
148 }
136149 const salt = Buffer.from(identityData.salt, 'hex')
137 const key = CryptoManager.deriveKey(passphrase, salt)
138 const encryptedMnemonic = Buffer.from(identityData.mnemonic, 'hex')
139
150 const params = {
151 ...CryptoManager.kdfParams(),
152 // Honour the cost the vault was WRITTEN with — using today's default would derive a
153 // different key and read as a wrong passphrase.
154 opslimit: identityData.opslimit || CryptoManager.kdfParams().opslimit,
155 memlimit: identityData.memlimit || CryptoManager.kdfParams().memlimit
156 }
140157 try {
141 const decryptedBuffer = CryptoManager.decryptData(encryptedMnemonic, key)
142 mnemonic = decryptedBuffer.toString('utf8')
143
158 const box = Buffer.from(identityData.mnemonic, 'hex')
159 mnemonic = CryptoManager.decryptSecret(box, passphrase, salt, params).toString('utf8')
160
144161 // Validate that the decrypted mnemonic is valid by trying to create an IdentityKey
145162 await IdentityKey.from({ mnemonic })
146163 } catch (error) {
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "hyperident",
3 "version": "0.2.5",
3 "version": "0.2.6",
44 "main": "index.js",
55 "bin": {
66 "hyperident": "./index.js"
test/crypto.test.js +45 -56
……@@ -12,89 +12,78 @@ test('CryptoManager - salt generation', async (t) => {
1212 t.not(salt1.equals(salt2), 'Should generate different salts each time')
1313})
1414
15test('CryptoManager - key derivation', async (t) => {
15test('CryptoManager - vault key derivation', async (t) => {
1616 t.plan(4)
17
17
1818 const passphrase = 'test-passphrase'
19 const salt = Buffer.alloc(32, 1) // Fill with 1s for consistent testing
20
21 const key1 = CryptoManager.deriveKey(passphrase, salt)
22 const key2 = CryptoManager.deriveKey(passphrase, salt)
23
19 const salt = Buffer.alloc(32, 1)
20
21 const key1 = CryptoManager.deriveVaultKey(passphrase, salt)
22 const key2 = CryptoManager.deriveVaultKey(passphrase, salt)
23
2424 t.ok(Buffer.isBuffer(key1), 'Should return a Buffer')
25 t.is(key1.length, 32, 'Should return 32-byte key')
25 t.is(key1.length, 32, 'Should return a 32-byte secretbox key')
2626 t.ok(key1.equals(key2), 'Same passphrase and salt should produce same key')
27
27
2828 const differentSalt = Buffer.alloc(32, 2)
29 const key3 = CryptoManager.deriveKey(passphrase, differentSalt)
30 t.not(key1.equals(key3), 'Different salt should produce different key')
29 t.not(key1.equals(CryptoManager.deriveVaultKey(passphrase, differentSalt)), 'Different salt should produce different key')
3130})
3231
33test('CryptoManager - key derivation validation', async (t) => {
32test('CryptoManager - vault key derivation validation', async (t) => {
3433 t.plan(2)
35
34
3635 try {
37 CryptoManager.deriveKey(123, Buffer.alloc(32))
38 t.fail('Should throw error for non-string passphrase')
36 CryptoManager.deriveVaultKey(123, Buffer.alloc(32))
37 t.fail('Should throw for non-string passphrase')
3938 } catch (error) {
40 t.is(error.message, 'Passphrase must be a string', 'Should validate passphrase type')
39 t.ok(/Passphrase must be/.test(error.message), 'Should validate passphrase type')
4140 }
42
41
4342 try {
44 CryptoManager.deriveKey('test', Buffer.alloc(16))
45 t.fail('Should throw error for wrong salt size')
43 // Shorter than crypto_pwhash_SALTBYTES.
44 CryptoManager.deriveVaultKey('test', Buffer.alloc(8))
45 t.fail('Should throw for an undersized salt')
4646 } catch (error) {
47 t.is(error.message, 'Salt must be a 32-byte Buffer', 'Should validate salt size')
47 t.ok(/Salt must be/.test(error.message), 'Should validate salt size')
4848 }
4949})
5050
51test('CryptoManager - encryption/decryption round trip', async (t) => {
52 t.plan(4)
53
54 const originalData = Buffer.from('Hello, World! This is a test message.')
55 const key = Buffer.alloc(32, 42) // Fill with consistent value for testing
56
57 const encrypted = CryptoManager.encryptData(originalData, key)
58 const decrypted = CryptoManager.decryptData(encrypted, key)
59
60 t.ok(Buffer.isBuffer(encrypted), 'Encryption should return a Buffer')
61 t.is(encrypted.length, originalData.length, 'Encrypted data should be same length')
62 t.not(encrypted.equals(originalData), 'Encrypted data should be different from original')
63 t.ok(decrypted.equals(originalData), 'Decrypted data should match original')
64})
51// The previous suite asserted `encrypt(encrypt(d, k), k) === d` — the self-inverse property of a
52// repeating-key XOR. That was a test OF the weakness (#47): a cipher with that property is
53// unauthenticated and its keystream is recoverable from known plaintext. It is deliberately not
54// replaced. The properties that matter now — round-trip, wrong-passphrase rejection, tamper
55// detection, nonce freshness — are asserted in test/vault-crypto.test.js.
56test('CryptoManager - vault encryption is authenticated and non-malleable', async (t) => {
57 t.plan(3)
6558
66test('CryptoManager - XOR properties', async (t) => {
67 t.plan(2)
68
69 const data = Buffer.from('test data')
70 const key = Buffer.alloc(32, 123)
71
72 // Test XOR property: encrypt(encrypt(data, key), key) == data
73 const encrypted = CryptoManager.encryptData(data, key)
74 const doubleEncrypted = CryptoManager.encryptData(encrypted, key)
75
76 t.ok(doubleEncrypted.equals(data), 'Double XOR should return original data')
77
78 // Test that decryption is same as encryption for XOR
79 const decrypted = CryptoManager.decryptData(encrypted, key)
80 t.ok(decrypted.equals(data), 'Decryption should work correctly')
59 const data = Buffer.from('Hello, World! This is a test message.')
60 const salt = CryptoManager.generateSalt()
61 const box = CryptoManager.encryptSecret(data, 'pw', salt)
62
63 t.ok(box.length > data.length, 'Ciphertext carries a nonce and a MAC, so it is LONGER than the plaintext')
64 t.ok(CryptoManager.decryptSecret(box, 'pw', salt).equals(data), 'Round-trips')
65
66 const twice = CryptoManager.encryptSecret(box, 'pw', salt)
67 t.not(CryptoManager.decryptSecret(twice, 'pw', salt).equals(data), 'Encrypting twice does NOT return the original — no self-inverse')
8168})
8269
83test('CryptoManager - encryption validation', async (t) => {
70test('CryptoManager - vault encryption validation', async (t) => {
8471 t.plan(2)
85
72
73 const salt = CryptoManager.generateSalt()
8674 try {
87 CryptoManager.encryptData('not a buffer', Buffer.alloc(32))
75 CryptoManager.encryptSecret('not a buffer', 'pw', salt)
8876 t.fail('Should throw error for non-Buffer data')
8977 } catch (error) {
9078 t.is(error.message, 'Data must be a Buffer', 'Should validate data type')
9179 }
92
80
9381 try {
94 CryptoManager.encryptData(Buffer.from('test'), Buffer.alloc(16))
95 t.fail('Should throw error for wrong key size')
82 // Too short to contain a nonce and a MAC, so it cannot be a vault box.
83 CryptoManager.decryptSecret(Buffer.alloc(8), 'pw', salt)
84 t.fail('Should throw error for a truncated box')
9685 } catch (error) {
97 t.is(error.message, 'Key must be a 32-byte Buffer', 'Should validate key size')
86 t.ok(/too short/.test(error.message), 'Should reject a truncated ciphertext')
9887 }
9988})
10089
test/vault-crypto.test.js +81 -0
……@@ -0,0 +1,81 @@
1// The vault must actually protect the root (hyper-social #47).
2//
3// WHY. `encrypted: true` on a hyperident vault used to mean:
4// deriveKey = sha256(passphrase || salt) — ONE round, no work factor
5// encryptData = data[i] ^ key[i % 32] — repeating-key XOR, unauthenticated
6//
7// Against a BIP39 mnemonic that is not protection. The plaintext is lowercase words from a PUBLIC
8// 2048-word list, space-separated, every byte in a narrow range, ~150-200 bytes against a 32-byte
9// key — five or six repetitions of the keystream. That is textbook repeating-key cryptanalysis: the
10// passphrase is not even needed. And a single SHA-256 makes guessing the passphrase billions/sec on
11// a GPU anyway.
12//
13// This matters beyond the file itself: the whole case for backing a root up across your own devices
14// rests on "a stolen device yields a locked vault". That premise is only as true as this code.
15//
16// These tests assert the PROPERTIES, not the implementation, so they survive a future algorithm
17// change. Run: brittle test/vault-crypto.test.js
18import test from 'brittle'
19import { CryptoManager } from '../lib/crypto.js'
20
21const PASS = 'correct horse battery staple'
22const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
23
24test('vault - round-trips a mnemonic', async (t) => {
25 const salt = CryptoManager.generateSalt()
26 const box = CryptoManager.encryptSecret(Buffer.from(MNEMONIC, 'utf8'), PASS, salt)
27 const out = CryptoManager.decryptSecret(box, PASS, salt)
28 t.is(out.toString('utf8'), MNEMONIC, 'decrypts back to the same mnemonic')
29})
30
31test('vault - a WRONG passphrase is REJECTED, not silently garbage', async (t) => {
32 // The old XOR scheme returned plausible-looking bytes for any key, so "wrong passphrase" was only
33 // ever inferred downstream when IdentityKey.from() happened to choke on the result. An unauthenticated
34 // cipher cannot tell you the key was wrong — it just hands you a different plaintext.
35 const salt = CryptoManager.generateSalt()
36 const box = CryptoManager.encryptSecret(Buffer.from(MNEMONIC, 'utf8'), PASS, salt)
37 t.exception(() => CryptoManager.decryptSecret(box, 'not the passphrase', salt),
38 'a wrong passphrase throws rather than returning garbage')
39})
40
41test('vault - tampered ciphertext is REJECTED (authenticated)', async (t) => {
42 const salt = CryptoManager.generateSalt()
43 const box = CryptoManager.encryptSecret(Buffer.from(MNEMONIC, 'utf8'), PASS, salt)
44 const flipped = Buffer.from(box)
45 flipped[flipped.length - 1] ^= 0x01
46 t.exception(() => CryptoManager.decryptSecret(flipped, PASS, salt),
47 'a single flipped bit is detected — the old scheme was malleable with no MAC')
48})
49
50test('vault - ciphertext carries no repeating-key structure', async (t) => {
51 // The concrete break on the old scheme: ct XOR pt recovers the keystream, and with a repeating key
52 // the recovered stream repeats at the key length. Assert it does NOT.
53 const salt = CryptoManager.generateSalt()
54 const pt = Buffer.from(MNEMONIC, 'utf8')
55 const box = CryptoManager.encryptSecret(pt, PASS, salt)
56 const ct = box.subarray(box.length - pt.length) // whatever tail aligns with the plaintext length
57 let repeats = 0
58 for (let i = 0; i + 32 < Math.min(ct.length, pt.length); i++) {
59 if ((ct[i] ^ pt[i]) === (ct[i + 32] ^ pt[i + 32])) repeats++
60 }
61 const span = Math.max(1, Math.min(ct.length, pt.length) - 32)
62 t.ok(repeats / span < 0.5, `no 32-byte keystream repetition (matched ${repeats}/${span})`)
63})
64
65test('vault - same secret and passphrase produce DIFFERENT ciphertexts', async (t) => {
66 const salt = CryptoManager.generateSalt()
67 const a = CryptoManager.encryptSecret(Buffer.from(MNEMONIC, 'utf8'), PASS, salt)
68 const b = CryptoManager.encryptSecret(Buffer.from(MNEMONIC, 'utf8'), PASS, salt)
69 t.not(a.toString('hex'), b.toString('hex'), 'a fresh nonce per encryption — identical output would leak equality')
70 t.is(CryptoManager.decryptSecret(b, PASS, salt).toString('utf8'), MNEMONIC, 'and both still decrypt')
71})
72
73test('vault - the KDF has a real work factor', async (t) => {
74 // Not a timing assertion (flaky), a parameter assertion: the scheme must EXPOSE its cost so it can be
75 // raised later, and it must not be a bare hash.
76 const params = CryptoManager.kdfParams()
77 t.is(params.kdf, 'argon2id', 'memory-hard KDF, not a single SHA-256')
78 t.ok(params.opslimit >= 2, 'a real ops cost')
79 t.ok(params.memlimit >= 64 * 1024 * 1024, 'a real memory cost')
80 t.is(params.saltBytes, 16, 'salt sized for the KDF that consumes it')
81})