diff --git a/example/src/tests/subtle/digest.ts b/example/src/tests/subtle/digest.ts index 0178417b..fa59c247 100644 --- a/example/src/tests/subtle/digest.ts +++ b/example/src/tests/subtle/digest.ts @@ -63,28 +63,29 @@ kTests.forEach(([algorithm, legacyName, bitLength]) => { // cSHAKE tests (XOF - extendable output functions) test(SUITE, 'hash: cSHAKE128', async () => { - const outputLength = 32; - const checkValue = createHash('shake128', { outputLength }) + const outputBytes = 32; + const checkValue = createHash('shake128', { outputLength: outputBytes }) .update(kData) .digest() .toString('hex'); + // CShakeParams.outputLength is in bits per spec. const result = await subtle.digest( - { name: 'cSHAKE128', length: outputLength }, + { name: 'cSHAKE128', outputLength: outputBytes * 8 }, kData, ); expect(ab2str(result)).to.equal(checkValue); }); test(SUITE, 'hash: cSHAKE256', async () => { - const outputLength = 64; - const checkValue = createHash('shake256', { outputLength }) + const outputBytes = 64; + const checkValue = createHash('shake256', { outputLength: outputBytes }) .update(kData) .digest() .toString('hex'); const result = await subtle.digest( - { name: 'cSHAKE256', length: outputLength }, + { name: 'cSHAKE256', outputLength: outputBytes * 8 }, kData, ); expect(ab2str(result)).to.equal(checkValue); diff --git a/example/src/tests/subtle/import_export.ts b/example/src/tests/subtle/import_export.ts index fff5edf2..a24d485b 100644 --- a/example/src/tests/subtle/import_export.ts +++ b/example/src/tests/subtle/import_export.ts @@ -3542,14 +3542,18 @@ for (const algorithm of ['KMAC128', 'KMAC256'] as const) { ); const data = new TextEncoder().encode('jwk round-trip test'); - const length = algorithm === 'KMAC128' ? 256 : 512; + const outputLength = algorithm === 'KMAC128' ? 256 : 512; const sig1 = await subtle.sign( - { name: algorithm, length }, + { name: algorithm, outputLength }, key as CryptoKey, data, ); - const sig2 = await subtle.sign({ name: algorithm, length }, imported, data); + const sig2 = await subtle.sign( + { name: algorithm, outputLength }, + imported, + data, + ); expect(ab2str(sig1, 'hex')).to.equal(ab2str(sig2, 'hex')); }); } diff --git a/example/src/tests/subtle/sign_verify.ts b/example/src/tests/subtle/sign_verify.ts index cb8f32a6..3a6ea388 100644 --- a/example/src/tests/subtle/sign_verify.ts +++ b/example/src/tests/subtle/sign_verify.ts @@ -1211,7 +1211,7 @@ for (const vec of kmacNistVectors) { const signature = await subtle.sign( { name: vec.algorithm, - length: vec.length, + outputLength: vec.length, customization: vec.customization, }, key, @@ -1239,7 +1239,7 @@ for (const vec of kmacNistVectors) { const result = await subtle.verify( { name: vec.algorithm, - length: vec.length, + outputLength: vec.length, customization: vec.customization, }, key, @@ -1259,18 +1259,18 @@ for (const algorithm of ['KMAC128', 'KMAC256'] as const) { ]); const data = new TextEncoder().encode('Hello KMAC!'); - const length = algorithm === 'KMAC128' ? 256 : 512; + const outputLength = algorithm === 'KMAC128' ? 256 : 512; const signature = await subtle.sign( - { name: algorithm, length }, + { name: algorithm, outputLength }, key as CryptoKey, data, ); - expect(signature.byteLength).to.equal(length / 8); + expect(signature.byteLength).to.equal(outputLength / 8); const valid = await subtle.verify( - { name: algorithm, length }, + { name: algorithm, outputLength }, key as CryptoKey, signature, data, @@ -1289,7 +1289,7 @@ test(SUITE, 'KMAC verify returns false for wrong signature', async () => { const data = new TextEncoder().encode('test data'); const signature = await subtle.sign( - { name: 'KMAC256', length: 256 }, + { name: 'KMAC256', outputLength: 256 }, key as CryptoKey, data, ); @@ -1298,7 +1298,7 @@ test(SUITE, 'KMAC verify returns false for wrong signature', async () => { corrupted[0] = corrupted[0]! ^ 0xff; const valid = await subtle.verify( - { name: 'KMAC256', length: 256 }, + { name: 'KMAC256', outputLength: 256 }, key as CryptoKey, corrupted, data, @@ -1318,7 +1318,7 @@ test( const sig1 = await subtle.sign( { name: 'KMAC128', - length: 256, + outputLength: 256, customization: new TextEncoder().encode('App A'), }, key as CryptoKey, @@ -1328,7 +1328,7 @@ test( const sig2 = await subtle.sign( { name: 'KMAC128', - length: 256, + outputLength: 256, customization: new TextEncoder().encode('App B'), }, key as CryptoKey, diff --git a/example/src/tests/subtle/supports.ts b/example/src/tests/subtle/supports.ts index 405692c1..f3a27aac 100644 --- a/example/src/tests/subtle/supports.ts +++ b/example/src/tests/subtle/supports.ts @@ -69,12 +69,17 @@ test(SUITE, 'generateKey: HKDF is not supported', () => { }); // --- DeriveBits --- -test(SUITE, 'deriveBits: HKDF is supported', () => { - expect(Subtle.supports('deriveBits', 'HKDF')).to.equal(true); +// HKDF/PBKDF2/Argon2 require an explicit length per Node webcrypto.js:1689-1714. +test(SUITE, 'deriveBits: HKDF with length is supported', () => { + expect(Subtle.supports('deriveBits', 'HKDF', 256)).to.equal(true); }); -test(SUITE, 'deriveBits: PBKDF2 is supported', () => { - expect(Subtle.supports('deriveBits', 'PBKDF2')).to.equal(true); +test(SUITE, 'deriveBits: PBKDF2 with length is supported', () => { + expect(Subtle.supports('deriveBits', 'PBKDF2', 256)).to.equal(true); +}); + +test(SUITE, 'deriveBits: HKDF without length is not supported', () => { + expect(Subtle.supports('deriveBits', 'HKDF')).to.equal(false); }); test(SUITE, 'deriveBits: X25519 is supported', () => { @@ -86,14 +91,25 @@ test(SUITE, 'deriveBits: AES-GCM is not supported', () => { }); // --- DeriveKey --- -test(SUITE, 'deriveKey: HKDF is supported', () => { - expect(Subtle.supports('deriveKey', 'HKDF')).to.equal(true); +test(SUITE, 'deriveKey: HKDF + AES-GCM with length 256 is supported', () => { + expect( + Subtle.supports('deriveKey', 'HKDF', { name: 'AES-GCM', length: 256 }), + ).to.equal(true); }); -test(SUITE, 'deriveKey: HKDF with AES-GCM output is supported', () => { - expect(Subtle.supports('deriveKey', 'HKDF', 'AES-GCM')).to.equal(true); +// AES key length is required for getKeyLength — Node webcrypto.js:269-279. +test(SUITE, 'deriveKey: HKDF + AES-GCM without length is not supported', () => { + expect(Subtle.supports('deriveKey', 'HKDF', 'AES-GCM')).to.equal(false); }); +test( + SUITE, + 'deriveKey: HKDF without additional algorithm returns false', + () => { + expect(Subtle.supports('deriveKey', 'HKDF')).to.equal(false); + }, +); + // --- GetPublicKey --- test(SUITE, 'getPublicKey: Ed25519 is supported', () => { expect(Subtle.supports('getPublicKey', 'Ed25519')).to.equal(true); @@ -124,6 +140,91 @@ test(SUITE, 'wrapKey: Ed25519 is not supported', () => { expect(Subtle.supports('wrapKey', 'Ed25519')).to.equal(false); }); +test(SUITE, 'wrapKey: AES-KW with AES-GCM exportKey decomposition', () => { + expect(Subtle.supports('wrapKey', 'AES-KW', 'AES-GCM')).to.equal(true); +}); + +test(SUITE, 'wrapKey: AES-KW with FAKE exportKey decomposition', () => { + expect(Subtle.supports('wrapKey', 'AES-KW', 'FAKE' as never)).to.equal(false); +}); + +// --- UnwrapKey --- +test(SUITE, 'unwrapKey: AES-KW is supported', () => { + expect(Subtle.supports('unwrapKey', 'AES-KW')).to.equal(true); +}); + +test(SUITE, 'unwrapKey: AES-KW with AES-GCM importKey decomposition', () => { + expect(Subtle.supports('unwrapKey', 'AES-KW', 'AES-GCM')).to.equal(true); +}); + +test(SUITE, 'unwrapKey: AES-KW with FAKE importKey decomposition', () => { + expect(Subtle.supports('unwrapKey', 'AES-KW', 'FAKE' as never)).to.equal( + false, + ); +}); + +// --- EncapsulateKey --- +test(SUITE, 'encapsulateKey: ML-KEM-768 + AES-GCM is supported', () => { + expect(Subtle.supports('encapsulateKey', 'ML-KEM-768', 'AES-GCM')).to.equal( + true, + ); +}); + +test(SUITE, 'encapsulateKey: ML-KEM-768 + Ed25519 is not supported', () => { + expect(Subtle.supports('encapsulateKey', 'ML-KEM-768', 'Ed25519')).to.equal( + false, + ); +}); + +test( + SUITE, + 'encapsulateKey: ML-KEM-768 + HMAC default length supported', + () => { + expect(Subtle.supports('encapsulateKey', 'ML-KEM-768', 'HMAC')).to.equal( + true, + ); + }, +); + +test(SUITE, 'encapsulateKey: ML-KEM-768 + HMAC length 256 supported', () => { + expect( + Subtle.supports('encapsulateKey', 'ML-KEM-768', { + name: 'HMAC', + length: 256, + }), + ).to.equal(true); +}); + +test( + SUITE, + 'encapsulateKey: ML-KEM-768 + HMAC non-default length not supported', + () => { + expect( + Subtle.supports('encapsulateKey', 'ML-KEM-768', { + name: 'HMAC', + length: 512, + }), + ).to.equal(false); + }, +); + +// --- DeriveBits per-algorithm length validators --- +test(SUITE, 'deriveBits: HKDF with non-multiple-of-8 length rejected', () => { + expect(Subtle.supports('deriveBits', 'HKDF', 257)).to.equal(false); +}); + +test(SUITE, 'deriveBits: PBKDF2 with non-multiple-of-8 length rejected', () => { + expect(Subtle.supports('deriveBits', 'PBKDF2', 257)).to.equal(false); +}); + +test(SUITE, 'deriveBits: Argon2id length below 32 rejected', () => { + expect(Subtle.supports('deriveBits', 'Argon2id', 16)).to.equal(false); +}); + +test(SUITE, 'deriveBits: Argon2id length 32 supported', () => { + expect(Subtle.supports('deriveBits', 'Argon2id', 32)).to.equal(true); +}); + // --- Invalid operation --- test(SUITE, 'invalid operation returns false', () => { expect(Subtle.supports('nonexistent', 'AES-GCM')).to.equal(false); diff --git a/packages/react-native-quick-crypto/src/hash.ts b/packages/react-native-quick-crypto/src/hash.ts index d51fd01c..d5640e23 100644 --- a/packages/react-native-quick-crypto/src/hash.ts +++ b/packages/react-native-quick-crypto/src/hash.ts @@ -267,19 +267,25 @@ export const asyncDigest = async ( } if (name === 'cSHAKE128' || name === 'cSHAKE256') { - if (typeof algorithm.length !== 'number' || algorithm.length <= 0) { + // CShakeParams.outputLength is required (in bits) per the WICG modern-algos + // spec, renamed from `length` (commit ab8dc2b84c2). Mirror Node's + // hash.js:223-228 / webidl.js:570-595. + if ( + typeof algorithm.outputLength !== 'number' || + algorithm.outputLength <= 0 + ) { throw lazyDOMException( - 'cSHAKE requires a length parameter', + 'CShakeParams.outputLength is required', 'OperationError', ); } - if (algorithm.length % 8) { + if (algorithm.outputLength % 8) { throw lazyDOMException( - 'Unsupported CShakeParams length', + 'Unsupported CShakeParams outputLength', 'NotSupportedError', ); } - return internalDigest(algorithm, data, algorithm.length); + return internalDigest(algorithm, data, algorithm.outputLength / 8); } throw lazyDOMException( diff --git a/packages/react-native-quick-crypto/src/subtle.ts b/packages/react-native-quick-crypto/src/subtle.ts index 28df31ca..e180b753 100644 --- a/packages/react-native-quick-crypto/src/subtle.ts +++ b/packages/react-native-quick-crypto/src/subtle.ts @@ -805,13 +805,22 @@ function kmacSignVerify( ): ArrayBuffer | boolean { const { name } = algorithm; - const defaultLength = name === 'KMAC128' ? 256 : 512; - const outputLengthBits = algorithm.length ?? defaultLength; + // KmacParams.outputLength is required per + // https://wicg.github.io/webcrypto-modern-algos/#KmacParams-dictionary + // and the rename from `length` (commit ab8dc2b84c2). Mirror Node's + // mac.js:213-223 by reading `outputLength` (in bits). + if (typeof algorithm.outputLength !== 'number') { + throw lazyDOMException( + `${name}Params.outputLength is required`, + 'OperationError', + ); + } + const outputLengthBits = algorithm.outputLength; if (outputLengthBits % 8 !== 0) { throw lazyDOMException( - 'KMAC output length must be a multiple of 8', - 'OperationError', + `Unsupported ${name}Params outputLength`, + 'NotSupportedError', ); } @@ -2469,50 +2478,227 @@ const ASYMMETRIC_ALGORITHMS = new Set([ 'ML-KEM-1024', ]); -export class Subtle { - static supports( - operation: string, - algorithm: SubtleAlgorithm | AnyAlgorithm, - _lengthOrAdditionalAlgorithm?: unknown, - ): boolean { - let normalizedAlgorithm: SubtleAlgorithm; - try { - normalizedAlgorithm = normalizeAlgorithm( - algorithm, - (operation === 'getPublicKey' ? 'exportKey' : operation) as Operation, - ); - } catch { +// Per-algorithm validators for deriveBits (mirrors Node's hkdf.js:141-149, +// pbkdf2.js:96-105, argon2.js:194-209). Used by Subtle.supports to reject +// length values that the actual deriveBits implementation would reject. +function validateKdfDeriveBitsLength( + length: number | null | undefined, + algName: string, +): void { + if (length === null || length === undefined) { + throw lazyDOMException('length cannot be null', 'OperationError'); + } + if (length % 8) { + throw lazyDOMException('length must be a multiple of 8', 'OperationError'); + } + if (algName.startsWith('Argon2') && length < 32) { + throw lazyDOMException('length must be >= 32', 'OperationError'); + } +} + +// Mirrors Node's webcrypto.js:1652-1731 `check`. Normalizes the algorithm, +// looks it up in SUPPORTED_ALGORITHMS, and runs per-algorithm validation +// (deriveBits length validators, HMAC+SHA3 generateKey rejection). +// `op` is the operation key in SUPPORTED_ALGORITHMS — wrapKey/unwrapKey fall +// back to encrypt/decrypt to mirror Node's normalize fallback. +function supportsCheck( + op: string, + alg: SubtleAlgorithm | AnyAlgorithm, + length?: number | null, +): boolean { + let normalizedAlgorithm: SubtleAlgorithm; + try { + normalizedAlgorithm = normalizeAlgorithm(alg, op as Operation); + } catch { + if (op === 'wrapKey') return supportsCheck('encrypt', alg); + if (op === 'unwrapKey') return supportsCheck('decrypt', alg); + return false; + } + + const supported = SUPPORTED_ALGORITHMS[op]; + if (!supported || !supported.has(normalizedAlgorithm.name)) { + if (op === 'wrapKey') return supportsCheck('encrypt', alg); + if (op === 'unwrapKey') return supportsCheck('decrypt', alg); + return false; + } + + if (op === 'deriveBits') { + const name = normalizedAlgorithm.name; + if (name === 'HKDF' || name === 'PBKDF2' || name.startsWith('Argon2')) { + try { + validateKdfDeriveBitsLength(length, name); + } catch { + return false; + } + } + } + + if (op === 'generateKey' && normalizedAlgorithm.name === 'HMAC') { + const hashName = + typeof normalizedAlgorithm.hash === 'string' + ? normalizedAlgorithm.hash + : (normalizedAlgorithm.hash as { name?: string } | undefined)?.name; + if ( + normalizedAlgorithm.length === undefined && + typeof hashName === 'string' && + hashName.startsWith('SHA3-') + ) { return false; } + } - const name = normalizedAlgorithm.name; + return true; +} - if (operation === 'getPublicKey') { - return ASYMMETRIC_ALGORITHMS.has(name); +function supportsImpl( + operation: string, + algorithm: SubtleAlgorithm | AnyAlgorithm, + lengthOrAdditionalAlgorithm: unknown, +): boolean { + switch (operation) { + case 'decapsulateBits': + case 'decapsulateKey': + case 'decrypt': + case 'deriveBits': + case 'deriveKey': + case 'digest': + case 'encapsulateBits': + case 'encapsulateKey': + case 'encrypt': + case 'exportKey': + case 'generateKey': + case 'getPublicKey': + case 'importKey': + case 'sign': + case 'unwrapKey': + case 'verify': + case 'wrapKey': + break; + default: + return false; + } + + let length: number | null | undefined; + + if (operation === 'deriveKey') { + // deriveKey decomposes to importKey of derived alg + deriveBits with that + // alg's key length. Node webcrypto.js:1547-1563. + if (lengthOrAdditionalAlgorithm != null) { + const additional = lengthOrAdditionalAlgorithm as + | SubtleAlgorithm + | AnyAlgorithm; + if (!supportsCheck('importKey', additional)) return false; + try { + length = getKeyLength(normalizeAlgorithm(additional, 'get key length')); + } catch { + return false; + } + return supportsCheck('deriveBits', algorithm, length); } + // No additional algorithm given — only check the deriveBits side. + return supportsCheck('deriveBits', algorithm); + } - if (operation === 'deriveKey') { - // deriveKey decomposes to deriveBits + importKey of additional algorithm - if (!SUPPORTED_ALGORITHMS.deriveBits?.has(name)) return false; - if (_lengthOrAdditionalAlgorithm != null) { - try { - const additionalAlg = normalizeAlgorithm( - _lengthOrAdditionalAlgorithm as SubtleAlgorithm | AnyAlgorithm, - 'importKey', - ); - return ( - SUPPORTED_ALGORITHMS.importKey?.has(additionalAlg.name) ?? false - ); - } catch { - return false; - } + if (operation === 'wrapKey') { + // wrapKey decomposes to encrypt of wrapping alg + exportKey of wrapped alg. + // Node webcrypto.js:1564-1572. + if (lengthOrAdditionalAlgorithm != null) { + const additional = lengthOrAdditionalAlgorithm as + | SubtleAlgorithm + | AnyAlgorithm; + if (!supportsCheck('exportKey', additional)) return false; + } + return supportsCheck('wrapKey', algorithm); + } + + if (operation === 'unwrapKey') { + // unwrapKey decomposes to decrypt of wrapping alg + importKey of wrapped + // alg. Node webcrypto.js:1573-1581. + if (lengthOrAdditionalAlgorithm != null) { + const additional = lengthOrAdditionalAlgorithm as + | SubtleAlgorithm + | AnyAlgorithm; + if (!supportsCheck('importKey', additional)) return false; + } + return supportsCheck('unwrapKey', algorithm); + } + + if (operation === 'deriveBits') { + if (lengthOrAdditionalAlgorithm == null) { + length = null; + } else if (typeof lengthOrAdditionalAlgorithm === 'number') { + length = lengthOrAdditionalAlgorithm; + } else { + return false; + } + return supportsCheck('deriveBits', algorithm, length); + } + + if (operation === 'getPublicKey') { + let normalized: SubtleAlgorithm; + try { + normalized = normalizeAlgorithm(algorithm, 'exportKey'); + } catch { + return false; + } + return ASYMMETRIC_ALGORITHMS.has(normalized.name); + } + + if (operation === 'encapsulateKey' || operation === 'decapsulateKey') { + // sharedKeyAlgorithm must support importKey, with HMAC/KMAC limited to + // length === undefined or 256 (Node webcrypto.js:1610-1645). + const additional = lengthOrAdditionalAlgorithm as + | SubtleAlgorithm + | AnyAlgorithm; + let normalizedAdd: SubtleAlgorithm; + try { + normalizedAdd = normalizeAlgorithm(additional, 'importKey'); + } catch { + return false; + } + switch (normalizedAdd.name) { + case 'AES-OCB': + case 'AES-KW': + case 'AES-GCM': + case 'AES-CTR': + case 'AES-CBC': + case 'ChaCha20-Poly1305': + case 'HKDF': + case 'PBKDF2': + case 'Argon2i': + case 'Argon2d': + case 'Argon2id': + break; + case 'HMAC': + case 'KMAC128': + case 'KMAC256': { + const addLen = normalizedAdd.length; + if (addLen !== undefined && addLen !== 256) return false; + break; } - return true; + default: + return false; } + return supportsCheck(operation, algorithm); + } + + return supportsCheck(operation, algorithm); +} - const supported = SUPPORTED_ALGORITHMS[operation]; - if (!supported) return false; - return supported.has(name); +export class Subtle { + // Spec-compliant capability detector. Mirrors Node's webcrypto.js:1506-1649 + // `SubtleCrypto.supports`, including: + // • composed-operation decomposition (deriveKey, wrapKey, unwrapKey, + // encapsulateKey, decapsulateKey, getPublicKey) + // • per-algorithm length validators for deriveBits (HKDF/PBKDF2/Argon2) + // • HMAC + SHA3 generateKey with no length → false + // Static-only per the WICG spec. + static supports( + operation: string, + algorithm: SubtleAlgorithm | AnyAlgorithm, + lengthOrAdditionalAlgorithm: unknown = null, + ): boolean { + return supportsImpl(operation, algorithm, lengthOrAdditionalAlgorithm); } async decrypt( diff --git a/packages/react-native-quick-crypto/src/utils/types.ts b/packages/react-native-quick-crypto/src/utils/types.ts index 797189e1..83562b94 100644 --- a/packages/react-native-quick-crypto/src/utils/types.ts +++ b/packages/react-native-quick-crypto/src/utils/types.ts @@ -245,8 +245,9 @@ export type SubtleAlgorithm = { secretValue?: BufferLike; associatedData?: BufferLike; version?: number; - // KMAC parameters + // KMAC / cSHAKE parameters customization?: BufferLike; + outputLength?: number; }; export type KeyPairType = @@ -532,7 +533,8 @@ export type Operation = | 'encapsulateBits' | 'decapsulateBits' | 'encapsulateKey' - | 'decapsulateKey'; + | 'decapsulateKey' + | 'get key length'; export interface KeyPairOptions { namedCurve: string;