1- import { Buffer , randomFillSync , xsalsa20 } from 'react-native-quick-crypto' ;
1+ import {
2+ Buffer ,
3+ createCipheriv ,
4+ createDecipheriv ,
5+ randomFillSync ,
6+ xsalsa20 ,
7+ } from 'react-native-quick-crypto' ;
28import { expect } from 'chai' ;
39import { test } from '../util' ;
410
@@ -24,3 +30,160 @@ test(SUITE, 'xsalsa20', () => {
2430 // test decrypted == data
2531 expect ( decrypted ) . eql ( data ) ;
2632} ) ;
33+
34+ // --- Streaming regression tests ---
35+ //
36+ // XSalsa20 is a stream cipher: chunked update() calls must advance the
37+ // keystream, NOT restart it from block 0 every time. The previous
38+ // implementation called crypto_stream_xor() on each update(), which restarted
39+ // the keystream and produced a two-time pad if the caller streamed >1 chunk.
40+ //
41+ // These tests pin that fix in place by checking streaming equivalence with
42+ // the one-shot xsalsa20() function, which is the correct reference output.
43+
44+ const STREAM_KEY = Buffer . from (
45+ 'a8a7d6a5d4a3d2a1a09f9e9d9c8b8a89a8a7d6a5d4a3d2a1a09f9e9d9c8b8a89' ,
46+ 'hex' ,
47+ ) ;
48+ const STREAM_NONCE = Buffer . from (
49+ '111213141516171821222324252627283132333435363738' ,
50+ 'hex' ,
51+ ) ;
52+
53+ // Block-aligned split: two 64-byte chunks (full Salsa20 blocks).
54+ test ( SUITE , 'xsalsa20 streaming equivalence — block-aligned split' , ( ) => {
55+ const data = Buffer . alloc ( 128 ) ;
56+ for ( let i = 0 ; i < data . length ; i ++ ) data [ i ] = i & 0xff ;
57+
58+ const oneShot = xsalsa20 (
59+ new Uint8Array ( STREAM_KEY ) ,
60+ new Uint8Array ( STREAM_NONCE ) ,
61+ new Uint8Array ( data ) ,
62+ ) ;
63+
64+ const cipher = createCipheriv ( 'xsalsa20' , STREAM_KEY , STREAM_NONCE ) ;
65+ const part1 = cipher . update ( data . subarray ( 0 , 64 ) ) ;
66+ const part2 = cipher . update ( data . subarray ( 64 ) ) ;
67+ const streamed = Buffer . concat ( [ part1 , part2 , cipher . final ( ) ] ) ;
68+
69+ expect ( new Uint8Array ( streamed ) ) . eql ( oneShot ) ;
70+ } ) ;
71+
72+ // Mid-block split: 30 + 70 bytes, neither chunk is a multiple of 64.
73+ test ( SUITE , 'xsalsa20 streaming equivalence — mid-block split' , ( ) => {
74+ const data = Buffer . alloc ( 100 ) ;
75+ for ( let i = 0 ; i < data . length ; i ++ ) data [ i ] = ( i * 7 + 3 ) & 0xff ;
76+
77+ const oneShot = xsalsa20 (
78+ new Uint8Array ( STREAM_KEY ) ,
79+ new Uint8Array ( STREAM_NONCE ) ,
80+ new Uint8Array ( data ) ,
81+ ) ;
82+
83+ const cipher = createCipheriv ( 'xsalsa20' , STREAM_KEY , STREAM_NONCE ) ;
84+ const part1 = cipher . update ( data . subarray ( 0 , 30 ) ) ;
85+ const part2 = cipher . update ( data . subarray ( 30 ) ) ;
86+ const streamed = Buffer . concat ( [ part1 , part2 , cipher . final ( ) ] ) ;
87+
88+ expect ( new Uint8Array ( streamed ) ) . eql ( oneShot ) ;
89+ } ) ;
90+
91+ // Many small chunks crossing several block boundaries.
92+ test ( SUITE , 'xsalsa20 streaming equivalence — many small chunks' , ( ) => {
93+ const data = Buffer . alloc ( 257 ) ;
94+ for ( let i = 0 ; i < data . length ; i ++ ) data [ i ] = ( i * 13 + 5 ) & 0xff ;
95+
96+ const oneShot = xsalsa20 (
97+ new Uint8Array ( STREAM_KEY ) ,
98+ new Uint8Array ( STREAM_NONCE ) ,
99+ new Uint8Array ( data ) ,
100+ ) ;
101+
102+ const cipher = createCipheriv ( 'xsalsa20' , STREAM_KEY , STREAM_NONCE ) ;
103+ const chunkSizes = [ 1 , 7 , 16 , 31 , 33 , 64 , 65 , 40 ] ;
104+ const parts : Buffer [ ] = [ ] ;
105+ let offset = 0 ;
106+ for ( const size of chunkSizes ) {
107+ const end = Math . min ( offset + size , data . length ) ;
108+ if ( end > offset ) parts . push ( cipher . update ( data . subarray ( offset , end ) ) ) ;
109+ offset = end ;
110+ }
111+ if ( offset < data . length ) parts . push ( cipher . update ( data . subarray ( offset ) ) ) ;
112+ parts . push ( cipher . final ( ) ) ;
113+ const streamed = Buffer . concat ( parts ) ;
114+
115+ expect ( new Uint8Array ( streamed ) ) . eql ( oneShot ) ;
116+ } ) ;
117+
118+ // Regression: identical plaintext in two consecutive update() calls MUST
119+ // produce different ciphertexts because the keystream advances. The previous
120+ // (buggy) implementation reset the keystream on every update(), so both
121+ // chunks would have been bitwise identical — a two-time-pad break.
122+ test ( SUITE , 'xsalsa20 keystream advances across update() calls' , ( ) => {
123+ const block = Buffer . alloc ( 64 , 0xaa ) ;
124+
125+ const cipher = createCipheriv ( 'xsalsa20' , STREAM_KEY , STREAM_NONCE ) ;
126+ const c1 = cipher . update ( block ) ;
127+ const c2 = cipher . update ( block ) ;
128+ cipher . final ( ) ;
129+
130+ expect ( c1 . length ) . to . equal ( block . length ) ;
131+ expect ( c2 . length ) . to . equal ( block . length ) ;
132+ // If the bug returns, c1 === c2 (catastrophic).
133+ expect ( c1 . equals ( c2 ) ) . to . equal ( false ) ;
134+ } ) ;
135+
136+ // Edge case: a chunk that exactly drains the leftover keystream to the block
137+ // boundary, followed by a subsequent update. Catches a regression where
138+ // `leftover_offset` doesn't wrap to the sentinel correctly.
139+ test (
140+ SUITE ,
141+ 'xsalsa20 streaming equivalence — drain-to-boundary then continue' ,
142+ ( ) => {
143+ // 60 + 4 + 100 = 164 bytes. After the 60-byte chunk, leftover_offset=60;
144+ // the 4-byte chunk drains exactly to 64 (sentinel); the 100-byte chunk
145+ // must then start cleanly on a fresh block boundary.
146+ const data = Buffer . alloc ( 164 ) ;
147+ for ( let i = 0 ; i < data . length ; i ++ ) data [ i ] = ( i * 5 + 19 ) & 0xff ;
148+
149+ const oneShot = xsalsa20 (
150+ new Uint8Array ( STREAM_KEY ) ,
151+ new Uint8Array ( STREAM_NONCE ) ,
152+ new Uint8Array ( data ) ,
153+ ) ;
154+
155+ const cipher = createCipheriv ( 'xsalsa20' , STREAM_KEY , STREAM_NONCE ) ;
156+ const part1 = cipher . update ( data . subarray ( 0 , 60 ) ) ;
157+ const part2 = cipher . update ( data . subarray ( 60 , 64 ) ) ;
158+ const part3 = cipher . update ( data . subarray ( 64 ) ) ;
159+ const streamed = Buffer . concat ( [ part1 , part2 , part3 , cipher . final ( ) ] ) ;
160+
161+ expect ( new Uint8Array ( streamed ) ) . eql ( oneShot ) ;
162+ } ,
163+ ) ;
164+
165+ // Streaming round-trip: encrypt and decrypt streamed across multiple
166+ // update() calls. Decryption is just XOR with the same keystream, so this
167+ // also exercises the streaming state on the decrypt side.
168+ test ( SUITE , 'xsalsa20 streaming round-trip across two cipher instances' , ( ) => {
169+ const data = Buffer . alloc ( 200 ) ;
170+ for ( let i = 0 ; i < data . length ; i ++ ) data [ i ] = ( i * 11 + 17 ) & 0xff ;
171+
172+ const enc = createCipheriv ( 'xsalsa20' , STREAM_KEY , STREAM_NONCE ) ;
173+ const ciphertext = Buffer . concat ( [
174+ enc . update ( data . subarray ( 0 , 50 ) ) ,
175+ enc . update ( data . subarray ( 50 , 130 ) ) ,
176+ enc . update ( data . subarray ( 130 ) ) ,
177+ enc . final ( ) ,
178+ ] ) ;
179+
180+ const dec = createDecipheriv ( 'xsalsa20' , STREAM_KEY , STREAM_NONCE ) ;
181+ const decrypted = Buffer . concat ( [
182+ dec . update ( ciphertext . subarray ( 0 , 17 ) ) ,
183+ dec . update ( ciphertext . subarray ( 17 , 99 ) ) ,
184+ dec . update ( ciphertext . subarray ( 99 ) ) ,
185+ dec . final ( ) ,
186+ ] ) ;
187+
188+ expect ( decrypted . equals ( data ) ) . to . equal ( true ) ;
189+ } ) ;
0 commit comments