Skip to content

Commit 21a5183

Browse files
authored
fix: base64 padding (#824)
1 parent 862a205 commit 21a5183

File tree

6 files changed

+199
-22
lines changed

6 files changed

+199
-22
lines changed

.claude/rules/code-typescript.xml

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,4 +105,53 @@
105105
Never use npm, yarn, or pnpm. Always use bun for install, add, remove, etc.
106106
</instructions>
107107
</rule>
108+
109+
<rule severity="CRITICAL" enforcement="BLOCKING">
110+
<name>Chai Assertions and ESLint Compliance</name>
111+
<description>Write Chai test assertions that pass ESLint @typescript-eslint/no-unused-expressions rule</description>
112+
<rationale>
113+
The @typescript-eslint/no-unused-expressions rule treats standalone expect() statements as errors.
114+
You MUST use assertion patterns that don't trigger this linting error.
115+
</rationale>
116+
<mustAcknowledge>true</mustAcknowledge>
117+
<instructions>
118+
WINNING PATTERNS (these work):
119+
1. Use .to.equal(), .to.match(), or other comparison assertions
120+
2. Use assert.isFalse(), assert.isTrue() instead of expect().to.be.false
121+
122+
FAILING PATTERNS (DO NOT USE):
123+
- expect(value?.endsWith('.')).to.be.false ❌ Triggers linting error
124+
- expect(value).to.not.be.undefined ❌ Triggers linting error
125+
- expect(value).to.exist ❌ Triggers linting error
126+
127+
CORRECT PATTERNS:
128+
- expect(value).to.equal('expected') ✅ Works
129+
- expect(value).to.match(/^[A-Za-z0-9_-]+$/) ✅ Works
130+
- assert.isFalse(value?.endsWith('.')) ✅ Works
131+
- expect(value.type).to.equal('public') ✅ Works
132+
</instructions>
133+
<example>
134+
<bad>
135+
// DON'T: This triggers @typescript-eslint/no-unused-expressions
136+
expect(exportedPub.n?.endsWith('.')).to.be.false;
137+
</bad>
138+
<good>
139+
// DO: Use regex match instead
140+
expect(exportedPub.n).to.match(/^[A-Za-z0-9_-]+$/);
141+
142+
// OR: Use assert.isFalse
143+
assert.isFalse(exportedPub.n?.endsWith('.'));
144+
145+
// OR: Test for expected value instead
146+
expect(exportedPub.n?.endsWith('.')).to.equal(false);
147+
</good>
148+
</example>
149+
<commonMistake>
150+
You will try to fix linting errors by adding message parameters like:
151+
expect(value?.endsWith('.'), 'should not end with period').to.be.false
152+
153+
THIS STILL FAILS! The message parameter doesn't fix the linting error.
154+
Use the CORRECT PATTERNS above instead.
155+
</commonMistake>
156+
</rule>
108157
</rules>

example/ios/Podfile.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2746,7 +2746,7 @@ SPEC CHECKSUMS:
27462746
hermes-engine: 4f8246b1f6d79f625e0d99472d1f3a71da4d28ca
27472747
NitroModules: 1715fe0e22defd9e2cdd48fb5e0dbfd01af54bec
27482748
OpenSSL-Universal: 6082b0bf950e5636fe0d78def171184e2b3899c2
2749-
QuickCrypto: e9aa5975fe95267d5e2b5db26a8034b257c5c8c4
2749+
QuickCrypto: 64c608e53920dc58536312851e75db4dae46a185
27502750
RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669
27512751
RCTDeprecation: c4b9e2fd0ab200e3af72b013ed6113187c607077
27522752
RCTRequired: e97dd5dafc1db8094e63bc5031e0371f092ae92a

example/src/hooks/useTestsList.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import '../tests/subtle/digest';
1717
// import '../tests/subtle/encrypt_decrypt';
1818
import '../tests/subtle/generateKey';
1919
import '../tests/subtle/import_export';
20+
import '../tests/subtle/jwk_rfc7517_tests';
2021
import '../tests/subtle/sign_verify';
2122

2223
export const useTestsList = (): [

example/src/tests/subtle/import_export.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -461,9 +461,9 @@ test(SUITE, 'EC import raw / export spki (osp)', async () => {
461461
jwk: {
462462
kty: 'EC',
463463
crv: 'P-256',
464-
x: '1ugyipX-Ka_Nwwl3uSUe-7IZAigH9rFLs0aVtrS9uT4.',
465-
y: '5mhFSKStE8SdAEM8RTFegnTzVA9Y9dee96GxhPTCHRc.',
466-
d: 'K8LtomXkaGbvqPj5namTF1tshcJG4V3OrtfjBw8T-_g.',
464+
x: '1ugyipX-Ka_Nwwl3uSUe-7IZAigH9rFLs0aVtrS9uT4',
465+
y: '5mhFSKStE8SdAEM8RTFegnTzVA9Y9dee96GxhPTCHRc',
466+
d: 'K8LtomXkaGbvqPj5namTF1tshcJG4V3OrtfjBw8T-_g',
467467
},
468468
},
469469
};
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { expect } from 'chai';
2+
import type { CryptoKey, CryptoKeyPair, JWK } from 'react-native-quick-crypto';
3+
import { subtle } from 'react-native-quick-crypto';
4+
import { test } from '../util';
5+
6+
const SUITE = 'subtle.importKey/exportKey';
7+
8+
// Issue #806: Ensure JWK exports are RFC 7517 compliant (valid base64url, no periods)
9+
test(SUITE, 'JWK export - RFC 7517 - RSA-OAEP', async () => {
10+
const { publicKey, privateKey } = (await subtle.generateKey(
11+
{
12+
name: 'RSA-OAEP',
13+
modulusLength: 2048,
14+
publicExponent: new Uint8Array([1, 0, 1]),
15+
hash: 'SHA-256',
16+
},
17+
true,
18+
['encrypt', 'decrypt'],
19+
)) as CryptoKeyPair;
20+
21+
const exportedPub = (await subtle.exportKey('jwk', publicKey)) as JWK;
22+
expect(exportedPub.n).to.match(/^[A-Za-z0-9_-]+$/);
23+
expect(exportedPub.e).to.match(/^[A-Za-z0-9_-]+$/);
24+
25+
const exportedPriv = (await subtle.exportKey('jwk', privateKey)) as JWK;
26+
expect(exportedPriv.n).to.match(/^[A-Za-z0-9_-]+$/);
27+
expect(exportedPriv.e).to.match(/^[A-Za-z0-9_-]+$/);
28+
expect(exportedPriv.d).to.match(/^[A-Za-z0-9_-]+$/);
29+
expect(exportedPriv.p).to.match(/^[A-Za-z0-9_-]+$/);
30+
expect(exportedPriv.q).to.match(/^[A-Za-z0-9_-]+$/);
31+
expect(exportedPriv.dp).to.match(/^[A-Za-z0-9_-]+$/);
32+
expect(exportedPriv.dq).to.match(/^[A-Za-z0-9_-]+$/);
33+
expect(exportedPriv.qi).to.match(/^[A-Za-z0-9_-]+$/);
34+
35+
// Verify roundtrip
36+
const imported = await subtle.importKey(
37+
'jwk',
38+
exportedPriv,
39+
{ name: 'RSA-OAEP', hash: 'SHA-256' },
40+
true,
41+
['decrypt'],
42+
);
43+
expect(imported.type).to.equal('private');
44+
});
45+
46+
test(SUITE, 'JWK export - RFC 7517 - ECDSA P-256', async () => {
47+
const { publicKey, privateKey } = (await subtle.generateKey(
48+
{ name: 'ECDSA', namedCurve: 'P-256' },
49+
true,
50+
['sign', 'verify'],
51+
)) as CryptoKeyPair;
52+
53+
const exportedPub = (await subtle.exportKey('jwk', publicKey)) as JWK;
54+
expect(exportedPub.x).to.match(/^[A-Za-z0-9_-]+$/);
55+
expect(exportedPub.y).to.match(/^[A-Za-z0-9_-]+$/);
56+
57+
const exportedPriv = (await subtle.exportKey('jwk', privateKey)) as JWK;
58+
expect(exportedPriv.x).to.match(/^[A-Za-z0-9_-]+$/);
59+
expect(exportedPriv.y).to.match(/^[A-Za-z0-9_-]+$/);
60+
expect(exportedPriv.d).to.match(/^[A-Za-z0-9_-]+$/);
61+
62+
const imported = await subtle.importKey(
63+
'jwk',
64+
exportedPriv,
65+
{ name: 'ECDSA', namedCurve: 'P-256' },
66+
true,
67+
['sign'],
68+
);
69+
expect(imported.type).to.equal('private');
70+
});
71+
72+
test(SUITE, 'JWK export - RFC 7517 - ECDSA P-384', async () => {
73+
const { privateKey } = (await subtle.generateKey(
74+
{ name: 'ECDSA', namedCurve: 'P-384' },
75+
true,
76+
['sign', 'verify'],
77+
)) as CryptoKeyPair;
78+
79+
const exported = (await subtle.exportKey('jwk', privateKey)) as JWK;
80+
expect(exported.x).to.match(/^[A-Za-z0-9_-]+$/);
81+
expect(exported.y).to.match(/^[A-Za-z0-9_-]+$/);
82+
expect(exported.d).to.match(/^[A-Za-z0-9_-]+$/);
83+
});
84+
85+
test(SUITE, 'JWK export - RFC 7517 - ECDSA P-521', async () => {
86+
const { privateKey } = (await subtle.generateKey(
87+
{ name: 'ECDSA', namedCurve: 'P-521' },
88+
true,
89+
['sign', 'verify'],
90+
)) as CryptoKeyPair;
91+
92+
const exported = (await subtle.exportKey('jwk', privateKey)) as JWK;
93+
expect(exported.x).to.match(/^[A-Za-z0-9_-]+$/);
94+
expect(exported.y).to.match(/^[A-Za-z0-9_-]+$/);
95+
expect(exported.d).to.match(/^[A-Za-z0-9_-]+$/);
96+
});
97+
98+
test(SUITE, 'JWK export - RFC 7517 - ECDH P-256', async () => {
99+
const { privateKey } = (await subtle.generateKey(
100+
{ name: 'ECDH', namedCurve: 'P-256' },
101+
true,
102+
['deriveKey'],
103+
)) as CryptoKeyPair;
104+
105+
const exported = (await subtle.exportKey('jwk', privateKey)) as JWK;
106+
expect(exported.x).to.match(/^[A-Za-z0-9_-]+$/);
107+
expect(exported.y).to.match(/^[A-Za-z0-9_-]+$/);
108+
expect(exported.d).to.match(/^[A-Za-z0-9_-]+$/);
109+
});
110+
111+
// Test exact scenario from issue #806
112+
test(SUITE, 'JWK export - issue #806 - no trailing periods', async () => {
113+
const { privateKey } = (await subtle.generateKey(
114+
{
115+
name: 'RSA-OAEP',
116+
modulusLength: 2048,
117+
publicExponent: new Uint8Array([1, 0, 1]),
118+
hash: 'SHA-256',
119+
},
120+
true,
121+
['encrypt', 'decrypt'],
122+
)) as CryptoKeyPair;
123+
124+
const jwk = (await subtle.exportKey('jwk', privateKey as CryptoKey)) as JWK;
125+
126+
// All fields must be valid base64url (only A-Za-z0-9_-)
127+
const fields = ['n', 'e', 'd', 'p', 'q', 'dp', 'dq', 'qi'] as const;
128+
for (const field of fields) {
129+
expect(jwk[field]).to.match(/^[A-Za-z0-9_-]+$/);
130+
}
131+
132+
// Critical: can we import this JWK?
133+
const imported = await subtle.importKey(
134+
'jwk',
135+
jwk,
136+
{ name: 'RSA-OAEP', hash: 'SHA-256' },
137+
true,
138+
['decrypt'],
139+
);
140+
expect(imported.type).to.equal('private');
141+
});

packages/react-native-quick-crypto/cpp/keys/HybridKeyObjectHandle.cpp

Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,8 @@ static std::string bn_to_base64url(const BIGNUM* bn, size_t expected_size = 0) {
3030
size_t offset = buffer_size - num_bytes;
3131
BN_bn2bin(bn, buffer.data() + offset);
3232

33-
std::string encoded = base64_encode<std::string>(buffer.data(), buffer.size(), true);
34-
35-
// Some JWK implementations use '.' instead of '=' for padding
36-
// Add trailing period if length % 4 == 3 (would need one '=' in standard base64)
37-
if (encoded.length() % 4 == 3) {
38-
encoded += '.';
39-
}
40-
41-
return encoded;
33+
// Return clean base64url - RFC 7517 compliant (no padding characters)
34+
return base64_encode<std::string>(buffer.data(), buffer.size(), true);
4235
}
4336

4437
// Helper to add padding to base64url strings
@@ -187,15 +180,8 @@ JWK HybridKeyObjectHandle::exportJwk(const JWK& key, bool handleRsaPss) {
187180
if (keyType == KeyType::SECRET) {
188181
auto symKey = data_.GetSymmetricKey();
189182
result.kty = JWKkty::OCT;
190-
std::string encoded = base64url_encode(reinterpret_cast<const unsigned char*>(symKey->data()), symKey->size());
191-
192-
// Some JWK implementations use '.' instead of '=' for padding
193-
// Add trailing period if length % 4 == 3 (would need one '=' in standard base64)
194-
if (encoded.length() % 4 == 3) {
195-
encoded += '.';
196-
}
197-
198-
result.k = encoded;
183+
// RFC 7517 compliant base64url encoding (no padding characters)
184+
result.k = base64url_encode(reinterpret_cast<const unsigned char*>(symKey->data()), symKey->size());
199185
return result;
200186
}
201187

0 commit comments

Comments
 (0)