94 lines
4.2 KiB
JavaScript
94 lines
4.2 KiB
JavaScript
// 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');
|