diff --git a/example/src/hooks/useTestsList.ts b/example/src/hooks/useTestsList.ts index 53f79485..1228d918 100644 --- a/example/src/hooks/useTestsList.ts +++ b/example/src/hooks/useTestsList.ts @@ -46,6 +46,7 @@ import '../tests/subtle/sign_verify'; import '../tests/subtle/supports'; import '../tests/subtle/getPublicKey'; import '../tests/subtle/usage_canonicalization'; +import '../tests/subtle/validation_ordering'; import '../tests/subtle/wrap_unwrap'; import '../tests/utils/utils_tests'; import '../tests/utils/encoding_tests'; diff --git a/example/src/tests/subtle/encrypt_decrypt.ts b/example/src/tests/subtle/encrypt_decrypt.ts index b4f908de..8efa607f 100644 --- a/example/src/tests/subtle/encrypt_decrypt.ts +++ b/example/src/tests/subtle/encrypt_decrypt.ts @@ -279,7 +279,7 @@ async function testRSAEncryptionWrongKey({ return assertThrowsAsync( async () => await subtle.encrypt(algorithm, privateKey as CryptoKey, plaintext), - 'The requested operation is not valid for the provided key', + 'InvalidAccessError', ); } @@ -301,7 +301,7 @@ async function testRSAEncryptionBadUsage({ return assertThrowsAsync( async () => await subtle.encrypt(algorithm, publicKey as CryptoKey, plaintext), - 'The requested operation is not valid', + 'InvalidAccessError', ); } @@ -328,7 +328,7 @@ async function testRSADecryptionWrongKey({ return assertThrowsAsync( async () => await subtle.decrypt(algorithm, publicKey as CryptoKey, ciphertext), - 'The requested operation is not valid', + 'InvalidAccessError', ); } @@ -355,7 +355,7 @@ async function testRSADecryptionBadUsage({ return assertThrowsAsync( async () => await subtle.decrypt(algorithm, publicKey as CryptoKey, ciphertext), - 'The requested operation is not valid', + 'InvalidAccessError', ); } @@ -647,7 +647,7 @@ test(SUITE, 'ChaCha20-Poly1305 wrong key usage encrypt', async () => { key as CryptoKey, getRandomValues(new Uint8Array(32)), ), - 'The requested operation is not valid for the provided key', + 'InvalidAccessError', ); }); @@ -674,7 +674,7 @@ test(SUITE, 'ChaCha20-Poly1305 wrong key usage decrypt', async () => { key as CryptoKey, ciphertext, ), - 'The requested operation is not valid for the provided key', + 'InvalidAccessError', ); }); @@ -936,7 +936,7 @@ async function testAESEncryptNoEncrypt({ await assertThrowsAsync( async () => await subtle.encrypt(algorithm, key, plaintext), - 'The requested operation is not valid for the provided key', + 'InvalidAccessError', ); } @@ -962,7 +962,7 @@ async function testAESEncryptNoDecrypt({ await assertThrowsAsync( async () => await subtle.decrypt(algorithm, key, output), - 'The requested operation is not valid for the provided key', + 'InvalidAccessError', ); } @@ -982,7 +982,7 @@ async function testAESEncryptWrongAlg( await assertThrowsAsync( async () => await subtle.encrypt(algorithm, key, plaintext), - 'The requested operation is not valid for the provided key', + 'InvalidAccessError', ); } diff --git a/example/src/tests/subtle/validation_ordering.ts b/example/src/tests/subtle/validation_ordering.ts new file mode 100644 index 00000000..cf60be4a --- /dev/null +++ b/example/src/tests/subtle/validation_ordering.ts @@ -0,0 +1,429 @@ +import { expect } from 'chai'; +import { subtle } from 'react-native-quick-crypto'; +import type { CryptoKey, CryptoKeyPair } from 'react-native-quick-crypto'; +import { test } from '../util'; + +// Issue #1003 — required-arg checks, validation step ordering, length=null +// handling, and getKeyLength hardening. Mirrors Node commits 856231e8c40 and +// 4cb1f284136 (lib/internal/crypto/webcrypto.js). +const SUITE = 'subtle.validation-ordering'; + +const subtleAny = subtle as unknown as { + importKey: (...args: unknown[]) => Promise; + exportKey: (...args: unknown[]) => Promise; + encrypt: (...args: unknown[]) => Promise; + decrypt: (...args: unknown[]) => Promise; + sign: (...args: unknown[]) => Promise; + verify: (...args: unknown[]) => Promise; + generateKey: (...args: unknown[]) => Promise; + deriveBits: (...args: unknown[]) => Promise; + deriveKey: (...args: unknown[]) => Promise; + wrapKey: (...args: unknown[]) => Promise; + unwrapKey: (...args: unknown[]) => Promise; + digest: (...args: unknown[]) => Promise; + getPublicKey: (...args: unknown[]) => Promise; +}; + +async function expectThrows( + fn: () => Promise | unknown, +): Promise { + let caught: unknown; + try { + await fn(); + } catch (e) { + caught = e; + } + expect(caught, 'expected an error').to.be.instanceOf(Error); + return caught as Error; +} + +// --- B.6 required-arg count checks ---------------------------------------- + +test(SUITE, 'importKey throws TypeError when fewer than 5 args', async () => { + const err = await expectThrows(() => + subtleAny.importKey('raw', new Uint8Array(16), { name: 'AES-GCM' }), + ); + expect(err.name).to.equal('TypeError'); +}); + +test(SUITE, 'generateKey throws TypeError when fewer than 3 args', async () => { + const err = await expectThrows(() => + subtleAny.generateKey({ name: 'AES-GCM', length: 256 }), + ); + expect(err.name).to.equal('TypeError'); +}); + +test(SUITE, 'sign throws TypeError when fewer than 3 args', async () => { + const err = await expectThrows(() => + subtleAny.sign({ name: 'HMAC' }, undefined), + ); + expect(err.name).to.equal('TypeError'); +}); + +test(SUITE, 'verify throws TypeError when fewer than 4 args', async () => { + const err = await expectThrows(() => + subtleAny.verify({ name: 'HMAC' }, undefined, undefined), + ); + expect(err.name).to.equal('TypeError'); +}); + +test(SUITE, 'encrypt throws TypeError when fewer than 3 args', async () => { + const err = await expectThrows(() => + subtleAny.encrypt({ name: 'AES-GCM', iv: new Uint8Array(12) }, undefined), + ); + expect(err.name).to.equal('TypeError'); +}); + +test(SUITE, 'decrypt throws TypeError when fewer than 3 args', async () => { + const err = await expectThrows(() => + subtleAny.decrypt({ name: 'AES-GCM', iv: new Uint8Array(12) }, undefined), + ); + expect(err.name).to.equal('TypeError'); +}); + +test(SUITE, 'deriveBits throws TypeError when fewer than 2 args', async () => { + const err = await expectThrows(() => + subtleAny.deriveBits({ name: 'PBKDF2' }), + ); + expect(err.name).to.equal('TypeError'); +}); + +test(SUITE, 'deriveKey throws TypeError when fewer than 5 args', async () => { + const err = await expectThrows(() => + subtleAny.deriveKey( + { name: 'PBKDF2' }, + undefined, + { name: 'AES-GCM', length: 256 }, + true, + ), + ); + expect(err.name).to.equal('TypeError'); +}); + +test(SUITE, 'wrapKey throws TypeError when fewer than 4 args', async () => { + const err = await expectThrows(() => + subtleAny.wrapKey('raw', undefined, undefined), + ); + expect(err.name).to.equal('TypeError'); +}); + +test(SUITE, 'unwrapKey throws TypeError when fewer than 7 args', async () => { + const err = await expectThrows(() => + subtleAny.unwrapKey( + 'raw', + new Uint8Array(0), + undefined, + { name: 'AES-KW' }, + { name: 'AES-GCM', length: 256 }, + true, + ), + ); + expect(err.name).to.equal('TypeError'); +}); + +test(SUITE, 'exportKey throws TypeError when fewer than 2 args', async () => { + const err = await expectThrows(() => subtleAny.exportKey('raw')); + expect(err.name).to.equal('TypeError'); +}); + +test(SUITE, 'digest throws TypeError when fewer than 2 args', async () => { + const err = await expectThrows(() => subtleAny.digest('SHA-256')); + expect(err.name).to.equal('TypeError'); +}); + +test( + SUITE, + 'getPublicKey throws TypeError when fewer than 2 args', + async () => { + const err = await expectThrows(() => subtleAny.getPublicKey(undefined)); + expect(err.name).to.equal('TypeError'); + }, +); + +// --- B.8 validation ordering: algorithm-mismatch before usage -------------- + +test( + SUITE, + 'sign with mismatched algorithm throws Key algorithm mismatch (not usage error)', + async () => { + // HMAC key, but ask to sign with ECDSA. Algorithm mismatch must take + // precedence over the (also-failing) usage check. + const key = (await subtle.generateKey( + { name: 'HMAC', hash: 'SHA-256' }, + true, + ['sign', 'verify'], + )) as CryptoKey; + + const err = await expectThrows(() => + subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, key, new Uint8Array(8)), + ); + expect(err.name).to.equal('InvalidAccessError'); + expect(err.message).to.contain('Key algorithm mismatch'); + }, +); + +test( + SUITE, + 'sign with correct algorithm but missing usage throws usage error', + async () => { + const key = (await subtle.generateKey( + { name: 'HMAC', hash: 'SHA-256' }, + true, + ['verify'], + )) as CryptoKey; + const err = await expectThrows(() => + subtle.sign({ name: 'HMAC' }, key, new Uint8Array(8)), + ); + expect(err.name).to.equal('InvalidAccessError'); + expect(err.message.toLowerCase()).to.contain('sign'); + }, +); + +test( + SUITE, + 'encrypt with mismatched algorithm throws Key algorithm mismatch first', + async () => { + const key = (await subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'], + )) as CryptoKey; + const err = await expectThrows(() => + subtle.encrypt( + { name: 'AES-CBC', iv: new Uint8Array(16) }, + key, + new Uint8Array(16), + ), + ); + expect(err.name).to.equal('InvalidAccessError'); + expect(err.message).to.contain('Key algorithm mismatch'); + }, +); + +// --- B.9 wrapKey/unwrapKey algorithm-mismatch check ------------------------ + +test( + SUITE, + 'wrapKey with mismatched wrappingKey algorithm throws Key algorithm mismatch', + async () => { + const key = (await subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'], + )) as CryptoKey; + // wrappingKey is AES-GCM but we ask to wrap with AES-KW. + const wrappingKey = (await subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + true, + ['wrapKey', 'unwrapKey'], + )) as CryptoKey; + + const err = await expectThrows(() => + subtle.wrapKey('raw', key, wrappingKey, { name: 'AES-KW' }), + ); + expect(err.name).to.equal('InvalidAccessError'); + expect(err.message).to.contain('Key algorithm mismatch'); + }, +); + +test( + SUITE, + 'unwrapKey with mismatched unwrappingKey algorithm throws Key algorithm mismatch', + async () => { + const unwrappingKey = (await subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + true, + ['wrapKey', 'unwrapKey'], + )) as CryptoKey; + + const err = await expectThrows(() => + subtle.unwrapKey( + 'raw', + new Uint8Array(40), + unwrappingKey, + { name: 'AES-KW' }, + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'], + ), + ); + expect(err.name).to.equal('InvalidAccessError'); + expect(err.message).to.contain('Key algorithm mismatch'); + }, +); + +// --- B.10 deriveBits length=null handling --------------------------------- + +test( + SUITE, + 'deriveBits ECDH with length=null returns full shared secret', + async () => { + const alice = (await subtle.generateKey( + { name: 'ECDH', namedCurve: 'P-256' }, + true, + ['deriveBits'], + )) as CryptoKeyPair; + const bob = (await subtle.generateKey( + { name: 'ECDH', namedCurve: 'P-256' }, + true, + ['deriveBits'], + )) as CryptoKeyPair; + + const full = await subtle.deriveBits( + { name: 'ECDH', public: bob.publicKey } as unknown as { + name: 'ECDH'; + public: CryptoKey; + }, + alice.privateKey as CryptoKey, + null as unknown as number, + ); + // P-256 shared secret is 32 bytes + expect(full.byteLength).to.equal(32); + }, +); + +test( + SUITE, + 'deriveBits with length omitted defaults to null (full secret)', + async () => { + const alice = (await subtle.generateKey( + { name: 'ECDH', namedCurve: 'P-256' }, + true, + ['deriveBits'], + )) as CryptoKeyPair; + const bob = (await subtle.generateKey( + { name: 'ECDH', namedCurve: 'P-256' }, + true, + ['deriveBits'], + )) as CryptoKeyPair; + + const full = await subtleAny.deriveBits( + { name: 'ECDH', public: bob.publicKey }, + alice.privateKey as CryptoKey, + ); + expect((full as ArrayBuffer).byteLength).to.equal(32); + }, +); + +test( + SUITE, + 'deriveBits HKDF with length=null throws OperationError', + async () => { + const baseKey = await subtle.importKey( + 'raw', + new Uint8Array(32), + { name: 'HKDF' }, + false, + ['deriveBits'], + ); + + const err = await expectThrows(() => + subtleAny.deriveBits( + { + name: 'HKDF', + hash: 'SHA-256', + salt: new Uint8Array(0), + info: new Uint8Array(0), + }, + baseKey, + null, + ), + ); + expect(err.name).to.equal('OperationError'); + expect(err.message.toLowerCase()).to.contain('null'); + }, +); + +test( + SUITE, + 'deriveBits PBKDF2 with length=null throws OperationError', + async () => { + const baseKey = await subtle.importKey( + 'raw', + new TextEncoder().encode('password'), + { name: 'PBKDF2' }, + false, + ['deriveBits'], + ); + + const err = await expectThrows(() => + subtleAny.deriveBits( + { + name: 'PBKDF2', + hash: 'SHA-256', + salt: new Uint8Array(8), + iterations: 1000, + }, + baseKey, + null, + ), + ); + expect(err.name).to.equal('OperationError'); + expect(err.message.toLowerCase()).to.contain('null'); + }, +); + +// --- B.11 getKeyLength validation ----------------------------------------- + +test( + SUITE, + 'deriveKey to AES-GCM with invalid length throws OperationError', + async () => { + const baseKey = await subtle.importKey( + 'raw', + new TextEncoder().encode('password'), + { name: 'PBKDF2' }, + false, + ['deriveKey'], + ); + const err = await expectThrows(() => + subtle.deriveKey( + { + name: 'PBKDF2', + hash: 'SHA-256', + salt: new Uint8Array(8), + iterations: 1000, + }, + baseKey, + // 100 is not a valid AES key length + { name: 'AES-GCM', length: 100 } as unknown as { name: 'AES-GCM' }, + true, + ['encrypt', 'decrypt'], + ), + ); + expect(err.name).to.equal('OperationError'); + expect(err.message).to.contain('Invalid key length'); + }, +); + +test( + SUITE, + 'deriveKey to HMAC with length=0 throws OperationError', + async () => { + const baseKey = await subtle.importKey( + 'raw', + new TextEncoder().encode('password'), + { name: 'PBKDF2' }, + false, + ['deriveKey'], + ); + const err = await expectThrows(() => + subtle.deriveKey( + { + name: 'PBKDF2', + hash: 'SHA-256', + salt: new Uint8Array(8), + iterations: 1000, + }, + baseKey, + { name: 'HMAC', hash: 'SHA-256', length: 0 } as unknown as { + name: 'HMAC'; + }, + true, + ['sign', 'verify'], + ), + ); + expect(err.name).to.equal('OperationError'); + expect(err.message).to.contain('Invalid key length'); + }, +); diff --git a/packages/react-native-quick-crypto/src/subtle.ts b/packages/react-native-quick-crypto/src/subtle.ts index cda31664..28df31ca 100644 --- a/packages/react-native-quick-crypto/src/subtle.ts +++ b/packages/react-native-quick-crypto/src/subtle.ts @@ -86,6 +86,18 @@ function hasAnyNotIn(usages: KeyUsage[], allowed: KeyUsage[]): boolean { return usages.some(usage => !allowed.includes(usage)); } +// Mirrors webidl.requiredArguments. Node throws TypeError when a SubtleCrypto +// method is called with fewer than the spec-required number of arguments +// (webcrypto.js:866 etc.); we relied on TypeScript types alone, which apps +// catching `instanceof TypeError` could not see at runtime. +function requireArgs(actual: number, required: number, method: string): void { + if (actual < required) { + throw new TypeError( + `Failed to execute '${method}' on 'SubtleCrypto': ${required} arguments required, but only ${actual} present.`, + ); + } +} + // WebCrypto §18.4.4: algorithm name lookup is case-insensitive, but the // canonical mixed-case form is preserved in the resulting `name` field // (e.g. "aes-gcm" → "AES-GCM"). This map is built lazily on first call so @@ -2196,7 +2208,11 @@ const signVerify = ( const usage: Operation = signature === undefined ? 'sign' : 'verify'; algorithm = normalizeAlgorithm(algorithm, usage); - if (!key.usages.includes(usage) || algorithm.name !== key.algorithm.name) { + if (algorithm.name !== key.algorithm.name) { + throw lazyDOMException('Key algorithm mismatch', 'InvalidAccessError'); + } + + if (!key.usages.includes(usage)) { throw lazyDOMException( `Unable to use this key to ${usage}`, 'InvalidAccessError', @@ -2229,23 +2245,16 @@ const signVerify = ( ); }; +// Algorithm-mismatch and usage checks live at the public-method call sites +// (encrypt / decrypt / wrapKey / unwrapKey) so spec-mandated message and +// ordering — algorithm-mismatch first, then usage — is preserved +// (Node webcrypto.js, commit 4cb1f284136). const cipherOrWrap = async ( mode: CipherOrWrapMode, algorithm: EncryptDecryptParams, key: CryptoKey, data: ArrayBuffer, - op: Operation, ): Promise => { - if ( - key.algorithm.name !== algorithm.name || - !key.usages.includes(op as KeyUsage) - ) { - throw lazyDOMException( - 'The requested operation is not valid for the provided key', - 'InvalidAccessError', - ); - } - validateMaxBufferLength(data, 'data'); switch (algorithm.name) { @@ -2511,13 +2520,25 @@ export class Subtle { key: CryptoKey, data: BufferLike, ): Promise { - const normalizedAlgorithm = normalizeAlgorithm(algorithm, 'decrypt'); + requireArgs(arguments.length, 3, 'decrypt'); + const normalizedAlgorithm = normalizeAlgorithm( + algorithm, + 'decrypt', + ) as EncryptDecryptParams; + if (normalizedAlgorithm.name !== key.algorithm.name) { + throw lazyDOMException('Key algorithm mismatch', 'InvalidAccessError'); + } + if (!key.usages.includes('decrypt')) { + throw lazyDOMException( + 'Unable to use this key to decrypt', + 'InvalidAccessError', + ); + } return cipherOrWrap( CipherOrWrapMode.kWebCryptoCipherDecrypt, - normalizedAlgorithm as EncryptDecryptParams, + normalizedAlgorithm, key, bufferLikeToArrayBuffer(data), - 'decrypt', ); } @@ -2525,6 +2546,7 @@ export class Subtle { algorithm: SubtleAlgorithm | AnyAlgorithm, data: BufferLike, ): Promise { + requireArgs(arguments.length, 2, 'digest'); const normalizedAlgorithm = normalizeAlgorithm( algorithm, 'digest' as Operation, @@ -2535,43 +2557,56 @@ export class Subtle { async deriveBits( algorithm: SubtleAlgorithm, baseKey: CryptoKey, - length: number, + length: number | null = null, ): Promise { + requireArgs(arguments.length, 2, 'deriveBits'); + const normalizedAlgorithm = normalizeAlgorithm(algorithm, 'deriveBits'); // WebCrypto §SubtleCrypto.deriveBits step 11: throw InvalidAccessError // unless `baseKey.[[usages]]` contains "deriveBits" specifically. The // previous `deriveBits || deriveKey` accept-either branch silently // promoted deriveKey-only keys into deriveBits use, contradicting the // spec usage gate. - if (!baseKey.keyUsages.includes('deriveBits')) { + if (!baseKey.usages.includes('deriveBits')) { throw lazyDOMException( 'baseKey does not have deriveBits usage', 'InvalidAccessError', ); } - if (baseKey.algorithm.name !== algorithm.name) - throw new Error('Key algorithm mismatch'); - switch (algorithm.name) { + if (baseKey.algorithm.name !== normalizedAlgorithm.name) { + throw lazyDOMException('Key algorithm mismatch', 'InvalidAccessError'); + } + switch (normalizedAlgorithm.name) { case 'PBKDF2': - return pbkdf2DeriveBits(algorithm, baseKey, length); + if (length === null) { + throw lazyDOMException('length cannot be null', 'OperationError'); + } + return pbkdf2DeriveBits(normalizedAlgorithm, baseKey, length); case 'X25519': // Fall through case 'X448': - return xDeriveBits(algorithm, baseKey, length); + return xDeriveBits(normalizedAlgorithm, baseKey, length); case 'ECDH': - return ecDeriveBits(algorithm, baseKey, length); + return ecDeriveBits(normalizedAlgorithm, baseKey, length); case 'HKDF': + if (length === null) { + throw lazyDOMException('length cannot be null', 'OperationError'); + } return hkdfDeriveBits( - algorithm as unknown as HkdfAlgorithm, + normalizedAlgorithm as unknown as HkdfAlgorithm, baseKey, length, ); case 'Argon2d': case 'Argon2i': case 'Argon2id': - return argon2DeriveBits(algorithm, baseKey, length); + if (length === null) { + throw lazyDOMException('length cannot be null', 'OperationError'); + } + return argon2DeriveBits(normalizedAlgorithm, baseKey, length); } - throw new Error( - `'subtle.deriveBits()' for ${algorithm.name} is not implemented.`, + throw lazyDOMException( + `'subtle.deriveBits()' for ${normalizedAlgorithm.name} is not implemented.`, + 'NotSupportedError', ); } @@ -2582,40 +2617,55 @@ export class Subtle { extractable: boolean, keyUsages: KeyUsage[], ): Promise { + requireArgs(arguments.length, 5, 'deriveKey'); + const normalizedAlgorithm = normalizeAlgorithm(algorithm, 'deriveBits'); + const normalizedDerivedKeyAlgorithm = normalizeAlgorithm( + derivedKeyAlgorithm, + 'importKey', + ); + // Validate baseKey usage - if ( - !baseKey.usages.includes('deriveKey') && - !baseKey.usages.includes('deriveBits') - ) { + if (!baseKey.usages.includes('deriveKey')) { throw lazyDOMException( - 'baseKey does not have deriveKey or deriveBits usage', + 'baseKey does not have deriveKey usage', 'InvalidAccessError', ); } - // Calculate required key length - const length = getKeyLength(derivedKeyAlgorithm); + if (baseKey.algorithm.name !== normalizedAlgorithm.name) { + throw lazyDOMException('Key algorithm mismatch', 'InvalidAccessError'); + } + + // Calculate required key length (may be null for KDF-derived material). + const length = getKeyLength(normalizedDerivedKeyAlgorithm); // Step 1: Derive bits let derivedBits: ArrayBuffer; - if (baseKey.algorithm.name !== algorithm.name) - throw new Error('Key algorithm mismatch'); - - switch (algorithm.name) { + switch (normalizedAlgorithm.name) { case 'PBKDF2': - derivedBits = await pbkdf2DeriveBits(algorithm, baseKey, length); + if (length === null) { + throw lazyDOMException('length cannot be null', 'OperationError'); + } + derivedBits = await pbkdf2DeriveBits( + normalizedAlgorithm, + baseKey, + length, + ); break; case 'X25519': // Fall through case 'X448': - derivedBits = await xDeriveBits(algorithm, baseKey, length); + derivedBits = await xDeriveBits(normalizedAlgorithm, baseKey, length); break; case 'ECDH': - derivedBits = await ecDeriveBits(algorithm, baseKey, length); + derivedBits = await ecDeriveBits(normalizedAlgorithm, baseKey, length); break; case 'HKDF': + if (length === null) { + throw lazyDOMException('length cannot be null', 'OperationError'); + } derivedBits = hkdfDeriveBits( - algorithm as unknown as HkdfAlgorithm, + normalizedAlgorithm as unknown as HkdfAlgorithm, baseKey, length, ); @@ -2623,11 +2673,15 @@ export class Subtle { case 'Argon2d': case 'Argon2i': case 'Argon2id': - derivedBits = argon2DeriveBits(algorithm, baseKey, length); + if (length === null) { + throw lazyDOMException('length cannot be null', 'OperationError'); + } + derivedBits = argon2DeriveBits(normalizedAlgorithm, baseKey, length); break; default: - throw new Error( - `'subtle.deriveKey()' for ${algorithm.name} is not implemented.`, + throw lazyDOMException( + `'subtle.deriveKey()' for ${normalizedAlgorithm.name} is not implemented.`, + 'NotSupportedError', ); } @@ -2647,13 +2701,25 @@ export class Subtle { key: CryptoKey, data: BufferLike, ): Promise { - const normalizedAlgorithm = normalizeAlgorithm(algorithm, 'encrypt'); + requireArgs(arguments.length, 3, 'encrypt'); + const normalizedAlgorithm = normalizeAlgorithm( + algorithm, + 'encrypt', + ) as EncryptDecryptParams; + if (normalizedAlgorithm.name !== key.algorithm.name) { + throw lazyDOMException('Key algorithm mismatch', 'InvalidAccessError'); + } + if (!key.usages.includes('encrypt')) { + throw lazyDOMException( + 'Unable to use this key to encrypt', + 'InvalidAccessError', + ); + } return cipherOrWrap( CipherOrWrapMode.kWebCryptoCipherEncrypt, - normalizedAlgorithm as EncryptDecryptParams, + normalizedAlgorithm, key, bufferLikeToArrayBuffer(data), - 'encrypt', ); } @@ -2661,6 +2727,7 @@ export class Subtle { format: ImportFormat, key: CryptoKey, ): Promise { + requireArgs(arguments.length, 2, 'exportKey'); if (!key.extractable) throw lazyDOMException('key is not extractable', 'InvalidAccessError'); @@ -2708,10 +2775,18 @@ export class Subtle { wrappingKey: CryptoKey, wrapAlgorithm: EncryptDecryptParams, ): Promise { - // Validate wrappingKey usage + requireArgs(arguments.length, 4, 'wrapKey'); + const normalizedWrapAlgorithm = normalizeAlgorithm( + wrapAlgorithm, + 'wrapKey', + ) as EncryptDecryptParams; + + if (normalizedWrapAlgorithm.name !== wrappingKey.algorithm.name) { + throw lazyDOMException('Key algorithm mismatch', 'InvalidAccessError'); + } if (!wrappingKey.usages.includes('wrapKey')) { throw lazyDOMException( - 'wrappingKey does not have wrapKey usage', + 'Unable to use this key to wrapKey', 'InvalidAccessError', ); } @@ -2726,7 +2801,7 @@ export class Subtle { const buffer = SBuffer.from(jwkString, 'utf8'); // For AES-KW, pad to multiple of 8 bytes (accounting for null terminator) - if (wrapAlgorithm.name === 'AES-KW') { + if (normalizedWrapAlgorithm.name === 'AES-KW') { const length = buffer.length; // Add 1 for null terminator, then pad to multiple of 8 const paddedLength = Math.ceil((length + 1) / 8) * 8; @@ -2745,10 +2820,9 @@ export class Subtle { // Step 3: Encrypt the exported key return cipherOrWrap( CipherOrWrapMode.kWebCryptoCipherEncrypt, - wrapAlgorithm, + normalizedWrapAlgorithm, wrappingKey, keyData, - 'wrapKey', ); } @@ -2761,10 +2835,18 @@ export class Subtle { extractable: boolean, keyUsages: KeyUsage[], ): Promise { - // Validate unwrappingKey usage + requireArgs(arguments.length, 7, 'unwrapKey'); + const normalizedUnwrapAlgorithm = normalizeAlgorithm( + unwrapAlgorithm, + 'unwrapKey', + ) as EncryptDecryptParams; + + if (normalizedUnwrapAlgorithm.name !== unwrappingKey.algorithm.name) { + throw lazyDOMException('Key algorithm mismatch', 'InvalidAccessError'); + } if (!unwrappingKey.usages.includes('unwrapKey')) { throw lazyDOMException( - 'unwrappingKey does not have unwrapKey usage', + 'Unable to use this key to unwrapKey', 'InvalidAccessError', ); } @@ -2772,10 +2854,9 @@ export class Subtle { // Step 1: Decrypt the wrapped key const decrypted = await cipherOrWrap( CipherOrWrapMode.kWebCryptoCipherDecrypt, - unwrapAlgorithm, + normalizedUnwrapAlgorithm, unwrappingKey, bufferLikeToArrayBuffer(wrappedKey), - 'unwrapKey', ); // Step 2: Convert to appropriate format @@ -2784,7 +2865,7 @@ export class Subtle { const buffer = SBuffer.from(decrypted); // For AES-KW, the data may be padded - find the null terminator let jwkString: string; - if (unwrapAlgorithm.name === 'AES-KW') { + if (normalizedUnwrapAlgorithm.name === 'AES-KW') { // Find the null terminator (if present) to get the original string const nullIndex = buffer.indexOf(0); if (nullIndex !== -1) { @@ -2816,6 +2897,7 @@ export class Subtle { extractable: boolean, keyUsages: KeyUsage[], ): Promise { + requireArgs(arguments.length, 3, 'generateKey'); algorithm = normalizeAlgorithm(algorithm, 'generateKey'); let result: CryptoKey | CryptoKeyPair; switch (algorithm.name) { @@ -2938,6 +3020,7 @@ export class Subtle { key: CryptoKey, keyUsages: KeyUsage[], ): Promise { + requireArgs(arguments.length, 2, 'getPublicKey'); if (key.type === 'secret') { throw lazyDOMException('key must be a private key', 'NotSupportedError'); } @@ -2956,6 +3039,7 @@ export class Subtle { extractable: boolean, keyUsages: KeyUsage[], ): Promise { + requireArgs(arguments.length, 5, 'importKey'); // Per-algorithm format handling. Some algorithms alias raw-secret/raw-public // to 'raw' (RSA, EC, Ed/X, HMAC, HKDF, PBKDF2); others demand the // disambiguated form (KMAC, AES-OCB, ChaCha20-Poly1305, Argon2, ML-DSA, @@ -3122,6 +3206,7 @@ export class Subtle { key: CryptoKey, data: BufferLike, ): Promise { + requireArgs(arguments.length, 3, 'sign'); return signVerify( normalizeAlgorithm(algorithm, 'sign'), key, @@ -3135,6 +3220,7 @@ export class Subtle { signature: BufferLike, data: BufferLike, ): Promise { + requireArgs(arguments.length, 4, 'verify'); return signVerify( normalizeAlgorithm(algorithm, 'verify'), key, @@ -3206,6 +3292,7 @@ export class Subtle { algorithm: SubtleAlgorithm, key: CryptoKey, ): Promise { + requireArgs(arguments.length, 2, 'encapsulateBits'); if (!key.usages.includes('encapsulateBits')) { throw lazyDOMException( 'Key does not have encapsulateBits usage', @@ -3223,6 +3310,7 @@ export class Subtle { extractable: boolean, usages: KeyUsage[], ): Promise<{ key: CryptoKey; ciphertext: ArrayBuffer }> { + requireArgs(arguments.length, 5, 'encapsulateKey'); if (!key.usages.includes('encapsulateKey')) { throw lazyDOMException( 'Key does not have encapsulateKey usage', @@ -3249,6 +3337,7 @@ export class Subtle { key: CryptoKey, ciphertext: BufferLike, ): Promise { + requireArgs(arguments.length, 3, 'decapsulateBits'); if (!key.usages.includes('decapsulateBits')) { throw lazyDOMException( 'Key does not have decapsulateBits usage', @@ -3267,6 +3356,7 @@ export class Subtle { extractable: boolean, usages: KeyUsage[], ): Promise { + requireArgs(arguments.length, 6, 'decapsulateKey'); if (!key.usages.includes('decapsulateKey')) { throw lazyDOMException( 'Key does not have decapsulateKey usage', @@ -3289,8 +3379,14 @@ export class Subtle { export const subtle = new Subtle(); -function getKeyLength(algorithm: SubtleAlgorithm): number { +// Returns the number of bits to derive for an `importKey` algorithm, mirroring +// Node's webcrypto.js:269-306 `getKeyLength`. Returns null for KDF algorithms +// (HKDF / PBKDF2 / Argon2) — those carry their full derived secret without a +// fixed key length. Throws OperationError on invalid AES / HMAC inputs rather +// than silently coercing to a default (Node commit 4cb1f284136 behavior). +function getKeyLength(algorithm: SubtleAlgorithm): number | null { const name = algorithm.name; + const length = (algorithm as { length?: number }).length; switch (name) { case 'AES-CTR': @@ -3298,18 +3394,38 @@ function getKeyLength(algorithm: SubtleAlgorithm): number { case 'AES-GCM': case 'AES-KW': case 'AES-OCB': - case 'ChaCha20-Poly1305': - return (algorithm as AesKeyGenParams).length || 256; + if (length !== 128 && length !== 192 && length !== 256) { + throw lazyDOMException('Invalid key length', 'OperationError'); + } + return length; case 'HMAC': { - const hmacAlg = algorithm as { length?: number }; - return hmacAlg.length || 256; + if (length === undefined) { + return getHmacBlockSize( + (algorithm.hash as { name?: string } | undefined)?.name ?? + (algorithm.hash as string | undefined), + ); + } + if (typeof length === 'number' && length !== 0) { + return length; + } + throw lazyDOMException('Invalid key length', 'OperationError'); } case 'KMAC128': - return algorithm.length || 128; + return typeof length === 'number' ? length : 128; case 'KMAC256': - return algorithm.length || 256; + return typeof length === 'number' ? length : 256; + + case 'ChaCha20-Poly1305': + return 256; + + case 'HKDF': + case 'PBKDF2': + case 'Argon2d': + case 'Argon2i': + case 'Argon2id': + return null; default: throw lazyDOMException( @@ -3318,3 +3434,25 @@ function getKeyLength(algorithm: SubtleAlgorithm): number { ); } } + +function getHmacBlockSize(name: string | undefined): number { + switch (name) { + case 'SHA-1': + case 'SHA-256': + return 512; + case 'SHA-384': + case 'SHA-512': + return 1024; + case 'SHA3-256': + case 'SHA3-384': + case 'SHA3-512': + // SHA-3 / HMAC interaction undefined — Node throws here too + // (webcrypto-modern-algos issue #23). + throw lazyDOMException( + 'Explicit algorithm length member is required', + 'NotSupportedError', + ); + default: + throw lazyDOMException('Invalid key length', 'OperationError'); + } +}