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;
}