castrum 0.9.3 → 0.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (206) hide show
  1. package/Cargo.lock +2242 -0
  2. package/Cargo.toml +155 -0
  3. package/README.md +14 -6
  4. package/build.rs +5 -0
  5. package/castrum.darwin-arm64.node +0 -0
  6. package/castrum.darwin-x64.node +0 -0
  7. package/castrum.linux-arm64-gnu.node +0 -0
  8. package/castrum.linux-arm64-musl.node +0 -0
  9. package/castrum.linux-x64-gnu.node +0 -0
  10. package/castrum.linux-x64-musl.node +0 -0
  11. package/castrum.linux-x64-v3-gnu.node +0 -0
  12. package/castrum.win32-x64-msvc.node +0 -0
  13. package/dist/index.d.ts +1508 -1291
  14. package/dist/index.js +3391 -2069
  15. package/docs/API.md +2 -1
  16. package/docs/ARCHITECTURE.md +3 -2
  17. package/docs/CASE_STUDY.md +5 -3
  18. package/docs/ENVIRONMENT.md +25 -2
  19. package/docs/FFI_BUN_GUIDE.md +242 -14
  20. package/docs/HOW_TO_ADD_AN_OP.md +10 -0
  21. package/docs/REPO_MAP.md +17 -6
  22. package/docs/RND-CONCURRENCY.md +772 -0
  23. package/docs/bun-builtins-decision-matrix.md +4 -1
  24. package/index.ts +24 -25
  25. package/package.json +173 -156
  26. package/rust/crypto/aead.rs +378 -0
  27. package/rust/crypto/argon2.rs +265 -0
  28. package/rust/crypto/base64.rs +420 -0
  29. package/rust/crypto/bcrypt.rs +89 -0
  30. package/rust/crypto/cookie_sign.rs +169 -0
  31. package/rust/crypto/csrf.rs +106 -0
  32. package/rust/crypto/ed25519.rs +215 -0
  33. package/rust/crypto/hashing.rs +140 -0
  34. package/rust/crypto/hmac_sha256.rs +88 -0
  35. package/rust/crypto/jwt/api.rs +286 -0
  36. package/rust/crypto/jwt/mod.rs +22 -0
  37. package/rust/crypto/jwt/tests.rs +312 -0
  38. package/rust/crypto/jwt/token.rs +360 -0
  39. package/rust/crypto/mod.rs +27 -0
  40. package/rust/crypto/pbkdf2.rs +105 -0
  41. package/rust/crypto/random_token.rs +85 -0
  42. package/rust/crypto/session.rs +222 -0
  43. package/rust/ffi/crypto.rs +708 -0
  44. package/rust/ffi/hashing.rs +285 -0
  45. package/rust/ffi/http.rs +752 -0
  46. package/rust/ffi/ingress.rs +303 -0
  47. package/rust/ffi/json.rs +517 -0
  48. package/rust/ffi/jwt.rs +419 -0
  49. package/rust/ffi/metrics.rs +373 -0
  50. package/rust/ffi/mod.rs +69 -0
  51. package/rust/ffi/payload.rs +275 -0
  52. package/rust/ffi/probe.rs +60 -0
  53. package/rust/ffi/rate_limit.rs +77 -0
  54. package/rust/ffi/route.rs +85 -0
  55. package/rust/ffi/task.rs +172 -0
  56. package/rust/ffi/tests.rs +2333 -0
  57. package/rust/ffi/util.rs +152 -0
  58. package/rust/ffi/validators.rs +123 -0
  59. package/rust/http/accept.rs +606 -0
  60. package/rust/http/cookie_parser.rs +140 -0
  61. package/rust/http/etag.rs +289 -0
  62. package/rust/http/form.rs +136 -0
  63. package/rust/http/headers.rs +267 -0
  64. package/rust/http/http_date.rs +286 -0
  65. package/rust/http/http_parser.rs +252 -0
  66. package/rust/http/media_type.rs +355 -0
  67. package/rust/http/method.rs +200 -0
  68. package/rust/http/mime_lookup.rs +130 -0
  69. package/rust/http/mod.rs +32 -0
  70. package/rust/http/multipart.rs +685 -0
  71. package/rust/http/query_parser.rs +211 -0
  72. package/rust/http/url_codec.rs +296 -0
  73. package/rust/http/url_join.rs +324 -0
  74. package/rust/ingress/api.rs +355 -0
  75. package/rust/ingress/cors.rs +403 -0
  76. package/rust/ingress/ingress_constants.rs +209 -0
  77. package/rust/ingress/ip_trust.rs +312 -0
  78. package/rust/ingress/mod.rs +48 -0
  79. package/rust/ingress/native_route.rs +770 -0
  80. package/rust/ingress/options.rs +176 -0
  81. package/rust/ingress/output.rs +406 -0
  82. package/rust/ingress/packed.rs +191 -0
  83. package/rust/ingress/pipeline.rs +534 -0
  84. package/rust/ingress/proxy.rs +138 -0
  85. package/rust/ingress/rate_limit.rs +538 -0
  86. package/rust/ingress/terminal.rs +114 -0
  87. package/rust/ingress/tests.rs +567 -0
  88. package/rust/ingress/time.rs +70 -0
  89. package/rust/json/fast_schema/capture.rs +336 -0
  90. package/rust/json/fast_schema/compile.rs +556 -0
  91. package/rust/json/fast_schema/cursor.rs +368 -0
  92. package/rust/json/fast_schema/email.rs +282 -0
  93. package/rust/json/fast_schema/errors.rs +247 -0
  94. package/rust/json/fast_schema/mod.rs +57 -0
  95. package/rust/json/fast_schema/tests.rs +1143 -0
  96. package/rust/json/fast_schema/types.rs +140 -0
  97. package/rust/json/fast_schema/validate.rs +931 -0
  98. package/rust/json/json_ops.rs +121 -0
  99. package/rust/json/json_schema.rs +837 -0
  100. package/rust/json/json_ser.rs +883 -0
  101. package/rust/json/mod.rs +19 -0
  102. package/rust/json/napi_marshal.rs +318 -0
  103. package/rust/json/patch/api.rs +60 -0
  104. package/rust/json/patch/engine.rs +153 -0
  105. package/rust/json/patch/mod.rs +47 -0
  106. package/rust/json/patch/ops.rs +270 -0
  107. package/rust/json/patch/pointer.rs +49 -0
  108. package/rust/json/patch/tests.rs +499 -0
  109. package/rust/lib.rs +73 -0
  110. package/rust/metrics/api.rs +148 -0
  111. package/rust/metrics/mod.rs +21 -0
  112. package/rust/metrics/registry.rs +961 -0
  113. package/rust/panic_safety.rs +40 -0
  114. package/rust/payload/compress.rs +512 -0
  115. package/rust/payload/mod.rs +13 -0
  116. package/rust/payload/sse.rs +214 -0
  117. package/rust/payload/template.rs +253 -0
  118. package/rust/payload/websocket.rs +69 -0
  119. package/rust/payload/ws_frames.rs +354 -0
  120. package/rust/proptest_suite.rs +139 -0
  121. package/rust/selection.rs +95 -0
  122. package/rust/src/bin/native-bench.rs +495 -0
  123. package/rust/task/completion.rs +119 -0
  124. package/rust/task/mod.rs +65 -0
  125. package/rust/task/ops.rs +503 -0
  126. package/rust/task/runtime.rs +220 -0
  127. package/rust/task/tests.rs +231 -0
  128. package/rust/test_support.rs +71 -0
  129. package/rust/util/batch/api.rs +266 -0
  130. package/rust/util/batch/core.rs +473 -0
  131. package/rust/util/batch/mod.rs +23 -0
  132. package/rust/util/batch/tests.rs +445 -0
  133. package/rust/util/batch_core.rs +184 -0
  134. package/rust/util/bytes.rs +594 -0
  135. package/rust/util/mod.rs +34 -0
  136. package/rust/util/packed.rs +473 -0
  137. package/rust/util/text.rs +130 -0
  138. package/rust/util/threadpool.rs +128 -0
  139. package/rust/util/validation.rs +419 -0
  140. package/rust-toolchain.toml +17 -0
  141. package/scripts/postinstall.mjs +199 -0
  142. package/src/ingress/constants.ts +1 -1
  143. package/src/ingress/context.ts +2 -2
  144. package/src/ingress/decode/baked-result.ts +13 -13
  145. package/src/ingress/decode/fast-result.ts +17 -17
  146. package/src/ingress/errors.ts +7 -7
  147. package/src/ingress/headers/baked-templates.ts +1 -1
  148. package/src/ingress/headers/fast-templates.ts +3 -3
  149. package/src/ingress/health.ts +1 -2
  150. package/src/ingress/index.ts +1 -1
  151. package/src/ingress/native-route.ts +9 -5
  152. package/src/ingress/options.ts +1 -1
  153. package/src/ingress/packing/gather-raw-headers.ts +1 -1
  154. package/src/ingress/packing/route-wire.ts +1 -5
  155. package/src/ingress/response/error-bodies.ts +5 -5
  156. package/src/ingress/response/terminal.ts +1 -1
  157. package/src/ingress/routes/echo.ts +4 -4
  158. package/src/ingress/routes/fallback.ts +2 -2
  159. package/src/ingress/routes/head.ts +2 -2
  160. package/src/ingress/routes/native.ts +2 -1
  161. package/src/ingress/routes/options.ts +1 -1
  162. package/src/ingress/routes/read.ts +2 -2
  163. package/src/ingress/server.ts +10 -6
  164. package/src/ingress/status.ts +7 -7
  165. package/src/integration/batch.ts +1 -1
  166. package/src/integration/index.ts +9 -9
  167. package/src/integration/pipeline.ts +6 -6
  168. package/src/integration/websocket.ts +2 -2
  169. package/src/loader/index.ts +1 -5
  170. package/src/loader/ops.ts +1 -1
  171. package/src/native/ffi/build/codecs.ts +130 -12
  172. package/src/native/ffi/build/compress.ts +22 -10
  173. package/src/native/ffi/build/instances.ts +46 -4
  174. package/src/native/ffi/build/metrics.ts +247 -0
  175. package/src/native/ffi/build/parse.ts +59 -23
  176. package/src/native/ffi/build/task.ts +79 -0
  177. package/src/native/ffi/build/util.ts +32 -1
  178. package/src/native/ffi/build.ts +4 -0
  179. package/src/native/ffi/selftest.ts +12 -3
  180. package/src/native/ffi/types.ts +151 -9
  181. package/src/native/ffi.ts +160 -15
  182. package/src/native/index.ts +1 -1
  183. package/src/native/loader.ts +55 -5
  184. package/src/native/types/instances.ts +34 -0
  185. package/src/native/types.ts +11 -0
  186. package/src/runtime/codec.ts +53 -9
  187. package/src/runtime/types.ts +7 -0
  188. package/src/rust-ffi/batch/build.ts +16 -9
  189. package/src/rust-ffi/index.ts +7 -6
  190. package/src/rust-ffi/options.ts +1 -3
  191. package/src/rust-ffi/proven.ts +3 -3
  192. package/src/rust-ffi/scalar/crypto.ts +1 -1
  193. package/src/rust-ffi/scalar/factories.ts +73 -0
  194. package/src/rust-ffi/scalar/hashing.ts +1 -1
  195. package/src/rust-ffi/scalar/http.ts +71 -27
  196. package/src/rust-ffi/scalar/index.ts +2 -0
  197. package/src/rust-ffi/scalar/interface.ts +20 -0
  198. package/src/rust-ffi/scalar/payload.ts +11 -3
  199. package/src/rust-ffi/scalar/validation.ts +121 -0
  200. package/src/rust-ffi/text.ts +25 -3
  201. package/src/selection.ts +1 -1
  202. package/src/shared/bytes.ts +24 -4
  203. package/src/shared/codec.ts +18 -3
  204. package/src/task/index.ts +24 -0
  205. package/src/task/op.ts +171 -0
  206. package/src/task/runtime.ts +568 -0
@@ -0,0 +1,378 @@
1
+ // rust/crypto/aead.rs — authenticated encryption (AES-256-GCM, ChaCha20-Poly1305).
2
+ //
3
+ // Backend-framework feature: symmetric encryption for cookies / session
4
+ // payloads / at-rest secrets. Uses aws-lc-rs's `aead` module (already a
5
+ // dependency for HMAC-SHA256), so no new crate is pulled in. Nonce and plaintext
6
+ // lengths are validated; a 12-byte nonce is required (both algorithms use the
7
+ // same 96-bit nonce length).
8
+ //
9
+ // Pure-Rust core (no napi types) stays unit-testable; only the entry points
10
+ // use napi types.
11
+
12
+ use aws_lc_rs::aead::{self, Aad, LessSafeKey, Nonce, UnboundKey};
13
+ use aws_lc_rs::error::Unspecified;
14
+ use napi::bindgen_prelude::*;
15
+ use napi_derive::napi;
16
+
17
+ const NONCE_LEN: usize = 12;
18
+
19
+ /// Derive a unique per-item nonce for the batch APIs.
20
+ ///
21
+ /// AES-GCM and ChaCha20-Poly1305 are catastrophic under nonce reuse with the
22
+ /// same key. The batch APIs accept ONE caller-provided base nonce; this derives
23
+ /// a distinct nonce per item by XOR-ing the item index into the low 8 bytes.
24
+ ///
25
+ /// SAFETY CONTRACT: callers MUST use a fresh base nonce for every batch call
26
+ /// (never reuse a base nonce with the same key across calls), otherwise item `i`
27
+ /// of two different batches share a nonce and keystream/tag reuse applies.
28
+ #[inline]
29
+ fn batch_nonce(base: &[u8], index: usize) -> [u8; NONCE_LEN] {
30
+ let mut nonce = [0u8; NONCE_LEN];
31
+ nonce.copy_from_slice(base);
32
+ let idx = (index as u64).to_be_bytes();
33
+ for (slot, byte) in nonce[4..].iter_mut().zip(idx.iter()) {
34
+ *slot ^= byte;
35
+ }
36
+ nonce
37
+ }
38
+
39
+ fn resolve_algorithm(name: Option<&str>) -> Result<&'static aead::Algorithm> {
40
+ match name {
41
+ None | Some("aes-256-gcm") => Ok(&aead::AES_256_GCM),
42
+ Some("chacha20-poly1305") => Ok(&aead::CHACHA20_POLY1305),
43
+ Some(other) => Err(Error::from_reason(format!(
44
+ "unsupported aead algorithm: {other} (expected aes-256-gcm | chacha20-poly1305)"
45
+ ))),
46
+ }
47
+ }
48
+
49
+ // ── Pure-Rust core ─────────────────────────────────────────────
50
+
51
+ /// Encrypt `plaintext`, appending the 16-byte auth tag. Returns the ciphertext
52
+ /// (plaintext length + tag).
53
+ pub fn encrypt(
54
+ alg: &'static aead::Algorithm,
55
+ key: &[u8],
56
+ nonce: &[u8],
57
+ plaintext: &[u8],
58
+ ) -> std::result::Result<Vec<u8>, Unspecified> {
59
+ let unbound = UnboundKey::new(alg, key)?;
60
+ let sealing = LessSafeKey::new(unbound);
61
+ encrypt_with_key(&sealing, nonce, plaintext)
62
+ }
63
+
64
+ /// Encrypt with a PRE-COMPILED key (no per-call `UnboundKey`/`LessSafeKey`
65
+ /// derivation — the win the `AeadCipher` instance exploits).
66
+ pub fn encrypt_with_key(
67
+ sealing: &LessSafeKey,
68
+ nonce: &[u8],
69
+ plaintext: &[u8],
70
+ ) -> std::result::Result<Vec<u8>, Unspecified> {
71
+ if nonce.len() != NONCE_LEN {
72
+ return Err(Unspecified);
73
+ }
74
+ let mut nonce_arr = [0u8; NONCE_LEN];
75
+ nonce_arr.copy_from_slice(nonce);
76
+ let nonce = Nonce::assume_unique_for_key(nonce_arr);
77
+ let mut in_out = plaintext.to_vec();
78
+ sealing.seal_in_place_append_tag(nonce, Aad::empty(), &mut in_out)?;
79
+ Ok(in_out)
80
+ }
81
+
82
+ /// Decrypt `ciphertext` (ciphertext + tag), returning the plaintext.
83
+ pub fn decrypt(
84
+ alg: &'static aead::Algorithm,
85
+ key: &[u8],
86
+ nonce: &[u8],
87
+ ciphertext: &[u8],
88
+ ) -> std::result::Result<Vec<u8>, Unspecified> {
89
+ let unbound = UnboundKey::new(alg, key)?;
90
+ let opening = LessSafeKey::new(unbound);
91
+ decrypt_with_key(&opening, nonce, ciphertext)
92
+ }
93
+
94
+ /// Decrypt with a PRE-COMPILED key (no per-call key derivation).
95
+ pub fn decrypt_with_key(
96
+ opening: &LessSafeKey,
97
+ nonce: &[u8],
98
+ ciphertext: &[u8],
99
+ ) -> std::result::Result<Vec<u8>, Unspecified> {
100
+ if nonce.len() != NONCE_LEN {
101
+ return Err(Unspecified);
102
+ }
103
+ let mut nonce_arr = [0u8; NONCE_LEN];
104
+ nonce_arr.copy_from_slice(nonce);
105
+ let nonce = Nonce::assume_unique_for_key(nonce_arr);
106
+ // Single copy: borrow the ciphertext in, then truncate in place to the
107
+ // plaintext length (the tag occupies the trailing bytes) and return it.
108
+ let mut in_out = ciphertext.to_vec();
109
+ let opened = opening.open_in_place(nonce, Aad::empty(), &mut in_out)?;
110
+ let plaintext_len = opened.len();
111
+ in_out.truncate(plaintext_len);
112
+ Ok(in_out)
113
+ }
114
+
115
+ // ── NAPI entry points ──────────────────────────────────────────
116
+
117
+ /// Encrypt `plaintext` → ciphertext+tag. `key` must be 32 bytes; `nonce` must
118
+ /// be 12 bytes. Defaults to AES-256-GCM; pass `"chacha20-poly1305"` to switch.
119
+ #[napi]
120
+ pub fn aead_encrypt(
121
+ key: Uint8Array,
122
+ nonce: Uint8Array,
123
+ plaintext: Uint8Array,
124
+ algorithm: Option<String>,
125
+ ) -> Result<Buffer> {
126
+ let alg = resolve_algorithm(algorithm.as_deref())?;
127
+ encrypt(alg, key.as_ref(), nonce.as_ref(), plaintext.as_ref())
128
+ .map(Buffer::from)
129
+ .map_err(|e| Error::from_reason(format!("aead encrypt failed: {e:?}")))
130
+ }
131
+
132
+ /// Decrypt `ciphertext`+tag → plaintext. Returns `null` when the tag fails to
133
+ /// authenticate (tampered / wrong key) or the inputs are malformed.
134
+ #[napi]
135
+ pub fn aead_decrypt(
136
+ key: Uint8Array,
137
+ nonce: Uint8Array,
138
+ ciphertext: Uint8Array,
139
+ algorithm: Option<String>,
140
+ ) -> Result<Option<Buffer>> {
141
+ let alg = resolve_algorithm(algorithm.as_deref())?;
142
+ match decrypt(alg, key.as_ref(), nonce.as_ref(), ciphertext.as_ref()) {
143
+ Ok(pt) => Ok(Some(Buffer::from(pt))),
144
+ Err(_) => Ok(None),
145
+ }
146
+ }
147
+
148
+ /// Higher-order instance: precompiles the algorithm + key into a `LessSafeKey`
149
+ /// once at construction, so `encrypt`/`decrypt` never re-derive the key on the
150
+ /// per-call path.
151
+ #[napi]
152
+ pub struct AeadCipher {
153
+ key: LessSafeKey,
154
+ }
155
+
156
+ #[napi]
157
+ impl AeadCipher {
158
+ #[napi(constructor)]
159
+ pub fn new(key: Uint8Array, algorithm: Option<String>) -> Result<Self> {
160
+ let alg = resolve_algorithm(algorithm.as_deref())?;
161
+ let unbound = UnboundKey::new(alg, key.as_ref())
162
+ .map_err(|e| Error::from_reason(format!("invalid aead key: {e:?}")))?;
163
+ Ok(Self {
164
+ key: LessSafeKey::new(unbound),
165
+ })
166
+ }
167
+
168
+ /// Encrypt `plaintext` → ciphertext+tag with the precompiled key.
169
+ #[napi]
170
+ pub fn encrypt(&self, nonce: Uint8Array, plaintext: Uint8Array) -> Result<Buffer> {
171
+ encrypt_with_key(&self.key, nonce.as_ref(), plaintext.as_ref())
172
+ .map(Buffer::from)
173
+ .map_err(|e| Error::from_reason(format!("aead encrypt failed: {e:?}")))
174
+ }
175
+
176
+ /// Decrypt `ciphertext`+tag → plaintext, or `null` on auth failure.
177
+ #[napi]
178
+ pub fn decrypt(&self, nonce: Uint8Array, ciphertext: Uint8Array) -> Result<Option<Buffer>> {
179
+ match decrypt_with_key(&self.key, nonce.as_ref(), ciphertext.as_ref()) {
180
+ Ok(pt) => Ok(Some(Buffer::from(pt))),
181
+ Err(_) => Ok(None),
182
+ }
183
+ }
184
+ }
185
+
186
+ /// Parallel encrypt batch: packed `[u32 count]{[u32 len][plaintext]}` in →
187
+ /// packed `[u32 count]{[u32 len][ciphertext+tag]}` out.
188
+ ///
189
+ /// SECURITY: a unique nonce is derived per item from the caller-provided base
190
+ /// `nonce` (item index XOR-ed into the low 8 bytes). The base nonce MUST be
191
+ /// fresh for every batch call — never reuse a base nonce with the same key.
192
+ #[napi]
193
+ pub fn aead_encrypt_batch_packed(
194
+ data: Uint8Array,
195
+ key: Uint8Array,
196
+ nonce: Uint8Array,
197
+ algorithm: Option<String>,
198
+ ) -> Result<Buffer> {
199
+ let alg = resolve_algorithm(algorithm.as_deref())?;
200
+
201
+ let enc_for = |plaintext: &[u8], index: usize| -> Vec<u8> {
202
+ let n = batch_nonce(nonce.as_ref(), index);
203
+ encrypt(alg, key.as_ref(), &n, plaintext).unwrap_or_default()
204
+ };
205
+
206
+ crate::util::run_packed_batch_idx(data.as_ref(), enc_for).map(Buffer::from)
207
+ }
208
+
209
+ /// Parallel decrypt batch: packed ciphertexts in → packed plaintexts out.
210
+ /// Items whose tag fails to authenticate produce an empty (zero-length) entry.
211
+ ///
212
+ /// SECURITY: the SAME per-item nonce derivation as
213
+ /// {@link aead_encrypt_batch_packed} must be used — pass the same base `nonce`
214
+ /// and batch ordering to decrypt what was encrypted.
215
+ #[napi]
216
+ pub fn aead_decrypt_batch_packed(
217
+ data: Uint8Array,
218
+ key: Uint8Array,
219
+ nonce: Uint8Array,
220
+ algorithm: Option<String>,
221
+ ) -> Result<Buffer> {
222
+ let alg = resolve_algorithm(algorithm.as_deref())?;
223
+
224
+ let dec_for = |ciphertext: &[u8], index: usize| -> Vec<u8> {
225
+ let n = batch_nonce(nonce.as_ref(), index);
226
+ decrypt(alg, key.as_ref(), &n, ciphertext).unwrap_or_default()
227
+ };
228
+
229
+ crate::util::run_packed_batch_idx(data.as_ref(), dec_for).map(Buffer::from)
230
+ }
231
+
232
+ #[cfg(test)]
233
+ mod tests {
234
+ use super::*;
235
+
236
+ fn key_bytes() -> [u8; 32] {
237
+ [7u8; 32]
238
+ }
239
+
240
+ fn nonce_bytes() -> [u8; 12] {
241
+ [3u8; 12]
242
+ }
243
+
244
+ #[test]
245
+ fn aes256gcm_roundtrip() {
246
+ let pt = b"secret session payload";
247
+ let ct = encrypt(&aead::AES_256_GCM, &key_bytes(), &nonce_bytes(), pt).unwrap();
248
+ assert_eq!(ct.len(), pt.len() + 16);
249
+ let dec = decrypt(&aead::AES_256_GCM, &key_bytes(), &nonce_bytes(), &ct).unwrap();
250
+ assert_eq!(dec, pt);
251
+ }
252
+
253
+ #[test]
254
+ fn aead_cipher_instance_roundtrips_and_rejects() {
255
+ // Precompiled-key instance: encrypt/decrypt with no per-call key
256
+ // derivation. Must match the scalar core exactly.
257
+ let cipher = AeadCipher::new(Uint8Array::new(key_bytes().to_vec()), None).unwrap();
258
+ let pt = b"session cookie value";
259
+
260
+ let ct = cipher
261
+ .encrypt(
262
+ Uint8Array::new(nonce_bytes().to_vec()),
263
+ Uint8Array::new(pt.to_vec()),
264
+ )
265
+ .unwrap();
266
+ assert_eq!(ct.len(), pt.len() + 16);
267
+
268
+ let ct_bytes = ct.to_vec();
269
+ let dec = cipher
270
+ .decrypt(
271
+ Uint8Array::new(nonce_bytes().to_vec()),
272
+ Uint8Array::new(ct_bytes.clone()),
273
+ )
274
+ .unwrap();
275
+ assert_eq!(&dec.unwrap()[..], pt);
276
+
277
+ // Wrong-key instance fails to authenticate.
278
+ let other = AeadCipher::new(Uint8Array::new([9u8; 32].to_vec()), None).unwrap();
279
+ assert!(other
280
+ .decrypt(
281
+ Uint8Array::new(nonce_bytes().to_vec()),
282
+ Uint8Array::new(ct_bytes)
283
+ )
284
+ .unwrap()
285
+ .is_none());
286
+
287
+ // Malformed nonce length → error.
288
+ assert!(cipher
289
+ .encrypt(Uint8Array::new(vec![0u8; 8]), Uint8Array::new(pt.to_vec()))
290
+ .is_err());
291
+
292
+ // Unsupported algorithm → construction error.
293
+ assert!(AeadCipher::new(
294
+ Uint8Array::new(key_bytes().to_vec()),
295
+ Some("aes-128-gcm".to_string())
296
+ )
297
+ .is_err());
298
+ }
299
+
300
+ #[test]
301
+ fn batch_nonce_unique_per_item() {
302
+ // Regression: the batch APIs must NEVER reuse a nonce across items.
303
+ let base = nonce_bytes();
304
+ let n0 = batch_nonce(&base, 0);
305
+ let n1 = batch_nonce(&base, 1);
306
+ let n2 = batch_nonce(&base, 2);
307
+ assert_ne!(n0, n1, "nonce for item 0 and 1 must differ");
308
+ assert_ne!(n1, n2, "nonce for item 1 and 2 must differ");
309
+ assert_ne!(n0, n2);
310
+ // Uniqueness for a full large batch too.
311
+ let mut seen = std::collections::HashSet::new();
312
+ for i in 0..1024 {
313
+ assert!(
314
+ seen.insert(batch_nonce(&base, i)),
315
+ "nonce reused at item {i}"
316
+ );
317
+ }
318
+ }
319
+
320
+ #[test]
321
+ fn batch_nonce_derived_encrypt_roundtrip() {
322
+ // Each item encrypted with its derived nonce decrypts correctly, and a
323
+ // mismatched nonce fails to authenticate (catches nonce-reuse misuse).
324
+ let alg = &aead::AES_256_GCM;
325
+ let key = key_bytes();
326
+ let base = nonce_bytes();
327
+
328
+ let p0 = b"first payload";
329
+ let p1 = b"second payload";
330
+
331
+ let c0 = encrypt(alg, &key, &batch_nonce(&base, 0), p0).unwrap();
332
+ let c1 = encrypt(alg, &key, &batch_nonce(&base, 1), p1).unwrap();
333
+
334
+ assert_eq!(decrypt(alg, &key, &batch_nonce(&base, 0), &c0).unwrap(), p0);
335
+ assert_eq!(decrypt(alg, &key, &batch_nonce(&base, 1), &c1).unwrap(), p1);
336
+ // Cross-nonce decryption must fail authentication.
337
+ assert!(decrypt(alg, &key, &batch_nonce(&base, 1), &c0).is_err());
338
+ assert!(decrypt(alg, &key, &batch_nonce(&base, 0), &c1).is_err());
339
+ }
340
+
341
+ #[test]
342
+ fn chacha20_roundtrip() {
343
+ let pt = b"another payload";
344
+ let ct = encrypt(&aead::CHACHA20_POLY1305, &key_bytes(), &nonce_bytes(), pt).unwrap();
345
+ let dec = decrypt(&aead::CHACHA20_POLY1305, &key_bytes(), &nonce_bytes(), &ct).unwrap();
346
+ assert_eq!(dec, pt);
347
+ }
348
+
349
+ #[test]
350
+ fn tampered_ciphertext_fails_auth() {
351
+ let pt = b"integrity matters";
352
+ let mut ct = encrypt(&aead::AES_256_GCM, &key_bytes(), &nonce_bytes(), pt).unwrap();
353
+ ct[0] ^= 0xff;
354
+ assert!(decrypt(&aead::AES_256_GCM, &key_bytes(), &nonce_bytes(), &ct).is_err());
355
+ }
356
+
357
+ #[test]
358
+ fn wrong_key_fails_auth() {
359
+ let pt = b"integrity matters";
360
+ let ct = encrypt(&aead::AES_256_GCM, &key_bytes(), &nonce_bytes(), pt).unwrap();
361
+ let wrong_key = [9u8; 32];
362
+ assert!(decrypt(&aead::AES_256_GCM, &wrong_key, &nonce_bytes(), &ct).is_err());
363
+ }
364
+
365
+ #[test]
366
+ fn wrong_nonce_fails_auth() {
367
+ let pt = b"integrity matters";
368
+ let ct = encrypt(&aead::AES_256_GCM, &key_bytes(), &nonce_bytes(), pt).unwrap();
369
+ let wrong_nonce = [4u8; 12];
370
+ assert!(decrypt(&aead::AES_256_GCM, &key_bytes(), &wrong_nonce, &ct).is_err());
371
+ }
372
+
373
+ #[test]
374
+ fn rejects_bad_nonce_length() {
375
+ assert!(encrypt(&aead::AES_256_GCM, &key_bytes(), &[0u8; 8], b"x").is_err());
376
+ assert!(decrypt(&aead::AES_256_GCM, &key_bytes(), &[0u8; 8], b"x").is_err());
377
+ }
378
+ }
@@ -0,0 +1,265 @@
1
+ // rust/crypto/argon2.rs — argon2id password hashing.
2
+ //
3
+ // Backend-framework feature: password storage hashing. argon2id is the OWASP
4
+ // recommendation for new hashes; it is deliberately CPU/memory-heavy, which
5
+ // makes it an ideal candidate to offload to Rust (and parallelize with rayon).
6
+ // The `argon2` crate (RustCrypto) is pure Rust; `SaltString::encode_b64` lets us
7
+ // feed a caller-supplied salt so outputs are deterministic (unit-testable).
8
+ //
9
+ // Pure-Rust core (no napi types) stays unit-testable; only the entry points
10
+ // use napi types.
11
+
12
+ use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
13
+ use argon2::{Algorithm, Argon2, Params, Version};
14
+ use napi::bindgen_prelude::*;
15
+ use napi_derive::napi;
16
+
17
+ // ── Options ────────────────────────────────────────────────────
18
+
19
+ /// argon2id parameters (defaults follow OWASP: 19 MiB, 2 iterations, 1 lane,
20
+ /// 32-byte output). Callers may lower the memory cost for fast benchmarks.
21
+ #[napi(object)]
22
+ pub struct PasswordHashOptions {
23
+ /// Memory cost in KiB (m). Default: 19_456 (19 MiB).
24
+ pub m_cost: Option<u32>,
25
+ /// Iterations (t). Default: 2.
26
+ pub t_cost: Option<u32>,
27
+ /// Parallelism (p). Default: 1.
28
+ pub p_cost: Option<u32>,
29
+ /// Output length in bytes. Default: 32.
30
+ pub out_len: Option<u32>,
31
+ }
32
+
33
+ fn resolve_opts(o: Option<&PasswordHashOptions>) -> (u32, u32, u32, u32) {
34
+ match o {
35
+ Some(o) => (
36
+ o.m_cost.unwrap_or(19_456),
37
+ o.t_cost.unwrap_or(2),
38
+ o.p_cost.unwrap_or(1),
39
+ o.out_len.unwrap_or(32),
40
+ ),
41
+ None => (19_456, 2, 1, 32),
42
+ }
43
+ }
44
+
45
+ // ── Pure-Rust core ─────────────────────────────────────────────
46
+
47
+ /// Hash `password` with argon2id using the given raw salt bytes. Returns the
48
+ /// PHC string (`$argon2id$v=19$m=...,t=...,p=...$<b64 salt>$<b64 hash>`).
49
+ pub fn hash_password(
50
+ password: &[u8],
51
+ salt: &[u8],
52
+ m_cost: u32,
53
+ t_cost: u32,
54
+ p_cost: u32,
55
+ out_len: u32,
56
+ ) -> std::result::Result<String, argon2::password_hash::Error> {
57
+ let params = Params::new(m_cost, t_cost, p_cost, Some(out_len as usize))?;
58
+ let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
59
+ let salt = SaltString::encode_b64(salt)?;
60
+ Ok(argon2.hash_password(password, &salt)?.to_string())
61
+ }
62
+
63
+ /// Verify a password against a PHC string (constant-time internally).
64
+ pub fn verify_password(password: &[u8], phc: &[u8]) -> bool {
65
+ let Ok(phc_str) = std::str::from_utf8(phc) else {
66
+ return false;
67
+ };
68
+ let Ok(parsed) = PasswordHash::new(phc_str) else {
69
+ return false;
70
+ };
71
+ Argon2::default().verify_password(password, &parsed).is_ok()
72
+ }
73
+
74
+ // ── NAPI entry points ──────────────────────────────────────────
75
+
76
+ /// Hash a password with argon2id. `salt` must be at least 8 bytes; output is a
77
+ /// PHC string. Deterministic given the same inputs.
78
+ #[napi]
79
+ pub fn password_hash(
80
+ password: Uint8Array,
81
+ salt: Uint8Array,
82
+ options: Option<PasswordHashOptions>,
83
+ ) -> Result<Buffer> {
84
+ let (m, t, p, out_len) = resolve_opts(options.as_ref());
85
+ let phc = hash_password(password.as_ref(), salt.as_ref(), m, t, p, out_len)
86
+ .map_err(|e| Error::from_reason(format!("argon2 hash failed: {e}")))?;
87
+ Ok(Buffer::from(phc.into_bytes()))
88
+ }
89
+
90
+ /// Verify a password against a PHC string. Returns false on any parse failure.
91
+ #[napi]
92
+ pub fn password_verify(password: Uint8Array, phc: Buffer) -> bool {
93
+ verify_password(password.as_ref(), phc.as_ref())
94
+ }
95
+
96
+ /// Packed password-verify batch (two packed lists: passwords + PHC strings,
97
+ /// zipped) → bitset of valid. Items with malformed PHC strings yield `false`.
98
+ #[napi]
99
+ pub fn password_verify_batch_packed(passwords: Uint8Array, phcs: Uint8Array) -> Result<Buffer> {
100
+ use crate::util::packed::PackedIter;
101
+ let pws = passwords.as_ref();
102
+ let ph = phcs.as_ref();
103
+ let count = PackedIter::new(pws)?.len().min(PackedIter::new(ph)?.len());
104
+ let mut out = Vec::with_capacity(4 + count.div_ceil(8));
105
+ out.extend_from_slice(&(count as u32).to_le_bytes());
106
+ out.resize(4 + count.div_ceil(8), 0);
107
+ for (i, (pw, phc)) in PackedIter::new(pws)?.zip(PackedIter::new(ph)?).enumerate() {
108
+ if verify_password(pw, phc) {
109
+ out[4 + (i >> 3)] |= 1 << (i & 7);
110
+ }
111
+ }
112
+ Ok(Buffer::from(out))
113
+ }
114
+
115
+ /// Higher-order instance: precompiles the argon2id `Params`/`Argon2` once at
116
+ /// construction (fixed m/t/p/out_len), so every `hash` skips the per-call
117
+ /// parameter construction — the CPU/memory cost of the hash itself is
118
+ /// unchanged, only the setup is amortized.
119
+ #[napi]
120
+ pub struct Argon2Hasher {
121
+ argon2: Argon2<'static>,
122
+ }
123
+
124
+ #[napi]
125
+ impl Argon2Hasher {
126
+ #[napi(constructor)]
127
+ pub fn new(options: Option<PasswordHashOptions>) -> Result<Self> {
128
+ let (m, t, p, out_len) = resolve_opts(options.as_ref());
129
+ let params = Params::new(m, t, p, Some(out_len as usize))
130
+ .map_err(|e| Error::from_reason(format!("invalid argon2 params: {e}")))?;
131
+ Ok(Self {
132
+ argon2: Argon2::new(Algorithm::Argon2id, Version::V0x13, params),
133
+ })
134
+ }
135
+
136
+ /// Hash a password with the precompiled params → PHC string bytes.
137
+ #[napi]
138
+ pub fn hash(&self, password: Uint8Array, salt: Uint8Array) -> Result<Buffer> {
139
+ let salt = SaltString::encode_b64(salt.as_ref())
140
+ .map_err(|e| Error::from_reason(format!("invalid salt: {e}")))?;
141
+ let phc = self
142
+ .argon2
143
+ .hash_password(password.as_ref(), &salt)
144
+ .map_err(|e| Error::from_reason(format!("argon2 hash failed: {e}")))?;
145
+ Ok(Buffer::from(phc.to_string().into_bytes()))
146
+ }
147
+
148
+ /// Verify a password against a PHC string. Returns false on any failure.
149
+ #[napi]
150
+ pub fn verify(&self, password: Uint8Array, phc: Buffer) -> bool {
151
+ verify_password(password.as_ref(), phc.as_ref())
152
+ }
153
+ }
154
+
155
+ /// Parallel argon2id batch: packed `[u32 count]{[u32 len][password]}` in →
156
+ /// packed `[u32 count]{[u32 len][phc]}` out, all hashed with the SAME salt.
157
+ #[napi]
158
+ pub fn password_hash_batch_packed(
159
+ data: Uint8Array,
160
+ salt: Uint8Array,
161
+ options: Option<PasswordHashOptions>,
162
+ ) -> Result<Buffer> {
163
+ let (m, t, p, out_len) = resolve_opts(options.as_ref());
164
+
165
+ let phc_for = |password: &[u8]| -> Vec<u8> {
166
+ hash_password(password, salt.as_ref(), m, t, p, out_len)
167
+ .map(|s| s.into_bytes())
168
+ .unwrap_or_default()
169
+ };
170
+
171
+ crate::util::run_packed_batch(data.as_ref(), phc_for).map(Buffer::from)
172
+ }
173
+
174
+ #[cfg(test)]
175
+ mod tests {
176
+ use super::*;
177
+
178
+ const PASSWORD: &[u8] = b"correct horse battery staple";
179
+ const SALT: &[u8] = b"0123456789abcdef";
180
+
181
+ #[test]
182
+ fn hash_then_verify_roundtrip() {
183
+ let phc = hash_password(PASSWORD, SALT, 4096, 2, 1, 32).unwrap();
184
+ assert!(phc.starts_with("$argon2id$v=19$"));
185
+ assert!(verify_password(PASSWORD, phc.as_bytes()));
186
+ }
187
+
188
+ #[test]
189
+ fn argon2_hasher_instance_roundtrips() {
190
+ // Precompiled-params instance: hash/verify with no per-call Params
191
+ // construction. Output must match the scalar core for the same params.
192
+ let hasher = Argon2Hasher::new(Some(PasswordHashOptions {
193
+ m_cost: Some(4096),
194
+ t_cost: Some(2),
195
+ p_cost: Some(1),
196
+ out_len: Some(32),
197
+ }))
198
+ .unwrap();
199
+
200
+ let phc = hasher
201
+ .hash(
202
+ Uint8Array::new(PASSWORD.to_vec()),
203
+ Uint8Array::new(SALT.to_vec()),
204
+ )
205
+ .unwrap();
206
+ let phc_str = String::from_utf8(phc.to_vec()).unwrap();
207
+ assert!(phc_str.starts_with("$argon2id$v=19$"));
208
+
209
+ // The instance output matches the scalar core byte-for-byte.
210
+ let scalar = hash_password(PASSWORD, SALT, 4096, 2, 1, 32).unwrap();
211
+ assert_eq!(phc_str, scalar);
212
+
213
+ // Verify: correct password true, wrong password false.
214
+ assert!(hasher.verify(
215
+ Uint8Array::new(PASSWORD.to_vec()),
216
+ Buffer::from(phc_str.clone().into_bytes())
217
+ ));
218
+ assert!(!hasher.verify(
219
+ Uint8Array::new(b"wrong-password".to_vec()),
220
+ Buffer::from(phc_str.into_bytes())
221
+ ));
222
+
223
+ // Invalid options → construction error.
224
+ assert!(Argon2Hasher::new(Some(PasswordHashOptions {
225
+ m_cost: Some(0),
226
+ t_cost: Some(2),
227
+ p_cost: Some(1),
228
+ out_len: Some(32),
229
+ }))
230
+ .is_err());
231
+ }
232
+
233
+ #[test]
234
+ fn verify_rejects_wrong_password() {
235
+ let phc = hash_password(PASSWORD, SALT, 4096, 2, 1, 32).unwrap();
236
+ assert!(!verify_password(b"wrong password", phc.as_bytes()));
237
+ }
238
+
239
+ #[test]
240
+ fn verify_is_deterministic_given_salt() {
241
+ let a = hash_password(PASSWORD, SALT, 4096, 2, 1, 32).unwrap();
242
+ let b = hash_password(PASSWORD, SALT, 4096, 2, 1, 32).unwrap();
243
+ assert_eq!(a, b);
244
+ }
245
+
246
+ #[test]
247
+ fn different_salt_different_hash() {
248
+ let a = hash_password(PASSWORD, b"aaaaaaaaaaaaaaaa", 4096, 2, 1, 32).unwrap();
249
+ let b = hash_password(PASSWORD, b"bbbbbbbbbbbbbbbb", 4096, 2, 1, 32).unwrap();
250
+ assert_ne!(a, b);
251
+ }
252
+
253
+ #[test]
254
+ fn verify_rejects_garbage() {
255
+ assert!(!verify_password(PASSWORD, b"not-a-phc-string"));
256
+ assert!(!verify_password(PASSWORD, b""));
257
+ }
258
+
259
+ #[test]
260
+ fn params_validation() {
261
+ assert!(hash_password(PASSWORD, SALT, 0, 2, 1, 32).is_err());
262
+ // Salt too short (argon2 requires >= 8 bytes).
263
+ assert!(hash_password(PASSWORD, b"123", 4096, 2, 1, 32).is_err());
264
+ }
265
+ }