| … | … | @@ -4,6 +4,7 @@ |
| 4 | 4 | */ |
| 5 | 5 | |
| 6 | 6 | import crypto from 'node:crypto' |
| 7 | import sodium from 'sodium-native' | |
| 7 | 8 | |
| 8 | 9 | /** |
| 9 | 10 | * CryptoManager class for encryption, decryption, and key derivation |
| … | … | @@ -23,57 +24,80 @@ export class CryptoManager { |
| 23 | 24 | * @param {Buffer} salt - 32-byte salt |
| 24 | 25 | * @returns {Buffer} 32-byte derived key |
| 25 | 26 | */ |
| 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 | |
| 32 | 52 | } |
| 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() | |
| 38 | 53 | } |
| 39 | 54 | |
| 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') | |
| 52 | 61 | } |
| 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 | } | |
| 53 | 71 | |
| 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]) | |
| 59 | 83 | } |
| 60 | 84 | |
| 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 | |
| 70 | 99 | } |
| 71 | 100 | |
| 72 | /** | |
| 73 | * Hash data using SHA256 | |
| 74 | * @param {Buffer} data - Data to hash | |
| 75 | * @returns {Buffer} 32-byte hash | |
| 76 | */ | |
| 77 | 101 | static hash(data) { |
| 78 | 102 | if (!Buffer.isBuffer(data)) { |
| 79 | 103 | throw new Error('Data must be a Buffer') |
| … | … | @@ -81,13 +81,17 @@ export class IdentityManager { |
| 81 | 81 | } |
| 82 | 82 | |
| 83 | 83 | 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. | |
| 85 | 86 | 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') | |
| 90 | 91 | identityData.salt = salt.toString('hex') |
| 92 | identityData.kdf = params.kdf | |
| 93 | identityData.opslimit = params.opslimit | |
| 94 | identityData.memlimit = params.memlimit | |
| 91 | 95 | } else { |
| 92 | 96 | // Store mnemonic in plain text |
| 93 | 97 | identityData.mnemonic = mnemonic |
| … | … | @@ -132,15 +136,28 @@ export class IdentityManager { |
| 132 | 136 | throw new Error('Identity is encrypted but no passphrase provided') |
| 133 | 137 | } |
| 134 | 138 | |
| 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 | } | |
| 136 | 149 | 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 | } | |
| 140 | 157 | 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 | ||
| 144 | 161 | // Validate that the decrypted mnemonic is valid by trying to create an IdentityKey |
| 145 | 162 | await IdentityKey.from({ mnemonic }) |
| 146 | 163 | } catch (error) { |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "hyperident", |
| 3 | "version": "0.2.5", | |
| 3 | "version": "0.2.6", | |
| 4 | 4 | "main": "index.js", |
| 5 | 5 | "bin": { |
| 6 | 6 | "hyperident": "./index.js" |
| … | … | @@ -12,89 +12,78 @@ test('CryptoManager - salt generation', async (t) => { |
| 12 | 12 | t.not(salt1.equals(salt2), 'Should generate different salts each time') |
| 13 | 13 | }) |
| 14 | 14 | |
| 15 | test('CryptoManager - key derivation', async (t) => { | |
| 15 | test('CryptoManager - vault key derivation', async (t) => { | |
| 16 | 16 | t.plan(4) |
| 17 | ||
| 17 | ||
| 18 | 18 | 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 | ||
| 24 | 24 | 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') | |
| 26 | 26 | t.ok(key1.equals(key2), 'Same passphrase and salt should produce same key') |
| 27 | ||
| 27 | ||
| 28 | 28 | 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') | |
| 31 | 30 | }) |
| 32 | 31 | |
| 33 | test('CryptoManager - key derivation validation', async (t) => { | |
| 32 | test('CryptoManager - vault key derivation validation', async (t) => { | |
| 34 | 33 | t.plan(2) |
| 35 | ||
| 34 | ||
| 36 | 35 | 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') | |
| 39 | 38 | } 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') | |
| 41 | 40 | } |
| 42 | ||
| 41 | ||
| 43 | 42 | 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') | |
| 46 | 46 | } 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') | |
| 48 | 48 | } |
| 49 | 49 | }) |
| 50 | 50 | |
| 51 | test('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. | |
| 56 | test('CryptoManager - vault encryption is authenticated and non-malleable', async (t) => { | |
| 57 | t.plan(3) | |
| 65 | 58 | |
| 66 | test('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') | |
| 81 | 68 | }) |
| 82 | 69 | |
| 83 | test('CryptoManager - encryption validation', async (t) => { | |
| 70 | test('CryptoManager - vault encryption validation', async (t) => { | |
| 84 | 71 | t.plan(2) |
| 85 | ||
| 72 | ||
| 73 | const salt = CryptoManager.generateSalt() | |
| 86 | 74 | try { |
| 87 | CryptoManager.encryptData('not a buffer', Buffer.alloc(32)) | |
| 75 | CryptoManager.encryptSecret('not a buffer', 'pw', salt) | |
| 88 | 76 | t.fail('Should throw error for non-Buffer data') |
| 89 | 77 | } catch (error) { |
| 90 | 78 | t.is(error.message, 'Data must be a Buffer', 'Should validate data type') |
| 91 | 79 | } |
| 92 | ||
| 80 | ||
| 93 | 81 | 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') | |
| 96 | 85 | } 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') | |
| 98 | 87 | } |
| 99 | 88 | }) |
| 100 | 89 |
| … | … | @@ -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 | |
| 18 | import test from 'brittle' | |
| 19 | import { CryptoManager } from '../lib/crypto.js' | |
| 20 | ||
| 21 | const PASS = 'correct horse battery staple' | |
| 22 | const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' | |
| 23 | ||
| 24 | test('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 | ||
| 31 | test('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 | ||
| 41 | test('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 | ||
| 50 | test('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 | ||
| 65 | test('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 | ||
| 73 | test('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 | }) |