diff --git a/.claude/rules/code-typescript.xml b/.claude/rules/code-typescript.xml index 9505cf37..d087e394 100644 --- a/.claude/rules/code-typescript.xml +++ b/.claude/rules/code-typescript.xml @@ -105,4 +105,53 @@ Never use npm, yarn, or pnpm. Always use bun for install, add, remove, etc. + + + Chai Assertions and ESLint Compliance + Write Chai test assertions that pass ESLint @typescript-eslint/no-unused-expressions rule + + The @typescript-eslint/no-unused-expressions rule treats standalone expect() statements as errors. + You MUST use assertion patterns that don't trigger this linting error. + + true + + WINNING PATTERNS (these work): + 1. Use .to.equal(), .to.match(), or other comparison assertions + 2. Use assert.isFalse(), assert.isTrue() instead of expect().to.be.false + + FAILING PATTERNS (DO NOT USE): + - expect(value?.endsWith('.')).to.be.false ❌ Triggers linting error + - expect(value).to.not.be.undefined ❌ Triggers linting error + - expect(value).to.exist ❌ Triggers linting error + + CORRECT PATTERNS: + - expect(value).to.equal('expected') ✅ Works + - expect(value).to.match(/^[A-Za-z0-9_-]+$/) ✅ Works + - assert.isFalse(value?.endsWith('.')) ✅ Works + - expect(value.type).to.equal('public') ✅ Works + + + + // DON'T: This triggers @typescript-eslint/no-unused-expressions + expect(exportedPub.n?.endsWith('.')).to.be.false; + + + // DO: Use regex match instead + expect(exportedPub.n).to.match(/^[A-Za-z0-9_-]+$/); + + // OR: Use assert.isFalse + assert.isFalse(exportedPub.n?.endsWith('.')); + + // OR: Test for expected value instead + expect(exportedPub.n?.endsWith('.')).to.equal(false); + + + + You will try to fix linting errors by adding message parameters like: + expect(value?.endsWith('.'), 'should not end with period').to.be.false + + THIS STILL FAILS! The message parameter doesn't fix the linting error. + Use the CORRECT PATTERNS above instead. + + diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 56dcf18a..e268725f 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -2746,7 +2746,7 @@ SPEC CHECKSUMS: hermes-engine: 4f8246b1f6d79f625e0d99472d1f3a71da4d28ca NitroModules: 1715fe0e22defd9e2cdd48fb5e0dbfd01af54bec OpenSSL-Universal: 6082b0bf950e5636fe0d78def171184e2b3899c2 - QuickCrypto: e9aa5975fe95267d5e2b5db26a8034b257c5c8c4 + QuickCrypto: 64c608e53920dc58536312851e75db4dae46a185 RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 RCTDeprecation: c4b9e2fd0ab200e3af72b013ed6113187c607077 RCTRequired: e97dd5dafc1db8094e63bc5031e0371f092ae92a diff --git a/example/src/hooks/useTestsList.ts b/example/src/hooks/useTestsList.ts index c99444c9..61ea2043 100644 --- a/example/src/hooks/useTestsList.ts +++ b/example/src/hooks/useTestsList.ts @@ -17,6 +17,7 @@ import '../tests/subtle/digest'; // import '../tests/subtle/encrypt_decrypt'; import '../tests/subtle/generateKey'; import '../tests/subtle/import_export'; +import '../tests/subtle/jwk_rfc7517_tests'; import '../tests/subtle/sign_verify'; export const useTestsList = (): [ diff --git a/example/src/tests/subtle/import_export.ts b/example/src/tests/subtle/import_export.ts index 86d10427..6812237f 100644 --- a/example/src/tests/subtle/import_export.ts +++ b/example/src/tests/subtle/import_export.ts @@ -461,9 +461,9 @@ test(SUITE, 'EC import raw / export spki (osp)', async () => { jwk: { kty: 'EC', crv: 'P-256', - x: '1ugyipX-Ka_Nwwl3uSUe-7IZAigH9rFLs0aVtrS9uT4.', - y: '5mhFSKStE8SdAEM8RTFegnTzVA9Y9dee96GxhPTCHRc.', - d: 'K8LtomXkaGbvqPj5namTF1tshcJG4V3OrtfjBw8T-_g.', + x: '1ugyipX-Ka_Nwwl3uSUe-7IZAigH9rFLs0aVtrS9uT4', + y: '5mhFSKStE8SdAEM8RTFegnTzVA9Y9dee96GxhPTCHRc', + d: 'K8LtomXkaGbvqPj5namTF1tshcJG4V3OrtfjBw8T-_g', }, }, }; diff --git a/example/src/tests/subtle/jwk_rfc7517_tests.ts b/example/src/tests/subtle/jwk_rfc7517_tests.ts new file mode 100644 index 00000000..8bb98b4f --- /dev/null +++ b/example/src/tests/subtle/jwk_rfc7517_tests.ts @@ -0,0 +1,141 @@ +import { expect } from 'chai'; +import type { CryptoKey, CryptoKeyPair, JWK } from 'react-native-quick-crypto'; +import { subtle } from 'react-native-quick-crypto'; +import { test } from '../util'; + +const SUITE = 'subtle.importKey/exportKey'; + +// Issue #806: Ensure JWK exports are RFC 7517 compliant (valid base64url, no periods) +test(SUITE, 'JWK export - RFC 7517 - RSA-OAEP', async () => { + const { publicKey, privateKey } = (await subtle.generateKey( + { + name: 'RSA-OAEP', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, + true, + ['encrypt', 'decrypt'], + )) as CryptoKeyPair; + + const exportedPub = (await subtle.exportKey('jwk', publicKey)) as JWK; + expect(exportedPub.n).to.match(/^[A-Za-z0-9_-]+$/); + expect(exportedPub.e).to.match(/^[A-Za-z0-9_-]+$/); + + const exportedPriv = (await subtle.exportKey('jwk', privateKey)) as JWK; + expect(exportedPriv.n).to.match(/^[A-Za-z0-9_-]+$/); + expect(exportedPriv.e).to.match(/^[A-Za-z0-9_-]+$/); + expect(exportedPriv.d).to.match(/^[A-Za-z0-9_-]+$/); + expect(exportedPriv.p).to.match(/^[A-Za-z0-9_-]+$/); + expect(exportedPriv.q).to.match(/^[A-Za-z0-9_-]+$/); + expect(exportedPriv.dp).to.match(/^[A-Za-z0-9_-]+$/); + expect(exportedPriv.dq).to.match(/^[A-Za-z0-9_-]+$/); + expect(exportedPriv.qi).to.match(/^[A-Za-z0-9_-]+$/); + + // Verify roundtrip + const imported = await subtle.importKey( + 'jwk', + exportedPriv, + { name: 'RSA-OAEP', hash: 'SHA-256' }, + true, + ['decrypt'], + ); + expect(imported.type).to.equal('private'); +}); + +test(SUITE, 'JWK export - RFC 7517 - ECDSA P-256', async () => { + const { publicKey, privateKey } = (await subtle.generateKey( + { name: 'ECDSA', namedCurve: 'P-256' }, + true, + ['sign', 'verify'], + )) as CryptoKeyPair; + + const exportedPub = (await subtle.exportKey('jwk', publicKey)) as JWK; + expect(exportedPub.x).to.match(/^[A-Za-z0-9_-]+$/); + expect(exportedPub.y).to.match(/^[A-Za-z0-9_-]+$/); + + const exportedPriv = (await subtle.exportKey('jwk', privateKey)) as JWK; + expect(exportedPriv.x).to.match(/^[A-Za-z0-9_-]+$/); + expect(exportedPriv.y).to.match(/^[A-Za-z0-9_-]+$/); + expect(exportedPriv.d).to.match(/^[A-Za-z0-9_-]+$/); + + const imported = await subtle.importKey( + 'jwk', + exportedPriv, + { name: 'ECDSA', namedCurve: 'P-256' }, + true, + ['sign'], + ); + expect(imported.type).to.equal('private'); +}); + +test(SUITE, 'JWK export - RFC 7517 - ECDSA P-384', async () => { + const { privateKey } = (await subtle.generateKey( + { name: 'ECDSA', namedCurve: 'P-384' }, + true, + ['sign', 'verify'], + )) as CryptoKeyPair; + + const exported = (await subtle.exportKey('jwk', privateKey)) as JWK; + expect(exported.x).to.match(/^[A-Za-z0-9_-]+$/); + expect(exported.y).to.match(/^[A-Za-z0-9_-]+$/); + expect(exported.d).to.match(/^[A-Za-z0-9_-]+$/); +}); + +test(SUITE, 'JWK export - RFC 7517 - ECDSA P-521', async () => { + const { privateKey } = (await subtle.generateKey( + { name: 'ECDSA', namedCurve: 'P-521' }, + true, + ['sign', 'verify'], + )) as CryptoKeyPair; + + const exported = (await subtle.exportKey('jwk', privateKey)) as JWK; + expect(exported.x).to.match(/^[A-Za-z0-9_-]+$/); + expect(exported.y).to.match(/^[A-Za-z0-9_-]+$/); + expect(exported.d).to.match(/^[A-Za-z0-9_-]+$/); +}); + +test(SUITE, 'JWK export - RFC 7517 - ECDH P-256', async () => { + const { privateKey } = (await subtle.generateKey( + { name: 'ECDH', namedCurve: 'P-256' }, + true, + ['deriveKey'], + )) as CryptoKeyPair; + + const exported = (await subtle.exportKey('jwk', privateKey)) as JWK; + expect(exported.x).to.match(/^[A-Za-z0-9_-]+$/); + expect(exported.y).to.match(/^[A-Za-z0-9_-]+$/); + expect(exported.d).to.match(/^[A-Za-z0-9_-]+$/); +}); + +// Test exact scenario from issue #806 +test(SUITE, 'JWK export - issue #806 - no trailing periods', async () => { + const { privateKey } = (await subtle.generateKey( + { + name: 'RSA-OAEP', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, + true, + ['encrypt', 'decrypt'], + )) as CryptoKeyPair; + + const jwk = (await subtle.exportKey('jwk', privateKey as CryptoKey)) as JWK; + + // All fields must be valid base64url (only A-Za-z0-9_-) + const fields = ['n', 'e', 'd', 'p', 'q', 'dp', 'dq', 'qi'] as const; + for (const field of fields) { + expect(jwk[field]).to.match(/^[A-Za-z0-9_-]+$/); + } + + // Critical: can we import this JWK? + const imported = await subtle.importKey( + 'jwk', + jwk, + { name: 'RSA-OAEP', hash: 'SHA-256' }, + true, + ['decrypt'], + ); + expect(imported.type).to.equal('private'); +}); diff --git a/packages/react-native-quick-crypto/cpp/keys/HybridKeyObjectHandle.cpp b/packages/react-native-quick-crypto/cpp/keys/HybridKeyObjectHandle.cpp index 27cad486..19fd953c 100644 --- a/packages/react-native-quick-crypto/cpp/keys/HybridKeyObjectHandle.cpp +++ b/packages/react-native-quick-crypto/cpp/keys/HybridKeyObjectHandle.cpp @@ -30,15 +30,8 @@ static std::string bn_to_base64url(const BIGNUM* bn, size_t expected_size = 0) { size_t offset = buffer_size - num_bytes; BN_bn2bin(bn, buffer.data() + offset); - std::string encoded = base64_encode(buffer.data(), buffer.size(), true); - - // Some JWK implementations use '.' instead of '=' for padding - // Add trailing period if length % 4 == 3 (would need one '=' in standard base64) - if (encoded.length() % 4 == 3) { - encoded += '.'; - } - - return encoded; + // Return clean base64url - RFC 7517 compliant (no padding characters) + return base64_encode(buffer.data(), buffer.size(), true); } // Helper to add padding to base64url strings @@ -187,15 +180,8 @@ JWK HybridKeyObjectHandle::exportJwk(const JWK& key, bool handleRsaPss) { if (keyType == KeyType::SECRET) { auto symKey = data_.GetSymmetricKey(); result.kty = JWKkty::OCT; - std::string encoded = base64url_encode(reinterpret_cast(symKey->data()), symKey->size()); - - // Some JWK implementations use '.' instead of '=' for padding - // Add trailing period if length % 4 == 3 (would need one '=' in standard base64) - if (encoded.length() % 4 == 3) { - encoded += '.'; - } - - result.k = encoded; + // RFC 7517 compliant base64url encoding (no padding characters) + result.k = base64url_encode(reinterpret_cast(symKey->data()), symKey->size()); return result; }