castrum 0.9.3 → 0.9.4

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 (184) hide show
  1. package/Cargo.lock +2259 -0
  2. package/Cargo.toml +138 -0
  3. package/README.md +6 -0
  4. package/build.rs +5 -0
  5. package/castrum.linux-x64-gnu.node +0 -0
  6. package/dist/index.d.ts +1348 -1295
  7. package/dist/index.js +4307 -3776
  8. package/docs/API.md +1 -1
  9. package/docs/ARCHITECTURE.md +3 -2
  10. package/docs/CASE_STUDY.md +2 -2
  11. package/docs/ENVIRONMENT.md +16 -0
  12. package/docs/FFI_BUN_GUIDE.md +89 -6
  13. package/docs/REPO_MAP.md +2 -2
  14. package/index.ts +19 -25
  15. package/package.json +9 -1
  16. package/rust/crypto/aead.rs +378 -0
  17. package/rust/crypto/argon2.rs +265 -0
  18. package/rust/crypto/base64.rs +420 -0
  19. package/rust/crypto/bcrypt.rs +89 -0
  20. package/rust/crypto/cookie_sign.rs +169 -0
  21. package/rust/crypto/csrf.rs +106 -0
  22. package/rust/crypto/ed25519.rs +215 -0
  23. package/rust/crypto/hashing.rs +140 -0
  24. package/rust/crypto/hmac_sha256.rs +88 -0
  25. package/rust/crypto/jwt/api.rs +286 -0
  26. package/rust/crypto/jwt/mod.rs +22 -0
  27. package/rust/crypto/jwt/tests.rs +312 -0
  28. package/rust/crypto/jwt/token.rs +360 -0
  29. package/rust/crypto/mod.rs +27 -0
  30. package/rust/crypto/pbkdf2.rs +96 -0
  31. package/rust/crypto/random_token.rs +85 -0
  32. package/rust/crypto/session.rs +222 -0
  33. package/rust/ffi/crypto.rs +708 -0
  34. package/rust/ffi/hashing.rs +285 -0
  35. package/rust/ffi/http.rs +752 -0
  36. package/rust/ffi/ingress.rs +303 -0
  37. package/rust/ffi/json.rs +517 -0
  38. package/rust/ffi/jwt.rs +419 -0
  39. package/rust/ffi/metrics.rs +373 -0
  40. package/rust/ffi/mod.rs +68 -0
  41. package/rust/ffi/payload.rs +275 -0
  42. package/rust/ffi/probe.rs +60 -0
  43. package/rust/ffi/rate_limit.rs +77 -0
  44. package/rust/ffi/route.rs +85 -0
  45. package/rust/ffi/tests.rs +2333 -0
  46. package/rust/ffi/util.rs +152 -0
  47. package/rust/ffi/validators.rs +123 -0
  48. package/rust/http/accept.rs +606 -0
  49. package/rust/http/cookie_parser.rs +140 -0
  50. package/rust/http/etag.rs +289 -0
  51. package/rust/http/form.rs +132 -0
  52. package/rust/http/headers.rs +267 -0
  53. package/rust/http/http_date.rs +286 -0
  54. package/rust/http/http_parser.rs +252 -0
  55. package/rust/http/media_type.rs +355 -0
  56. package/rust/http/method.rs +200 -0
  57. package/rust/http/mime_lookup.rs +130 -0
  58. package/rust/http/mod.rs +32 -0
  59. package/rust/http/multipart.rs +685 -0
  60. package/rust/http/query_parser.rs +180 -0
  61. package/rust/http/url_codec.rs +296 -0
  62. package/rust/http/url_join.rs +324 -0
  63. package/rust/ingress/api.rs +355 -0
  64. package/rust/ingress/cors.rs +403 -0
  65. package/rust/ingress/ingress_constants.rs +209 -0
  66. package/rust/ingress/ip_trust.rs +312 -0
  67. package/rust/ingress/mod.rs +48 -0
  68. package/rust/ingress/native_route.rs +779 -0
  69. package/rust/ingress/options.rs +176 -0
  70. package/rust/ingress/output.rs +406 -0
  71. package/rust/ingress/packed.rs +191 -0
  72. package/rust/ingress/pipeline.rs +534 -0
  73. package/rust/ingress/proxy.rs +138 -0
  74. package/rust/ingress/rate_limit.rs +538 -0
  75. package/rust/ingress/terminal.rs +114 -0
  76. package/rust/ingress/tests.rs +566 -0
  77. package/rust/ingress/time.rs +70 -0
  78. package/rust/json/fast_schema/capture.rs +336 -0
  79. package/rust/json/fast_schema/compile.rs +556 -0
  80. package/rust/json/fast_schema/cursor.rs +368 -0
  81. package/rust/json/fast_schema/email.rs +282 -0
  82. package/rust/json/fast_schema/errors.rs +247 -0
  83. package/rust/json/fast_schema/mod.rs +57 -0
  84. package/rust/json/fast_schema/tests.rs +1143 -0
  85. package/rust/json/fast_schema/types.rs +140 -0
  86. package/rust/json/fast_schema/validate.rs +931 -0
  87. package/rust/json/json_ops.rs +121 -0
  88. package/rust/json/json_schema.rs +837 -0
  89. package/rust/json/json_ser.rs +881 -0
  90. package/rust/json/mod.rs +19 -0
  91. package/rust/json/napi_marshal.rs +318 -0
  92. package/rust/json/patch/api.rs +60 -0
  93. package/rust/json/patch/engine.rs +153 -0
  94. package/rust/json/patch/mod.rs +47 -0
  95. package/rust/json/patch/ops.rs +270 -0
  96. package/rust/json/patch/pointer.rs +49 -0
  97. package/rust/json/patch/tests.rs +499 -0
  98. package/rust/lib.rs +72 -0
  99. package/rust/metrics/api.rs +148 -0
  100. package/rust/metrics/mod.rs +21 -0
  101. package/rust/metrics/registry.rs +961 -0
  102. package/rust/panic_safety.rs +40 -0
  103. package/rust/payload/compress.rs +512 -0
  104. package/rust/payload/mod.rs +13 -0
  105. package/rust/payload/sse.rs +214 -0
  106. package/rust/payload/template.rs +253 -0
  107. package/rust/payload/websocket.rs +69 -0
  108. package/rust/payload/ws_frames.rs +354 -0
  109. package/rust/proptest_suite.rs +139 -0
  110. package/rust/selection.rs +95 -0
  111. package/rust/src/bin/native-bench.rs +495 -0
  112. package/rust/test_support.rs +71 -0
  113. package/rust/util/batch/api.rs +266 -0
  114. package/rust/util/batch/core.rs +473 -0
  115. package/rust/util/batch/mod.rs +23 -0
  116. package/rust/util/batch/tests.rs +445 -0
  117. package/rust/util/batch_core.rs +184 -0
  118. package/rust/util/bytes.rs +407 -0
  119. package/rust/util/mod.rs +34 -0
  120. package/rust/util/packed.rs +473 -0
  121. package/rust/util/text.rs +130 -0
  122. package/rust/util/threadpool.rs +128 -0
  123. package/rust/util/validation.rs +419 -0
  124. package/rust-toolchain.toml +17 -0
  125. package/scripts/postinstall.mjs +199 -0
  126. package/src/ingress/constants.ts +1 -1
  127. package/src/ingress/context.ts +2 -2
  128. package/src/ingress/decode/baked-result.ts +13 -13
  129. package/src/ingress/decode/fast-result.ts +17 -17
  130. package/src/ingress/errors.ts +7 -7
  131. package/src/ingress/headers/baked-templates.ts +1 -1
  132. package/src/ingress/headers/fast-templates.ts +3 -3
  133. package/src/ingress/index.ts +1 -1
  134. package/src/ingress/native-route.ts +9 -5
  135. package/src/ingress/options.ts +1 -1
  136. package/src/ingress/packing/gather-raw-headers.ts +1 -1
  137. package/src/ingress/packing/route-wire.ts +1 -5
  138. package/src/ingress/response/error-bodies.ts +5 -5
  139. package/src/ingress/response/terminal.ts +1 -1
  140. package/src/ingress/routes/echo.ts +4 -4
  141. package/src/ingress/routes/fallback.ts +2 -2
  142. package/src/ingress/routes/head.ts +2 -2
  143. package/src/ingress/routes/native.ts +2 -1
  144. package/src/ingress/routes/options.ts +1 -1
  145. package/src/ingress/routes/read.ts +2 -2
  146. package/src/ingress/server.ts +10 -6
  147. package/src/ingress/status.ts +7 -7
  148. package/src/integration/batch.ts +1 -1
  149. package/src/integration/index.ts +9 -9
  150. package/src/integration/pipeline.ts +6 -6
  151. package/src/integration/websocket.ts +2 -2
  152. package/src/loader/index.ts +1 -5
  153. package/src/loader/ops.ts +1 -1
  154. package/src/native/ffi/build/codecs.ts +104 -8
  155. package/src/native/ffi/build/compress.ts +14 -10
  156. package/src/native/ffi/build/instances.ts +40 -3
  157. package/src/native/ffi/build/metrics.ts +224 -0
  158. package/src/native/ffi/build/parse.ts +59 -23
  159. package/src/native/ffi/build/util.ts +14 -1
  160. package/src/native/ffi/build.ts +2 -0
  161. package/src/native/ffi/selftest.ts +10 -3
  162. package/src/native/ffi/types.ts +111 -9
  163. package/src/native/ffi.ts +87 -1
  164. package/src/native/index.ts +1 -1
  165. package/src/native/loader.ts +2 -2
  166. package/src/native/types/instances.ts +34 -0
  167. package/src/native/types.ts +11 -0
  168. package/src/runtime/codec.ts +53 -9
  169. package/src/runtime/types.ts +7 -0
  170. package/src/rust-ffi/batch/build.ts +16 -9
  171. package/src/rust-ffi/index.ts +7 -6
  172. package/src/rust-ffi/options.ts +1 -3
  173. package/src/rust-ffi/proven.ts +3 -3
  174. package/src/rust-ffi/scalar/crypto.ts +1 -1
  175. package/src/rust-ffi/scalar/factories.ts +73 -0
  176. package/src/rust-ffi/scalar/hashing.ts +1 -1
  177. package/src/rust-ffi/scalar/http.ts +17 -11
  178. package/src/rust-ffi/scalar/index.ts +2 -0
  179. package/src/rust-ffi/scalar/interface.ts +20 -0
  180. package/src/rust-ffi/scalar/payload.ts +11 -3
  181. package/src/rust-ffi/scalar/validation.ts +121 -0
  182. package/src/selection.ts +1 -1
  183. package/src/shared/bytes.ts +5 -4
  184. package/src/shared/codec.ts +18 -3
@@ -0,0 +1,27 @@
1
+ //! Auth & hashing (pure-Rust primitives + compiled-once napi instances).
2
+ //
3
+ // Pure-Rust crypto primitives + compiled-once napi instances (each instance
4
+ // precompiles its key/engine/params once at construction):
5
+ // - hmac_sha256.rs HMAC-SHA256 (+ HmacSigner instance)
6
+ // - cookie_sign.rs signed cookies `value.signature` (CookieSigner)
7
+ // - csrf.rs CSRF tokens, random hex + HMAC (CsrfProtector)
8
+ // - jwt/ hand-rolled HS256 + EdDSA JWT (token.rs / napi.rs)
9
+ // - aead.rs AES-256-GCM / chacha20-poly1305 (AeadCipher) + batch
10
+ // - argon2.rs argon2id password hashing (Argon2Hasher) + batch//! - bcrypt.rs bcrypt password hashing (PHC `$2b$`) + verify
11
+ //! - pbkdf2.rs PBKDF2-HMAC-SHA256 key derivation// - base64.rs base64/base64url/hex codecs (Base64Codec)
12
+ // - hashing.rs FNV-1a / XXH3 / crc32 checksums
13
+ // - random_token.rs random hex tokens
14
+
15
+ pub mod aead;
16
+ pub mod argon2;
17
+ pub mod base64;
18
+ pub mod bcrypt;
19
+ pub mod cookie_sign;
20
+ pub mod csrf;
21
+ pub mod ed25519;
22
+ pub mod hashing;
23
+ pub mod hmac_sha256;
24
+ pub mod jwt;
25
+ pub mod pbkdf2;
26
+ pub mod random_token;
27
+ pub mod session;
@@ -0,0 +1,96 @@
1
+ // rust/crypto/pbkdf2.rs — PBKDF2-HMAC-SHA256 key derivation.
2
+ //
3
+ // Pure-Rust (RustCrypto pbkdf2 + sha2). Standard NIST 800-132 KDF, exposed as
4
+ // a NAPI scalar. Bun has no synchronous PBKDF2 built-in (crypto.subtle.deriveBits
5
+ // is async), so this fills a real gap — the honest baseline is node:crypto's
6
+ // pbkdf2Sync, and @flux/native can prefer Bun's async path where a caller wants
7
+ // it. Output length is caller-chosen (default 32 bytes).
8
+
9
+ use napi::bindgen_prelude::*;
10
+ use napi_derive::napi;
11
+
12
+ use pbkdf2::pbkdf2_hmac;
13
+ use sha2::Sha256;
14
+
15
+ /// Minimum output length (1 byte) / maximum sanity cap (1 MiB).
16
+ pub const PBKDF2_MIN_LEN: u32 = 1;
17
+ /// Cap derived-key length so a hostile caller can't request a huge allocation.
18
+ pub const PBKDF2_MAX_LEN: u32 = 1024 * 1024;
19
+
20
+ /// Derive a PBKDF2-HMAC-SHA256 key: `out = PBKDF2(password, salt, rounds, dkLen)`.
21
+ #[napi]
22
+ pub fn pbkdf2_sha256(
23
+ password: Uint8Array,
24
+ salt: Uint8Array,
25
+ rounds: u32,
26
+ dk_len: u32,
27
+ ) -> Result<Buffer> {
28
+ let dk_len = dk_len.clamp(PBKDF2_MIN_LEN, PBKDF2_MAX_LEN);
29
+ let mut out = vec![0u8; dk_len as usize];
30
+ // PBKDF2 rounds must be >= 1 (a 0-round call would be meaningless).
31
+ let rounds = rounds.max(1);
32
+ pbkdf2_hmac::<Sha256>(password.as_ref(), salt.as_ref(), rounds, &mut out);
33
+ Ok(out.into())
34
+ }
35
+
36
+ #[cfg(test)]
37
+ mod tests {
38
+ use super::*;
39
+
40
+ #[test]
41
+ fn pbkdf2_sha256_known_vector() {
42
+ // RFC 7914 / NIST-style known answer: password "password", salt "salt",
43
+ // 1 iteration, 32 bytes — cross-checked with node:crypto pbkdf2Sync.
44
+ let out = pbkdf2_sha256(
45
+ Uint8Array::new(b"password".to_vec()),
46
+ Uint8Array::new(b"salt".to_vec()),
47
+ 1,
48
+ 32,
49
+ )
50
+ .unwrap();
51
+ let expected: [u8; 32] = [
52
+ 0x12, 0x0f, 0xb6, 0xcf, 0xfc, 0xf8, 0xb3, 0x2c, 0x43, 0xe7, 0x22, 0x52, 0x56, 0xc4,
53
+ 0xf8, 0x37, 0xa8, 0x65, 0x48, 0xc9, 0x2c, 0xcc, 0x35, 0x48, 0x08, 0x05, 0x98, 0x7c,
54
+ 0xb7, 0x0b, 0xe1, 0x7b,
55
+ ];
56
+ assert_eq!(out.as_ref(), &expected);
57
+ }
58
+
59
+ #[test]
60
+ fn pbkdf2_is_iteration_sensitive() {
61
+ let a = pbkdf2_sha256(
62
+ Uint8Array::new(b"pw".to_vec()),
63
+ Uint8Array::new(b"salt".to_vec()),
64
+ 1,
65
+ 16,
66
+ )
67
+ .unwrap();
68
+ let b = pbkdf2_sha256(
69
+ Uint8Array::new(b"pw".to_vec()),
70
+ Uint8Array::new(b"salt".to_vec()),
71
+ 2,
72
+ 16,
73
+ )
74
+ .unwrap();
75
+ assert_ne!(a.as_ref(), b.as_ref());
76
+ }
77
+
78
+ #[test]
79
+ fn pbkdf2_zero_rounds_treated_as_one() {
80
+ let one = pbkdf2_sha256(
81
+ Uint8Array::new(b"pw".to_vec()),
82
+ Uint8Array::new(b"salt".to_vec()),
83
+ 1,
84
+ 16,
85
+ )
86
+ .unwrap();
87
+ let zero = pbkdf2_sha256(
88
+ Uint8Array::new(b"pw".to_vec()),
89
+ Uint8Array::new(b"salt".to_vec()),
90
+ 0,
91
+ 16,
92
+ )
93
+ .unwrap();
94
+ assert_eq!(one.as_ref(), zero.as_ref());
95
+ }
96
+ }
@@ -0,0 +1,85 @@
1
+ // rust/crypto/random_token.rs — random hex tokens (16/32/64 bytes use stack buffers)
2
+ use crate::util::bytes::hex_encode;
3
+ use napi::bindgen_prelude::*;
4
+ use napi_derive::napi;
5
+
6
+ #[napi]
7
+ pub fn random_token(byte_len: u32) -> Result<Buffer> {
8
+ // Enterprise guard: never allocate more than this from a single call, even
9
+ // on API misuse — a u32 byte_len would otherwise permit a ~4 GiB alloc.
10
+ const MAX_BYTE_LEN: usize = 16 * 1024 * 1024;
11
+ if byte_len as usize > MAX_BYTE_LEN {
12
+ return Err(Error::from_reason(format!(
13
+ "random_token: byte_len {byte_len} exceeds max {MAX_BYTE_LEN}"
14
+ )));
15
+ }
16
+
17
+ let len = byte_len as usize;
18
+
19
+ // ⭐ Common sizes (16, 32, 64) use stack buffers — no heap alloc.
20
+ match len {
21
+ 16 => {
22
+ let mut bytes = [0u8; 16];
23
+ getrandom::fill(&mut bytes).map_err(|e| Error::from_reason(e.to_string()))?;
24
+ let mut out = [0u8; 32];
25
+ hex_encode(&bytes, &mut out);
26
+ Ok(Buffer::from(out.to_vec()))
27
+ }
28
+ 32 => {
29
+ let mut bytes = [0u8; 32];
30
+ getrandom::fill(&mut bytes).map_err(|e| Error::from_reason(e.to_string()))?;
31
+ let mut out = [0u8; 64];
32
+ hex_encode(&bytes, &mut out);
33
+ Ok(Buffer::from(out.to_vec()))
34
+ }
35
+ 64 => {
36
+ let mut bytes = [0u8; 64];
37
+ getrandom::fill(&mut bytes).map_err(|e| Error::from_reason(e.to_string()))?;
38
+ let mut out = [0u8; 128];
39
+ hex_encode(&bytes, &mut out);
40
+ Ok(Buffer::from(out.to_vec()))
41
+ }
42
+ _ => {
43
+ let out_len = len
44
+ .checked_mul(2)
45
+ .ok_or_else(|| Error::from_reason("byte_len too large"))?;
46
+ let mut bytes = vec![0u8; len];
47
+ getrandom::fill(&mut bytes).map_err(|e| Error::from_reason(e.to_string()))?;
48
+ let mut out = vec![0u8; out_len];
49
+ hex_encode(&bytes, &mut out);
50
+ Ok(Buffer::from(out))
51
+ }
52
+ }
53
+ }
54
+
55
+ #[cfg(test)]
56
+ mod tests {
57
+ use super::*;
58
+
59
+ #[test]
60
+ fn common_sizes_return_hex_of_requested_length() {
61
+ // The token is hex-encoded, so the output is exactly `len * 2` bytes
62
+ // of lowercase hex characters.
63
+ for len in [16u32, 32, 64, 7, 128] {
64
+ let out = random_token(len).expect("valid len must succeed");
65
+ assert_eq!(out.len(), len as usize * 2);
66
+ for b in out.iter() {
67
+ assert!(b.is_ascii_hexdigit(), "byte 0x{b:02x} is not hex");
68
+ }
69
+ }
70
+ }
71
+
72
+ #[test]
73
+ fn tokens_are_random() {
74
+ let a = random_token(32).unwrap();
75
+ let b = random_token(32).unwrap();
76
+ assert_ne!(a.to_vec(), b.to_vec(), "two tokens must not collide");
77
+ }
78
+
79
+ #[test]
80
+ fn huge_length_is_rejected() {
81
+ // The 16 MiB guard prevents a ~4 GiB single allocation from a u32 arg.
82
+ assert!(random_token(16 * 1024 * 1024 + 1).is_err());
83
+ assert!(random_token(u32::MAX).is_err());
84
+ }
85
+ }
@@ -0,0 +1,222 @@
1
+ // rust/crypto/session.rs — fused session-envelope seal/open.
2
+ //
3
+ // The framework's session plugin per-request path was:
4
+ // seal: JSON.stringify(envelope) → signCookie(json, secret)
5
+ // open: verifyCookie(token, secret) → JSON.parse(unsigned)
6
+ // i.e. two FFI crossings + a full envelope stringify/parse. Both steps here
7
+ // build/extract the envelope NATIVELY (id/data/exp), so the caller crosses
8
+ // ONCE and only `data` (the arbitrary user object) is stringified/parsed in
9
+ // JS — the id and exp never round-trip through JSC.
10
+
11
+ use crate::crypto::cookie_sign::{sign_cookie_bytes, verify_cookie_bytes};
12
+ use aws_lc_rs::hmac;
13
+
14
+ /// Build the envelope JSON for `id` / `dataJson` / `exp` and sign it with
15
+ /// HMAC-SHA256 (same wire format as signCookie: `payload.<64-hex>`).
16
+ /// `data_json` must be a valid JSON object/value — embedded verbatim.
17
+ pub fn seal_core(id: &[u8], data_json: &[u8], exp_secs: i64, secret: &[u8]) -> Option<Vec<u8>> {
18
+ if id.is_empty() || secret.is_empty() {
19
+ return None;
20
+ }
21
+ // Envelope size: {"id":"","data":<data>,"exp":0} + escapes on id only
22
+ // (data is already valid JSON; exp is an integer literal).
23
+ let id_esc_len = crate::json::json_ser::json_escaped_len(id);
24
+ let mut json = Vec::with_capacity(id_esc_len + data_json.len() + 32);
25
+ json.push(b'{');
26
+ json.extend_from_slice(b"\"id\":\"");
27
+ let mut wpos = json.len();
28
+ json.resize(json.len() + id_esc_len, 0);
29
+ crate::json::json_ser::write_json_escaped(&mut json, &mut wpos, id);
30
+ json.truncate(wpos);
31
+ json.extend_from_slice(b"\",\"data\":");
32
+ json.extend_from_slice(data_json);
33
+ json.extend_from_slice(b",\"exp\":");
34
+ json.extend_from_slice(exp_secs.to_string().as_bytes());
35
+ json.push(b'}');
36
+
37
+ let key = hmac::Key::new(hmac::HMAC_SHA256, secret);
38
+ Some(sign_cookie_bytes(&json, &key))
39
+ }
40
+
41
+ /// Verify a sealed token and extract the envelope fields into the packed
42
+ /// layout: `[u8 ok=1][i64 exp][u32 idLen][id][u32 dataLen][dataJson]`.
43
+ /// Returns `None` on bad signature / malformed envelope.
44
+ pub fn open_core(token: &[u8], secret: &[u8]) -> Option<(i64, Vec<u8>, Vec<u8>)> {
45
+ if secret.is_empty() {
46
+ return None;
47
+ }
48
+ let key = hmac::Key::new(hmac::HMAC_SHA256, secret);
49
+ let json = verify_cookie_bytes(token, &key)?;
50
+ // Minimal extraction over the known shape {"id":"…","data":…,"exp":N}.
51
+ // The id/data values were written by `seal` (or an equivalent signer), so
52
+ // a targeted scan of the top-level keys is sufficient — no DOM needed.
53
+ let mut id: Option<Vec<u8>> = None;
54
+ let mut data: Option<Vec<u8>> = None;
55
+ let mut exp: Option<i64> = None;
56
+
57
+ let mut pos = 1usize; // skip '{'
58
+ let bytes = &json[..];
59
+ while pos < bytes.len() {
60
+ match bytes[pos] {
61
+ b',' | b'{' | b'}' => pos += 1,
62
+ b'"' => {
63
+ // key string
64
+ let kstart = pos + 1;
65
+ let mut kend = kstart;
66
+ while kend < bytes.len() && bytes[kend] != b'"' {
67
+ kend += 1;
68
+ }
69
+ if kend >= bytes.len() {
70
+ return None;
71
+ }
72
+ let key = &bytes[kstart..kend];
73
+ pos = kend + 1;
74
+ if bytes.get(pos) != Some(&b':') {
75
+ return None;
76
+ }
77
+ pos += 1;
78
+ while matches!(bytes.get(pos), Some(b' ')) {
79
+ pos += 1;
80
+ }
81
+ match key {
82
+ b"id" => {
83
+ if bytes.get(pos) != Some(&b'"') {
84
+ return None;
85
+ }
86
+ let vs = pos + 1;
87
+ let mut ve = vs;
88
+ while ve < bytes.len() && bytes[ve] != b'"' {
89
+ ve += 1;
90
+ }
91
+ id = Some(bytes[vs..ve].to_vec());
92
+ pos = ve + 1;
93
+ }
94
+ b"exp" => {
95
+ let vs = pos;
96
+ while matches!(bytes.get(pos), Some(c) if c.is_ascii_digit() || *c == b'-')
97
+ {
98
+ pos += 1;
99
+ }
100
+ exp = std::str::from_utf8(&bytes[vs..pos])
101
+ .ok()
102
+ .and_then(|s| s.parse::<i64>().ok());
103
+ }
104
+ b"data" => {
105
+ // Capture the raw JSON value: object/array/string/num.
106
+ let vs = pos;
107
+ let ve = skip_json_value(bytes, vs)?;
108
+ data = Some(bytes[vs..ve].to_vec());
109
+ pos = ve;
110
+ }
111
+ _ => {
112
+ // Unknown key: skip its value generically.
113
+ pos = skip_json_value(bytes, pos)?;
114
+ }
115
+ }
116
+ }
117
+ _ => pos += 1,
118
+ }
119
+ }
120
+
121
+ let id = id?;
122
+ if id.is_empty() {
123
+ return None;
124
+ }
125
+ Some((exp.unwrap_or(0), id, data.unwrap_or_else(|| b"{}".to_vec())))
126
+ }
127
+
128
+ /// Skip one JSON value starting at `pos`; returns the end offset.
129
+ fn skip_json_value(bytes: &[u8], pos: usize) -> Option<usize> {
130
+ match bytes.get(pos)? {
131
+ b'"' => {
132
+ let mut e = pos + 1;
133
+ while e < bytes.len() {
134
+ if bytes[e] == b'\\' {
135
+ e += 2;
136
+ continue;
137
+ }
138
+ if bytes[e] == b'"' {
139
+ return Some(e + 1);
140
+ }
141
+ e += 1;
142
+ }
143
+ None
144
+ }
145
+ b'{' | b'[' => {
146
+ let open = bytes[pos];
147
+ let close = if open == b'{' { b'}' } else { b']' };
148
+ let mut depth = 0usize;
149
+ let mut e = pos;
150
+ while e < bytes.len() {
151
+ match bytes[e] {
152
+ b'"' => e = skip_json_value(bytes, e)? - 1, // string inside
153
+ c if c == open => depth += 1,
154
+ c if c == close => {
155
+ depth -= 1;
156
+ if depth == 0 {
157
+ return Some(e + 1);
158
+ }
159
+ }
160
+ _ => {}
161
+ }
162
+ e += 1;
163
+ }
164
+ None
165
+ }
166
+ _ => {
167
+ // number / true / false / null → until delimiter
168
+ let mut e = pos;
169
+ while e < bytes.len() && !matches!(bytes[e], b',' | b'}' | b']') {
170
+ e += 1;
171
+ }
172
+ Some(e)
173
+ }
174
+ }
175
+ }
176
+
177
+ #[cfg(test)]
178
+ mod tests {
179
+ use super::*;
180
+
181
+ const SECRET: &[u8] = b"test-secret";
182
+
183
+ #[test]
184
+ fn seal_open_round_trip() {
185
+ let token =
186
+ seal_core(b"sess-123", br#"{"cart":["a","b"]}"#, 1_700_000_000, SECRET).expect("seal");
187
+ let (exp, id, data) = open_core(&token, SECRET).expect("open");
188
+ assert_eq!(id, b"sess-123");
189
+ assert_eq!(exp, 1_700_000_000);
190
+ assert_eq!(data, br#"{"cart":["a","b"]}"#);
191
+ }
192
+
193
+ #[test]
194
+ fn tampered_token_rejected() {
195
+ let mut token = seal_core(b"id1", b"{}", 0, SECRET).expect("seal");
196
+ let last = token.len() - 1;
197
+ token[last] ^= 0xff;
198
+ assert!(open_core(&token, SECRET).is_none());
199
+ }
200
+
201
+ #[test]
202
+ fn wrong_secret_rejected() {
203
+ let token = seal_core(b"id1", b"{}", 0, SECRET).expect("seal");
204
+ assert!(open_core(&token, b"other").is_none());
205
+ }
206
+
207
+ #[test]
208
+ fn empty_id_rejected() {
209
+ assert!(seal_core(b"", b"{}", 0, SECRET).is_none());
210
+ }
211
+
212
+ #[test]
213
+ fn wire_format_matches_sign_cookie() {
214
+ // The sealed token must be verifiable by the EXISTING cookie verify
215
+ // core (same "payload.<hex>" wire format) so old readers stay valid.
216
+ let token = seal_core(b"sid", br#"{"n":1}"#, 5, SECRET).expect("seal");
217
+ let key = hmac::Key::new(hmac::HMAC_SHA256, SECRET);
218
+ let unsigned = verify_cookie_bytes(&token, &key).expect("verify via cookie core");
219
+ let s = String::from_utf8(unsigned).expect("utf8");
220
+ assert!(s.starts_with("{\"id\":\"sid\",\"data\":{\"n\":1},\"exp\":5}"));
221
+ }
222
+ }