Files
securebit-chat/tests/key-derivation-compat.test.mjs
T

94 lines
4.2 KiB
JavaScript
Raw Normal View History

// deriveSharedKeys() stopped routing the ECDH shared secret through an
// extractable AES key + exportKey() (which left the secret sitting in the heap
// unwiped) and now uses deriveBits() directly.
//
// That is only safe if the BYTES are identical: both peers must derive the same
// session keys, and a 5.6.1 client has to interoperate with a 5.6.0 one. This is
// the test that says so — WebCrypto's ECDH deriveBits(n) returns the leftmost n
// bits of the shared X coordinate, which is exactly what deriveKey to
// AES-GCM-256 consumed. If a future change bumps 256 to 384 "for strength", this
// fails, and it should: that is a protocol break, not an improvement.
import assert from 'node:assert/strict';
import { webcrypto } from 'node:crypto';
const { subtle } = webcrypto;
const toHex = (buf) => Array.from(new Uint8Array(buf))
.map((b) => b.toString(16).padStart(2, '0')).join('');
for (const namedCurve of ['P-384', 'P-256']) {
const alice = await subtle.generateKey({ name: 'ECDH', namedCurve }, false, ['deriveKey', 'deriveBits']);
const bob = await subtle.generateKey({ name: 'ECDH', namedCurve }, false, ['deriveKey', 'deriveBits']);
// ── the old path: extractable AES key, then exportKey ────────────────────
const legacyKey = await subtle.deriveKey(
{ name: 'ECDH', public: bob.publicKey },
alice.privateKey,
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
);
const legacyBytes = await subtle.exportKey('raw', legacyKey);
// ── the new path: deriveBits, no extractable key, buffer wipeable ────────
const newBytes = await subtle.deriveBits(
{ name: 'ECDH', public: bob.publicKey },
alice.privateKey,
256
);
assert.equal(toHex(newBytes), toHex(legacyBytes),
`${namedCurve}: deriveBits(256) must reproduce the previous shared secret exactly`);
// Sanity: the two peers still agree, which is the property the whole session
// rests on and is worth asserting rather than assuming.
const bobBytes = await subtle.deriveBits(
{ name: 'ECDH', public: alice.publicKey },
bob.privateKey,
256
);
assert.equal(toHex(bobBytes), toHex(newBytes), `${namedCurve}: both peers must derive the same secret`);
// ── the fingerprint material took the same detour ────────────────────────
const ikm = await subtle.importKey('raw', newBytes, { name: 'HKDF', hash: 'SHA-256' }, false,
['deriveKey', 'deriveBits']);
const salt = new Uint8Array(64).fill(7);
const info = new TextEncoder().encode('fingerprint-generation-v4');
const legacyFpKey = await subtle.deriveKey(
{ name: 'HKDF', hash: 'SHA-256', salt, info },
ikm,
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
);
const legacyFpBytes = await subtle.exportKey('raw', legacyFpKey);
const newFpBytes = await subtle.deriveBits({ name: 'HKDF', hash: 'SHA-256', salt, info }, ikm, 256);
assert.equal(toHex(newFpBytes), toHex(legacyFpBytes),
`${namedCurve}: HKDF deriveBits(256) must reproduce the previous fingerprint material`);
}
// ── zeroizeBuffer actually overwrites ────────────────────────────────────────
{
globalThis.window = { document: {} };
const { EnhancedSecureCryptoUtils } = await import('../src/crypto/EnhancedSecureCryptoUtils.js');
const secret = new Uint8Array(32).fill(0xAB);
EnhancedSecureCryptoUtils.zeroizeBuffer(secret);
assert.ok(secret.every((b) => b === 0), 'a Uint8Array must end up zeroed');
const buf = new ArrayBuffer(32);
new Uint8Array(buf).fill(0xCD);
EnhancedSecureCryptoUtils.zeroizeBuffer(buf);
assert.ok(new Uint8Array(buf).every((b) => b === 0), 'an ArrayBuffer must end up zeroed');
// Must not throw on the shapes it will legitimately be handed.
EnhancedSecureCryptoUtils.zeroizeBuffer(null);
EnhancedSecureCryptoUtils.zeroizeBuffer(undefined);
EnhancedSecureCryptoUtils.zeroizeBuffer(new ArrayBuffer(0));
}
console.log('key-derivation-compat.test.mjs: all assertions passed');