Skip to content

Commit e040d63

Browse files
authored
feat: implement ML-KEM encapsulate/decapsulate (#941)
1 parent 674a146 commit e040d63

26 files changed

Lines changed: 1521 additions & 42 deletions

.docs/implementation-coverage.md

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -125,9 +125,9 @@ These algorithms provide quantum-resistant cryptography.
125125
-`crypto.createSecretKey(key[, encoding])`
126126
-`crypto.createSign(algorithm[, options])`
127127
-`crypto.createVerify(algorithm[, options])`
128-
- `crypto.decapsulate(key, ciphertext[, callback])`
128+
- `crypto.decapsulate(key, ciphertext[, callback])`
129129
-`crypto.diffieHellman(options[, callback])`
130-
- `crypto.encapsulate(key[, callback])`
130+
- `crypto.encapsulate(key[, callback])`
131131
- `-` `crypto.fips` deprecated, not applicable to RN
132132
-`crypto.generateKey(type, options, callback)`
133133
-`crypto.generateKeyPair(type, options, callback)`
@@ -272,14 +272,14 @@ These ciphers are **not available in Node.js** but are provided by RNQC via libs
272272

273273
- 🚧 Class: `SubtleCrypto`
274274
- ✅ static `supports(operation, algorithm[, lengthOrAdditionalAlgorithm])`
275-
- `subtle.decapsulateBits(decapsulationAlgorithm, decapsulationKey, ciphertext)`
276-
- `subtle.decapsulateKey(decapsulationAlgorithm, decapsulationKey, ciphertext, sharedKeyAlgorithm, extractable, usages)`
275+
- `subtle.decapsulateBits(decapsulationAlgorithm, decapsulationKey, ciphertext)`
276+
- `subtle.decapsulateKey(decapsulationAlgorithm, decapsulationKey, ciphertext, sharedKeyAlgorithm, extractable, usages)`
277277
-`subtle.decrypt(algorithm, key, data)`
278278
-`subtle.deriveBits(algorithm, baseKey, length)`
279279
-`subtle.deriveKey(algorithm, baseKey, derivedKeyAlgorithm, extractable, keyUsages)`
280280
- 🚧 `subtle.digest(algorithm, data)`
281-
- `subtle.encapsulateBits(encapsulationAlgorithm, encapsulationKey)`
282-
- `subtle.encapsulateKey(encapsulationAlgorithm, encapsulationKey, sharedKeyAlgorithm, extractable, usages)`
281+
- `subtle.encapsulateBits(encapsulationAlgorithm, encapsulationKey)`
282+
- `subtle.encapsulateKey(encapsulationAlgorithm, encapsulationKey, sharedKeyAlgorithm, extractable, usages)`
283283
- 🚧 `subtle.encrypt(algorithm, key, data)`
284284
- 🚧 `subtle.exportKey(format, key)`
285285
- 🚧 `subtle.generateKey(algorithm, extractable, keyUsages)`
@@ -370,9 +370,9 @@ These ciphers are **not available in Node.js** but are provided by RNQC via libs
370370
| `ML-DSA-44` |||| | |||
371371
| `ML-DSA-65` |||| | |||
372372
| `ML-DSA-87` |||| | |||
373-
| `ML-KEM-512` | | | | | |||
374-
| `ML-KEM-768` | | | | | |||
375-
| `ML-KEM-1024` | | | | | |||
373+
| `ML-KEM-512` | | | | | |||
374+
| `ML-KEM-768` | | | | | |||
375+
| `ML-KEM-1024` | | | | | |||
376376
| `RSA-OAEP` |||| | | | |
377377
| `RSA-PSS` |||| | | | |
378378
| `RSASSA-PKCS1-v1_5` |||| | | | |
@@ -394,9 +394,9 @@ These ciphers are **not available in Node.js** but are provided by RNQC via libs
394394
| `ML-DSA-44` ||
395395
| `ML-DSA-65` ||
396396
| `ML-DSA-87` ||
397-
| `ML-KEM-512` | |
398-
| `ML-KEM-768` | |
399-
| `ML-KEM-1024` | |
397+
| `ML-KEM-512` | |
398+
| `ML-KEM-768` | |
399+
| `ML-KEM-1024` | |
400400
| `RSA-OAEP` ||
401401
| `RSA-PSS` ||
402402
| `RSASSA-PKCS1-v1_5` ||
@@ -439,9 +439,9 @@ These ciphers are **not available in Node.js** but are provided by RNQC via libs
439439
| `ML-DSA-44` |||| | |||
440440
| `ML-DSA-65` |||| | |||
441441
| `ML-DSA-87` |||| | |||
442-
| `ML-KEM-512` | | | | | |||
443-
| `ML-KEM-768` | | | | | |||
444-
| `ML-KEM-1024` | | | | | |||
442+
| `ML-KEM-512` | | | | | |||
443+
| `ML-KEM-768` | | | | | |||
444+
| `ML-KEM-1024` | | | | | |||
445445
| `PBKDF2` | | | ||| | |
446446
| `RSA-OAEP` |||| | | | |
447447
| `RSA-PSS` |||| | | | |

.github/ISSUE_TEMPLATE/FEATURE_REQUEST.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name: ✨ Feature request
22
description: Suggest an idea for this project
3-
title: ""
3+
title: ''
44
labels: [✨ feature]
55
body:
66
- type: textarea
@@ -18,6 +18,7 @@ body:
1818
options:
1919
- iOS
2020
- Android
21+
- Both
2122
validations:
2223
required: true
2324
- type: textarea

CLAUDE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,10 @@ Tests run in the React Native example app environment, not standard Node.js test
132132

133133
Don't ask to run tests - they must be executed in the example React Native application.
134134

135+
### Metro Logs
136+
137+
Metro output is tee'd to `/tmp/rnqc-metro.log`. When debugging test failures, read this file to see console output including test pass/fail results. Use `grep -E "FAIL|❌|failed" /tmp/rnqc-metro.log | tail -20` to quickly find failures.
138+
135139
## Quality Checks
136140

137141
Before committing:

example/src/hooks/useTestsList.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import '../tests/subtle/argon2_deriveBits';
3434
import '../tests/subtle/deriveBits';
3535
import '../tests/subtle/derive_key';
3636
import '../tests/subtle/digest';
37+
import '../tests/subtle/encap_decap';
3738
import '../tests/subtle/encrypt_decrypt';
3839
import '../tests/subtle/generateKey';
3940
import '../tests/subtle/import_export';
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { expect } from 'chai';
2+
import { test } from '../util';
3+
import crypto from 'react-native-quick-crypto';
4+
import type { WebCryptoKeyPair } from 'react-native-quick-crypto';
5+
import {
6+
MLKEM_VARIANTS,
7+
MLKEM_CIPHERTEXT_SIZES,
8+
SHARED_SECRET_SIZE,
9+
} from './mlkem_constants';
10+
11+
const { subtle } = crypto;
12+
13+
const SUITE = 'subtle.encapsulate/decapsulate';
14+
15+
// --- encapsulateBits / decapsulateBits ---
16+
17+
for (const variant of MLKEM_VARIANTS) {
18+
test(SUITE, `${variant} encapsulateBits/decapsulateBits`, async () => {
19+
const keyPair = await subtle.generateKey({ name: variant }, true, [
20+
'encapsulateBits',
21+
'decapsulateBits',
22+
]);
23+
24+
const { publicKey, privateKey } = keyPair as WebCryptoKeyPair;
25+
26+
const { sharedKey, ciphertext } = await subtle.encapsulateBits(
27+
{ name: variant },
28+
publicKey,
29+
);
30+
31+
expect(ciphertext.byteLength).to.equal(MLKEM_CIPHERTEXT_SIZES[variant]);
32+
expect(sharedKey.byteLength).to.equal(SHARED_SECRET_SIZE);
33+
34+
const decapsulated = await subtle.decapsulateBits(
35+
{ name: variant },
36+
privateKey,
37+
ciphertext,
38+
);
39+
40+
expect(decapsulated.byteLength).to.equal(SHARED_SECRET_SIZE);
41+
42+
const encapsulatedBytes = new Uint8Array(sharedKey);
43+
const decapsulatedBytes = new Uint8Array(decapsulated);
44+
expect(encapsulatedBytes).to.deep.equal(decapsulatedBytes);
45+
});
46+
47+
// --- encapsulateKey / decapsulateKey with AES-GCM ---
48+
49+
test(SUITE, `${variant} encapsulateKey/decapsulateKey AES-GCM`, async () => {
50+
const keyPair = await subtle.generateKey({ name: variant }, true, [
51+
'encapsulateKey',
52+
'decapsulateKey',
53+
]);
54+
55+
const { publicKey, privateKey } = keyPair as WebCryptoKeyPair;
56+
57+
const { key: aesKey, ciphertext } = await subtle.encapsulateKey(
58+
{ name: variant },
59+
publicKey,
60+
{ name: 'AES-GCM', length: 256 },
61+
true,
62+
['encrypt', 'decrypt'],
63+
);
64+
65+
expect(ciphertext.byteLength).to.equal(MLKEM_CIPHERTEXT_SIZES[variant]);
66+
expect(aesKey.algorithm.name).to.equal('AES-GCM');
67+
expect(aesKey.extractable).to.equal(true);
68+
expect(aesKey.usages).to.include('encrypt');
69+
70+
const decapsulatedKey = await subtle.decapsulateKey(
71+
{ name: variant },
72+
privateKey,
73+
ciphertext,
74+
{ name: 'AES-GCM', length: 256 },
75+
true,
76+
['encrypt', 'decrypt'],
77+
);
78+
79+
expect(decapsulatedKey.algorithm.name).to.equal('AES-GCM');
80+
81+
const rawEncapsulated = await subtle.exportKey('raw', aesKey);
82+
const rawDecapsulated = await subtle.exportKey('raw', decapsulatedKey);
83+
expect(new Uint8Array(rawEncapsulated as ArrayBuffer)).to.deep.equal(
84+
new Uint8Array(rawDecapsulated as ArrayBuffer),
85+
);
86+
});
87+
88+
// --- Import then encapsulate/decapsulate roundtrip ---
89+
90+
test(SUITE, `${variant} import then encap/decap`, async () => {
91+
const keyPair = await subtle.generateKey({ name: variant }, true, [
92+
'encapsulateBits',
93+
'decapsulateBits',
94+
]);
95+
96+
const { publicKey, privateKey } = keyPair as WebCryptoKeyPair;
97+
98+
const spki = (await subtle.exportKey('spki', publicKey)) as ArrayBuffer;
99+
const pkcs8 = (await subtle.exportKey('pkcs8', privateKey)) as ArrayBuffer;
100+
101+
const importedPub = await subtle.importKey(
102+
'spki',
103+
spki,
104+
{ name: variant },
105+
true,
106+
['encapsulateBits'],
107+
);
108+
const importedPriv = await subtle.importKey(
109+
'pkcs8',
110+
pkcs8,
111+
{ name: variant },
112+
true,
113+
['decapsulateBits'],
114+
);
115+
116+
const { sharedKey, ciphertext } = await subtle.encapsulateBits(
117+
{ name: variant },
118+
importedPub,
119+
);
120+
121+
const decapsulated = await subtle.decapsulateBits(
122+
{ name: variant },
123+
importedPriv,
124+
ciphertext,
125+
);
126+
127+
expect(new Uint8Array(sharedKey)).to.deep.equal(
128+
new Uint8Array(decapsulated),
129+
);
130+
});
131+
}
132+
133+
// --- Top-level crypto.encapsulate/decapsulate ---
134+
135+
for (const variant of MLKEM_VARIANTS) {
136+
test(SUITE, `${variant} crypto.encapsulate/decapsulate`, async () => {
137+
const keyPair = await subtle.generateKey({ name: variant }, true, [
138+
'encapsulateBits',
139+
'decapsulateBits',
140+
]);
141+
142+
const { publicKey, privateKey } = keyPair as WebCryptoKeyPair;
143+
144+
const result = crypto.encapsulate(publicKey);
145+
expect(result).to.have.property('sharedKey');
146+
expect(result).to.have.property('ciphertext');
147+
148+
const { sharedKey, ciphertext } = result!;
149+
expect(ciphertext.byteLength).to.equal(MLKEM_CIPHERTEXT_SIZES[variant]);
150+
expect(sharedKey.byteLength).to.equal(SHARED_SECRET_SIZE);
151+
152+
const decapsulated = crypto.decapsulate(privateKey, ciphertext);
153+
expect(decapsulated).to.be.an.instanceOf(ArrayBuffer);
154+
expect((decapsulated as ArrayBuffer).byteLength).to.equal(
155+
SHARED_SECRET_SIZE,
156+
);
157+
158+
expect(new Uint8Array(sharedKey)).to.deep.equal(
159+
new Uint8Array(decapsulated as ArrayBuffer),
160+
);
161+
});
162+
}

example/src/tests/subtle/generateKey.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ interface TestCryptoKeyPair {
1414
publicKey: CryptoKey;
1515
privateKey: CryptoKey;
1616
}
17+
import { MLKEM_VARIANTS } from './mlkem_constants';
1718
import { test, assertThrowsAsync } from '../util';
1819

1920
const SUITE = 'subtle.generateKey';
@@ -597,6 +598,52 @@ test(SUITE, 'ML-DSA bad usages', async () => {
597598
);
598599
});
599600

601+
// --- ML-KEM Key Generation Tests ---
602+
603+
for (const variant of MLKEM_VARIANTS) {
604+
test(SUITE, `ML-KEM keygen: ${variant}`, async () => {
605+
const keyPair = await subtle.generateKey({ name: variant }, true, [
606+
'encapsulateBits',
607+
'decapsulateBits',
608+
]);
609+
610+
const { publicKey, privateKey } = keyPair as TestCryptoKeyPair;
611+
612+
expect(publicKey.type).to.equal('public');
613+
expect(privateKey.type).to.equal('private');
614+
expect(publicKey.algorithm.name).to.equal(variant);
615+
expect(privateKey.algorithm.name).to.equal(variant);
616+
expect(publicKey.extractable).to.equal(true);
617+
});
618+
619+
test(SUITE, `ML-KEM keygen non-extractable: ${variant}`, async () => {
620+
const keyPair = await subtle.generateKey({ name: variant }, false, [
621+
'encapsulateBits',
622+
'decapsulateBits',
623+
]);
624+
625+
const { publicKey, privateKey } = keyPair as TestCryptoKeyPair;
626+
627+
expect(publicKey.extractable).to.equal(true);
628+
expect(privateKey.extractable).to.equal(false);
629+
});
630+
}
631+
632+
test(SUITE, 'ML-KEM bad usages', async () => {
633+
await assertThrowsAsync(
634+
async () => await subtle.generateKey({ name: 'ML-KEM-768' }, true, []),
635+
'Usages cannot be empty',
636+
);
637+
638+
await assertThrowsAsync(
639+
async () =>
640+
await subtle.generateKey({ name: 'ML-KEM-768' }, true, [
641+
'sign',
642+
] as KeyUsage[]),
643+
'Unsupported key usage',
644+
);
645+
});
646+
600647
/*
601648
// Test AES Key Generation
602649
type AESArgs = [AESAlgorithm, AESLength, KeyUsage[]];

0 commit comments

Comments
 (0)