castrum 0.9.2 → 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.
- package/Cargo.lock +2259 -0
- package/Cargo.toml +138 -0
- package/README.md +6 -0
- package/build.rs +5 -0
- package/castrum.linux-x64-gnu.node +0 -0
- package/dist/index.d.ts +1348 -1295
- package/dist/index.js +4307 -3776
- package/docs/API.md +1 -1
- package/docs/ARCHITECTURE.md +3 -2
- package/docs/CASE_STUDY.md +2 -2
- package/docs/ENVIRONMENT.md +16 -0
- package/docs/FFI_BUN_GUIDE.md +89 -6
- package/docs/REPO_MAP.md +2 -2
- package/index.ts +19 -25
- package/package.json +9 -1
- package/rust/crypto/aead.rs +378 -0
- package/rust/crypto/argon2.rs +265 -0
- package/rust/crypto/base64.rs +420 -0
- package/rust/crypto/bcrypt.rs +89 -0
- package/rust/crypto/cookie_sign.rs +169 -0
- package/rust/crypto/csrf.rs +106 -0
- package/rust/crypto/ed25519.rs +215 -0
- package/rust/crypto/hashing.rs +140 -0
- package/rust/crypto/hmac_sha256.rs +88 -0
- package/rust/crypto/jwt/api.rs +286 -0
- package/rust/crypto/jwt/mod.rs +22 -0
- package/rust/crypto/jwt/tests.rs +312 -0
- package/rust/crypto/jwt/token.rs +360 -0
- package/rust/crypto/mod.rs +27 -0
- package/rust/crypto/pbkdf2.rs +96 -0
- package/rust/crypto/random_token.rs +85 -0
- package/rust/crypto/session.rs +222 -0
- package/rust/ffi/crypto.rs +708 -0
- package/rust/ffi/hashing.rs +285 -0
- package/rust/ffi/http.rs +752 -0
- package/rust/ffi/ingress.rs +303 -0
- package/rust/ffi/json.rs +517 -0
- package/rust/ffi/jwt.rs +419 -0
- package/rust/ffi/metrics.rs +373 -0
- package/rust/ffi/mod.rs +68 -0
- package/rust/ffi/payload.rs +275 -0
- package/rust/ffi/probe.rs +60 -0
- package/rust/ffi/rate_limit.rs +77 -0
- package/rust/ffi/route.rs +85 -0
- package/rust/ffi/tests.rs +2333 -0
- package/rust/ffi/util.rs +152 -0
- package/rust/ffi/validators.rs +123 -0
- package/rust/http/accept.rs +606 -0
- package/rust/http/cookie_parser.rs +140 -0
- package/rust/http/etag.rs +289 -0
- package/rust/http/form.rs +132 -0
- package/rust/http/headers.rs +267 -0
- package/rust/http/http_date.rs +286 -0
- package/rust/http/http_parser.rs +252 -0
- package/rust/http/media_type.rs +355 -0
- package/rust/http/method.rs +200 -0
- package/rust/http/mime_lookup.rs +130 -0
- package/rust/http/mod.rs +32 -0
- package/rust/http/multipart.rs +685 -0
- package/rust/http/query_parser.rs +180 -0
- package/rust/http/url_codec.rs +296 -0
- package/rust/http/url_join.rs +324 -0
- package/rust/ingress/api.rs +355 -0
- package/rust/ingress/cors.rs +403 -0
- package/rust/ingress/ingress_constants.rs +209 -0
- package/rust/ingress/ip_trust.rs +312 -0
- package/rust/ingress/mod.rs +48 -0
- package/rust/ingress/native_route.rs +779 -0
- package/rust/ingress/options.rs +176 -0
- package/rust/ingress/output.rs +406 -0
- package/rust/ingress/packed.rs +191 -0
- package/rust/ingress/pipeline.rs +534 -0
- package/rust/ingress/proxy.rs +138 -0
- package/rust/ingress/rate_limit.rs +538 -0
- package/rust/ingress/terminal.rs +114 -0
- package/rust/ingress/tests.rs +566 -0
- package/rust/ingress/time.rs +70 -0
- package/rust/json/fast_schema/capture.rs +336 -0
- package/rust/json/fast_schema/compile.rs +556 -0
- package/rust/json/fast_schema/cursor.rs +368 -0
- package/rust/json/fast_schema/email.rs +282 -0
- package/rust/json/fast_schema/errors.rs +247 -0
- package/rust/json/fast_schema/mod.rs +57 -0
- package/rust/json/fast_schema/tests.rs +1143 -0
- package/rust/json/fast_schema/types.rs +140 -0
- package/rust/json/fast_schema/validate.rs +931 -0
- package/rust/json/json_ops.rs +121 -0
- package/rust/json/json_schema.rs +837 -0
- package/rust/json/json_ser.rs +881 -0
- package/rust/json/mod.rs +19 -0
- package/rust/json/napi_marshal.rs +318 -0
- package/rust/json/patch/api.rs +60 -0
- package/rust/json/patch/engine.rs +153 -0
- package/rust/json/patch/mod.rs +47 -0
- package/rust/json/patch/ops.rs +270 -0
- package/rust/json/patch/pointer.rs +49 -0
- package/rust/json/patch/tests.rs +499 -0
- package/rust/lib.rs +72 -0
- package/rust/metrics/api.rs +148 -0
- package/rust/metrics/mod.rs +21 -0
- package/rust/metrics/registry.rs +961 -0
- package/rust/panic_safety.rs +40 -0
- package/rust/payload/compress.rs +512 -0
- package/rust/payload/mod.rs +13 -0
- package/rust/payload/sse.rs +214 -0
- package/rust/payload/template.rs +253 -0
- package/rust/payload/websocket.rs +69 -0
- package/rust/payload/ws_frames.rs +354 -0
- package/rust/proptest_suite.rs +139 -0
- package/rust/selection.rs +95 -0
- package/rust/src/bin/native-bench.rs +495 -0
- package/rust/test_support.rs +71 -0
- package/rust/util/batch/api.rs +266 -0
- package/rust/util/batch/core.rs +473 -0
- package/rust/util/batch/mod.rs +23 -0
- package/rust/util/batch/tests.rs +445 -0
- package/rust/util/batch_core.rs +184 -0
- package/rust/util/bytes.rs +407 -0
- package/rust/util/mod.rs +34 -0
- package/rust/util/packed.rs +473 -0
- package/rust/util/text.rs +130 -0
- package/rust/util/threadpool.rs +128 -0
- package/rust/util/validation.rs +419 -0
- package/rust-toolchain.toml +17 -0
- package/scripts/postinstall.mjs +199 -0
- package/src/ingress/constants.ts +1 -1
- package/src/ingress/context.ts +2 -2
- package/src/ingress/decode/baked-result.ts +13 -13
- package/src/ingress/decode/fast-result.ts +17 -17
- package/src/ingress/errors.ts +7 -7
- package/src/ingress/headers/baked-templates.ts +1 -1
- package/src/ingress/headers/fast-templates.ts +3 -3
- package/src/ingress/index.ts +1 -1
- package/src/ingress/native-route.ts +9 -5
- package/src/ingress/options.ts +1 -1
- package/src/ingress/packing/gather-raw-headers.ts +1 -1
- package/src/ingress/packing/route-wire.ts +1 -5
- package/src/ingress/response/error-bodies.ts +5 -5
- package/src/ingress/response/terminal.ts +1 -1
- package/src/ingress/routes/echo.ts +4 -4
- package/src/ingress/routes/fallback.ts +2 -2
- package/src/ingress/routes/head.ts +2 -2
- package/src/ingress/routes/native.ts +2 -1
- package/src/ingress/routes/options.ts +1 -1
- package/src/ingress/routes/read.ts +2 -2
- package/src/ingress/server.ts +10 -6
- package/src/ingress/status.ts +7 -7
- package/src/integration/batch.ts +1 -1
- package/src/integration/index.ts +9 -9
- package/src/integration/pipeline.ts +6 -6
- package/src/integration/websocket.ts +2 -2
- package/src/loader/index.ts +1 -5
- package/src/loader/ops.ts +1 -1
- package/src/native/ffi/build/codecs.ts +104 -8
- package/src/native/ffi/build/compress.ts +14 -10
- package/src/native/ffi/build/instances.ts +40 -3
- package/src/native/ffi/build/metrics.ts +224 -0
- package/src/native/ffi/build/parse.ts +59 -23
- package/src/native/ffi/build/util.ts +14 -1
- package/src/native/ffi/build.ts +2 -0
- package/src/native/ffi/selftest.ts +10 -3
- package/src/native/ffi/types.ts +111 -9
- package/src/native/ffi.ts +87 -1
- package/src/native/index.ts +1 -1
- package/src/native/loader.ts +2 -2
- package/src/native/types/instances.ts +34 -0
- package/src/native/types.ts +11 -0
- package/src/runtime/codec.ts +53 -9
- package/src/runtime/types.ts +7 -0
- package/src/rust-ffi/batch/build.ts +16 -9
- package/src/rust-ffi/index.ts +7 -6
- package/src/rust-ffi/options.ts +1 -3
- package/src/rust-ffi/proven.ts +3 -3
- package/src/rust-ffi/scalar/crypto.ts +1 -1
- package/src/rust-ffi/scalar/factories.ts +73 -0
- package/src/rust-ffi/scalar/hashing.ts +1 -1
- package/src/rust-ffi/scalar/http.ts +17 -11
- package/src/rust-ffi/scalar/index.ts +2 -0
- package/src/rust-ffi/scalar/interface.ts +20 -0
- package/src/rust-ffi/scalar/payload.ts +11 -3
- package/src/rust-ffi/scalar/validation.ts +121 -0
- package/src/selection.ts +1 -1
- package/src/shared/bytes.ts +5 -4
- package/src/shared/codec.ts +18 -3
package/docs/API.md
CHANGED
|
@@ -38,7 +38,7 @@ runtimes, and binary ops (`gzip*`, `brotli*`, `aead*`, `pbkdf2Sha256`,
|
|
|
38
38
|
| `proven`, `PROVEN_SELECTION`, `provenImpl`, `provenStatus`, `isProven`, `provenSurface`, `provenSummary` | The full `rust.*` surface + the baked (pure-data) proven-selection registry of benchmark winners (`native` / `js` / `bun`). |
|
|
39
39
|
| `opImpl`, `isNativeOp`, `opDecision` | Native-vs-JS selection hints (benchmark-driven). |
|
|
40
40
|
| `loader`, `createLoader` | Higher-order loader over the batch surface. |
|
|
41
|
-
| `encoder`, `decoder` | Codec-backed UTF-8 helpers (
|
|
41
|
+
| `encoder`, `decoder` | Codec-backed UTF-8 helpers (shared native `TextEncoder` / Bun: `CString`; Node: `TextEncoder`/`TextDecoder`). `encode`/`decode` accept the `Uint8Array | string` union and normalize. |
|
|
42
42
|
| `uuidv7()` | UUIDv7 — `Bun.randomUUIDv7` on Bun, `crypto.randomUUID` on Node. |
|
|
43
43
|
| `AdaptiveEstimate`, `AdaptiveEstimateOptions` | Bounded EWMA adaptive-estimate utility. |
|
|
44
44
|
| `createMetrics`, `DEFAULT_BUCKETS` | Zero-dependency metrics registry (counters/gauges/histograms + Prometheus text). |
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -31,8 +31,9 @@ The bridge between TypeScript and Rust has **two transports**, both loading the
|
|
|
31
31
|
cdylib (built with napi-rs):
|
|
32
32
|
|
|
33
33
|
- **`bun:ffi` (PRIMARY on Bun)** — `src/native/ffi.ts` `dlopen`s the addon's
|
|
34
|
-
`extern "C"` exports (`rust/ffi/`,
|
|
35
|
-
`validator_c_abi!` + 4 `
|
|
34
|
+
`extern "C"` exports (`rust/ffi/`, 109 `castrum_*` symbols — 97 direct + 4
|
|
35
|
+
`validator_c_abi!` + 4 `validator_bytes_c_abi!` + 4 `compress_to_out!`,
|
|
36
|
+
parity guarded by
|
|
36
37
|
`test/unit/native/ffi-symbol-parity.test.ts`) and Bun JIT-calls them
|
|
37
38
|
directly (~10-20ns crossing vs ~100-350ns N-API). A bind-time self-test guards
|
|
38
39
|
correctness: on ANY failure the layer is disabled and calls fall back to napi.
|
package/docs/CASE_STUDY.md
CHANGED
|
@@ -42,8 +42,8 @@ as a pre-baked pipeline, and still be usable from both runtimes.
|
|
|
42
42
|
### 2.1 One cdylib, two transports
|
|
43
43
|
|
|
44
44
|
The addon is built with napi-rs (so Node can load it), but it *also* exports
|
|
45
|
-
`extern "C"` symbols (`rust/ffi/`, **
|
|
46
|
-
`validator_c_abi!` + 4 `compress_to_out!`) so Bun can
|
|
45
|
+
`extern "C"` symbols (`rust/ffi/`, **109 `castrum_*` symbols** — 97 direct + 4
|
|
46
|
+
`validator_c_abi!` + 4 `validator_bytes_c_abi!` + 4 `compress_to_out!`) so Bun can
|
|
47
47
|
`dlopen` it directly:
|
|
48
48
|
|
|
49
49
|
- **`bun:ffi` (primary on Bun)** — Bun JIT-calls the C-ABI exports at
|
package/docs/ENVIRONMENT.md
CHANGED
|
@@ -72,6 +72,22 @@ whether the ffi transport is live.
|
|
|
72
72
|
|----------|------|---------|-------------|
|
|
73
73
|
| `CASTRUM_PUBLISH_ALLOW_PARTIAL` | bool | — | When `1`, `prepublishOnly` ships a tarball containing only the locally-built platforms instead of failing on the full `napi.targets` set. Set automatically by `bun run publish:manual`; **never** set it for a normal `npm publish`.
|
|
74
74
|
|
|
75
|
+
## Postinstall fallback build (`scripts/postinstall.mjs`)
|
|
76
|
+
|
|
77
|
+
The package ships prebuilt addons for every `napi.targets` platform (built by
|
|
78
|
+
the CI "build" job on push/PR and staged into the tarball on a `v*` tag push).
|
|
79
|
+
`postinstall` is a fast no-op when a matching prebuilt is present. When none
|
|
80
|
+
ships for the host (an exotic platform / libc, or a partial tarball), it
|
|
81
|
+
compiles the addon **from source** in the consumer's `node_modules` via
|
|
82
|
+
`cargo build --release` (needs a Rust toolchain + crates.io access).
|
|
83
|
+
|
|
84
|
+
| Variable | Type | Default | Description |
|
|
85
|
+
|----------|------|---------|-------------|
|
|
86
|
+
| `CASTRUM_SKIP_BUILD` | (presence) | — | Skip the source-build fallback entirely (warn and continue). Use when a Rust toolchain is intentionally unavailable; the runtime loader will then fail with a clear "build it" error if the addon is needed. |
|
|
87
|
+
| `CASTRUM_REQUIRE_BUILD` | (presence) | — | Turn every postinstall "warn and skip" (missing toolchain, build failure, missing cdylib) into a hard install failure — for environments that must guarantee a loadable addon. |
|
|
88
|
+
| `npm_config_build_from_source` | bool | — | Set by `npm install --build-from-source`; forces a rebuild even when a prebuilt artifact is present. |
|
|
89
|
+
| `CI` | bool | — | GitHub Actions (and other CI) set this; the postinstall skips the fallback build because CI builds the addon explicitly (`bun run build`). |
|
|
90
|
+
|
|
75
91
|
## Security guidance
|
|
76
92
|
|
|
77
93
|
- **Proxy trust**: `trustProxy: true` is deprecated — it trusts EVERY hop of
|
package/docs/FFI_BUN_GUIDE.md
CHANGED
|
@@ -99,14 +99,23 @@ the matching shape and nothing else:
|
|
|
99
99
|
(no copy). **Benchmark-gated** (`bench:margin` `cstringArg` scenario):
|
|
100
100
|
~40–52 ns/call vs ~209–233 ns for `encodeUtf8` + `(ptr,len)` — a ~76–82% win,
|
|
101
101
|
including non-ASCII inputs. It is a LOSS for BYTE inputs (decode + re-encode)
|
|
102
|
-
— keep `(ptr,len)` there
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
102
|
+
— keep `(ptr,len)` there (the validators ship BOTH shapes:
|
|
103
|
+
`castrum_validate_*` for strings, `castrum_validate_*_bytes` for bytes).
|
|
104
|
+
**Verified `bun:ffi` facts**: JS `null` → NULL pointer, and JS `''` (empty
|
|
105
|
+
string) → NULL too — a present-but-empty value is indistinguishable from
|
|
106
|
+
absent through a `cstring` arg UNLESS a presence flag crosses alongside it.
|
|
107
|
+
Adopted by: the validators (`castrum_validate_*`),
|
|
108
|
+
`castrum_ws_accept_key`, `castrum_mime_from_extension`,
|
|
107
109
|
`castrum_password_verify_bcrypt` (phc), `castrum_rate_limiter_check` (key),
|
|
108
110
|
and `castrum_ingress_handle_components` (url/ip).
|
|
109
111
|
|
|
112
|
+
> **CASE STUDY — a conversion we REVERTED (2026-08-23)**: SSE's event/id
|
|
113
|
+
> were converted to `cstring` ARGs (+ presence flags) and shipped briefly,
|
|
114
|
+
> then reverted. The conversion itself was sound; what failed was the dev
|
|
115
|
+
> loop — see §14. Every "bun:ffi is broken" signal during that episode
|
|
116
|
+
> traced back to a mismatched addon↔JS state, never to the engine. Do not
|
|
117
|
+
> cite this as an engine bug.
|
|
118
|
+
|
|
110
119
|
Opaque instance handles are passed as a **bare `usize`** (`inner: usize`), and
|
|
111
120
|
must be null-checked at the top of the function (return the `0` sentinel) —
|
|
112
121
|
castrum's `castrum_ingress_handle_packed` / `castrum_*_matcher_matches` etc.
|
|
@@ -328,8 +337,82 @@ Rust-based runtime family. Castrum's own measurements live in `bench/`.
|
|
|
328
337
|
**Verify after any FFI change**:
|
|
329
338
|
|
|
330
339
|
```bash
|
|
331
|
-
nm -D --defined-only <addon> | grep -c castrum_ # =
|
|
340
|
+
nm -D --defined-only <addon> | grep -c castrum_ # = 109
|
|
332
341
|
bun run bench:http:smoke # after touching decoders/handlers
|
|
333
342
|
bun test test/unit/native/ffi.test.ts # FFI↔napi parity + self-test
|
|
334
343
|
bun test test/unit/native/ffi-symbol-parity.test.ts
|
|
335
344
|
```
|
|
345
|
+
|
|
346
|
+
---
|
|
347
|
+
|
|
348
|
+
## 14. Changing an existing symbol's ABI — the desync hazard (post-mortem 2026-08-23)
|
|
349
|
+
|
|
350
|
+
Adding or re-signing a `castrum_*` symbol means touching **Rust and the JS
|
|
351
|
+
dlopen map in lockstep**. `bun:ffi` performs ZERO runtime validation of the
|
|
352
|
+
declared ABI: if the addon on disk and `src/native/ffi.ts` disagree — arity,
|
|
353
|
+
arg order, or arg type — the call still crosses, the callee reads your scalar
|
|
354
|
+
as a pointer (or vice versa), and the process **segfaults**. This is not a
|
|
355
|
+
Bun bug; it is inherent to raw FFI, and it is why the bind-time self-test is
|
|
356
|
+
mandatory.
|
|
357
|
+
|
|
358
|
+
### The 2026-08-23 incident (recorded so nobody repeats it)
|
|
359
|
+
|
|
360
|
+
Converting `castrum_sse_encode_into`'s event/id to `cstring` args triggered
|
|
361
|
+
repeated SIGSEGVs that looked like an engine bug ("crash at address 0x5/0x6",
|
|
362
|
+
flaky across runs, "raw bindings pass but the package crashes"). After a full
|
|
363
|
+
root-cause pass, **every** crash traced to a development-loop desync — never
|
|
364
|
+
to bun:ffi itself:
|
|
365
|
+
|
|
366
|
+
1. **Half-updated trees**: rebuilding the Rust side while the JS map (or a
|
|
367
|
+
scratch probe) still declared the old signature — e.g. after `git stash`
|
|
368
|
+
/ `git checkout` of one side. The callee then dereferenced a small integer
|
|
369
|
+
argument as a pointer: crash addresses literally equalled values passed in
|
|
370
|
+
earlier calls (`0x5`, `0x6`).
|
|
371
|
+
2. **Probe methodology bugs**: shell probes treated ANY non-zero exit as a
|
|
372
|
+
crash — including clean `TypeError: Symbol "<key>" not found` failures
|
|
373
|
+
from a misnamed dlopen-map key. Several "crashes" were exit-code-1
|
|
374
|
+
TypeErrors.
|
|
375
|
+
3. **A mid-session runtime auto-update** (Bun canary replaced under us),
|
|
376
|
+
invalidating comparisons across runs.
|
|
377
|
+
|
|
378
|
+
The minimal C-dylib control experiment bound every suspect signature shape
|
|
379
|
+
(`cstring` leading/middle/trailing, mixed with `buffer`/`buffer_length`,
|
|
380
|
+
`u64_fast` returns) against trivial exports: all marshaled correctly on Bun
|
|
381
|
+
1.4.0/1.4.1 canaries. **No engine bug exists to report.**
|
|
382
|
+
|
|
383
|
+
### Rules for any ABI change (signature, arg order, or type)
|
|
384
|
+
|
|
385
|
+
1. **Treat Rust signature + JS dlopen-map entry + JS wrapper as ONE commit.**
|
|
386
|
+
Never run anything that binds (`bun test`, benches, `import castrum`)
|
|
387
|
+
between editing one side and the other.
|
|
388
|
+
2. **Rebuild before you bind**: after ANY `rust/ffi/**` edit (including
|
|
389
|
+
reverting one via git), run `bun run build` before running tests. A stale
|
|
390
|
+
`castrum.*.node` next to fresh JS is a guaranteed segfault, not an error.
|
|
391
|
+
3. **Fail loudly while debugging**: use `CASTRUM_FFI_MODE=ffi`. In `auto`
|
|
392
|
+
mode a failed bind/self-test silently falls back to napi — which masks
|
|
393
|
+
breakage as mysterious slowness or wrong-path results instead of an error.
|
|
394
|
+
4. **Know the failure signatures**:
|
|
395
|
+
- `SIGSEGV at a tiny address (0x5–0xB)` right after a binding → almost
|
|
396
|
+
always an ABI mismatch; check `git status rust/ src/native/ffi.ts` and
|
|
397
|
+
rebuild before theorizing about the engine.
|
|
398
|
+
- `Symbol "<name>" not found` at dlopen → key/name drift (parity test's
|
|
399
|
+
domain).
|
|
400
|
+
- ffi silently off → self-test returned false; run with
|
|
401
|
+
`CASTRUM_FFI_MODE=ffi` to get the throw instead of the fallback.
|
|
402
|
+
5. **Known guard gap**: `test/unit/native/ffi-symbol-parity.test.ts` checks
|
|
403
|
+
symbol NAMES (rust exports ⊆ dlopen map ⊆ self-test), NOT arities/types.
|
|
404
|
+
Arity drift surfaces only at call time — as a crash. When changing a
|
|
405
|
+
signature, update BOTH sides plus the self-test vectors in the same edit,
|
|
406
|
+
and let `bun test test/unit/native/ffi.test.ts` (FFI↔napi parity) catch
|
|
407
|
+
value divergence.
|
|
408
|
+
6. **Probe discipline when investigating**: distinguish crash from failure
|
|
409
|
+
explicitly (exit code 139 + `Segmentation fault` line vs exit 1 +
|
|
410
|
+
stderr), verify `bun --version` before AND after long sessions (canary
|
|
411
|
+
auto-updates invalidate comparisons), and test hypotheses against a
|
|
412
|
+
minimal dylib before blaming the engine.
|
|
413
|
+
7. **No repo-wide auto-fixers mid-investigation**: `bun run lint:fix`
|
|
414
|
+
rewrites import order across ~90 files; in this codebase import ORDER
|
|
415
|
+
carries side-effect semantics (eager-dlopen contract). Scope formatters
|
|
416
|
+
to your changed files, and treat mass diffs appearing during a debug
|
|
417
|
+
session as a variable to eliminate (revert them) before drawing
|
|
418
|
+
conclusions.
|
package/docs/REPO_MAP.md
CHANGED
|
@@ -169,8 +169,8 @@ rust/ ONE cdylib crate (Cargo [lib] → lib.rs).
|
|
|
169
169
|
native stack), options/time/packed, cors, proxy, ip_trust, rate_limit,
|
|
170
170
|
terminal, output.rs (single numeric layout source),
|
|
171
171
|
ingress_constants.rs (napi projection).
|
|
172
|
-
ffi/ #[no_mangle] extern "C" exports (
|
|
173
|
-
validator_c_abi! + 4 compress_to_out!; parity guarded by
|
|
172
|
+
ffi/ #[no_mangle] extern "C" exports (109 castrum_* symbols — 97 direct + 4
|
|
173
|
+
validator_c_abi! + 4 validator_bytes_c_abi! + 4 compress_to_out!; parity guarded by
|
|
174
174
|
test/unit/native/ffi-symbol-parity.test.ts) for Bun's bun:ffi
|
|
175
175
|
primary transport, incl. castrum_ingress_layout (the layout blob).
|
|
176
176
|
mod.rs (module map) + util.rs (panic_guard / HMAC cache / cstring helpers)
|
package/index.ts
CHANGED
|
@@ -11,50 +11,44 @@
|
|
|
11
11
|
// load. Do NOT add more eager-native modules; keep everything else lazy (see
|
|
12
12
|
// test/unit/features/import-contract.test.ts).
|
|
13
13
|
|
|
14
|
+
export * from './src/ingress'
|
|
15
|
+
// Framework-agnostic integration helpers: run the ingress pipeline as a
|
|
16
|
+
// request stage (createPipeline), RFC 6455 upgrade (createWebSocketUpgrade),
|
|
17
|
+
// and SSE framing (sseResponse). See docs/INGRESS.md.
|
|
18
|
+
export * from './src/integration'
|
|
19
|
+
// Higher-order loader (HFC) over the curated op set: createLoader / loader
|
|
20
|
+
// dispatch single items, packed batches, and load() coalescing. See README.
|
|
21
|
+
export * from './src/loader'
|
|
14
22
|
export * from './src/rust-ffi'
|
|
15
|
-
|
|
23
|
+
export type { OpDecision, OpImpl } from './src/selection'
|
|
16
24
|
// Native-vs-JS selection (OWNED HERE, not by consumers): `opImpl(op)` is the
|
|
17
25
|
// benchmark-driven recommendation for which operations should use the Rust
|
|
18
26
|
// addon vs a pure-JS implementation. Consumers read it once at load time and
|
|
19
27
|
// bind each op to a fixed impl. Source: rust/selection.rs (audited by
|
|
20
28
|
// `bun run check:selection`). See src/selection.ts.
|
|
21
|
-
export {
|
|
22
|
-
export type {
|
|
23
|
-
|
|
24
|
-
export { encoder, decoder } from './src/shared/bytes'
|
|
29
|
+
export { isNativeOp, opDecision, opImpl } from './src/selection'
|
|
30
|
+
export type { AdaptiveEstimateOptions } from './src/shared/adaptive'
|
|
25
31
|
// Bounded EWMA adaptive-estimate utility (drives BufferPool adaptive sizing
|
|
26
32
|
// and reusable runtime self-optimization decisions).
|
|
27
33
|
export { AdaptiveEstimate } from './src/shared/adaptive'
|
|
28
|
-
export
|
|
29
|
-
|
|
30
|
-
export { uuidv7 } from './src/shared/uuid'
|
|
34
|
+
export { decoder, encoder } from './src/shared/bytes'
|
|
35
|
+
export type { Counter, Gauge, Histogram, MetricsRegistry } from './src/shared/metrics'
|
|
31
36
|
// Zero-dep metrics registry (counters/gauges/histograms + Prometheus render).
|
|
32
37
|
export { createMetrics, DEFAULT_BUCKETS } from './src/shared/metrics'
|
|
33
|
-
export type {
|
|
34
|
-
|
|
38
|
+
export type { Pair, ParsedHttpRequestPacked } from './src/shared/packed'
|
|
35
39
|
export {
|
|
36
40
|
packBatch,
|
|
37
41
|
packPairs,
|
|
38
|
-
readPairsPacked,
|
|
39
|
-
readHttpPacked,
|
|
40
42
|
pairsToObject,
|
|
41
|
-
parseQueryString,
|
|
42
43
|
parseCookieHeader,
|
|
43
44
|
parseFormBody,
|
|
45
|
+
parseQueryString,
|
|
46
|
+
readHttpPacked,
|
|
47
|
+
readPairsPacked,
|
|
44
48
|
unpackBitset,
|
|
45
49
|
unpackByteResults,
|
|
46
50
|
unpackI64ArrayAsBigInt,
|
|
47
51
|
unpackU32Array,
|
|
48
52
|
} from './src/shared/packed'
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
export * from './src/ingress'
|
|
52
|
-
|
|
53
|
-
// Framework-agnostic integration helpers: run the ingress pipeline as a
|
|
54
|
-
// request stage (createPipeline), RFC 6455 upgrade (createWebSocketUpgrade),
|
|
55
|
-
// and SSE framing (sseResponse). See docs/INGRESS.md.
|
|
56
|
-
export * from './src/integration'
|
|
57
|
-
|
|
58
|
-
// Higher-order loader (HFC) over the curated op set: createLoader / loader
|
|
59
|
-
// dispatch single items, packed batches, and load() coalescing. See README.
|
|
60
|
-
export * from './src/loader'
|
|
53
|
+
// UUIDv7 generation — delegates to Bun.randomUUIDv7, crypto.randomUUID on Node.
|
|
54
|
+
export { uuidv7 } from './src/shared/uuid'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "castrum",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.4",
|
|
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",
|
|
@@ -48,11 +48,18 @@
|
|
|
48
48
|
"docs",
|
|
49
49
|
"README.md",
|
|
50
50
|
"*.node",
|
|
51
|
+
"scripts/postinstall.mjs",
|
|
52
|
+
"rust",
|
|
53
|
+
"Cargo.toml",
|
|
54
|
+
"Cargo.lock",
|
|
55
|
+
"build.rs",
|
|
56
|
+
"rust-toolchain.toml",
|
|
51
57
|
"!src/bench",
|
|
52
58
|
"!src/baseline",
|
|
53
59
|
"!src/data"
|
|
54
60
|
],
|
|
55
61
|
"scripts": {
|
|
62
|
+
"postinstall": "node scripts/postinstall.mjs",
|
|
56
63
|
"build": "napi build --release --platform",
|
|
57
64
|
"build:perf": "bash scripts/build-perf.sh",
|
|
58
65
|
"build:v3": "bash scripts/build-v3.sh",
|
|
@@ -104,6 +111,7 @@
|
|
|
104
111
|
"bench:startup": "bun run bench/startup.ts",
|
|
105
112
|
"bench:load": "bun run bench/http/load-phase.ts",
|
|
106
113
|
"bench:ffi": "bun run bench/ffi/ffi-all.ts",
|
|
114
|
+
"bench:newops": "bun bench/cost/rust-vs-js-new-ops.ts",
|
|
107
115
|
"bench:ffi:load": "bun run bench/ffi/ffi-load.ts",
|
|
108
116
|
"bench:ffi:public": "bun run bench/ffi/ffi-public.ts",
|
|
109
117
|
"bench:ffi:workers": "bun run bench/ffi/ffi-workers.ts",
|
|
@@ -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
|
+
}
|