security: harden crypto ffi buffers

ober

4c21d5c15f0c864975f52facda09e04194114af6

diff --git a/docs/ffi-audit.md b/docs/ffi-audit.md
index 5ce9568..e7f43df 100644
--- a/docs/ffi-audit.md
+++ b/docs/ffi-audit.md
@@ -41,9 +41,9 @@ Latest summary from `tools/ffi-audit-report.ss`:
     (c-file-count 5)
     (no-mangle-exports 196)
     (unsafe-sites 438)
-    (unsafe-sites-with-nearby-safety-comment 29)
-    (unsafe-sites-without-nearby-safety-comment 409)
-    (unsafe-comment-review-sites 409)
+    (unsafe-sites-with-nearby-safety-comment 82)
+    (unsafe-sites-without-nearby-safety-comment 356)
+    (unsafe-comment-review-sites 356)
     (export-review-sites 196))
   (vendor
     (jsqlite (path "vendor/jsqlite") (status accepted-risk-cve-gated)))
@@ -75,8 +75,12 @@ unsafe operation still has to live inside an explicit `unsafe { ... }` block.
 On 2026-07-28, the `aproc.rs` and `seccomp.rs` unsafe sites covering the
 worker pre-exec launch path, fd handoff/cleanup, FFI buffer copies, rlimit
 decoding, wait4 outputs, and seccomp `prctl` calls gained nearby `SAFETY:`
-invariant comments. The generated inventory now reports 29 annotated native
-unsafe sites and 409 remaining unsafe review sites.
+invariant comments. The `crypto.rs` digest, HMAC, random, constant-time
+comparison, AEAD, scrypt, PBKDF2, and Argon2id FFI buffer sites also gained
+nearby `SAFETY:` invariant comments; the same pass added fail-closed
+nonzero-null input checks for HMAC and AEAD optional buffers and checked AEAD
+seal output length addition for overflow. The generated inventory now reports
+82 annotated native unsafe sites and 356 remaining unsafe review sites.
 
 Remaining work before closing K3-P1-01:
 
diff --git a/docs/kimi3-security-recommmendations.md b/docs/kimi3-security-recommmendations.md
index bf894ba..7b9ee63 100644
--- a/docs/kimi3-security-recommmendations.md
+++ b/docs/kimi3-security-recommmendations.md
@@ -622,10 +622,12 @@ not started."
   export markers removed; the remaining zero-caller exports are the 7
   standalone C/binary-tooling helpers explicitly retained in the decision
   file. On 2026-07-28, `aproc.rs` and `seccomp.rs` gained `SAFETY:`
-  invariant comments for the worker launch/seccomp unsafe sites; the generated
-  report now shows 29 annotated native unsafe sites and 409 remaining unsafe
-  review sites. Remaining work: continue unsafe invariant comments across the
-  rest of `jerboa-native-rs`.
+  invariant comments for the worker launch/seccomp unsafe sites. `crypto.rs`
+  also gained invariant comments for digest/HMAC/random/compare/AEAD/KDF
+  buffer sites plus fail-closed nonzero-null input checks and AEAD seal length
+  overflow checks. The generated report now shows 82 annotated native unsafe
+  sites and 356 remaining unsafe review sites. Remaining work: continue unsafe
+  invariant comments across the rest of `jerboa-native-rs`.
 
 ### K3-P1-02 — TOCTOU-safe filesystem capability checks
 **Serves:** G2. **Effort:** 1 week.
@@ -1113,7 +1115,7 @@ Track these in `docs/status.md` per release:
 | Confined worker exists | facade, audit lifecycle, output caps, deadline, process-group kill, memory rlimit pre-exec path, explicit sandbox-axis refusal, egress proxy env wiring, and Linux syscall/ptrace seccomp pre-exec setup landed; Landlock path/net and Seatbelt/Capsicum parity pending | yes, tested (P0-02) |
 | Unclassified raw `read`/FASL/`load` sites | scanner-driven manifest classification gate closed 2026-07-27; trusted build, compiler, REPL/dev, and test surfaces are centrally justified by exact source line | 0 |
 | FFI bindings audited | scanner inventory and provisional verdicts in [ffi-audit.md](ffi-audit.md); native exports gated and justified | 100% with final verdicts |
-| Un-annotated Rust `unsafe` blocks | 409 unsafe review sites without nearby `SAFETY:` comments in [ffi-audit.md](ffi-audit.md) | 0 |
+| Un-annotated Rust `unsafe` blocks | 356 unsafe review sites without nearby `SAFETY:` comments in [ffi-audit.md](ffi-audit.md) | 0 |
 | Fuzz corpora / crash regressions | 0 / 0 | per-parser corpora + every crash a test |
 | Exploit-shaped regression tests | ~0 | ≥ 1 per historical finding |
 | Sandboxed-by-default app entry | no | yes (P0-06) |
diff --git a/docs/reviews/2026-07-27-native-export-review.sexp b/docs/reviews/2026-07-27-native-export-review.sexp
index 011e28c..4541e5e 100644
--- a/docs/reviews/2026-07-27-native-export-review.sexp
+++ b/docs/reviews/2026-07-27-native-export-review.sexp
@@ -11,8 +11,8 @@
     (export (symbol "embed_pbkdf2_sha256") (file "jerboa-native-rs/src/embed_crypto.rs") (line 19) (scheme-callers 0 (none)))
     (export (symbol "embed_random_bytes") (file "jerboa-native-rs/src/embed_crypto.rs") (line 157) (scheme-callers 0 (none)))
     (export (symbol "embed_read_passphrase") (file "jerboa-native-rs/src/embed_crypto.rs") (line 180) (scheme-callers 0 (none)))
-    (export (symbol "jerboa_aead_open") (file "jerboa-native-rs/src/crypto.rs") (line 383) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 161))))
-    (export (symbol "jerboa_aead_seal") (file "jerboa-native-rs/src/crypto.rs") (line 313) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 140))))
+    (export (symbol "jerboa_aead_open") (file "jerboa-native-rs/src/crypto.rs") (line 431) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 161))))
+    (export (symbol "jerboa_aead_seal") (file "jerboa-native-rs/src/crypto.rs") (line 343) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 140))))
     (export (symbol "jerboa_antidebug_check_all") (file "jerboa-native-rs/src/antidebug.rs") (line 147) (scheme-callers 1 (caller (file "lib/std/os/antidebug.ss") (line 46))))
     (export (symbol "jerboa_antidebug_check_breakpoint") (file "jerboa-native-rs/src/antidebug.rs") (line 98) (scheme-callers 1 (caller (file "lib/std/os/antidebug.ss") (line 42))))
     (export (symbol "jerboa_antidebug_check_ld_preload") (file "jerboa-native-rs/src/antidebug.rs") (line 60) (scheme-callers 1 (caller (file "lib/std/os/antidebug.ss") (line 40))))
@@ -24,10 +24,10 @@
     (export (symbol "jerboa_aproc_spawn") (file "jerboa-native-rs/src/aproc.rs") (line 152) (scheme-callers 2 (caller (file "lib/std/os/aproc.ss") (line 127)) (caller (file "lib/std/os/aproc.ss") (line 142))))
     (export (symbol "jerboa_aproc_spawn_pty") (file "jerboa-native-rs/src/aproc.rs") (line 374) (scheme-callers 1 (caller (file "lib/std/os/aproc.ss") (line 142))))
     (export (symbol "jerboa_aproc_wait4") (file "jerboa-native-rs/src/aproc.rs") (line 691) (scheme-callers 1 (caller (file "lib/std/os/aproc.ss") (line 159))))
-    (export (symbol "jerboa_argon2id_hash") (file "jerboa-native-rs/src/crypto.rs") (line 709) (scheme-callers 2 (caller (file "lib/std/crypto/native-rust.ss") (line 276)) (caller (file "lib/std/crypto/password.ss") (line 35))))
-    (export (symbol "jerboa_argon2id_verify") (file "jerboa-native-rs/src/crypto.rs") (line 758) (scheme-callers 2 (caller (file "lib/std/crypto/native-rust.ss") (line 296)) (caller (file "lib/std/crypto/password.ss") (line 42))))
-    (export (symbol "jerboa_chacha20_open") (file "jerboa-native-rs/src/crypto.rs") (line 526) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 204))))
-    (export (symbol "jerboa_chacha20_seal") (file "jerboa-native-rs/src/crypto.rs") (line 457) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 182))))
+    (export (symbol "jerboa_argon2id_hash") (file "jerboa-native-rs/src/crypto.rs") (line 814) (scheme-callers 2 (caller (file "lib/std/crypto/native-rust.ss") (line 276)) (caller (file "lib/std/crypto/password.ss") (line 35))))
+    (export (symbol "jerboa_argon2id_verify") (file "jerboa-native-rs/src/crypto.rs") (line 867) (scheme-callers 2 (caller (file "lib/std/crypto/native-rust.ss") (line 296)) (caller (file "lib/std/crypto/password.ss") (line 42))))
+    (export (symbol "jerboa_chacha20_open") (file "jerboa-native-rs/src/crypto.rs") (line 606) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 204))))
+    (export (symbol "jerboa_chacha20_seal") (file "jerboa-native-rs/src/crypto.rs") (line 519) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 182))))
     (export (symbol "jerboa_deflate") (file "jerboa-native-rs/src/compress.rs") (line 9) (scheme-callers 1 (caller (file "lib/std/compress/native-rust.ss") (line 30))))
     (export (symbol "jerboa_duckdb_bind_blob") (file "jerboa-native-rs/src/duckdb_native.rs") (line 334) (scheme-callers 1 (caller (file "lib/std/db/duckdb-native.ss") (line 62))))
     (export (symbol "jerboa_duckdb_bind_bool") (file "jerboa-native-rs/src/duckdb_native.rs") (line 386) (scheme-callers 1 (caller (file "lib/std/db/duckdb-native.ss") (line 66))))
@@ -63,8 +63,8 @@
     (export (symbol "jerboa_eventfd_signal") (file "jerboa-native-rs/src/epoll.rs") (line 106) (scheme-callers 1 (caller (file "lib/std/os/epoll-native.ss") (line 296))))
     (export (symbol "jerboa_gunzip") (file "jerboa-native-rs/src/compress.rs") (line 137) (scheme-callers 1 (caller (file "lib/std/compress/native-rust.ss") (line 39))))
     (export (symbol "jerboa_gzip") (file "jerboa-native-rs/src/compress.rs") (line 97) (scheme-callers 1 (caller (file "lib/std/compress/native-rust.ss") (line 36))))
-    (export (symbol "jerboa_hmac_sha256") (file "jerboa-native-rs/src/crypto.rs") (line 196) (scheme-callers 2 (caller (file "lib/std/crypto/native-rust.ss") (line 109)) (caller (file "lib/std/crypto/native-rust.ss") (line 120))))
-    (export (symbol "jerboa_hmac_sha256_verify") (file "jerboa-native-rs/src/crypto.rs") (line 227) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 120))))
+    (export (symbol "jerboa_hmac_sha256") (file "jerboa-native-rs/src/crypto.rs") (line 206) (scheme-callers 2 (caller (file "lib/std/crypto/native-rust.ss") (line 109)) (caller (file "lib/std/crypto/native-rust.ss") (line 120))))
+    (export (symbol "jerboa_hmac_sha256_verify") (file "jerboa-native-rs/src/crypto.rs") (line 245) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 120))))
     (export (symbol "jerboa_http_parse") (file "jerboa-native-rs/src/http_parse.rs") (line 25) (scheme-callers 4 (caller (file "lib/std/net/fiber-httpd.ss") (line 78)) (caller (file "lib/std/net/fiber-httpd.ss") (line 81)) (caller (file "lib/std/net/httpsd.ss") (line 28)) (caller (file "lib/std/net/httpsd.ss") (line 71))))
     (export (symbol "jerboa_inflate") (file "jerboa-native-rs/src/compress.rs") (line 49) (scheme-callers 1 (caller (file "lib/std/compress/native-rust.ss") (line 33))))
     (export (symbol "jerboa_inotify_add_watch") (file "jerboa-native-rs/src/inotify_native.rs") (line 22) (scheme-callers 1 (caller (file "lib/std/os/inotify-native.ss") (line 65))))
@@ -83,8 +83,8 @@
     (export (symbol "jerboa_landlock_create_ruleset") (file "jerboa-native-rs/src/landlock.rs") (line 91) (scheme-callers 1 (caller (file "lib/std/os/landlock-native.ss") (line 80))))
     (export (symbol "jerboa_landlock_enforce") (file "jerboa-native-rs/src/landlock.rs") (line 234) (scheme-callers 1 (caller (file "lib/std/os/landlock-native.ss") (line 89))))
     (export (symbol "jerboa_last_error") (file "jerboa-native-rs/src/panic.rs") (line 9) (scheme-callers 11 (caller (file "lib/std/compress/native-rust.ss") (line 42)) (caller (file "lib/std/crypto/native-rust.ss") (line 48)) (caller (file "lib/std/crypto/x509.ss") (line 24)) (caller (file "lib/std/native.ss") (line 56)) (caller (file "lib/std/net/tls-rustls.ss") (line 167)) (caller (file "lib/std/os/aproc.ss") (line 164)) (caller (file "lib/std/os/integrity.ss") (line 42)) (caller (file "lib/std/os/secure-output.ss") (line 47)) (caller (file "lib/std/pcap.ss") (line 66)) (caller (file "lib/std/regex-native.ss") (line 51)) (caller (file "lib/std/wasm/sandbox.ss") (line 196))))
-    (export (symbol "jerboa_pbkdf2_derive") (file "jerboa-native-rs/src/crypto.rs") (line 642) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 246))))
-    (export (symbol "jerboa_pbkdf2_verify") (file "jerboa-native-rs/src/crypto.rs") (line 671) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 261))))
+    (export (symbol "jerboa_pbkdf2_derive") (file "jerboa-native-rs/src/crypto.rs") (line 740) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 246))))
+    (export (symbol "jerboa_pbkdf2_verify") (file "jerboa-native-rs/src/crypto.rs") (line 773) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 261))))
     (export (symbol "jerboa_pcap_close") (file "jerboa-native-rs/src/pcap_capture.rs") (line 324) (scheme-callers 1 (caller (file "lib/std/pcap.ss") (line 58))))
     (export (symbol "jerboa_pcap_list_interfaces") (file "jerboa-native-rs/src/pcap_capture.rs") (line 350) (scheme-callers 1 (caller (file "lib/std/pcap.ss") (line 62))))
     (export (symbol "jerboa_pcap_next") (file "jerboa-native-rs/src/pcap_capture.rs") (line 260) (scheme-callers 1 (caller (file "lib/std/pcap.ss") (line 53))))
@@ -99,7 +99,7 @@
     (export (symbol "jerboa_pg_ncols") (file "jerboa-native-rs/src/postgres_native.rs") (line 223) (scheme-callers 1 (caller (file "lib/std/db/postgresql-native.ss") (line 34))))
     (export (symbol "jerboa_pg_nrows") (file "jerboa-native-rs/src/postgres_native.rs") (line 215) (scheme-callers 1 (caller (file "lib/std/db/postgresql-native.ss") (line 32))))
     (export (symbol "jerboa_pg_query") (file "jerboa-native-rs/src/postgres_native.rs") (line 172) (scheme-callers 1 (caller (file "lib/std/db/postgresql-native.ss") (line 30))))
-    (export (symbol "jerboa_random_bytes") (file "jerboa-native-rs/src/crypto.rs") (line 260) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 98))))
+    (export (symbol "jerboa_random_bytes") (file "jerboa-native-rs/src/crypto.rs") (line 285) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 98))))
     (export (symbol "jerboa_regex_captures") (file "jerboa-native-rs/src/regex_native.rs") (line 274) (scheme-callers 2 (caller (file "lib/std/regex-native.ss") (line 40)) (caller (file "lib/std/regex.ss") (line 106))))
     (export (symbol "jerboa_regex_compile") (file "jerboa-native-rs/src/regex_native.rs") (line 14) (scheme-callers 3 (caller (file "lib/std/regex-native.ss") (line 27)) (caller (file "lib/std/regex.ss") (line 81)) (caller (file "lib/std/regex.ss") (line 85))))
     (export (symbol "jerboa_regex_find") (file "jerboa-native-rs/src/regex_native.rs") (line 75) (scheme-callers 4 (caller (file "lib/std/regex-native.ss") (line 33)) (caller (file "lib/std/regex.ss") (line 89)) (caller (file "lib/std/regex.ss") (line 97)) (caller (file "lib/std/regex.ss") (line 657))))
@@ -108,7 +108,7 @@
     (export (symbol "jerboa_regex_group_count") (file "jerboa-native-rs/src/regex_native.rs") (line 327) (scheme-callers 2 (caller (file "lib/std/regex-native.ss") (line 37)) (caller (file "lib/std/regex.ss") (line 102))))
     (export (symbol "jerboa_regex_is_match") (file "jerboa-native-rs/src/regex_native.rs") (line 49) (scheme-callers 1 (caller (file "lib/std/regex-native.ss") (line 30))))
     (export (symbol "jerboa_regex_replace_all") (file "jerboa-native-rs/src/regex_native.rs") (line 112) (scheme-callers 1 (caller (file "lib/std/regex-native.ss") (line 44))))
-    (export (symbol "jerboa_scrypt") (file "jerboa-native-rs/src/crypto.rs") (line 600) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 226))))
+    (export (symbol "jerboa_scrypt") (file "jerboa-native-rs/src/crypto.rs") (line 694) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 226))))
     (export (symbol "jerboa_seccomp_available") (file "jerboa-native-rs/src/seccomp.rs") (line 285) (scheme-callers 1 (caller (file "lib/std/os/seccomp.ss") (line 32))))
     (export (symbol "jerboa_seccomp_lock") (file "jerboa-native-rs/src/seccomp.rs") (line 179) (scheme-callers 2 (caller (file "lib/std/os/seccomp.ss") (line 34)) (caller (file "lib/std/os/seccomp.ss") (line 36))))
     (export (symbol "jerboa_seccomp_lock_strict") (file "jerboa-native-rs/src/seccomp.rs") (line 196) (scheme-callers 1 (caller (file "lib/std/os/seccomp.ss") (line 36))))
@@ -128,10 +128,10 @@
     (export (symbol "jerboa_secure_random_fill") (file "jerboa-native-rs/src/secure_mem.rs") (line 169) (scheme-callers 1 (caller (file "lib/std/crypto/secure-mem.ss") (line 44))))
     (export (symbol "jerboa_secure_read_file") (file "jerboa-native-rs/src/secure_fs.rs") (line 673) (scheme-callers 1 (caller (file "lib/std/os/secure-output.ss") (line 65))))
     (export (symbol "jerboa_secure_wipe") (file "jerboa-native-rs/src/secure_mem.rs") (line 156) (scheme-callers 1 (caller (file "lib/std/crypto/secure-mem.ss") (line 41))))
-    (export (symbol "jerboa_sha1") (file "jerboa-native-rs/src/crypto.rs") (line 65) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 60))))
-    (export (symbol "jerboa_sha256") (file "jerboa-native-rs/src/crypto.rs") (line 81) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 69))))
-    (export (symbol "jerboa_sha384") (file "jerboa-native-rs/src/crypto.rs") (line 174) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 78))))
-    (export (symbol "jerboa_sha512") (file "jerboa-native-rs/src/crypto.rs") (line 184) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 87))))
+    (export (symbol "jerboa_sha1") (file "jerboa-native-rs/src/crypto.rs") (line 71) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 60))))
+    (export (symbol "jerboa_sha256") (file "jerboa-native-rs/src/crypto.rs") (line 87) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 69))))
+    (export (symbol "jerboa_sha384") (file "jerboa-native-rs/src/crypto.rs") (line 184) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 78))))
+    (export (symbol "jerboa_sha512") (file "jerboa-native-rs/src/crypto.rs") (line 194) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 87))))
     (export (symbol "jerboa_sm_add_fuel") (file "jerboa-native-rs/src/wasm_sm.rs") (line 964) (scheme-callers 1 (caller (file "lib/std/wasm/sandbox.ss") (line 186))))
     (export (symbol "jerboa_sm_call") (file "jerboa-native-rs/src/wasm_sm.rs") (line 837) (scheme-callers 1 (caller (file "lib/std/wasm/sandbox.ss") (line 168))))
     (export (symbol "jerboa_sm_get_log") (file "jerboa-native-rs/src/wasm_sm.rs") (line 977) (scheme-callers 1 (caller (file "lib/std/wasm/sandbox.ss") (line 180))))
@@ -162,7 +162,7 @@
     (export (symbol "jerboa_sqlite_prepare") (file "jerboa-native-rs/src/sqlite.rs") (line 152) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 50))))
     (export (symbol "jerboa_sqlite_reset") (file "jerboa-native-rs/src/sqlite.rs") (line 564) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 54))))
     (export (symbol "jerboa_sqlite_step") (file "jerboa-native-rs/src/sqlite.rs") (line 385) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 68))))
-    (export (symbol "jerboa_timing_safe_equal") (file "jerboa-native-rs/src/crypto.rs") (line 283) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 131))))
+    (export (symbol "jerboa_timing_safe_equal") (file "jerboa-native-rs/src/crypto.rs") (line 310) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 131))))
     (export (symbol "jerboa_tls_accept") (file "jerboa-native-rs/src/tls.rs") (line 807) (scheme-callers 1 (caller (file "lib/std/net/tls-rustls.ss") (line 86))))
     (export (symbol "jerboa_tls_close") (file "jerboa-native-rs/src/tls.rs") (line 1675) (scheme-callers 1 (caller (file "lib/std/net/tls-rustls.ss") (line 144))))
     (export (symbol "jerboa_tls_connect") (file "jerboa-native-rs/src/tls.rs") (line 154) (scheme-callers 5 (caller (file "lib/std/net/tls-rustls.ss") (line 92)) (caller (file "lib/std/net/tls-rustls.ss") (line 98)) (caller (file "lib/std/net/tls-rustls.ss") (line 107)) (caller (file "lib/std/net/tls-rustls.ss") (line 114)) (caller (file "lib/std/net/tls-rustls.ss") (line 121))))
diff --git a/docs/status.md b/docs/status.md
index 7e397ea..33b00ea 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` and `seccomp.rs` now has nearby `SAFETY:` comments, reducing the generated unannotated unsafe-site count to 409. | Continue adding `SAFETY:` invariant comments near the remaining Rust unsafe sites. |
+| 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` and the crypto FFI buffer path in `crypto.rs` now have nearby `SAFETY:` comments, reducing the generated unannotated unsafe-site count to 356. | Continue adding `SAFETY:` invariant comments near the remaining Rust unsafe sites. |
 | 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 for requested axes, explicit sandbox-axis refusal, and egress proxy env wiring. | Install native Landlock path/net rules in the worker pre-exec path and keep Linux/macOS/FreeBSD parity tests current. |
 | Safe surface | Direct scripts default to the safe prelude; raw access requires `--unsafe-prelude` or `(jerboa prelude unsafe)`. | Continue moving risky APIs behind explicit unsafe imports as new modules land. |
diff --git a/jerboa-native-rs/src/crypto.rs b/jerboa-native-rs/src/crypto.rs
index 608b342..0db968b 100644
--- a/jerboa-native-rs/src/crypto.rs
+++ b/jerboa-native-rs/src/crypto.rs
@@ -9,6 +9,8 @@ use std::sync::{Mutex, MutexGuard, OnceLock};
 
 fn secure_zero_bytes(buf: &mut [u8]) {
     for byte in buf {
+        // SAFETY: `byte` is a unique mutable reference from the slice iterator;
+        // volatile write prevents the wipe from being optimized away.
         unsafe {
             std::ptr::write_volatile(byte as *mut u8, 0);
         }
@@ -39,9 +41,13 @@ fn digest_impl(
         let data = if input_len == 0 {
             &[]
         } else {
+            // SAFETY: non-null input was checked for nonzero length above, and
+            // the FFI caller guarantees `input_len` readable bytes for this call.
             unsafe { std::slice::from_raw_parts(input, input_len) }
         };
         let hash = digest::digest(algorithm, data);
+        // SAFETY: output is non-null and `output_len >= expected`; copy length
+        // is exactly the digest output size from ring.
         unsafe {
             std::ptr::copy_nonoverlapping(hash.as_ref().as_ptr(), output, expected);
         }
@@ -130,6 +136,8 @@ pub extern "C" fn jerboa_sha256_ctx_update(handle: u64, input: *const u8, input_
         let data = if input_len == 0 {
             &[]
         } else {
+            // SAFETY: non-null input was checked for nonzero length above, and
+            // the context update borrows the slice only for this call.
             unsafe { std::slice::from_raw_parts(input, input_len) }
         };
         let mut contexts = lock_sha256_contexts();
@@ -154,6 +162,8 @@ pub extern "C" fn jerboa_sha256_ctx_final(handle: u64, output: *mut u8, output_l
             None => return -1,
         };
         let hash = context.finish();
+        // SAFETY: output is non-null and `output_len` is at least the fixed
+        // SHA-256 digest size checked above.
         unsafe {
             std::ptr::copy_nonoverlapping(
                 hash.as_ref().as_ptr(),
@@ -208,14 +218,22 @@ pub extern "C" fn jerboa_hmac_sha256(
         if output_len < 32 {
             return -1;
         }
+        // SAFETY: key is non-null and borrowed only until ring constructs its
+        // own HMAC key during this call.
         let k = unsafe { std::slice::from_raw_parts(key, key_len) };
+        if data.is_null() && data_len > 0 {
+            return -1;
+        }
         let d = if data_len == 0 {
             &[]
         } else {
+            // SAFETY: data is non-null for nonzero length and borrowed only for
+            // this HMAC operation.
             unsafe { std::slice::from_raw_parts(data, data_len) }
         };
         let signing_key = hmac::Key::new(hmac::HMAC_SHA256, k);
         let tag = hmac::sign(&signing_key, d);
+        // SAFETY: output is non-null and `output_len >= 32` was checked above.
         unsafe {
             std::ptr::copy_nonoverlapping(tag.as_ref().as_ptr(), output, 32);
         }
@@ -239,12 +257,19 @@ pub extern "C" fn jerboa_hmac_sha256_verify(
         if tag_len != 32 {
             return 0;
         }
+        // SAFETY: key is non-null and borrowed only for this verification call.
         let k = unsafe { std::slice::from_raw_parts(key, key_len) };
+        if data.is_null() && data_len > 0 {
+            return -1;
+        }
         let d = if data_len == 0 {
             &[]
         } else {
+            // SAFETY: data is non-null for nonzero length and borrowed only for
+            // this verification call.
             unsafe { std::slice::from_raw_parts(data, data_len) }
         };
+        // SAFETY: tag is non-null and tag_len is exactly the HMAC-SHA256 size.
         let t = unsafe { std::slice::from_raw_parts(tag, tag_len) };
         let verification_key = hmac::Key::new(hmac::HMAC_SHA256, k);
         match hmac::verify(&verification_key, d, t) {
@@ -266,6 +291,8 @@ pub extern "C" fn jerboa_random_bytes(output: *mut u8, len: usize) -> i32 {
             return 0;
         }
         let rng = rand::SystemRandom::new();
+        // SAFETY: output is non-null and `len > 0`; the caller provides a
+        // writable buffer of `len` bytes for SecureRandom::fill.
         let buf = unsafe { std::slice::from_raw_parts_mut(output, len) };
         match rand::SecureRandom::fill(&rng, buf) {
             Ok(()) => 0,
@@ -296,7 +323,10 @@ pub extern "C" fn jerboa_timing_safe_equal(
         if a.is_null() || b.is_null() {
             return 0;
         }
+        // SAFETY: both pointers are non-null, equal-length buffers, and are
+        // borrowed only for the constant-time comparison.
         let sa = unsafe { std::slice::from_raw_parts(a, a_len) };
+        // SAFETY: same invariant as `sa`; `b_len == a_len` was checked first.
         let sb = unsafe { std::slice::from_raw_parts(b, b_len) };
         #[allow(deprecated)]
         if constant_time::verify_slices_are_equal(sa, sb).is_ok() {
@@ -327,7 +357,16 @@ pub extern "C" fn jerboa_aead_seal(
         if key.is_null() || nonce.is_null() || output.is_null() || output_len.is_null() {
             return -1;
         }
-        let needed = pt_len + aead::AES_256_GCM.tag_len();
+        if plaintext.is_null() && pt_len > 0 {
+            return -1;
+        }
+        if aad.is_null() && aad_len > 0 {
+            return -1;
+        }
+        let needed = match pt_len.checked_add(aead::AES_256_GCM.tag_len()) {
+            Some(n) => n,
+            None => return -1,
+        };
         if output_max < needed {
             return -1;
         }
@@ -338,16 +377,22 @@ pub extern "C" fn jerboa_aead_seal(
             return -1;
         }
 
+        // SAFETY: key is non-null and exactly 32 bytes as checked above.
         let k = unsafe { std::slice::from_raw_parts(key, key_len) };
+        // SAFETY: nonce is non-null and exactly 12 bytes as checked above.
         let n = unsafe { std::slice::from_raw_parts(nonce, nonce_len) };
         let pt = if pt_len == 0 {
             &[]
         } else {
+            // SAFETY: plaintext is non-null for nonzero length and borrowed
+            // only until it is copied into the output buffer.
             unsafe { std::slice::from_raw_parts(plaintext, pt_len) }
         };
-        let ad = if aad_len == 0 || aad.is_null() {
+        let ad = if aad_len == 0 {
             &[]
         } else {
+            // SAFETY: aad is non-null for nonzero length and borrowed only for
+            // ring's authenticated-data view.
             unsafe { std::slice::from_raw_parts(aad, aad_len) }
         };
 
@@ -362,6 +407,8 @@ pub extern "C" fn jerboa_aead_seal(
         };
 
         // Copy plaintext to output buffer, seal in place
+        // SAFETY: output is non-null and `output_max >= needed`, so it can
+        // hold the plaintext plus authentication tag.
         let out = unsafe { std::slice::from_raw_parts_mut(output, output_max) };
         out[..pt_len].copy_from_slice(pt);
 
@@ -369,6 +416,7 @@ pub extern "C" fn jerboa_aead_seal(
         match sealing_key.seal_in_place_separate_tag(nonce_val, aad_obj, &mut out[..pt_len]) {
             Ok(tag) => {
                 out[pt_len..pt_len + tag.as_ref().len()].copy_from_slice(tag.as_ref());
+                // SAFETY: output_len is non-null and points to a usize result slot.
                 unsafe {
                     *output_len = needed;
                 }
@@ -397,6 +445,12 @@ pub extern "C" fn jerboa_aead_open(
         if key.is_null() || nonce.is_null() || output.is_null() || output_len.is_null() {
             return -1;
         }
+        if ciphertext.is_null() && ct_len > 0 {
+            return -1;
+        }
+        if aad.is_null() && aad_len > 0 {
+            return -1;
+        }
         let tag_len = aead::AES_256_GCM.tag_len();
         if ct_len < tag_len {
             return -1;
@@ -412,12 +466,18 @@ pub extern "C" fn jerboa_aead_open(
             return -1;
         }
 
+        // SAFETY: key is non-null and exactly 32 bytes as checked above.
         let k = unsafe { std::slice::from_raw_parts(key, key_len) };
+        // SAFETY: nonce is non-null and exactly 12 bytes as checked above.
         let n = unsafe { std::slice::from_raw_parts(nonce, nonce_len) };
+        // SAFETY: ciphertext is non-null for nonzero length and `ct_len`
+        // includes at least the tag length checked above.
         let ct = unsafe { std::slice::from_raw_parts(ciphertext, ct_len) };
-        let ad = if aad_len == 0 || aad.is_null() {
+        let ad = if aad_len == 0 {
             &[]
         } else {
+            // SAFETY: aad is non-null for nonzero length and borrowed only for
+            // ring's authenticated-data view.
             unsafe { std::slice::from_raw_parts(aad, aad_len) }
         };
 
@@ -431,6 +491,7 @@ pub extern "C" fn jerboa_aead_open(
             Err(_) => return -1,
         };
 
+        // SAFETY: output is non-null and `output_max >= pt_len`, checked above.
         let out = unsafe { std::slice::from_raw_parts_mut(output, output_max) };
         let mut in_out = ct.to_vec();
 
@@ -439,6 +500,7 @@ pub extern "C" fn jerboa_aead_open(
             Ok(plaintext) => {
                 let plen = plaintext.len();
                 out[..plen].copy_from_slice(plaintext);
+                // SAFETY: output_len is non-null and points to a usize result slot.
                 unsafe {
                     *output_len = plen;
                 }
@@ -471,7 +533,16 @@ pub extern "C" fn jerboa_chacha20_seal(
         if key.is_null() || nonce.is_null() || output.is_null() || output_len.is_null() {
             return -1;
         }
-        let needed = pt_len + aead::CHACHA20_POLY1305.tag_len();
+        if plaintext.is_null() && pt_len > 0 {
+            return -1;
+        }
+        if aad.is_null() && aad_len > 0 {
+            return -1;
+        }
+        let needed = match pt_len.checked_add(aead::CHACHA20_POLY1305.tag_len()) {
+            Some(n) => n,
+            None => return -1,
+        };
         if output_max < needed {
             return -1;
         }
@@ -482,16 +553,22 @@ pub extern "C" fn jerboa_chacha20_seal(
             return -1;
         }
 
+        // SAFETY: key is non-null and exactly 32 bytes as checked above.
         let k = unsafe { std::slice::from_raw_parts(key, key_len) };
+        // SAFETY: nonce is non-null and exactly 12 bytes as checked above.
         let n = unsafe { std::slice::from_raw_parts(nonce, nonce_len) };
         let pt = if pt_len == 0 {
             &[]
         } else {
+            // SAFETY: plaintext is non-null for nonzero length and borrowed
+            // only until it is copied into the output buffer.
             unsafe { std::slice::from_raw_parts(plaintext, pt_len) }
         };
-        let ad = if aad_len == 0 || aad.is_null() {
+        let ad = if aad_len == 0 {
             &[]
         } else {
+            // SAFETY: aad is non-null for nonzero length and borrowed only for
+            // ring's authenticated-data view.
             unsafe { std::slice::from_raw_parts(aad, aad_len) }
         };
 
@@ -505,6 +582,8 @@ pub extern "C" fn jerboa_chacha20_seal(
             Err(_) => return -1,
         };
 
+        // SAFETY: output is non-null and `output_max >= needed`, so it can
+        // hold the plaintext plus authentication tag.
         let out = unsafe { std::slice::from_raw_parts_mut(output, output_max) };
         out[..pt_len].copy_from_slice(pt);
 
@@ -512,6 +591,7 @@ pub extern "C" fn jerboa_chacha20_seal(
         match sealing_key.seal_in_place_separate_tag(nonce_val, aad_obj, &mut out[..pt_len]) {
             Ok(tag) => {
                 out[pt_len..pt_len + tag.as_ref().len()].copy_from_slice(tag.as_ref());
+                // SAFETY: output_len is non-null and points to a usize result slot.
                 unsafe {
                     *output_len = needed;
                 }
@@ -540,6 +620,12 @@ pub extern "C" fn jerboa_chacha20_open(
         if key.is_null() || nonce.is_null() || output.is_null() || output_len.is_null() {
             return -1;
         }
+        if ciphertext.is_null() && ct_len > 0 {
+            return -1;
+        }
+        if aad.is_null() && aad_len > 0 {
+            return -1;
+        }
         let tag_len = aead::CHACHA20_POLY1305.tag_len();
         if ct_len < tag_len {
             return -1;
@@ -555,12 +641,18 @@ pub extern "C" fn jerboa_chacha20_open(
             return -1;
         }
 
+        // SAFETY: key is non-null and exactly 32 bytes as checked above.
         let k = unsafe { std::slice::from_raw_parts(key, key_len) };
+        // SAFETY: nonce is non-null and exactly 12 bytes as checked above.
         let n = unsafe { std::slice::from_raw_parts(nonce, nonce_len) };
+        // SAFETY: ciphertext is non-null for nonzero length and `ct_len`
+        // includes at least the tag length checked above.
         let ct = unsafe { std::slice::from_raw_parts(ciphertext, ct_len) };
-        let ad = if aad_len == 0 || aad.is_null() {
+        let ad = if aad_len == 0 {
             &[]
         } else {
+            // SAFETY: aad is non-null for nonzero length and borrowed only for
+            // ring's authenticated-data view.
             unsafe { std::slice::from_raw_parts(aad, aad_len) }
         };
 
@@ -574,6 +666,7 @@ pub extern "C" fn jerboa_chacha20_open(
             Err(_) => return -1,
         };
 
+        // SAFETY: output is non-null and `output_max >= pt_len`, checked above.
         let out = unsafe { std::slice::from_raw_parts_mut(output, output_max) };
         let mut in_out = ct.to_vec();
 
@@ -582,6 +675,7 @@ pub extern "C" fn jerboa_chacha20_open(
             Ok(plaintext) => {
                 let plen = plaintext.len();
                 out[..plen].copy_from_slice(plaintext);
+                // SAFETY: output_len is non-null and points to a usize result slot.
                 unsafe {
                     *output_len = plen;
                 }
@@ -615,8 +709,12 @@ pub extern "C" fn jerboa_scrypt(
         if output_len == 0 {
             return -1;
         }
+        // SAFETY: password is non-null and borrowed only for this KDF call.
         let pw = unsafe { std::slice::from_raw_parts(password, password_len) };
+        // SAFETY: salt is non-null and borrowed only for this KDF call.
         let s = unsafe { std::slice::from_raw_parts(salt, salt_len) };
+        // SAFETY: output is non-null and `output_len > 0`; scrypt writes
+        // exactly into this caller-provided output buffer.
         let out = unsafe { std::slice::from_raw_parts_mut(output, output_len) };
 
         let params = match scrypt::Params::new(log_n, r, p, output_len) {
@@ -655,8 +753,12 @@ pub extern "C" fn jerboa_pbkdf2_derive(
         if iterations == 0 {
             return -1;
         }
+        // SAFETY: password is non-null and borrowed only for this PBKDF2 call.
         let pw = unsafe { std::slice::from_raw_parts(password, password_len) };
+        // SAFETY: salt is non-null and borrowed only for this PBKDF2 call.
         let s = unsafe { std::slice::from_raw_parts(salt, salt_len) };
+        // SAFETY: output is non-null; ring fills the entire caller-provided
+        // derived-key buffer.
         let out = unsafe { std::slice::from_raw_parts_mut(output, output_len) };
         let iters = match NonZeroU32::new(iterations) {
             Some(n) => n,
@@ -684,8 +786,11 @@ pub extern "C" fn jerboa_pbkdf2_verify(
         if iterations == 0 {
             return -1;
         }
+        // SAFETY: password is non-null and borrowed only for this verification.
         let pw = unsafe { std::slice::from_raw_parts(password, password_len) };
+        // SAFETY: salt is non-null and borrowed only for this verification.
         let s = unsafe { std::slice::from_raw_parts(salt, salt_len) };
+        // SAFETY: expected is non-null and borrowed only for ring verification.
         let exp = unsafe { std::slice::from_raw_parts(expected, expected_len) };
         let iters = match NonZeroU32::new(iterations) {
             Some(n) => n,
@@ -729,8 +834,12 @@ pub extern "C" fn jerboa_argon2id_hash(
             return -1;
         }
 
+        // SAFETY: password is non-null and borrowed only for this Argon2 call.
         let pw = unsafe { std::slice::from_raw_parts(password, password_len) };
+        // SAFETY: salt is non-null with length >= 8 as checked above.
         let s = unsafe { std::slice::from_raw_parts(salt, salt_len) };
+        // SAFETY: output is non-null and `output_len > 0`; Argon2 writes into
+        // the caller-provided output buffer.
         let out = unsafe { std::slice::from_raw_parts_mut(output, output_len) };
 
         let params = match Params::new(m_cost, t_cost, p_cost, Some(output_len)) {
@@ -774,8 +883,12 @@ pub extern "C" fn jerboa_argon2id_verify(
             return -1;
         }
 
+        // SAFETY: password is non-null and borrowed only for this Argon2 call.
         let pw = unsafe { std::slice::from_raw_parts(password, password_len) };
+        // SAFETY: salt is non-null with length >= 8 as checked above.
         let s = unsafe { std::slice::from_raw_parts(salt, salt_len) };
+        // SAFETY: expected is non-null and borrowed only for constant-time
+        // verification against the computed hash.
         let exp = unsafe { std::slice::from_raw_parts(expected, expected_len) };
 
         let params = match Params::new(m_cost, t_cost, p_cost, Some(expected_len)) {
@@ -943,4 +1056,115 @@ mod tests {
         output.truncate(output_len);
         assert_eq!(output, plaintext);
     }
+
+    #[test]
+    fn hmac_rejects_null_data_with_nonzero_length() {
+        let key = [1u8; 32];
+        let mut tag = [0u8; 32];
+        assert_eq!(
+            jerboa_hmac_sha256(
+                key.as_ptr(),
+                key.len(),
+                std::ptr::null(),
+                1,
+                tag.as_mut_ptr(),
+                tag.len(),
+            ),
+            -1
+        );
+        assert_eq!(
+            jerboa_hmac_sha256_verify(
+                key.as_ptr(),
+                key.len(),
+                std::ptr::null(),
+                1,
+                tag.as_ptr(),
+                32
+            ),
+            -1
+        );
+    }
+
+    #[test]
+    fn aead_rejects_null_nonzero_buffers() {
+        let key = [7u8; 32];
+        let nonce = [3u8; 12];
+        let mut output = [0u8; 32];
+        let mut output_len = 0usize;
+
+        assert_eq!(
+            jerboa_aead_seal(
+                key.as_ptr(),
+                key.len(),
+                nonce.as_ptr(),
+                nonce.len(),
+                std::ptr::null(),
+                1,
+                std::ptr::null(),
+                0,
+                output.as_mut_ptr(),
+                output.len(),
+                &mut output_len,
+            ),
+            -1
+        );
+        assert_eq!(
+            jerboa_chacha20_seal(
+                key.as_ptr(),
+                key.len(),
+                nonce.as_ptr(),
+                nonce.len(),
+                std::ptr::null(),
+                1,
+                std::ptr::null(),
+                0,
+                output.as_mut_ptr(),
+                output.len(),
+                &mut output_len,
+            ),
+            -1
+        );
+    }
+
+    #[test]
+    fn aead_seal_rejects_length_overflow() {
+        let key = [7u8; 32];
+        let nonce = [3u8; 12];
+        let plaintext = [0u8; 1];
+        let mut output = [0u8; 16];
+        let mut output_len = 0usize;
+
+        assert_eq!(
+            jerboa_aead_seal(
+                key.as_ptr(),
+                key.len(),
+                nonce.as_ptr(),
+                nonce.len(),
+                plaintext.as_ptr(),
+                usize::MAX,
+                std::ptr::null(),
+                0,
+                output.as_mut_ptr(),
+                output.len(),
+                &mut output_len,
+            ),
+            -1
+        );
+        assert_eq!(
+            jerboa_chacha20_seal(
+                key.as_ptr(),
+                key.len(),
+                nonce.as_ptr(),
+                nonce.len(),
+                plaintext.as_ptr(),
+                usize::MAX,
+                std::ptr::null(),
+                0,
+                output.as_mut_ptr(),
+                output.len(),
+                &mut output_len,
+            ),
+            -1
+        );
+    }
 }