Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions example/src/tests/subtle/deriveBits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
144 changes: 107 additions & 37 deletions example/src/tests/subtle/derive_key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);

Expand Down
5 changes: 4 additions & 1 deletion packages/react-native-quick-crypto/src/ec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading