diff --git a/example/src/tests/subtle/deriveBits.ts b/example/src/tests/subtle/deriveBits.ts index 065d2423..271e0dfa 100644 --- a/example/src/tests/subtle/deriveBits.ts +++ b/example/src/tests/subtle/deriveBits.ts @@ -275,6 +275,61 @@ for (const { curve, bitLen } of ecdhCurves) { }); } +// --- ECDH deriveBits truncation tests (regression for #946) --- +// When the curve's shared secret is larger than the requested bit length, +// the result must be properly truncated (not return the full backing buffer). +const truncationTests: Array<{ + curve: NamedCurve; + fullBitLen: number; + requestBitLen: number; +}> = [ + { curve: 'P-384', fullBitLen: 384, requestBitLen: 256 }, + { curve: 'P-384', fullBitLen: 384, requestBitLen: 128 }, + { curve: 'P-521', fullBitLen: 528, requestBitLen: 256 }, + { curve: 'P-521', fullBitLen: 528, requestBitLen: 128 }, +]; + +for (const { curve, fullBitLen, requestBitLen } of truncationTests) { + test( + SUITE, + `ECDH deriveBits truncation - ${curve} ${requestBitLen} bits`, + async () => { + const alice = (await subtle.generateKey( + { name: 'ECDH', namedCurve: curve }, + true, + ['deriveBits'], + )) as WebCryptoKeyPair; + const bob = (await subtle.generateKey( + { name: 'ECDH', namedCurve: curve }, + true, + ['deriveBits'], + )) as WebCryptoKeyPair; + + // Get full-length secret + const fullBits = await subtle.deriveBits( + { name: 'ECDH', public: bob.publicKey }, + alice.privateKey, + fullBitLen, + ); + + // Get truncated secret + const truncBits = await subtle.deriveBits( + { name: 'ECDH', public: bob.publicKey }, + alice.privateKey, + requestBitLen, + ); + + expect(truncBits.byteLength).to.equal(requestBitLen / 8); + + // Truncated result must be a prefix of the full result + const fullPrefix = Buffer.from(fullBits).subarray(0, requestBitLen / 8); + expect(Buffer.from(truncBits).toString('hex')).to.equal( + fullPrefix.toString('hex'), + ); + }, + ); +} + // --- X448 diffieHellman Tests --- test(SUITE, 'x448 - shared secret', () => { diff --git a/example/src/tests/subtle/derive_key.ts b/example/src/tests/subtle/derive_key.ts index f2dcff6f..cd01e193 100644 --- a/example/src/tests/subtle/derive_key.ts +++ b/example/src/tests/subtle/derive_key.ts @@ -99,62 +99,132 @@ test(SUITE, 'X25519 deriveKey to AES-GCM', async () => { ); }); -// Test 3: ECDH deriveKey -test(SUITE, 'ECDH P-256 deriveKey to AES-GCM', async () => { - const aliceKeyPair = await subtle.generateKey( - { name: 'ECDH', namedCurve: 'P-256' }, +// Tests 3-N: ECDH deriveKey for all curves and AES key lengths +// P-384 and P-521 are regression tests for #946: shared secret > derived key +// length must be properly truncated (subarray().buffer returned full backing buffer) +const ecdhDeriveKeyTests: Array<{ + curve: 'P-256' | 'P-384' | 'P-521'; + aesLength: 128 | 256; +}> = [ + { curve: 'P-256', aesLength: 256 }, + { curve: 'P-384', aesLength: 256 }, + { curve: 'P-384', aesLength: 128 }, + { curve: 'P-521', aesLength: 256 }, + { curve: 'P-521', aesLength: 128 }, +]; + +for (const { curve, aesLength } of ecdhDeriveKeyTests) { + test(SUITE, `ECDH ${curve} deriveKey to AES-GCM-${aesLength}`, async () => { + const aliceKeyPair = await subtle.generateKey( + { name: 'ECDH', namedCurve: curve }, + false, + ['deriveKey', 'deriveBits'], + ); + + const bobKeyPair = await subtle.generateKey( + { name: 'ECDH', namedCurve: curve }, + false, + ['deriveKey', 'deriveBits'], + ); + + const aliceDerivedKey = await subtleAny.deriveKey( + { + name: 'ECDH', + public: (aliceKeyPair as CryptoKeyPair).publicKey, + }, + (bobKeyPair as CryptoKeyPair).privateKey, + { name: 'AES-GCM', length: aesLength }, + true, + ['encrypt', 'decrypt'], + ); + + const bobDerivedKey = await subtleAny.deriveKey( + { + name: 'ECDH', + public: (bobKeyPair as CryptoKeyPair).publicKey, + }, + (aliceKeyPair as CryptoKeyPair).privateKey, + { name: 'AES-GCM', length: aesLength }, + true, + ['encrypt', 'decrypt'], + ); + + const aliceRaw = await subtle.exportKey( + 'raw', + aliceDerivedKey as CryptoKey, + ); + const bobRaw = await subtle.exportKey('raw', bobDerivedKey as CryptoKey); + + expect(Buffer.from(aliceRaw as ArrayBuffer).byteLength).to.equal( + aesLength / 8, + ); + expect(Buffer.from(aliceRaw as ArrayBuffer).toString('hex')).to.equal( + Buffer.from(bobRaw as ArrayBuffer).toString('hex'), + ); + + // Verify encrypt/decrypt round-trip + const plaintext = new Uint8Array([1, 2, 3, 4]); + const iv = getRandomValues(new Uint8Array(12)); + + const ciphertext = await subtle.encrypt( + { name: 'AES-GCM', iv }, + aliceDerivedKey as CryptoKey, + plaintext, + ); + + const decrypted = await subtle.decrypt( + { name: 'AES-GCM', iv }, + bobDerivedKey as CryptoKey, + ciphertext, + ); + + expect(Buffer.from(decrypted).toString('hex')).to.equal( + Buffer.from(plaintext).toString('hex'), + ); + }); +} + +// Test: ECDH P-384 deriveKey to AES-CBC-256 +test(SUITE, 'ECDH P-384 deriveKey to AES-CBC-256', async () => { + const alice = await subtle.generateKey( + { name: 'ECDH', namedCurve: 'P-384' }, false, - ['deriveKey', 'deriveBits'], + ['deriveKey'], ); - - const bobKeyPair = await subtle.generateKey( - { name: 'ECDH', namedCurve: 'P-256' }, + const bob = await subtle.generateKey( + { name: 'ECDH', namedCurve: 'P-384' }, false, - ['deriveKey', 'deriveBits'], + ['deriveKey'], ); - const aliceDerivedKey = await subtleAny.deriveKey( - { - name: 'ECDH', - public: (aliceKeyPair as CryptoKeyPair).publicKey, - }, - (bobKeyPair as CryptoKeyPair).privateKey, - { name: 'AES-GCM', length: 256 }, + const aliceKey = await subtleAny.deriveKey( + { name: 'ECDH', public: (bob as CryptoKeyPair).publicKey }, + (alice as CryptoKeyPair).privateKey, + { name: 'AES-CBC', length: 256 }, true, ['encrypt', 'decrypt'], ); - const bobDerivedKey = await subtleAny.deriveKey( - { - name: 'ECDH', - public: (bobKeyPair as CryptoKeyPair).publicKey, - }, - (aliceKeyPair as CryptoKeyPair).privateKey, - { name: 'AES-GCM', length: 256 }, + const bobKey = await subtleAny.deriveKey( + { name: 'ECDH', public: (alice as CryptoKeyPair).publicKey }, + (bob as CryptoKeyPair).privateKey, + { name: 'AES-CBC', length: 256 }, true, ['encrypt', 'decrypt'], ); - const aliceRaw = await subtle.exportKey('raw', aliceDerivedKey as CryptoKey); - const bobRaw = await subtle.exportKey('raw', bobDerivedKey as CryptoKey); - - expect(Buffer.from(aliceRaw as ArrayBuffer).toString('hex')).to.equal( - Buffer.from(bobRaw as ArrayBuffer).toString('hex'), - ); - - // Verify key works for encrypt/decrypt - const plaintext = new Uint8Array([1, 2, 3, 4]); - const iv = getRandomValues(new Uint8Array(12)); + const plaintext = new Uint8Array([5, 6, 7, 8]); + const iv = getRandomValues(new Uint8Array(16)); const ciphertext = await subtle.encrypt( - { name: 'AES-GCM', iv }, - aliceDerivedKey as CryptoKey, + { name: 'AES-CBC', iv }, + aliceKey as CryptoKey, plaintext, ); const decrypted = await subtle.decrypt( - { name: 'AES-GCM', iv }, - bobDerivedKey as CryptoKey, + { name: 'AES-CBC', iv }, + bobKey as CryptoKey, ciphertext, ); diff --git a/packages/react-native-quick-crypto/src/ec.ts b/packages/react-native-quick-crypto/src/ec.ts index 1a23f41b..890cf1ef 100644 --- a/packages/react-native-quick-crypto/src/ec.ts +++ b/packages/react-native-quick-crypto/src/ec.ts @@ -547,7 +547,10 @@ export function ecDeriveBits( // If length is specified, truncate const byteLength = Math.ceil(length / 8); if (secretBuf.byteLength >= byteLength) { - return secretBuf.subarray(0, byteLength).buffer as ArrayBuffer; + return secretBuf.buffer.slice( + secretBuf.byteOffset, + secretBuf.byteOffset + byteLength, + ) as ArrayBuffer; } throw new Error('Derived key is shorter than requested length');