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
49 changes: 49 additions & 0 deletions .claude/rules/code-typescript.xml
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,53 @@
Never use npm, yarn, or pnpm. Always use bun for install, add, remove, etc.
</instructions>
</rule>

<rule severity="CRITICAL" enforcement="BLOCKING">
<name>Chai Assertions and ESLint Compliance</name>
<description>Write Chai test assertions that pass ESLint @typescript-eslint/no-unused-expressions rule</description>
<rationale>
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.
</rationale>
<mustAcknowledge>true</mustAcknowledge>
<instructions>
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
</instructions>
<example>
<bad>
// DON'T: This triggers @typescript-eslint/no-unused-expressions
expect(exportedPub.n?.endsWith('.')).to.be.false;
</bad>
<good>
// 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);
</good>
</example>
<commonMistake>
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.
</commonMistake>
</rule>
</rules>
2 changes: 1 addition & 1 deletion example/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions example/src/hooks/useTestsList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (): [
Expand Down
6 changes: 3 additions & 3 deletions example/src/tests/subtle/import_export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
},
};
Expand Down
141 changes: 141 additions & 0 deletions example/src/tests/subtle/jwk_rfc7517_tests.ts
Original file line number Diff line number Diff line change
@@ -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');
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::string>(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<std::string>(buffer.data(), buffer.size(), true);
}

// Helper to add padding to base64url strings
Expand Down Expand Up @@ -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<const unsigned char*>(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<const unsigned char*>(symKey->data()), symKey->size());
return result;
}

Expand Down