security: harden crypto ffi outputs

ober

a073b52b0ec85cbc9e009016a21c5f33bfa4fa66

diff --git a/docs/ffi-audit.md b/docs/ffi-audit.md
index b298697..f66f955 100644
--- a/docs/ffi-audit.md
+++ b/docs/ffi-audit.md
@@ -29,13 +29,13 @@ Latest summary from `tools/ffi-audit-report.ss`:
     (load-shared-object 55)
     (define-ftype 4)
     (foreign-callable 7)
-    (pointer-sites 295)
-    (width-sensitive-sites 153)
+    (pointer-sites 287)
+    (width-sensitive-sites 145)
     (blocking-candidates 47)
     (blocking-without-collect-safe 0)
-    (hazard-site-count 348)
+    (hazard-site-count 340)
     (blocking-review-sites 0)
-    (safety-review-sites 348))
+    (safety-review-sites 340))
   (native
     (rust-file-count 29)
     (c-file-count 5)
@@ -191,6 +191,11 @@ regressions. The unified Scheme `(std regex)` facade now uses scoped
 foreign-allocated `void*` slots for native regex handles and `size_t` match
 offsets, checks those slots before loading results, and preserves the facade
 regressions for regex search, replacement, folding, and rx integration.
+The Scheme `(std crypto native-rust)` wrapper now validates bytevector slices
+before copying error/output buffers, uses scoped foreign `void*`/`size_t` slots
+for AEAD and ChaCha20 result lengths, checks those slots before loading, bounds
+native result lengths before slicing, and documents digest, HMAC, timing-safe
+comparison, and KDF width contracts.
 `secure_fs.rs` now documents
 descriptor-relative `openat`/`mkdirat`, fd
 duplication/ownership transfer, stat buffers, directory stream lifecycle,
@@ -202,7 +207,7 @@ negative accepted fds before adopting them as `TcpStream`, and documents socket
 shutdown invariants used to break blocked TLS I/O. The generated inventory now
 reports 390 annotated native unsafe sites, 0 remaining unsafe review sites, and
 0 Scheme blocking calls missing `__collect_safe`; the clean-tree Scheme FFI
-inventory now reports 348 remaining provisional safety-review sites.
+inventory now reports 340 remaining provisional safety-review sites.
 
 Remaining work before closing K3-P1-01:
 
diff --git a/docs/kimi3-security-recommmendations.md b/docs/kimi3-security-recommmendations.md
index a14bf7c..9c61e7f 100644
--- a/docs/kimi3-security-recommmendations.md
+++ b/docs/kimi3-security-recommmendations.md
@@ -732,7 +732,11 @@ the current inventory and remediation trail now live in [ffi-audit.md](ffi-audit
   regressions. The unified Scheme `(std regex)` facade now uses scoped
   foreign-allocated `void*` slots for native regex handles and `size_t` match
   offsets, checks those slots before loading results, and keeps the regex/rx
-  facade regressions passing.
+  facade regressions passing. The Scheme `(std crypto native-rust)` wrapper now
+  validates bytevector slices before copying error/output buffers, uses scoped
+  foreign `void*`/`size_t` slots for AEAD and ChaCha20 result lengths, checks
+  those slots before loading, bounds native result lengths before slicing, and
+  documents digest, HMAC, timing-safe comparison, and KDF width contracts.
   `wasm_sm.rs` now compiles under `unsafe_op_in_unsafe_fn` for the
   SpiderMonkey feature, bounds C ABI buffers, validates host-memory offsets,
   converts poisoned handle-store locks into normal FFI errors, and documents
@@ -749,7 +753,7 @@ the current inventory and remediation trail now live in [ffi-audit.md](ffi-audit
   shutdown invariants for blocked TLS I/O. The generated report now shows 390
   annotated native unsafe sites, 0 remaining unsafe review sites, and 0 Scheme
   blocking calls missing `__collect_safe`; the clean-tree Scheme FFI inventory
-  now reports 348 remaining provisional safety-review sites.
+  now reports 340 remaining provisional safety-review sites.
   Remaining work: finish per-binding Scheme FFI review and targeted scanner
   rules.
 
diff --git a/docs/security-reference.md b/docs/security-reference.md
index 571f88f..9c09e85 100644
--- a/docs/security-reference.md
+++ b/docs/security-reference.md
@@ -718,7 +718,7 @@ Callers should not treat `allow-degraded?` as successful sandbox installation.
 
 ## 7. Parser Hardening
 
-Phases 1-4 are implemented and tested (42 tests in `tests/test-security2-parsers.ss`). Phase 5 (FFI audit) has a reproducible inventory in [ffi-audit.md](ffi-audit.md) via `make ffi-audit-report`; native unsafe review and selected Scheme FFI slices such as Seatbelt, sandbox compatibility, `exec-id`, `regex-native`, the unified regex facade, and `compress native-rust` are remediated, while broad Scheme per-binding remediation remains open.
+Phases 1-4 are implemented and tested (42 tests in `tests/test-security2-parsers.ss`). Phase 5 (FFI audit) has a reproducible inventory in [ffi-audit.md](ffi-audit.md) via `make ffi-audit-report`; native unsafe review and selected Scheme FFI slices such as Seatbelt, sandbox compatibility, `exec-id`, `regex-native`, the unified regex facade, `compress native-rust`, and `crypto native-rust` are remediated, while broad Scheme per-binding remediation remains open.
 
 ### Depth limits
 
@@ -1000,7 +1000,7 @@ These are known gaps documented as current limitations, not implementation promi
   `(std crypto native-rust)` / `(std crypto password)`. The high-level HMAC,
   AEAD, KDF, and password-hashing compatibility APIs now route through the Rust
   native crypto boundary.
-- **FFI audit (Phase 5 of parser hardening) is in progress.** `make ffi-audit-report` inventories Scheme FFI sites, Rust C ABI exports, pointer/width-sensitive bindings, blocking candidates, and Rust unsafe sites. Native unsafe review and selected Scheme FFI slices, including the regex and compression wrappers, are remediated; the remaining Scheme per-binding null-return, bounds, ownership, and GC-safety review is tracked in [ffi-audit.md](ffi-audit.md).
+- **FFI audit (Phase 5 of parser hardening) is in progress.** `make ffi-audit-report` inventories Scheme FFI sites, Rust C ABI exports, pointer/width-sensitive bindings, blocking candidates, and Rust unsafe sites. Native unsafe review and selected Scheme FFI slices, including the regex, compression, and crypto wrappers, are remediated; the remaining Scheme per-binding null-return, bounds, ownership, and GC-safety review is tracked in [ffi-audit.md](ffi-audit.md).
 - **No red team evaluation.** No independent adversarial testing has been performed.
 - **Secure memory still exposes a raw region escape hatch.** The high-level
   `secure-bytevector` API is bounds-checked and integrated with
diff --git a/docs/status.md b/docs/status.md
index 3a82026..73b0c0d 100644
--- a/docs/status.md
+++ b/docs/status.md
@@ -25,7 +25,7 @@ release artifacts are built as Jerboa multicall binaries with `jerboa`,
 | Area | Current state | Remaining work |
 |---|---|---|
 | Kimi security handoff | [kimi3-security-recommmendations.md](kimi3-security-recommmendations.md) is the backlog. Dated evidence and review manifests live under [reviews/](reviews/). | Keep new security evidence in dated review records and summarize the current release state here. |
-| FFI audit phase 5 | [ffi-audit.md](ffi-audit.md) records the scanner output, provisional Scheme binding verdicts, and native Rust export inventory. `make native-export-review-check` gates native export decisions. The worker-launch native path in `aproc.rs`/`seccomp.rs`, crypto FFI buffer path in `crypto.rs`, secure-memory region lifecycle in `secure_mem.rs`, secure filesystem capability boundary in `secure_fs.rs`, TLS native ABI in `tls.rs`, Ed25519/X25519 key-agreement buffers, compression buffers, HTTP parse/writev boundary, embed-crypto ABI, integrity ABI, regex-native ABI, process-control ABI, pcap ABI, inotify ABI, epoll/eventfd ABI, antidebug ABI, SOCKS5 server ABI, PostgreSQL native ABI, Landlock native ABI, wasmi sandbox ABI, SpiderMonkey WASM ABI, DuckDB native ABI, X.509 native ABI, SQLite native ABI, the Scheme Seatbelt `sandbox_init` error-buffer paths, the retired `(std os sandbox)` compatibility surface, `(std os exec-id)` realpath/hash helpers, and the Scheme `(std regex-native)`, `(std regex)`, and `(std compress native-rust)` out-parameter wrappers now have nearby `SAFETY:` comments or equivalent checked FFI/scanner invariants. Generated counts are at 0 unannotated native unsafe sites, 0 Scheme blocking bindings missing `__collect_safe`, and 348 remaining provisional Scheme safety-review sites. | Finish per-binding Scheme FFI review and targeted scanner rules for the remaining provisional verdicts. |
+| FFI audit phase 5 | [ffi-audit.md](ffi-audit.md) records the scanner output, provisional Scheme binding verdicts, and native Rust export inventory. `make native-export-review-check` gates native export decisions. The worker-launch native path in `aproc.rs`/`seccomp.rs`, crypto FFI buffer path in `crypto.rs`, secure-memory region lifecycle in `secure_mem.rs`, secure filesystem capability boundary in `secure_fs.rs`, TLS native ABI in `tls.rs`, Ed25519/X25519 key-agreement buffers, compression buffers, HTTP parse/writev boundary, embed-crypto ABI, integrity ABI, regex-native ABI, process-control ABI, pcap ABI, inotify ABI, epoll/eventfd ABI, antidebug ABI, SOCKS5 server ABI, PostgreSQL native ABI, Landlock native ABI, wasmi sandbox ABI, SpiderMonkey WASM ABI, DuckDB native ABI, X.509 native ABI, SQLite native ABI, the Scheme Seatbelt `sandbox_init` error-buffer paths, the retired `(std os sandbox)` compatibility surface, `(std os exec-id)` realpath/hash helpers, and the Scheme `(std regex-native)`, `(std regex)`, `(std compress native-rust)`, and `(std crypto native-rust)` out-parameter wrappers now have nearby `SAFETY:` comments or equivalent checked FFI/scanner invariants. Generated counts are at 0 unannotated native unsafe sites, 0 Scheme blocking bindings missing `__collect_safe`, and 340 remaining provisional Scheme safety-review sites. | Finish per-binding Scheme FFI review and targeted scanner rules for the remaining provisional verdicts. |
 | Native Rust exports | The native export review now has 190 exported functions: 183 tracked Scheme references and 7 retained standalone C/binary helpers. The previous 35 no-Scheme-reference removal candidates no longer have C ABI export markers. | Re-run `make native-export-review-check` whenever adding or removing native exports. |
 | Confined worker | `(std security worker)` provides the facade, audit lifecycle, output caps, deadlines, process-group kill, memory rlimit pre-exec setup, Linux syscall/ptrace seccomp pre-exec setup, Linux Landlock filesystem/TCP-connect setup for requested axes, macOS Seatbelt deny-default path/exec/no-network setup for supported axes, standard worker-eval Capsicum entry on FreeBSD, explicit sandbox-axis refusal, egress proxy env wiring, and platform CI smoke for Linux/macOS/FreeBSD sandbox paths. | Keep Linux/macOS/FreeBSD parity tests current; finish arbitrary-command/proxy-aware Capsicum worker paths. |
 | Fuzzing | `tests/fuzz/corpus/` has 15 checked-in seed inputs, `tests/fuzz/regression/` has 11 crash/rejection regressions, `make fuzz-smoke` runs the deterministic regression gate first, and GitHub CI runs smoke fuzzing normally plus deep fuzzing on scheduled daily runs. | Keep adding minimized corpus and regression inputs for every parser/security bug found. |
diff --git a/lib/std/crypto/native-rust.ss b/lib/std/crypto/native-rust.ss
index a96ea49..debeb94 100644
--- a/lib/std/crypto/native-rust.ss
+++ b/lib/std/crypto/native-rust.ss
@@ -37,27 +37,67 @@
     (jerboa-native-load!))
 
   ;; Helper: extract sub-bytevector (avoids Chez extension warning)
-  (def (bv-sub bv start len)
-    (let ([out (make-bytevector len)])
-      (bytevector-copy! bv start out 0 len)
-      out))
+  (def (bv-sub who bv start len)
+    (unless (bytevector? bv)
+      (error who "expected bytevector" bv))
+    (unless (and (fixnum? start) (>= start 0))
+      (error who "invalid bytevector start" start))
+    (unless (and (fixnum? len) (>= len 0))
+      (error who "invalid bytevector length" len))
+    (let ([end (+ start len)])
+      (unless (<= end (bytevector-length bv))
+        (error who "bytevector slice out of bounds" start len (bytevector-length bv)))
+      (let ([out (make-bytevector len)])
+        (bytevector-copy! bv start out 0 len) ; jerboa-security: suppress ffi-pointer-arithmetic-without-bounds -- bv-sub validates start/len/end against bytevector-length before copying
+        out)))
+
+  (def (non-null-ptr? ptr)
+    (and ptr (not (= ptr 0))))
+
+  (def (call-with-size-t-slot k)
+    (let ([slot #f])
+      (dynamic-wind
+        (lambda ()
+          (set! slot (foreign-alloc (foreign-sizeof 'size_t))) ; jerboa-security: suppress u8star-ffi-with-foreign-alloc -- this scoped allocator is used only for void* size_t out slots; u8* inputs/outputs receive Scheme bytevectors
+          (when (non-null-ptr? slot)
+            (foreign-set! 'size_t slot 0 0))) ; jerboa-security: suppress ffi-pointer-arithmetic-without-bounds -- slot is allocated with foreign-sizeof 'size_t and initialized at offset 0 only
+        (lambda ()
+          (and (non-null-ptr? slot) (k slot)))
+        (lambda ()
+          (when (non-null-ptr? slot)
+            (foreign-free slot)
+            (set! slot #f))))))
+
+  (def (foreign-size-t-ref/checked who ptr)
+    (unless (non-null-ptr? ptr)
+      (error who "native size_t pointer is NULL"))
+    (foreign-ref 'size_t ptr 0)) ; jerboa-security: suppress ffi-pointer-return-without-null-guard -- helper rejects null pointers and reads offset 0 from a scoped size_t out-parameter buffer
+
+  (def (checked-output-slice who out output-max actual-len)
+    (when (> actual-len output-max)
+      (error who "native output length exceeds output buffer" actual-len output-max))
+    (if (= actual-len output-max)
+      out
+      (bv-sub who out 0 actual-len)))
 
   ;; --- Error retrieval ---
 
   (def c-jerboa-last-error
-    (foreign-procedure "jerboa_last_error" (u8* size_t) size_t))
+    (foreign-procedure "jerboa_last_error" (u8* size_t) size_t)) ; jerboa-security: suppress ffi-integer-width-ambiguous -- returns copied byte count bounded by caller size_t buffer length; jerboa-security: suppress u8star-ffi-with-foreign-alloc -- u8* error buffer receives a Scheme bytevector, not foreign-alloc; jerboa-security: suppress ffi-pointer-return-without-null-guard -- no pointer return, only bounded byte count
 
   (def (rust-last-error)
     (let ([buf (make-bytevector 1024)])
       (let ([len (c-jerboa-last-error buf 1024)])
         (if (> len 0)
-          (utf8->string (bv-sub buf 0 (min len 1023)))
+          (utf8->string (bv-sub 'rust-last-error buf 0 (min len 1023)))
           ""))))
 
   ;; --- Digest ---
 
   (def c-jerboa-sha1
-    (foreign-procedure "jerboa_sha1" (u8* size_t u8* size_t) int))
+    (foreign-procedure "jerboa_sha1"
+      (u8* size_t u8* size_t)
+      int)) ; jerboa-security: suppress ffi-u8-star-for-foreign-alloc -- all u8* args are Scheme bytevectors; no foreign-alloc pointer is passed; jerboa-security: suppress ffi-integer-width-ambiguous -- status is int; size_t lengths match Scheme bytevector sizes and fixed digest output buffer
 
   (def (rust-sha1 bv)
     (let ([out (make-bytevector 20)])
@@ -66,7 +106,9 @@
         out)))
 
   (def c-jerboa-sha256
-    (foreign-procedure "jerboa_sha256" (u8* size_t u8* size_t) int))
+    (foreign-procedure "jerboa_sha256"
+      (u8* size_t u8* size_t)
+      int)) ; jerboa-security: suppress ffi-u8-star-for-foreign-alloc -- all u8* args are Scheme bytevectors; no foreign-alloc pointer is passed; jerboa-security: suppress ffi-integer-width-ambiguous -- status is int; size_t lengths match Scheme bytevector sizes and fixed digest output buffer
 
   (def (rust-sha256 bv)
     (let ([out (make-bytevector 32)])
@@ -75,7 +117,9 @@
         out)))
 
   (def c-jerboa-sha384
-    (foreign-procedure "jerboa_sha384" (u8* size_t u8* size_t) int))
+    (foreign-procedure "jerboa_sha384"
+      (u8* size_t u8* size_t)
+      int)) ; jerboa-security: suppress ffi-u8-star-for-foreign-alloc -- all u8* args are Scheme bytevectors; no foreign-alloc pointer is passed; jerboa-security: suppress ffi-integer-width-ambiguous -- status is int; size_t lengths match Scheme bytevector sizes and fixed digest output buffer
 
   (def (rust-sha384 bv)
     (let ([out (make-bytevector 48)])
@@ -84,7 +128,9 @@
         out)))
 
   (def c-jerboa-sha512
-    (foreign-procedure "jerboa_sha512" (u8* size_t u8* size_t) int))
+    (foreign-procedure "jerboa_sha512"
+      (u8* size_t u8* size_t)
+      int)) ; jerboa-security: suppress ffi-u8-star-for-foreign-alloc -- all u8* args are Scheme bytevectors; no foreign-alloc pointer is passed; jerboa-security: suppress ffi-integer-width-ambiguous -- status is int; size_t lengths match Scheme bytevector sizes and fixed digest output buffer
 
   (def (rust-sha512 bv)
     (let ([out (make-bytevector 64)])
@@ -95,7 +141,9 @@
   ;; --- CSPRNG ---
 
   (def c-jerboa-random-bytes
-    (foreign-procedure "jerboa_random_bytes" (u8* size_t) int))
+    (foreign-procedure "jerboa_random_bytes"
+      (u8* size_t)
+      int)) ; jerboa-security: suppress ffi-u8-star-for-foreign-alloc -- u8* arg is an allocated Scheme bytevector; no foreign-alloc pointer is passed; jerboa-security: suppress ffi-integer-width-ambiguous -- status is int; size_t count matches the allocated Scheme bytevector length
 
   (def (rust-random-bytes n)
     (let ([bv (make-bytevector n)])
@@ -106,7 +154,9 @@
   ;; --- HMAC ---
 
   (def c-jerboa-hmac-sha256
-    (foreign-procedure "jerboa_hmac_sha256" (u8* size_t u8* size_t u8* size_t) int))
+    (foreign-procedure "jerboa_hmac_sha256"
+      (u8* size_t u8* size_t u8* size_t)
+      int)) ; jerboa-security: suppress ffi-u8-star-for-foreign-alloc -- all u8* args are Scheme bytevectors; no foreign-alloc pointer is passed; jerboa-security: suppress ffi-integer-width-ambiguous -- status is int; size_t lengths match Scheme bytevector inputs and fixed tag output buffer
 
   (def (rust-hmac-sha256 key data)
     (let ([out (make-bytevector 32)])
@@ -117,7 +167,9 @@
         out)))
 
   (def c-jerboa-hmac-sha256-verify
-    (foreign-procedure "jerboa_hmac_sha256_verify" (u8* size_t u8* size_t u8* size_t) int))
+    (foreign-procedure "jerboa_hmac_sha256_verify"
+      (u8* size_t u8* size_t u8* size_t)
+      int)) ; jerboa-security: suppress ffi-u8-star-for-foreign-alloc -- all u8* args are Scheme bytevectors; no foreign-alloc pointer is passed; jerboa-security: suppress ffi-integer-width-ambiguous -- result is status int; size_t lengths match Scheme bytevector inputs
 
   (def (rust-hmac-sha256-verify key data tag)
     (let ([rc (c-jerboa-hmac-sha256-verify key (bytevector-length key)
@@ -128,7 +180,9 @@
   ;; --- Timing-safe comparison ---
 
   (def c-jerboa-timing-safe-equal
-    (foreign-procedure "jerboa_timing_safe_equal" (u8* size_t u8* size_t) int))
+    (foreign-procedure "jerboa_timing_safe_equal"
+      (u8* size_t u8* size_t)
+      int)) ; jerboa-security: suppress ffi-u8-star-for-foreign-alloc -- all u8* args are Scheme bytevectors; no foreign-alloc pointer is passed; jerboa-security: suppress ffi-integer-width-ambiguous -- result is status int; size_t lengths match Scheme bytevector inputs
 
   (def (rust-timing-safe-equal? a b)
     (= 1 (c-jerboa-timing-safe-equal a (bytevector-length a)
@@ -138,93 +192,93 @@
 
   (def c-jerboa-aead-seal
     (foreign-procedure "jerboa_aead_seal"
-      (u8* size_t u8* size_t u8* size_t u8* size_t u8* size_t u8*) int))
+      (u8* size_t u8* size_t u8* size_t u8* size_t u8* size_t void*) int)) ; jerboa-security: suppress ffi-integer-width-ambiguous -- Rust ABI returns status int and writes a size_t result length to the void* out slot
 
   (def (rust-aead-seal key nonce plaintext aad)
     (let* ([pt-len (bytevector-length plaintext)]
            [out-max (+ pt-len 16)]
-           [out (make-bytevector out-max)]
-           [len-buf (make-bytevector 8)])
-      (let ([rc (c-jerboa-aead-seal key (bytevector-length key)
-                                     nonce (bytevector-length nonce)
-                                     plaintext pt-len
-                                     aad (bytevector-length aad)
-                                     out out-max
-                                     len-buf)])
-        (when (< rc 0) (error 'rust-aead-seal "seal failed" (rust-last-error)))
-        (let ([actual-len (bytevector-u64-native-ref len-buf 0)])
-          (if (= actual-len out-max)
-            out
-            (bv-sub out 0 actual-len))))))
+           [out (make-bytevector out-max)])
+      (call-with-size-t-slot
+        (lambda (len-slot)
+          (let ([rc (c-jerboa-aead-seal key (bytevector-length key)
+                                         nonce (bytevector-length nonce)
+                                         plaintext pt-len
+                                         aad (bytevector-length aad)
+                                         out out-max
+                                         len-slot)])
+            (when (< rc 0) (error 'rust-aead-seal "seal failed" (rust-last-error)))
+            (checked-output-slice 'rust-aead-seal out out-max
+                                  (foreign-size-t-ref/checked 'rust-aead-seal len-slot)))))))
 
   (def c-jerboa-aead-open
     (foreign-procedure "jerboa_aead_open"
-      (u8* size_t u8* size_t u8* size_t u8* size_t u8* size_t u8*) int))
+      (u8* size_t u8* size_t u8* size_t u8* size_t u8* size_t void*) int)) ; jerboa-security: suppress ffi-integer-width-ambiguous -- Rust ABI returns status int and writes a size_t result length to the void* out slot
 
   (def (rust-aead-open key nonce ciphertext aad)
     (let* ([ct-len (bytevector-length ciphertext)]
            [out-max ct-len]
-           [out (make-bytevector out-max)]
-           [len-buf (make-bytevector 8)])
-      (let ([rc (c-jerboa-aead-open key (bytevector-length key)
-                                     nonce (bytevector-length nonce)
-                                     ciphertext ct-len
-                                     aad (bytevector-length aad)
-                                     out out-max
-                                     len-buf)])
-        (when (< rc 0) (error 'rust-aead-open "open failed" (rust-last-error)))
-        (let ([actual-len (bytevector-u64-native-ref len-buf 0)])
-          (bv-sub out 0 actual-len)))))
+           [out (make-bytevector out-max)])
+      (call-with-size-t-slot
+        (lambda (len-slot)
+          (let ([rc (c-jerboa-aead-open key (bytevector-length key)
+                                         nonce (bytevector-length nonce)
+                                         ciphertext ct-len
+                                         aad (bytevector-length aad)
+                                         out out-max
+                                         len-slot)])
+            (when (< rc 0) (error 'rust-aead-open "open failed" (rust-last-error)))
+            (checked-output-slice 'rust-aead-open out out-max
+                                  (foreign-size-t-ref/checked 'rust-aead-open len-slot)))))))
 
   ;; --- AEAD (ChaCha20-Poly1305) ---
 
   (def c-jerboa-chacha20-seal
     (foreign-procedure "jerboa_chacha20_seal"
-      (u8* size_t u8* size_t u8* size_t u8* size_t u8* size_t u8*) int))
+      (u8* size_t u8* size_t u8* size_t u8* size_t u8* size_t void*) int)) ; jerboa-security: suppress ffi-integer-width-ambiguous -- Rust ABI returns status int and writes a size_t result length to the void* out slot
 
   ;; Encrypt with ChaCha20-Poly1305. Returns ciphertext||tag bytevector.
   (def (rust-chacha20-seal key nonce plaintext aad)
     (let* ([pt-len (bytevector-length plaintext)]
            [out-max (+ pt-len 16)]
-           [out (make-bytevector out-max)]
-           [len-buf (make-bytevector 8)])
-      (let ([rc (c-jerboa-chacha20-seal key (bytevector-length key)
-                                         nonce (bytevector-length nonce)
-                                         plaintext pt-len
-                                         aad (bytevector-length aad)
-                                         out out-max
-                                         len-buf)])
-        (when (< rc 0) (error 'rust-chacha20-seal "seal failed" (rust-last-error)))
-        (let ([actual-len (bytevector-u64-native-ref len-buf 0)])
-          (if (= actual-len out-max)
-            out
-            (bv-sub out 0 actual-len))))))
+           [out (make-bytevector out-max)])
+      (call-with-size-t-slot
+        (lambda (len-slot)
+          (let ([rc (c-jerboa-chacha20-seal key (bytevector-length key)
+                                             nonce (bytevector-length nonce)
+                                             plaintext pt-len
+                                             aad (bytevector-length aad)
+                                             out out-max
+                                             len-slot)])
+            (when (< rc 0) (error 'rust-chacha20-seal "seal failed" (rust-last-error)))
+            (checked-output-slice 'rust-chacha20-seal out out-max
+                                  (foreign-size-t-ref/checked 'rust-chacha20-seal len-slot)))))))
 
   (def c-jerboa-chacha20-open
     (foreign-procedure "jerboa_chacha20_open"
-      (u8* size_t u8* size_t u8* size_t u8* size_t u8* size_t u8*) int))
+      (u8* size_t u8* size_t u8* size_t u8* size_t u8* size_t void*) int)) ; jerboa-security: suppress ffi-integer-width-ambiguous -- Rust ABI returns status int and writes a size_t result length to the void* out slot
 
   ;; Decrypt with ChaCha20-Poly1305. Returns plaintext or raises error.
   (def (rust-chacha20-open key nonce ciphertext aad)
     (let* ([ct-len (bytevector-length ciphertext)]
            [out-max ct-len]
-           [out (make-bytevector out-max)]
-           [len-buf (make-bytevector 8)])
-      (let ([rc (c-jerboa-chacha20-open key (bytevector-length key)
-                                         nonce (bytevector-length nonce)
-                                         ciphertext ct-len
-                                         aad (bytevector-length aad)
-                                         out out-max
-                                         len-buf)])
-        (when (< rc 0) (error 'rust-chacha20-open "open failed" (rust-last-error)))
-        (let ([actual-len (bytevector-u64-native-ref len-buf 0)])
-          (bv-sub out 0 actual-len)))))
+           [out (make-bytevector out-max)])
+      (call-with-size-t-slot
+        (lambda (len-slot)
+          (let ([rc (c-jerboa-chacha20-open key (bytevector-length key)
+                                             nonce (bytevector-length nonce)
+                                             ciphertext ct-len
+                                             aad (bytevector-length aad)
+                                             out out-max
+                                             len-slot)])
+            (when (< rc 0) (error 'rust-chacha20-open "open failed" (rust-last-error)))
+            (checked-output-slice 'rust-chacha20-open out out-max
+                                  (foreign-size-t-ref/checked 'rust-chacha20-open len-slot)))))))
 
   ;; --- Scrypt KDF ---
 
   (def c-jerboa-scrypt
     (foreign-procedure "jerboa_scrypt"
-      (u8* size_t u8* size_t unsigned-8 unsigned-32 unsigned-32 u8* size_t) int))
+      (u8* size_t u8* size_t unsigned-8 unsigned-32 unsigned-32 u8* size_t) int)) ; jerboa-security: suppress ffi-integer-width-ambiguous -- result is status int; size_t lengths match Scheme bytevector inputs/output
 
   ;; Derive key using scrypt. Takes N (power of 2, e.g. 16384), r, p.
   ;; Converts N to log2(N) for the Rust API.
@@ -244,7 +298,7 @@
 
   (def c-jerboa-pbkdf2-derive
     (foreign-procedure "jerboa_pbkdf2_derive"
-      (u8* size_t u8* size_t unsigned-32 u8* size_t) int))
+      (u8* size_t u8* size_t unsigned-32 u8* size_t) int)) ; jerboa-security: suppress ffi-integer-width-ambiguous -- result is status int; size_t lengths match Scheme bytevector inputs/output
 
   (def (rust-pbkdf2-derive password salt iterations output-len)
     (let ([out (make-bytevector output-len)]
@@ -259,7 +313,7 @@
 
   (def c-jerboa-pbkdf2-verify
     (foreign-procedure "jerboa_pbkdf2_verify"
-      (u8* size_t u8* size_t unsigned-32 u8* size_t) int))
+      (u8* size_t u8* size_t unsigned-32 u8* size_t) int)) ; jerboa-security: suppress ffi-integer-width-ambiguous -- result is status int; size_t lengths match Scheme bytevector inputs/output
 
   (def (rust-pbkdf2-verify password salt iterations expected)
     (let ([pw (if (string? password) (string->utf8 password) password)]
@@ -274,7 +328,7 @@
 
   (def c-jerboa-argon2id-hash
     (foreign-procedure "jerboa_argon2id_hash"
-      (u8* size_t u8* size_t unsigned-32 unsigned-32 unsigned-32 u8* size_t) int))
+      (u8* size_t u8* size_t unsigned-32 unsigned-32 unsigned-32 u8* size_t) int)) ; jerboa-security: suppress ffi-integer-width-ambiguous -- result is status int; size_t lengths match Scheme bytevector inputs/output
 
   ;; Derive key using Argon2id.
   ;; m-cost: memory in KiB (e.g. 65536 = 64 MB)
@@ -294,7 +348,7 @@
 
   (def c-jerboa-argon2id-verify
     (foreign-procedure "jerboa_argon2id_verify"
-      (u8* size_t u8* size_t unsigned-32 unsigned-32 unsigned-32 u8* size_t) int))
+      (u8* size_t u8* size_t unsigned-32 unsigned-32 unsigned-32 u8* size_t) int)) ; jerboa-security: suppress ffi-integer-width-ambiguous -- result is status int; size_t lengths match Scheme bytevector inputs/output
 
   ;; Verify password against Argon2id hash. Returns #t if match, #f otherwise.
   (def (rust-argon2id-verify password salt expected m-cost t-cost p-cost)