Skip to content

Commit a0453cf

Browse files
authored
feat: add XChaCha20-Poly1305 and XSalsa20-Poly1305 cipher support (#908)
1 parent b360ffd commit a0453cf

13 files changed

Lines changed: 847 additions & 5 deletions

File tree

.docs/implementation-coverage.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,18 @@ These algorithms provide quantum-resistant cryptography.
227227
| `Ed448` ||
228228
| `HMAC` ||
229229

230+
## Extended Ciphers (Beyond Node.js API)
231+
232+
These ciphers are **not available in Node.js** but are provided by RNQC via libsodium for mobile use cases requiring extended nonces.
233+
234+
| Cipher | Key | Nonce | Tag | AAD | Notes |
235+
| ------ | :-: | :---: | :-: | :-: | ----- |
236+
| `xchacha20-poly1305` | 32B | 24B | 16B || AEAD with extended nonce |
237+
| `xsalsa20-poly1305` | 32B | 24B | 16B || Authenticated encryption (secretbox) |
238+
| `xsalsa20` | 32B | 24B | - | - | Stream cipher (no authentication) |
239+
240+
> **Note:** These ciphers require `SODIUM_ENABLED=1` on both iOS and Android.
241+
230242
# `WebCrypto`
231243

232244
* ❌ Class: `Crypto`

docs/content/docs/api/cipher.mdx

Lines changed: 111 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@ import { Callout } from 'fumadocs-ui/components/callout';
77
import { TypeTable } from 'fumadocs-ui/components/type-table';
88
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
99

10-
The `Cipher` module provides implementations of symmetric cipher algorithms. It supports standard Block Ciphers (AES) and Stream Ciphers (ChaCha20).
10+
The `Cipher` module provides implementations of symmetric cipher algorithms. It supports standard Block Ciphers (AES), Stream Ciphers (ChaCha20), and extended ciphers via libsodium (XChaCha20, XSalsa20).
1111

1212
## Table of Contents
1313

1414
- [Theory](#theory)
15+
- [Supported Algorithms](#supported-algorithms)
1516
- [Class: Cipher](#class-cipher)
1617
- [Class: Decipher](#class-decipher)
1718
- [Module Methods](#module-methods)
@@ -33,6 +34,51 @@ Symmetric ciphers use the same key for encryption and decryption.
3334

3435
---
3536

37+
## Supported Algorithms
38+
39+
### Block Ciphers (AES)
40+
41+
| Algorithm | Key Size | IV Size | Mode | AEAD |
42+
| --------- | -------- | ------- | ---- | :--: |
43+
| `aes-128-cbc` | 16 bytes | 16 bytes | CBC | No |
44+
| `aes-192-cbc` | 24 bytes | 16 bytes | CBC | No |
45+
| `aes-256-cbc` | 32 bytes | 16 bytes | CBC | No |
46+
| `aes-128-ctr` | 16 bytes | 16 bytes | CTR | No |
47+
| `aes-192-ctr` | 24 bytes | 16 bytes | CTR | No |
48+
| `aes-256-ctr` | 32 bytes | 16 bytes | CTR | No |
49+
| `aes-128-gcm` | 16 bytes | 12 bytes | GCM | Yes |
50+
| `aes-192-gcm` | 24 bytes | 12 bytes | GCM | Yes |
51+
| `aes-256-gcm` | 32 bytes | 12 bytes | GCM | Yes |
52+
| `aes-128-ccm` | 16 bytes | 7-13 bytes | CCM | Yes |
53+
| `aes-192-ccm` | 24 bytes | 7-13 bytes | CCM | Yes |
54+
| `aes-256-ccm` | 32 bytes | 7-13 bytes | CCM | Yes |
55+
| `aes-128-ocb` | 16 bytes | 12 bytes | OCB | Yes |
56+
| `aes-192-ocb` | 24 bytes | 12 bytes | OCB | Yes |
57+
| `aes-256-ocb` | 32 bytes | 12 bytes | OCB | Yes |
58+
59+
### Stream Ciphers (ChaCha20)
60+
61+
| Algorithm | Key Size | Nonce Size | Tag Size | AEAD | AAD |
62+
| --------- | -------- | ---------- | -------- | :--: | :-: |
63+
| `chacha20` | 32 bytes | 16 bytes | - | No | No |
64+
| `chacha20-poly1305` | 32 bytes | 12 bytes | 16 bytes | Yes | Yes |
65+
66+
### Extended Ciphers (libsodium)
67+
68+
<Callout type="warn" title="Requires SODIUM_ENABLED">
69+
These ciphers require `SODIUM_ENABLED=1` on both iOS and Android. They are **not available in Node.js** and are provided as extensions for mobile use cases.
70+
</Callout>
71+
72+
| Algorithm | Key Size | Nonce Size | Tag Size | AEAD | AAD | Notes |
73+
| --------- | -------- | ---------- | -------- | :--: | :-: | ----- |
74+
| `xchacha20-poly1305` | 32 bytes | 24 bytes | 16 bytes | Yes | Yes | Extended nonce variant |
75+
| `xsalsa20-poly1305` | 32 bytes | 24 bytes | 16 bytes | Yes | No | NaCl secretbox |
76+
| `xsalsa20` | 32 bytes | 24 bytes | - | No | No | Stream cipher only |
77+
78+
The extended nonce (24 bytes vs 12 bytes) in XChaCha20 and XSalsa20 variants allows safe random nonce generation without risk of collision, making them ideal for high-volume encryption scenarios.
79+
80+
---
81+
3682
## Class: Cipher
3783

3884
Instances of the `Cipher` class are used to encrypt data.
@@ -141,7 +187,7 @@ function encrypt(text: string) {
141187
}
142188
```
143189

144-
### File Encryption (Scanning)
190+
### File Encryption (Streaming)
145191

146192
Encrypting a file using streams with AES-CTR (counter mode).
147193

@@ -158,3 +204,66 @@ const output = fs.createWriteStream('output.enc');
158204

159205
input.pipe(cipher).pipe(output);
160206
```
207+
208+
### XChaCha20-Poly1305 (Extended Nonce)
209+
210+
XChaCha20-Poly1305 uses a 24-byte nonce, making random nonce generation safe for high-volume encryption.
211+
212+
<Callout type="info" title="Requires libsodium">
213+
Set `SODIUM_ENABLED=1` environment variable before building.
214+
</Callout>
215+
216+
```ts
217+
import { createCipheriv, createDecipheriv, randomBytes } from 'react-native-quick-crypto';
218+
219+
const key = randomBytes(32);
220+
221+
function encrypt(plaintext: Buffer, aad?: Buffer) {
222+
// 24-byte nonce - safe to generate randomly
223+
const nonce = randomBytes(24);
224+
225+
const cipher = createCipheriv('xchacha20-poly1305', key, nonce);
226+
if (aad) cipher.setAAD(aad);
227+
228+
const ciphertext = Buffer.concat([
229+
cipher.update(plaintext),
230+
cipher.final()
231+
]);
232+
const tag = cipher.getAuthTag();
233+
234+
return { ciphertext, nonce, tag };
235+
}
236+
237+
function decrypt(ciphertext: Buffer, nonce: Buffer, tag: Buffer, aad?: Buffer) {
238+
const decipher = createDecipheriv('xchacha20-poly1305', key, nonce);
239+
if (aad) decipher.setAAD(aad);
240+
decipher.setAuthTag(tag);
241+
242+
return Buffer.concat([
243+
decipher.update(ciphertext),
244+
decipher.final()
245+
]);
246+
}
247+
```
248+
249+
### XSalsa20-Poly1305 (NaCl Secretbox)
250+
251+
XSalsa20-Poly1305 provides authenticated encryption without AAD support (similar to NaCl's secretbox).
252+
253+
```ts
254+
import { createCipheriv, createDecipheriv, randomBytes } from 'react-native-quick-crypto';
255+
256+
const key = randomBytes(32);
257+
const nonce = randomBytes(24);
258+
const message = Buffer.from('Secret message');
259+
260+
// Encrypt
261+
const cipher = createCipheriv('xsalsa20-poly1305', key, nonce);
262+
const ciphertext = Buffer.concat([cipher.update(message), cipher.final()]);
263+
const tag = cipher.getAuthTag();
264+
265+
// Decrypt
266+
const decipher = createDecipheriv('xsalsa20-poly1305', key, nonce);
267+
decipher.setAuthTag(tag);
268+
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
269+
```

example/ios/Podfile.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2811,7 +2811,7 @@ SPEC CHECKSUMS:
28112811
MMKVCore: f2dd4c9befea04277a55e84e7812f930537993df
28122812
NitroMmkv: afbc5b2fbf963be567c6c545aa1efcf6a9cec68e
28132813
NitroModules: 11bba9d065af151eae51e38a6425e04c3b223ff3
2814-
QuickCrypto: 7f2ca14820e56e0785fad4e1b5527f2cc7b962d3
2814+
QuickCrypto: 9e46baaa4fea5a22fdf23c3aae184b983c948b23
28152815
RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669
28162816
RCTDeprecation: c4b9e2fd0ab200e3af72b013ed6113187c607077
28172817
RCTRequired: e97dd5dafc1db8094e63bc5031e0371f092ae92a

example/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
"react-native-mmkv": "4.0.1",
4141
"react-native-nitro-modules": "0.33.2",
4242
"react-native-quick-base64": "2.2.2",
43-
"react-native-quick-crypto": "1.0.9",
43+
"react-native-quick-crypto": "workspace:*",
4444
"react-native-safe-area-context": "5.6.2",
4545
"react-native-screens": "4.18.0",
4646
"react-native-vector-icons": "10.3.0",

example/src/hooks/useTestsList.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import '../tests/blake3/blake3_tests';
66
import '../tests/cipher/cipher_tests';
77
import '../tests/cipher/chacha_tests';
88
import '../tests/cipher/xsalsa20_tests';
9+
import '../tests/cipher/xsalsa20_poly1305_tests';
10+
import '../tests/cipher/xchacha20_poly1305_tests';
911
import '../tests/dh/dh_tests';
1012
import '../tests/ecdh/ecdh_tests';
1113
import '../tests/hash/hash_tests';
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
/**
2+
* XChaCha20-Poly1305 tests
3+
*
4+
* Test vectors from IETF draft-irtf-cfrg-xchacha and libsodium test suite.
5+
* XChaCha20-Poly1305 is an AEAD cipher with:
6+
* - 32-byte key
7+
* - 24-byte nonce (extended nonce)
8+
* - 16-byte authentication tag
9+
* - AAD (Additional Authenticated Data) support
10+
*/
11+
12+
import {
13+
Buffer,
14+
createCipheriv,
15+
createDecipheriv,
16+
} from 'react-native-quick-crypto';
17+
import { expect } from 'chai';
18+
import { test } from '../util';
19+
import { roundTripAuth } from './roundTrip';
20+
21+
const SUITE = 'cipher';
22+
23+
function fromHex(h: string | Buffer): Buffer {
24+
if (typeof h === 'string') {
25+
h = h.replace(/([^0-9a-f])/gi, '');
26+
return Buffer.from(h, 'hex');
27+
}
28+
return h;
29+
}
30+
31+
interface XChaCha20Poly1305TestVector {
32+
key: string;
33+
nonce: string;
34+
plaintext: string;
35+
aad: string | Buffer;
36+
ciphertext: string;
37+
tag: string;
38+
}
39+
40+
// Test vector from IETF draft-irtf-cfrg-xchacha (Appendix A.3.1)
41+
const ietfA31Vector: XChaCha20Poly1305TestVector = {
42+
key: '808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f',
43+
nonce: '404142434445464748494a4b4c4d4e4f5051525354555657',
44+
plaintext:
45+
'4c616469657320616e642047656e746c656d656e206f662074686520636c6173' +
46+
'73206f66202739393a204966204920636f756c64206f6666657220796f75206f' +
47+
'6e6c79206f6e652074697020666f7220746865206675747572652c2073756e73' +
48+
'637265656e20776f756c642062652069742e',
49+
aad: '50515253c0c1c2c3c4c5c6c7',
50+
ciphertext:
51+
'bd6d179d3e83d43b9576579493c0e939572a1700252bfaccbed2902c21396cbb' +
52+
'731c7f1b0b4aa6440bf3a82f4eda7e39ae64c6708c54c216cb96b72e1213b452' +
53+
'2f8c9ba40db5d945b11b69b982c1bb9e3f3fac2bc369488f76b2383565d3fff9' +
54+
'21f9664c97637da9768812f615c68b13b52e',
55+
tag: 'c0875924c1c7987947deafd8780acf49',
56+
};
57+
58+
function testXChaCha20Poly1305Vector(
59+
vector: XChaCha20Poly1305TestVector,
60+
description: string,
61+
) {
62+
test(SUITE, `xchacha20-poly1305 ${description}`, () => {
63+
const key = fromHex(vector.key);
64+
const nonce = fromHex(vector.nonce);
65+
const plaintext = fromHex(vector.plaintext);
66+
const aad = fromHex(vector.aad);
67+
const expectedCiphertext = fromHex(vector.ciphertext);
68+
const expectedTag = fromHex(vector.tag);
69+
70+
// First test round trip
71+
roundTripAuth('xchacha20-poly1305', key, nonce, plaintext, aad);
72+
73+
// Then test against expected values
74+
const cipher = createCipheriv('xchacha20-poly1305', key, nonce);
75+
cipher.setAAD(aad);
76+
const actualCiphertext = Buffer.concat([
77+
cipher.update(plaintext),
78+
cipher.final(),
79+
]);
80+
const actualTag = cipher.getAuthTag();
81+
82+
expect(actualCiphertext).to.deep.equal(expectedCiphertext);
83+
expect(actualTag).to.deep.equal(expectedTag);
84+
});
85+
}
86+
87+
testXChaCha20Poly1305Vector(ietfA31Vector, 'IETF draft A.3.1 vector');
88+
89+
// Basic round-trip tests
90+
test(SUITE, 'xchacha20-poly1305 basic round trip', () => {
91+
const key = Buffer.alloc(32, 0x42);
92+
const nonce = Buffer.alloc(24, 0x24);
93+
const plaintext = Buffer.from('Hello, XChaCha20-Poly1305!', 'utf8');
94+
const aad = Buffer.from('additional data', 'utf8');
95+
96+
roundTripAuth('xchacha20-poly1305', key, nonce, plaintext, aad);
97+
});
98+
99+
test(SUITE, 'xchacha20-poly1305 without AAD', () => {
100+
const key = Buffer.alloc(32, 0x42);
101+
const nonce = Buffer.alloc(24, 0x24);
102+
const plaintext = Buffer.from('Hello, XChaCha20-Poly1305!', 'utf8');
103+
104+
roundTripAuth('xchacha20-poly1305', key, nonce, plaintext);
105+
});
106+
107+
test(SUITE, 'xchacha20-poly1305 empty plaintext', () => {
108+
const key = Buffer.alloc(32, 0x42);
109+
const nonce = Buffer.alloc(24, 0x24);
110+
const plaintext = Buffer.alloc(0);
111+
const aad = Buffer.from('aad only', 'utf8');
112+
113+
roundTripAuth('xchacha20-poly1305', key, nonce, plaintext, aad);
114+
});
115+
116+
test(SUITE, 'xchacha20-poly1305 large plaintext', () => {
117+
const key = Buffer.alloc(32, 0x42);
118+
const nonce = Buffer.alloc(24, 0x24);
119+
const plaintext = Buffer.alloc(4096, 0x55);
120+
const aad = Buffer.from('large data test', 'utf8');
121+
122+
roundTripAuth('xchacha20-poly1305', key, nonce, plaintext, aad);
123+
});
124+
125+
// Error case tests
126+
test(SUITE, 'xchacha20-poly1305 wrong key size throws', () => {
127+
const key = Buffer.alloc(16, 0x42); // Wrong size: should be 32
128+
const nonce = Buffer.alloc(24, 0x24);
129+
130+
expect(() => {
131+
createCipheriv('xchacha20-poly1305', key, nonce);
132+
}).to.throw(/key must be 32 bytes/i);
133+
});
134+
135+
test(SUITE, 'xchacha20-poly1305 wrong nonce size throws', () => {
136+
const key = Buffer.alloc(32, 0x42);
137+
const nonce = Buffer.alloc(12, 0x24); // Wrong size: should be 24
138+
139+
expect(() => {
140+
createCipheriv('xchacha20-poly1305', key, nonce);
141+
}).to.throw(/nonce must be 24 bytes/i);
142+
});
143+
144+
test(SUITE, 'xchacha20-poly1305 tag mismatch throws', () => {
145+
const key = Buffer.alloc(32, 0x42);
146+
const nonce = Buffer.alloc(24, 0x24);
147+
const plaintext = Buffer.from('test message', 'utf8');
148+
149+
// Encrypt
150+
const cipher = createCipheriv('xchacha20-poly1305', key, nonce);
151+
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
152+
153+
// Try to decrypt with wrong tag
154+
const decipher = createDecipheriv('xchacha20-poly1305', key, nonce);
155+
const wrongTag = Buffer.alloc(16, 0xff); // Wrong tag
156+
decipher.setAuthTag(wrongTag);
157+
decipher.update(ciphertext);
158+
159+
expect(() => {
160+
decipher.final();
161+
}).to.throw(/authentication tag mismatch/i);
162+
});

0 commit comments

Comments
 (0)