castrum 0.9.7 → 0.9.9

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 (108) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/castrum.darwin-arm64.node +0 -0
  4. package/castrum.darwin-x64.node +0 -0
  5. package/castrum.linux-arm64-gnu.node +0 -0
  6. package/castrum.linux-arm64-musl.node +0 -0
  7. package/castrum.linux-x64-gnu.node +0 -0
  8. package/castrum.linux-x64-musl.node +0 -0
  9. package/castrum.linux-x64-v3-gnu.node +0 -0
  10. package/castrum.win32-x64-msvc.node +0 -0
  11. package/dist/index.d.ts +664 -8
  12. package/dist/index.js +2014 -1327
  13. package/docs/BENCHMARKS.md +13 -0
  14. package/docs/CASE_STUDY.md +1 -1
  15. package/docs/ENVIRONMENT.md +8 -1
  16. package/docs/FFI_BUN_GUIDE.md +1 -1
  17. package/docs/INGRESS.md +4 -4
  18. package/docs/REPO_MAP.md +7 -5
  19. package/docs/RND-CONCURRENCY.md +20 -4
  20. package/docs/superpowers/plans/2026-09-18-castrum-hardening-cleanup.md +617 -0
  21. package/docs/superpowers/plans/2026-09-18-elysia-learnings-adoption.md +634 -0
  22. package/index.ts +6 -0
  23. package/package.json +4 -2
  24. package/rust/crypto/aead.rs +44 -0
  25. package/rust/crypto/argon2.rs +32 -2
  26. package/rust/crypto/jwt/tests.rs +19 -0
  27. package/rust/crypto/jwt/token.rs +43 -28
  28. package/rust/crypto/session.rs +6 -0
  29. package/rust/ffi/crypto.rs +63 -32
  30. package/rust/ffi/hashing.rs +48 -33
  31. package/rust/ffi/http.rs +62 -36
  32. package/rust/ffi/metrics.rs +6 -2
  33. package/rust/ffi/payload.rs +47 -27
  34. package/rust/ffi/task.rs +55 -35
  35. package/rust/ffi/tests.rs +24 -0
  36. package/rust/ffi/util.rs +40 -0
  37. package/rust/http/headers.rs +13 -0
  38. package/rust/http/multipart.rs +139 -0
  39. package/rust/ingress/api.rs +21 -5
  40. package/rust/ingress/mod.rs +7 -0
  41. package/rust/ingress/native_route.rs +25 -5
  42. package/rust/ingress/schema_cache.rs +266 -0
  43. package/rust/task/completion.rs +7 -0
  44. package/rust/task/mod.rs +2 -1
  45. package/rust/task/runtime.rs +55 -7
  46. package/rust/task/tests.rs +128 -1
  47. package/src/ingress/abort.ts +48 -0
  48. package/src/ingress/body.ts +104 -8
  49. package/src/ingress/decode/baked-result.ts +13 -5
  50. package/src/ingress/decode/fast-result.ts +1 -1
  51. package/src/ingress/decode/result-base.ts +16 -2
  52. package/src/ingress/handlers.ts +104 -31
  53. package/src/ingress/headers/memoized-headers.ts +40 -0
  54. package/src/ingress/health.ts +22 -0
  55. package/src/ingress/index.ts +3 -1
  56. package/src/ingress/native-route.ts +25 -6
  57. package/src/ingress/options.ts +26 -2
  58. package/src/ingress/packing/gather-raw-headers.ts +24 -8
  59. package/src/ingress/response/baked-response.ts +118 -28
  60. package/src/ingress/router.ts +50 -3
  61. package/src/ingress/routes/common.ts +35 -11
  62. package/src/ingress/routes/echo.ts +60 -7
  63. package/src/ingress/routes/json-write.ts +32 -8
  64. package/src/ingress/routes/native.ts +24 -5
  65. package/src/ingress/routes/options.ts +6 -2
  66. package/src/ingress/routes/responder.ts +29 -9
  67. package/src/ingress/routes/safe-record.ts +57 -0
  68. package/src/ingress/server-error.ts +111 -0
  69. package/src/ingress/server-node.ts +135 -37
  70. package/src/ingress/server.ts +143 -36
  71. package/src/ingress/shared.ts +1 -1
  72. package/src/ingress/types.ts +15 -0
  73. package/src/integration/pipeline.ts +6 -0
  74. package/src/loader/ops.ts +1 -1
  75. package/src/native/ffi/build/codecs-selftest.ts +305 -0
  76. package/src/native/ffi/build/codecs.ts +0 -296
  77. package/src/native/ffi/build/instances-selftest.ts +264 -0
  78. package/src/native/ffi/build/instances.ts +10 -259
  79. package/src/native/ffi/build/parse-selftest.ts +238 -0
  80. package/src/native/ffi/build/parse.ts +11 -234
  81. package/src/native/ffi/build/task.ts +2 -1
  82. package/src/native/ffi/build.ts +5 -1
  83. package/src/native/ffi/selftest.ts +3 -3
  84. package/src/native/ffi/symbols.ts +460 -0
  85. package/src/native/ffi/types.ts +6 -1
  86. package/src/native/ffi.ts +2 -434
  87. package/src/native/ingress-binding.ts +39 -0
  88. package/src/native/types.ts +6 -0
  89. package/src/runtime/codec.ts +24 -0
  90. package/src/runtime/detect.ts +14 -0
  91. package/src/runtime/types.ts +11 -0
  92. package/src/rust-ffi/batch/build.ts +18 -8
  93. package/src/rust-ffi/batch/types.ts +15 -0
  94. package/src/rust-ffi/context.ts +46 -3
  95. package/src/rust-ffi/options.ts +19 -0
  96. package/src/rust-ffi/scalar/crypto.ts +8 -0
  97. package/src/rust-ffi/scalar/factories.ts +53 -20
  98. package/src/rust-ffi/scalar/http.ts +60 -10
  99. package/src/rust-ffi/scalar/interface.ts +18 -1
  100. package/src/rust-ffi/scalar/payload.ts +27 -1
  101. package/src/rust-ffi/text.ts +5 -4
  102. package/src/shared/buffer-pool.ts +54 -9
  103. package/src/shared/codec.ts +26 -0
  104. package/src/shared/memory.ts +68 -0
  105. package/src/shared/metrics.ts +86 -3
  106. package/src/shared/packed/parsers.ts +64 -7
  107. package/src/shared/runtime.ts +7 -0
  108. package/src/task/runtime.ts +81 -37
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castrum",
3
- "version": "0.9.7",
3
+ "version": "0.9.9",
4
4
  "private": false,
5
5
  "description": "Enterprise-grade Bun + Rust runtime: Rust-accelerated FFI primitives, a production-grade HTTP ingress pipeline, validation, hashing, and JSON processing with benchmark-grade performance.",
6
6
  "type": "module",
@@ -66,7 +66,7 @@
66
66
  "build:all": "bun run build && bun run build:v3",
67
67
  "build:js": "bun run build:js:bundle && bun run build:js:types",
68
68
  "build:js:bundle": "bun build ./index.ts --target=node --format=esm --outdir=dist",
69
- "build:js:types": "dts-bundle-generator -o dist/index.d.ts index.ts",
69
+ "build:js:types": "node scripts/ensure-dts-typescript.mjs && dts-bundle-generator -o dist/index.d.ts index.ts",
70
70
  "bench": "bun run build && bun bench.ts",
71
71
  "check": "bun bench.ts",
72
72
  "test": "bun test test/unit test/property test/compat",
@@ -74,6 +74,7 @@
74
74
  "test:coverage": "node scripts/check-coverage.mjs",
75
75
  "setup": "bun install && bun run build",
76
76
  "test:node": "node --test test/integration/node-smoke.test.mjs test/integration/node-enterprise.test.mjs",
77
+ "test:differential": "bun test test/differential",
77
78
  "verify:install": "node scripts/verify-install.mjs",
78
79
  "build:debug": "napi build --platform",
79
80
  "lint": "bunx biome check .",
@@ -146,6 +147,7 @@
146
147
  "fast-json-patch": "^3.1.1",
147
148
  "mime-types": "^3.0.2",
148
149
  "typescript": "7.0.2",
150
+ "typescript-dts": "npm:typescript@5.9.3",
149
151
  "validator": "^13.15.35"
150
152
  },
151
153
  "overrides": {
@@ -36,6 +36,18 @@ fn batch_nonce(base: &[u8], index: usize) -> [u8; NONCE_LEN] {
36
36
  nonce
37
37
  }
38
38
 
39
+ /// Reject a nonce that is not exactly [`NONCE_LEN`] bytes. Guards the batch
40
+ /// entry points, whose `copy_from_slice` would otherwise panic.
41
+ fn validate_nonce(nonce: &[u8]) -> Result<()> {
42
+ if nonce.len() != NONCE_LEN {
43
+ return Err(Error::from_reason(format!(
44
+ "aead nonce must be {NONCE_LEN} bytes, got {}",
45
+ nonce.len()
46
+ )));
47
+ }
48
+ Ok(())
49
+ }
50
+
39
51
  fn resolve_algorithm(name: Option<&str>) -> Result<&'static aead::Algorithm> {
40
52
  match name {
41
53
  None | Some("aes-256-gcm") => Ok(&aead::AES_256_GCM),
@@ -197,6 +209,7 @@ pub fn aead_encrypt_batch_packed(
197
209
  algorithm: Option<String>,
198
210
  ) -> Result<Buffer> {
199
211
  let alg = resolve_algorithm(algorithm.as_deref())?;
212
+ validate_nonce(nonce.as_ref())?;
200
213
 
201
214
  let enc_for = |plaintext: &[u8], index: usize| -> Vec<u8> {
202
215
  let n = batch_nonce(nonce.as_ref(), index);
@@ -220,6 +233,7 @@ pub fn aead_decrypt_batch_packed(
220
233
  algorithm: Option<String>,
221
234
  ) -> Result<Buffer> {
222
235
  let alg = resolve_algorithm(algorithm.as_deref())?;
236
+ validate_nonce(nonce.as_ref())?;
223
237
 
224
238
  let dec_for = |ciphertext: &[u8], index: usize| -> Vec<u8> {
225
239
  let n = batch_nonce(nonce.as_ref(), index);
@@ -317,6 +331,36 @@ mod tests {
317
331
  }
318
332
  }
319
333
 
334
+ fn packed_one(bytes: &[u8]) -> Uint8Array {
335
+ let mut data = Vec::new();
336
+ data.extend_from_slice(&1u32.to_le_bytes());
337
+ data.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
338
+ data.extend_from_slice(bytes);
339
+ Uint8Array::new(data)
340
+ }
341
+
342
+ #[test]
343
+ fn batch_rejects_a_non_12_byte_nonce() {
344
+ // A wrong-length nonce used to panic inside `copy_from_slice` (and the
345
+ // napi sync trampoline unwinds through extern "C" → abort). It must be
346
+ // a normal JS error instead.
347
+ let key = key_bytes();
348
+ assert!(aead_encrypt_batch_packed(
349
+ packed_one(b"abc"),
350
+ Uint8Array::new(key.to_vec()),
351
+ Uint8Array::new(vec![0u8; 16]),
352
+ None,
353
+ )
354
+ .is_err());
355
+ assert!(aead_decrypt_batch_packed(
356
+ packed_one(b"abc"),
357
+ Uint8Array::new(key.to_vec()),
358
+ Uint8Array::new(vec![0u8; 8]),
359
+ None,
360
+ )
361
+ .is_err());
362
+ }
363
+
320
364
  #[test]
321
365
  fn batch_nonce_derived_encrypt_roundtrip() {
322
366
  // Each item encrypted with its derived nonce decrypts correctly, and a
@@ -30,8 +30,16 @@ pub struct PasswordHashOptions {
30
30
  pub out_len: Option<u32>,
31
31
  }
32
32
 
33
+ /// Sanity caps for caller-supplied argon2 params: a hostile or mis-set config
34
+ /// must not force a multi-GiB allocation or a multi-minute CPU burn. Generous
35
+ /// enough for any real deployment (defaults are 19 MiB / 2 / 1 / 32).
36
+ pub const ARGON2_MAX_M_COST: u32 = 1_048_576; // 1 GiB, in KiB
37
+ pub const ARGON2_MAX_T_COST: u32 = 64;
38
+ pub const ARGON2_MAX_P_COST: u32 = 64;
39
+ pub const ARGON2_MAX_OUT_LEN: u32 = 1024;
40
+
33
41
  fn resolve_opts(o: Option<&PasswordHashOptions>) -> (u32, u32, u32, u32) {
34
- match o {
42
+ let (m, t, p, out) = match o {
35
43
  Some(o) => (
36
44
  o.m_cost.unwrap_or(19_456),
37
45
  o.t_cost.unwrap_or(2),
@@ -39,7 +47,13 @@ fn resolve_opts(o: Option<&PasswordHashOptions>) -> (u32, u32, u32, u32) {
39
47
  o.out_len.unwrap_or(32),
40
48
  ),
41
49
  None => (19_456, 2, 1, 32),
42
- }
50
+ };
51
+ (
52
+ m.min(ARGON2_MAX_M_COST),
53
+ t.min(ARGON2_MAX_T_COST),
54
+ p.min(ARGON2_MAX_P_COST),
55
+ out.min(ARGON2_MAX_OUT_LEN),
56
+ )
43
57
  }
44
58
 
45
59
  // ── Pure-Rust core ─────────────────────────────────────────────
@@ -230,6 +244,22 @@ mod tests {
230
244
  .is_err());
231
245
  }
232
246
 
247
+ #[test]
248
+ fn argon2_params_are_capped() {
249
+ let (m, t, p, out) = resolve_opts(Some(&PasswordHashOptions {
250
+ m_cost: Some(u32::MAX),
251
+ t_cost: Some(u32::MAX),
252
+ p_cost: Some(u32::MAX),
253
+ out_len: Some(u32::MAX),
254
+ }));
255
+ assert_eq!(m, ARGON2_MAX_M_COST);
256
+ assert_eq!(t, ARGON2_MAX_T_COST);
257
+ assert_eq!(p, ARGON2_MAX_P_COST);
258
+ assert_eq!(out, ARGON2_MAX_OUT_LEN);
259
+ // Sane defaults pass through untouched.
260
+ assert_eq!(resolve_opts(None), (19_456, 2, 1, 32));
261
+ }
262
+
233
263
  #[test]
234
264
  fn verify_rejects_wrong_password() {
235
265
  let phc = hash_password(PASSWORD, SALT, 4096, 2, 1, 32).unwrap();
@@ -76,6 +76,25 @@ fn verify_token_enforces_iat_leeway() {
76
76
  assert!(verify_token(&near, SECRET, now).is_some());
77
77
  }
78
78
 
79
+ #[test]
80
+ fn verify_token_rejects_non_integer_or_non_numeric_exp() {
81
+ let now = 1_000_000i64;
82
+ // A fractional exp in the PAST must reject. Pre-fix `as_i64()` returned
83
+ // None for a float, so the expiry check was skipped and the token verified
84
+ // as never-expiring.
85
+ let frac_past = token(&serde_json::json!({ "sub": "1", "exp": 999_999.5 }), SECRET);
86
+ assert!(verify_token(&frac_past, SECRET, now).is_none());
87
+ // Present-but-non-numeric exp is malformed → reject.
88
+ let string_exp = token(&serde_json::json!({ "sub": "1", "exp": "999999" }), SECRET);
89
+ assert!(verify_token(&string_exp, SECRET, now).is_none());
90
+ // A fractional exp in the FUTURE is a valid RFC 7519 NumericDate → accept.
91
+ let frac_future = token(
92
+ &serde_json::json!({ "sub": "1", "exp": 1_000_000.5 }),
93
+ SECRET,
94
+ );
95
+ assert!(verify_token(&frac_future, SECRET, now).is_some());
96
+ }
97
+
79
98
  #[test]
80
99
  fn verify_token_accepts_valid_claims() {
81
100
  let claims = serde_json::json!({ "sub": "123", "role": "admin" });
@@ -113,19 +113,6 @@ pub fn build_token_with_key(
113
113
  signing_input
114
114
  }
115
115
 
116
- /// Constant-time byte comparison (no early exit on length mismatch beyond the
117
- /// required guard — lengths differ → false).
118
- fn ct_eq(a: &[u8], b: &[u8]) -> bool {
119
- if a.len() != b.len() {
120
- return false;
121
- }
122
- let mut diff = 0u8;
123
- for (x, y) in a.iter().zip(b.iter()) {
124
- diff |= x ^ y;
125
- }
126
- diff == 0
127
- }
128
-
129
116
  /// Verify an HS256 signature (constant-time). Does NOT check claims/expiry.
130
117
  ///
131
118
  /// Test-only convenience over the fresh-key derivation: production code uses
@@ -148,10 +135,11 @@ pub fn verify_signature_with_key(token: &[u8], key: &aws_lc_rs::hmac::Key) -> bo
148
135
  signing_input.push(b'.');
149
136
  signing_input.extend_from_slice(parts.payload_b64);
150
137
 
151
- let sig = hmac_sha256_with_key(key, &signing_input);
152
-
153
138
  match base64url_decode_bytes(parts.sig_b64) {
154
- Some(provided) => ct_eq(&sig, &provided),
139
+ // Audited constant-time comparison (aws-lc-rs), matching every other
140
+ // MAC path in the crate (cookie sign / CSRF / hmac_sha256) instead of a
141
+ // hand-rolled loop the compiler could optimize into an early exit.
142
+ Some(provided) => aws_lc_rs::hmac::verify(key, &signing_input, &provided).is_ok(),
155
143
  None => false,
156
144
  }
157
145
  }
@@ -239,31 +227,58 @@ pub(crate) fn verify_time_claims(payload_b64: &[u8], now_seconds: i64) -> Option
239
227
  let payload = base64url_decode_bytes(payload_b64)?;
240
228
  let value: sonic_rs::Value = sonic_rs::from_slice(&payload).ok()?;
241
229
 
230
+ // RFC 7519 NumericDate may be non-integer, so parse as f64 — `as_i64()`
231
+ // returns None for a float, which previously SKIPPED the check entirely and
232
+ // made a token with a fractional `exp` verify as never-expiring. A claim
233
+ // that is present but not a finite number is malformed → reject.
234
+ let now = now_seconds as f64;
235
+
242
236
  // `exp`: reject when now >= exp.
243
- if let Some(exp) = value.get("exp").and_then(|v| v.as_i64()) {
244
- if now_seconds >= exp {
245
- return None;
246
- }
237
+ match time_claim(&value, "exp") {
238
+ TimeClaim::Absent => {}
239
+ TimeClaim::Invalid => return None,
240
+ TimeClaim::Value(exp) if now >= exp => return None,
241
+ TimeClaim::Value(_) => {}
247
242
  }
248
243
 
249
244
  // `nbf` (not before): reject when the token is not yet valid.
250
- if let Some(nbf) = value.get("nbf").and_then(|v| v.as_i64()) {
251
- if now_seconds < nbf {
252
- return None;
253
- }
245
+ match time_claim(&value, "nbf") {
246
+ TimeClaim::Absent => {}
247
+ TimeClaim::Invalid => return None,
248
+ TimeClaim::Value(nbf) if now < nbf => return None,
249
+ TimeClaim::Value(_) => {}
254
250
  }
255
251
 
256
252
  // `iat` (issued at): reject tokens issued in the future beyond a small
257
253
  // clock-skew leeway.
258
- if let Some(iat) = value.get("iat").and_then(|v| v.as_i64()) {
259
- if now_seconds < iat.saturating_sub(CLOCK_SKEW_LEEWAY_SECS) {
260
- return None;
261
- }
254
+ match time_claim(&value, "iat") {
255
+ TimeClaim::Absent => {}
256
+ TimeClaim::Invalid => return None,
257
+ TimeClaim::Value(iat) if now < iat - CLOCK_SKEW_LEEWAY_SECS as f64 => return None,
258
+ TimeClaim::Value(_) => {}
262
259
  }
263
260
 
264
261
  Some(payload)
265
262
  }
266
263
 
264
+ /// Parsed state of a JWT time claim: absent, a finite numeric value, or present
265
+ /// but malformed (string / null / non-finite).
266
+ enum TimeClaim {
267
+ Absent,
268
+ Value(f64),
269
+ Invalid,
270
+ }
271
+
272
+ fn time_claim(value: &sonic_rs::Value, key: &str) -> TimeClaim {
273
+ match value.get(key) {
274
+ None => TimeClaim::Absent,
275
+ Some(v) => match v.as_f64() {
276
+ Some(n) if n.is_finite() => TimeClaim::Value(n),
277
+ _ => TimeClaim::Invalid,
278
+ },
279
+ }
280
+ }
281
+
267
282
  /// Verify with a PRE-COMPILED key (no per-call key derivation). Time-claim
268
283
  /// checks run over a compact `sonic_rs::Value` (zero per-key heap `String`);
269
284
  /// the decoded payload bytes are returned verbatim.
@@ -41,6 +41,12 @@ pub fn seal_core(id: &[u8], data_json: &[u8], exp_secs: i64, secret: &[u8]) -> O
41
41
  /// Verify a sealed token and extract the envelope fields into the packed
42
42
  /// layout: `[u8 ok=1][i64 exp][u32 idLen][id][u32 dataLen][dataJson]`.
43
43
  /// Returns `None` on bad signature / malformed envelope.
44
+ ///
45
+ /// The returned `exp` is **advisory**: this function does NOT compare it to the
46
+ /// current time, so a correctly signed but expired envelope still opens. The
47
+ /// caller MUST reject it (`exp != 0 && now >= exp`). (Enforcing it here was
48
+ /// considered and rejected: the wire contract is signature + extraction, and
49
+ /// the bind-time self-test seals with fixed historical timestamps.)
44
50
  pub fn open_core(token: &[u8], secret: &[u8]) -> Option<(i64, Vec<u8>, Vec<u8>)> {
45
51
  if secret.is_empty() {
46
52
  return None;
@@ -9,7 +9,7 @@ use pbkdf2::pbkdf2_hmac;
9
9
  use sha2::Sha256;
10
10
  use std::slice;
11
11
 
12
- use super::util::{aead_alg, cstring_return, hmac_key_cached, panic_guard};
12
+ use super::util::{aead_alg, cstring_return, panic_guard, with_hmac_key_cached};
13
13
 
14
14
  /// HMAC-SHA256 verify → 1/0 (constant-time, hex sig compared after
15
15
  /// whitespace-trim — mirrors the napi scalar path).
@@ -32,8 +32,9 @@ pub unsafe extern "C" fn castrum_hmac_sha256_verify(
32
32
  let Some(sig_bytes) = crate::util::bytes::hex_decode_32(sig_t) else {
33
33
  return 0;
34
34
  };
35
- let k = hmac_key_cached(slice::from_raw_parts(key, klen));
36
- u8::from(hmac::verify(&k, slice::from_raw_parts(data, dlen), &sig_bytes).is_ok())
35
+ with_hmac_key_cached(slice::from_raw_parts(key, klen), |k| {
36
+ u8::from(hmac::verify(k, slice::from_raw_parts(data, dlen), &sig_bytes).is_ok())
37
+ })
37
38
  }
38
39
 
39
40
  /// CSRF constant-time verify → 1/0 (token format `<64-hex>.<64-hex>`).
@@ -50,11 +51,12 @@ pub unsafe extern "C" fn castrum_csrf_verify(
50
51
  if token.is_null() || secret.is_null() {
51
52
  return 0;
52
53
  }
53
- let k = hmac_key_cached(slice::from_raw_parts(secret, slen));
54
- u8::from(crate::crypto::csrf::csrf_verify_with_key(
55
- slice::from_raw_parts(token, tlen),
56
- &k,
57
- ))
54
+ with_hmac_key_cached(slice::from_raw_parts(secret, slen), |k| {
55
+ u8::from(crate::crypto::csrf::csrf_verify_with_key(
56
+ slice::from_raw_parts(token, tlen),
57
+ k,
58
+ ))
59
+ })
58
60
  }
59
61
 
60
62
  /// Argon2id password verify → 1/0 (constant-time internally).
@@ -71,10 +73,17 @@ pub unsafe extern "C" fn castrum_password_verify(
71
73
  if password.is_null() || phc.is_null() {
72
74
  return 0;
73
75
  }
74
- u8::from(crate::crypto::argon2::verify_password(
75
- slice::from_raw_parts(password, plen),
76
- slice::from_raw_parts(phc, phclen),
77
- ))
76
+ // The argon2 core allocates/derives — a panic must become 0, not unwind
77
+ // through `extern "C"`.
78
+ panic_guard(
79
+ || {
80
+ u8::from(crate::crypto::argon2::verify_password(
81
+ slice::from_raw_parts(password, plen),
82
+ slice::from_raw_parts(phc, phclen),
83
+ ))
84
+ },
85
+ 0,
86
+ )
78
87
  }
79
88
 
80
89
  /// bcrypt password verify → 1/0 (PHC `$2b$` string).
@@ -95,10 +104,15 @@ pub unsafe extern "C" fn castrum_password_verify_bcrypt(
95
104
  if password.is_null() || hash.is_null() {
96
105
  return 0;
97
106
  }
98
- let Ok(h) = std::ffi::CStr::from_ptr(hash).to_str() else {
99
- return 0;
100
- };
101
- u8::from(bcrypt::verify(slice::from_raw_parts(password, plen), h).unwrap_or(false))
107
+ panic_guard(
108
+ || {
109
+ let Ok(h) = std::ffi::CStr::from_ptr(hash).to_str() else {
110
+ return 0;
111
+ };
112
+ u8::from(bcrypt::verify(slice::from_raw_parts(password, plen), h).unwrap_or(false))
113
+ },
114
+ 0,
115
+ )
102
116
  }
103
117
 
104
118
  /// HMAC-SHA256 hex (64 chars) into `out`.
@@ -117,8 +131,9 @@ pub unsafe extern "C" fn castrum_hmac_sha256(
117
131
  if key.is_null() || data.is_null() || out.is_null() || out_cap < 64 {
118
132
  return 0;
119
133
  }
120
- let k = hmac_key_cached(slice::from_raw_parts(key, klen));
121
- let tag = hmac::sign(&k, slice::from_raw_parts(data, dlen));
134
+ let tag = with_hmac_key_cached(slice::from_raw_parts(key, klen), |k| {
135
+ hmac::sign(k, slice::from_raw_parts(data, dlen))
136
+ });
122
137
  let mut hex = [0u8; 64];
123
138
  crate::util::bytes::hex_encode_32(tag.as_ref(), &mut hex);
124
139
  slice::from_raw_parts_mut(out, 64).copy_from_slice(&hex);
@@ -141,9 +156,10 @@ pub unsafe extern "C" fn castrum_sign_cookie(
141
156
  return std::ptr::null();
142
157
  }
143
158
  let v = slice::from_raw_parts(value, vlen);
144
- let key = hmac_key_cached(slice::from_raw_parts(secret, slen));
145
159
  cstring_return(vlen + 65, |out| {
146
- crate::crypto::cookie_sign::sign_cookie_into(v, &key, out)
160
+ with_hmac_key_cached(slice::from_raw_parts(secret, slen), |key| {
161
+ crate::crypto::cookie_sign::sign_cookie_into(v, key, out)
162
+ })
147
163
  })
148
164
  }
149
165
 
@@ -173,9 +189,14 @@ pub unsafe extern "C" fn castrum_sign_cookie_into(
173
189
  return needed;
174
190
  }
175
191
  let v = slice::from_raw_parts(value, vlen);
176
- let key = hmac_key_cached(slice::from_raw_parts(secret, slen));
177
- crate::crypto::cookie_sign::sign_cookie_into(v, &key, slice::from_raw_parts_mut(out, out_cap))
178
- .unwrap_or_default()
192
+ with_hmac_key_cached(slice::from_raw_parts(secret, slen), |key| {
193
+ crate::crypto::cookie_sign::sign_cookie_into(
194
+ v,
195
+ key,
196
+ slice::from_raw_parts_mut(out, out_cap),
197
+ )
198
+ })
199
+ .unwrap_or_default()
179
200
  }
180
201
 
181
202
  /// Verify a signed cookie → the value without its signature, returned as a
@@ -195,9 +216,10 @@ pub unsafe extern "C" fn castrum_verify_cookie(
195
216
  return std::ptr::null();
196
217
  }
197
218
  let s = slice::from_raw_parts(signed, slen);
198
- let key = hmac_key_cached(slice::from_raw_parts(secret, klen));
199
219
  cstring_return(slen, |out| {
200
- crate::crypto::cookie_sign::verify_cookie_into(s, &key, out)
220
+ with_hmac_key_cached(slice::from_raw_parts(secret, klen), |key| {
221
+ crate::crypto::cookie_sign::verify_cookie_into(s, key, out)
222
+ })
201
223
  })
202
224
  }
203
225
 
@@ -224,7 +246,6 @@ pub unsafe extern "C" fn castrum_verify_cookie_into(
224
246
  return 0;
225
247
  }
226
248
  let s = slice::from_raw_parts(signed, slen);
227
- let key = hmac_key_cached(slice::from_raw_parts(secret, klen));
228
249
  // The value is a prefix of `signed` (everything before the last `.`), so
229
250
  // `slen` is always a sufficient bound — needed-size only differs when the
230
251
  // caller passed less.
@@ -237,8 +258,14 @@ pub unsafe extern "C" fn castrum_verify_cookie_into(
237
258
  }
238
259
  return slen;
239
260
  }
240
- crate::crypto::cookie_sign::verify_cookie_into(s, &key, slice::from_raw_parts_mut(out, out_cap))
241
- .unwrap_or_default()
261
+ with_hmac_key_cached(slice::from_raw_parts(secret, klen), |key| {
262
+ crate::crypto::cookie_sign::verify_cookie_into(
263
+ s,
264
+ key,
265
+ slice::from_raw_parts_mut(out, out_cap),
266
+ )
267
+ })
268
+ .unwrap_or_default()
242
269
  }
243
270
 
244
271
  /// CSRF token (`<64-hex(rnd)>.<64-hex(sig)>`, 129 bytes) returned as a
@@ -254,7 +281,6 @@ pub unsafe extern "C" fn castrum_csrf_token(
254
281
  if secret.is_null() {
255
282
  return std::ptr::null();
256
283
  }
257
- let k = hmac_key_cached(slice::from_raw_parts(secret, slen));
258
284
  let mut rnd = [0u8; 32];
259
285
  if getrandom::fill(&mut rnd).is_err() {
260
286
  return std::ptr::null();
@@ -262,7 +288,10 @@ pub unsafe extern "C" fn castrum_csrf_token(
262
288
  let mut rnd_hex = [0u8; 64];
263
289
  crate::util::bytes::hex_encode(&rnd, &mut rnd_hex);
264
290
  let mut sig_hex = [0u8; 64];
265
- crate::util::bytes::hex_encode(hmac::sign(&k, &rnd_hex).as_ref(), &mut sig_hex);
291
+ let signed = with_hmac_key_cached(slice::from_raw_parts(secret, slen), |k| {
292
+ hmac::sign(k, &rnd_hex)
293
+ });
294
+ crate::util::bytes::hex_encode(signed.as_ref(), &mut sig_hex);
266
295
  cstring_return(129, |out| {
267
296
  out[..64].copy_from_slice(&rnd_hex);
268
297
  out[64] = b'.';
@@ -291,7 +320,6 @@ pub unsafe extern "C" fn castrum_csrf_token_into(
291
320
  if out_cap < 129 {
292
321
  return 129;
293
322
  }
294
- let k = hmac_key_cached(slice::from_raw_parts(secret, slen));
295
323
  let mut rnd = [0u8; 32];
296
324
  if getrandom::fill(&mut rnd).is_err() {
297
325
  return 0;
@@ -299,7 +327,10 @@ pub unsafe extern "C" fn castrum_csrf_token_into(
299
327
  let mut rnd_hex = [0u8; 64];
300
328
  crate::util::bytes::hex_encode(&rnd, &mut rnd_hex);
301
329
  let mut sig_hex = [0u8; 64];
302
- crate::util::bytes::hex_encode(hmac::sign(&k, &rnd_hex).as_ref(), &mut sig_hex);
330
+ let signed = with_hmac_key_cached(slice::from_raw_parts(secret, slen), |k| {
331
+ hmac::sign(k, &rnd_hex)
332
+ });
333
+ crate::util::bytes::hex_encode(signed.as_ref(), &mut sig_hex);
303
334
  let o = slice::from_raw_parts_mut(out, 129);
304
335
  o[..64].copy_from_slice(&rnd_hex);
305
336
  o[64] = b'.';
@@ -7,6 +7,8 @@
7
7
  use std::ptr;
8
8
  use std::slice;
9
9
 
10
+ use super::util::panic_guard;
11
+
10
12
  /// CRC32 over `data[0..len]`. Returns the CRC-32 checksum.
11
13
  ///
12
14
  /// # Safety
@@ -179,19 +181,22 @@ pub unsafe extern "C" fn castrum_json_sum_ids(
179
181
  if data.is_null() || out.is_null() {
180
182
  return 0;
181
183
  }
182
- match crate::json::json_ops::json_sum_ids_bytes(slice::from_raw_parts(data, len)) {
183
- Ok(sum) if out_len >= 9 => {
184
- *out = 1;
185
- ptr::copy_nonoverlapping(sum.to_le_bytes().as_ptr(), out.add(1), 8);
186
- 9
187
- }
188
- Ok(_) => 9, // too-small buffer → exact required size (growExact)
189
- Err(_) if out_len >= 1 => {
190
- *out = 0;
191
- 1
192
- }
193
- Err(_) => 1, // too-small buffer → exact required size
194
- }
184
+ panic_guard(
185
+ || match crate::json::json_ops::json_sum_ids_bytes(slice::from_raw_parts(data, len)) {
186
+ Ok(sum) if out_len >= 9 => {
187
+ *out = 1;
188
+ ptr::copy_nonoverlapping(sum.to_le_bytes().as_ptr(), out.add(1), 8);
189
+ 9
190
+ }
191
+ Ok(_) => 9, // too-small buffer → exact required size (growExact)
192
+ Err(_) if out_len >= 1 => {
193
+ *out = 0;
194
+ 1
195
+ }
196
+ Err(_) => 1, // too-small buffer → exact required size
197
+ },
198
+ 0,
199
+ )
195
200
  }
196
201
 
197
202
  /// Escape JS-RegExp metacharacters (`\ . * + ? ^ $ { } | ( ) [ ]`) in
@@ -212,13 +217,18 @@ pub unsafe extern "C" fn castrum_regex_escape(
212
217
  if data.is_null() || out.is_null() {
213
218
  return 0;
214
219
  }
215
- let input = slice::from_raw_parts(data, len);
216
- let needed = crate::util::text::regex_escape_len(input);
217
- if needed > out_cap {
218
- return needed;
219
- }
220
- crate::util::text::regex_escape_write(input, slice::from_raw_parts_mut(out, out_cap));
221
- needed
220
+ panic_guard(
221
+ || {
222
+ let input = slice::from_raw_parts(data, len);
223
+ let needed = crate::util::text::regex_escape_len(input);
224
+ if needed > out_cap {
225
+ return needed;
226
+ }
227
+ crate::util::text::regex_escape_write(input, slice::from_raw_parts_mut(out, out_cap));
228
+ needed
229
+ },
230
+ 0,
231
+ )
222
232
  }
223
233
 
224
234
  /// String-input / string-return sibling of [`castrum_regex_escape`]: the
@@ -269,17 +279,22 @@ pub unsafe extern "C" fn castrum_hex_validate_batch_str(
269
279
  if ids.is_null() || out.is_null() {
270
280
  return 0;
271
281
  }
272
- let input = std::ffi::CStr::from_ptr(ids).to_bytes();
273
- let needed = crate::util::validation::hex_batch_count(input);
274
- if needed > out_cap {
275
- return needed;
276
- }
277
- let mut buf = Vec::with_capacity(needed);
278
- match crate::util::validation::hex_batch_valid_into(input, width as usize, &mut buf) {
279
- Ok(()) if buf.len() <= out_cap => {
280
- slice::from_raw_parts_mut(out, buf.len()).copy_from_slice(&buf);
281
- buf.len()
282
- }
283
- _ => 0,
284
- }
282
+ panic_guard(
283
+ || {
284
+ let input = std::ffi::CStr::from_ptr(ids).to_bytes();
285
+ let needed = crate::util::validation::hex_batch_count(input);
286
+ if needed > out_cap {
287
+ return needed;
288
+ }
289
+ let mut buf = Vec::with_capacity(needed);
290
+ match crate::util::validation::hex_batch_valid_into(input, width as usize, &mut buf) {
291
+ Ok(()) if buf.len() <= out_cap => {
292
+ slice::from_raw_parts_mut(out, buf.len()).copy_from_slice(&buf);
293
+ buf.len()
294
+ }
295
+ _ => 0,
296
+ }
297
+ },
298
+ 0,
299
+ )
285
300
  }