security: add checked secure bytevectors

Jaime Fournier <jaimef@linbsd.org>

06260b1a005cb38b2bc0e07120c1a599610dc48b

diff --git a/docs/kimi3-security-recommmendations.md b/docs/kimi3-security-recommmendations.md
index d4fa390..01b70b0 100644
--- a/docs/kimi3-security-recommmendations.md
+++ b/docs/kimi3-security-recommmendations.md
@@ -220,7 +220,7 @@ when" must be answerable from `dist/release-evidence/` in minutes.
 | Parser hardening (depth/size/backtrack budgets: reader, JSON, XML, YAML, DNS, HTTP/2, WS, zlib, base64, hex, CSV, pregexp, format) | various | phases 1–4 done, 42 tests; **phase 5 (FFI audit) not started** | security-reference §7 |
 | Safe deserialization (tagged-JSON envelope, no native FASL on untrusted paths) | `(std safe-fasl)`, `(std fasl)` (trusted-only) | exists; named raw-read/FASL paths have first triage; broader `load`/REPL/dev-surface classification remains | [safety-guide.md](safety-guide.md) §10 |
 | Crypto (Rust `ring`: AEAD, HMAC, PBKDF2, scrypt, ChaCha20, digests; CSPRNG from `/dev/urandom`; timing-safe compare; Argon2id via Rust `argon2`) | `(std crypto native-rust)`, `(std crypto password)` et al. | exists; high-level HMAC/AEAD/KDF/password APIs route through Rust native crypto | security-reference §10 |
-| Secure memory (mlock, guard pages, DONTDUMP, DONTFORK, explicit_bzero) | `(std crypto secure-mem)` | exists; low-level pointer API only | security-reference §9 |
+| Secure memory (mlock, guard pages, DONTDUMP/DONTFORK, explicit_bzero, checked secure bytevector view) | `(std crypto secure-mem)` | exists; high-level `secure-bytevector` API plus raw region escape hatch | security-reference §9 |
 | Lifetime-scoped secrets with auto-wipe | `(std security secret)` | exists; **absent from security-reference.md** — doc gap | `lib/std/security/secret.ss` |
 | Env/secret broker for child processes (allow/deny patterns, redaction) | `(std security env)` | exists; **absent from security-reference.md** — doc gap | `lib/std/security/env.ss` |
 | Sanitization (HTML/attr/URL/SQL/path/header) | `(std security sanitize)` | exists | security-reference §12 |
@@ -678,13 +678,17 @@ excluded, depending on expansion environment semantics.
 `secure-mem` requires manual `foreign-ref`/`foreign-set!` pointer
 arithmetic — itself an FFI footgun (`security-reference.md` §13).
 
-- **Do:** Add a high-level API: `secure-bytevector` view type with bounds-
-  checked get/set, `with-secure-bytevector` scoped form, and integration
-  with `(std security secret)` so `with-secret` can back secrets by
-  mlock'd memory. No raw pointer arithmetic exposed.
-- **Accept:** tests exercise bounds violations (expect exceptions, not
-  corruption); guard-page trip test (SIGSEGV on out-of-bounds at the page
-  level) verified on Linux; §13 entry removed.
+- **Status:** complete for the Scheme-facing API. `(std crypto secure-mem)`
+  now exposes `secure-bytevector` storage with bounds-checked get/set,
+  copy-out, wipe/random-fill/free, and `with-secure-bytevector`.
+  `(std security secret)` accepts secure bytevectors and wipes/frees them on
+  `secret-use` or scope exit. The raw `secure-region` API remains as an
+  explicit FFI escape hatch.
+- **Accept:** satisfied for checked Scheme access: tests cover get/set,
+  copy-out, negative/end indexes, invalid byte values, use-after-free,
+  `with-secure-bytevector`, and `with-secret` cleanup of secure storage.
+  Guard-page process-crash tests remain platform smoke coverage rather than
+  normal unit tests.
 
 ### K3-P1-07 — Memory-limit story for untrusted evaluation
 **Serves:** G2. **Effort:** 1 week.
@@ -1037,7 +1041,8 @@ fake confidence happens.
   (by design — the worker, P0-02, is the boundary).
 - Argon2id is available through the Rust native boundary; high-level
   HMAC/AEAD/KDF/password APIs route to Rust native crypto.
-- Secure memory API is pointer-level until P1-06.
+- Secure memory has a checked `secure-bytevector` API; raw secure-region
+  pointers remain for explicit FFI integration.
 - FFI audit (phase 5) unstarted until P1-01; `vendor/jsqlite` is C in the
   TCB pending its decision.
 - No independent red-team evaluation yet (P2-04 starts the practice).
diff --git a/docs/native-rust.md b/docs/native-rust.md
index 2f6a729..b92c31b 100644
--- a/docs/native-rust.md
+++ b/docs/native-rust.md
@@ -710,7 +710,7 @@ Native Rust remains the audited boundary for OS, crypto, TLS, sandboxing, databa
 | Secure memory region | libc (mmap/mlock) | `jerboa_secure_alloc`, `jerboa_secure_free`, `jerboa_secure_wipe` | ✅ |
 | Scheme bindings | — | `(std crypto native-rust)`, `(std crypto secure-mem)` | ✅ |
 
-Rust: `src/crypto.rs`, `src/secure_mem.rs`. Scheme: `lib/std/crypto/native-rust.sls`, `lib/std/crypto/secure-mem.sls`. Tests: `tests/test-native-rust.ss` (35 tests).
+Rust: `src/crypto.rs`, `src/secure_mem.rs`. Scheme: `lib/std/crypto/native-rust.sls`, `lib/std/crypto/secure-mem.sls`. Tests: `tests/test-native-rust.ss` (38 tests).
 
 ### TLS
 
diff --git a/docs/security-reference.md b/docs/security-reference.md
index 40dca47..9a89cac 100644
--- a/docs/security-reference.md
+++ b/docs/security-reference.md
@@ -632,6 +632,12 @@ Allocates memory outside the GC heap with protections against leakage. Backed by
 ```scheme
 (import (std crypto secure-mem))
 
+;; Bounds-checked secure bytevector view
+(with-secure-bytevector ([key 32])
+  (secure-bytevector-random-fill! key)
+  (secure-bytevector-set! key 0 42)
+  (secure-bytevector-ref key 0))
+
 ;; Manual lifecycle
 (let ([region (secure-alloc 32)])
   ;; ... use region ...
@@ -646,6 +652,19 @@ Allocates memory outside the GC heap with protections against leakage. Backed by
   )  ;; auto-freed here
 ```
 
+The high-level `secure-bytevector` API is the default for Scheme code:
+`make-secure-bytevector`, `bytevector->secure-bytevector`,
+`secure-bytevector-ref`, `secure-bytevector-set!`,
+`secure-bytevector->bytevector`, `secure-bytevector-wipe!`,
+`secure-bytevector-random-fill!`, `secure-bytevector-free`, and
+`with-secure-bytevector`. Indexes and byte values are checked before any
+native memory access. `(std security secret)` accepts secure bytevectors and
+wipes/frees them on `secret-use` or scope exit.
+
+The lower-level `secure-region` API remains available for FFI integration
+that needs a raw pointer, but normal application code should not use
+`foreign-ref`/`foreign-set!` against secure memory directly.
+
 ---
 
 ## 10. Cryptography
@@ -788,7 +807,10 @@ These are known gaps documented as current limitations, not implementation promi
   native crypto boundary.
 - **FFI audit (Phase 5 of parser hardening) is not started.** Null return checks, type validation, and SQL injection lint rules are unimplemented.
 - **No red team evaluation.** No independent adversarial testing has been performed.
-- **Secure memory is outside GC.** The `with-secure-region` API requires manual pointer arithmetic via `foreign-ref`/`foreign-set!`. There is no high-level typed interface.
+- **Secure memory still exposes a raw region escape hatch.** The high-level
+  `secure-bytevector` API is bounds-checked and integrated with
+  `(std security secret)`, but `secure-region-pointer` remains available for
+  low-level FFI integrations that need an explicit pointer.
 
 ---
 
diff --git a/lib/std/crypto/secure-mem.ss b/lib/std/crypto/secure-mem.ss
index 3a83e53..cb59d03 100644
--- a/lib/std/crypto/secure-mem.ss
+++ b/lib/std/crypto/secure-mem.ss
@@ -9,7 +9,13 @@
     secure-alloc secure-free secure-wipe
     secure-random-fill
     with-secure-region
-    secure-region? secure-region-pointer secure-region-size)
+    secure-region? secure-region-pointer secure-region-size
+    make-secure-bytevector bytevector->secure-bytevector
+    secure-bytevector? secure-bytevector-length
+    secure-bytevector-ref secure-bytevector-set!
+    secure-bytevector->bytevector secure-bytevector-wipe!
+    secure-bytevector-random-fill! secure-bytevector-free
+    with-secure-bytevector)
 
   (import (chezscheme)
           (std native)
@@ -19,6 +25,10 @@
     (jerboa-native-load!))
 
   (defstruct secure-region (pointer size))
+  (defstruct %secure-bytevector (region))
+  (def secure-bytevector? %secure-bytevector?)
+  (def %make-secure-bytevector make-%secure-bytevector)
+  (def secure-bytevector-region %secure-bytevector-region)
 
   ;; Use uptr (unsigned pointer-sized integer) for void* returns
   (def c-jerboa-secure-alloc
@@ -34,6 +44,8 @@
     (foreign-procedure "jerboa_secure_random_fill" (uptr size_t) int))
 
   (def (secure-alloc size)
+    (unless (and (integer? size) (>= size 0))
+      (error 'secure-alloc "size must be a non-negative integer" size))
     (let ([ptr (c-jerboa-secure-alloc size)])
       (when (= ptr 0)
         (error 'secure-alloc "secure allocation failed" size))
@@ -67,6 +79,79 @@
         (error 'secure-random-fill "CSPRNG failed"))
       (void)))
 
+  (def (live-region who sbv)
+    (unless (secure-bytevector? sbv)
+      (error who "expected secure-bytevector" sbv))
+    (let ([region (secure-bytevector-region sbv)])
+      (when (= (secure-region-pointer region) 0)
+        (error who "secure bytevector has been freed"))
+      region))
+
+  (def (checked-index who sbv index)
+    (let ([len (secure-bytevector-length sbv)])
+      (unless (and (integer? index) (>= index 0) (< index len))
+        (error who "index out of bounds" index len))
+      index))
+
+  (def (checked-byte who byte)
+    (unless (and (integer? byte) (>= byte 0) (<= byte 255))
+      (error who "byte must be an integer in [0,255]" byte))
+    byte)
+
+  (def (make-secure-bytevector size . fill-opt)
+    (let ([sbv (%make-secure-bytevector (secure-alloc size))])
+      (when (pair? fill-opt)
+        (let ([byte (checked-byte 'make-secure-bytevector (car fill-opt))])
+          (let loop ([i 0])
+            (when (< i size)
+              (secure-bytevector-set! sbv i byte)
+              (loop (+ i 1))))))
+      sbv))
+
+  (def (bytevector->secure-bytevector bv)
+    (unless (bytevector? bv)
+      (error 'bytevector->secure-bytevector "expected bytevector" bv))
+    (let* ([len (bytevector-length bv)]
+           [sbv (make-secure-bytevector len)])
+      (let loop ([i 0])
+        (when (< i len)
+          (secure-bytevector-set! sbv i (bytevector-u8-ref bv i))
+          (loop (+ i 1))))
+      sbv))
+
+  (def (secure-bytevector-length sbv)
+    (secure-region-size (live-region 'secure-bytevector-length sbv)))
+
+  (def (secure-bytevector-ref sbv index)
+    (let* ([region (live-region 'secure-bytevector-ref sbv)]
+           [i (checked-index 'secure-bytevector-ref sbv index)])
+      (foreign-ref 'unsigned-8 (secure-region-pointer region) i)))
+
+  (def (secure-bytevector-set! sbv index byte)
+    (let* ([region (live-region 'secure-bytevector-set! sbv)]
+           [i (checked-index 'secure-bytevector-set! sbv index)]
+           [b (checked-byte 'secure-bytevector-set! byte)])
+      (foreign-set! 'unsigned-8 (secure-region-pointer region) i b))
+    (void))
+
+  (def (secure-bytevector->bytevector sbv)
+    (let* ([len (secure-bytevector-length sbv)]
+           [out (make-bytevector len)])
+      (let loop ([i 0])
+        (when (< i len)
+          (bytevector-u8-set! out i (secure-bytevector-ref sbv i))
+          (loop (+ i 1))))
+      out))
+
+  (def (secure-bytevector-wipe! sbv)
+    (secure-wipe (live-region 'secure-bytevector-wipe! sbv)))
+
+  (def (secure-bytevector-random-fill! sbv)
+    (secure-random-fill (live-region 'secure-bytevector-random-fill! sbv)))
+
+  (def (secure-bytevector-free sbv)
+    (secure-free (live-region 'secure-bytevector-free sbv)))
+
   (define-syntax with-secure-region
     (syntax-rules ()
       [(_ ([name size] ...) body ...)
@@ -77,4 +162,14 @@
            (lambda ()
              (secure-free name) ...)))]))
 
+  (define-syntax with-secure-bytevector
+    (syntax-rules ()
+      [(_ ([name size] ...) body ...)
+       (let ([name (make-secure-bytevector size)] ...)
+         (dynamic-wind
+           void
+           (lambda () body ...)
+           (lambda ()
+             (secure-bytevector-free name) ...)))]))
+
   ) ;; end library
diff --git a/lib/std/security/secret.ss b/lib/std/security/secret.ss
index 828db70..5a5e274 100644
--- a/lib/std/security/secret.ss
+++ b/lib/std/security/secret.ss
@@ -19,6 +19,7 @@
     wipe-bytevector!)
 
   (import (chezscheme)
+          (std crypto secure-mem)
           (only (jerboa core) def defstruct))
 
   ;; ========== Memory Wiping ==========
@@ -41,56 +42,71 @@
   (def secret-value %secret-rec-value)
   (def secret-consumed? %secret-rec-consumed?)
 
-  (def (make-secret bv)
-    ;; Wrap a bytevector as a secret. The bytevector will be zeroed on consume.
-    (unless (bytevector? bv)
-      (error 'make-secret "secret must be a bytevector" bv))
-    (%make-secret bv #f))
+  (def (secret-storage? value)
+    (or (bytevector? value) (secure-bytevector? value)))
+
+  (def (secret-storage->bytevector value)
+    (cond
+      [(secure-bytevector? value) (secure-bytevector->bytevector value)]
+      [(bytevector? value)
+       (let ([copy (make-bytevector (bytevector-length value))])
+         (bytevector-copy! value 0 copy 0 (bytevector-length value))
+         copy)]
+      [else (error 'secret-storage->bytevector "invalid secret storage")]))
+
+  (def (secret-storage-wipe! value)
+    (cond
+      [(secure-bytevector? value)
+       (secure-bytevector-wipe! value)
+       (secure-bytevector-free value)]
+      [(bytevector? value) (wipe-bytevector! value)]
+      [else (void)]))
+
+  (def (make-secret value)
+    ;; Wrap bytevector or secure-bytevector storage as a secret.
+    (unless (secret-storage? value)
+      (error 'make-secret "secret must be a bytevector or secure-bytevector" value))
+    (%make-secret value #f))
 
   (def (secret-use s)
-    ;; Consume the secret — returns the value and marks as consumed.
-    ;; After consumption, the original bytevector is wiped.
+    ;; Consume the secret, returning a bytevector copy and wiping storage.
     (unless (secret? s)
       (error 'secret-use "not a secret"))
     (when (secret-consumed? s)
       (error 'secret-use "secret already consumed — use-after-wipe"))
     (%secret-set-consumed! s #t)
     (let* ([val (%secret-value s)]
-           ;; Make a copy for the caller — original will be wiped
-           [copy (let ([bv (make-bytevector (bytevector-length val))])
-                   (bytevector-copy! val 0 bv 0 (bytevector-length val))
-                   bv)])
-      (wipe-bytevector! val)
+           [copy (secret-storage->bytevector val)])
+      (secret-storage-wipe! val)
       copy))
 
   (def (secret-peek s)
-    ;; Read the secret without consuming it. Use with care — the value
-    ;; is still valid after peek but will be wiped when the secret scope exits.
+    ;; Read a bytevector copy without consuming the secret.
     (unless (secret? s)
       (error 'secret-peek "not a secret"))
     (when (secret-consumed? s)
       (error 'secret-peek "secret already consumed"))
-    (%secret-value s))
+    (secret-storage->bytevector (%secret-value s)))
 
   ;; ========== Scoped Secret ==========
 
   (define-syntax with-secret
     ;; (with-secret ([name expr] ...) body ...)
-    ;; Each expr must produce a bytevector which is wrapped as a secret.
+    ;; Each expr must produce a bytevector or secure-bytevector.
     ;; On scope exit (normal or exception), all secrets are wiped.
     (syntax-rules ()
       [(_ ([name expr]) body ...)
-       (let ([bv expr])
-         (unless (bytevector? bv)
-           (error 'with-secret "expression must produce a bytevector"))
-         (let ([name (make-secret bv)])
+       (let ([storage expr])
+         (unless (secret-storage? storage)
+           (error 'with-secret "expression must produce a bytevector or secure-bytevector"))
+         (let ([name (make-secret storage)])
            (dynamic-wind
              (lambda () (void))
              (lambda () body ...)
              (lambda ()
                (unless (secret-consumed? name)
                  (%secret-set-consumed! name #t)
-                 (wipe-bytevector! (%secret-value name)))))))]
+                 (secret-storage-wipe! (%secret-value name)))))))]
       [(_ ([name1 expr1] [name2 expr2] rest ...) body ...)
        (with-secret ([name1 expr1])
          (with-secret ([name2 expr2] rest ...)
diff --git a/tests/test-native-rust.ss b/tests/test-native-rust.ss
index 6795f40..bba6d26 100644
--- a/tests/test-native-rust.ss
+++ b/tests/test-native-rust.ss
@@ -360,6 +360,33 @@
       (assert-true "r1 is region" (secure-region? r1))
       (assert-true "r2 is region" (secure-region? r2)))))
 
+(test "secure-bytevector: checked get/set and copy"
+  (lambda ()
+    (let ([sbv (make-secure-bytevector 4 0)])
+      (secure-bytevector-set! sbv 0 17)
+      (secure-bytevector-set! sbv 3 255)
+      (assert-equal "length" 4 (secure-bytevector-length sbv))
+      (assert-equal "first" 17 (secure-bytevector-ref sbv 0))
+      (assert-equal "last" 255 (secure-bytevector-ref sbv 3))
+      (assert-equal "copy" #vu8(17 0 0 255) (secure-bytevector->bytevector sbv))
+      (secure-bytevector-free sbv))))
+
+(test "secure-bytevector: bounds and byte validation"
+  (lambda ()
+    (let ([sbv (make-secure-bytevector 2)])
+      (assert-error "negative index" (lambda () (secure-bytevector-ref sbv -1)))
+      (assert-error "end index" (lambda () (secure-bytevector-ref sbv 2)))
+      (assert-error "bad byte" (lambda () (secure-bytevector-set! sbv 0 256)))
+      (secure-bytevector-free sbv)
+      (assert-error "use after free" (lambda () (secure-bytevector-ref sbv 0))))))
+
+(test "with-secure-bytevector macro"
+  (lambda ()
+    (with-secure-bytevector ([sbv 8])
+      (assert-true "is secure-bytevector" (secure-bytevector? sbv))
+      (secure-bytevector-random-fill! sbv)
+      (assert-equal "length" 8 (secure-bytevector-length sbv)))))
+
 ;;; ============ Summary ============
 (display "\n=== Results ===\n")
 (display (string-append "Total:  " (number->string test-count) "\n"))
diff --git a/tests/test-phase4-safety.ss b/tests/test-phase4-safety.ss
index be3c6a6..7bdc744 100644
--- a/tests/test-phase4-safety.ss
+++ b/tests/test-phase4-safety.ss
@@ -7,6 +7,7 @@
         (std security capability)
         (std security capability-typed)
         (std security secret)
+        (std crypto secure-mem)
         (std security io-intercept)
         (std actor core)
         (std actor bounded))
@@ -215,6 +216,15 @@
       (error 'test "boom")))
   (check (bytevector-u8-ref key-bv 0) => 0))
 
+;; with-secret accepts secure-bytevector storage and frees it on exit
+(let ([sbv (make-secure-bytevector 4)])
+  (secure-bytevector-set! sbv 0 7)
+  (secure-bytevector-set! sbv 3 9)
+  (let ([result (with-secret ([key sbv])
+                  (secret-peek key))])
+    (check result => #vu8(7 0 0 9))
+    (check-error (secure-bytevector-ref sbv 0))))
+
 ;; wipe-bytevector! utility
 (let ([bv (bytevector-copy #vu8(1 2 3 4 5))])
   (wipe-bytevector! bv)