security: authenticate distributed actor frames

ober

635fd700ddf564db83e086b2daa11ba2d1798120

diff --git a/docs/kimi3-security-recommmendations.md b/docs/kimi3-security-recommmendations.md
index 844fede..9d935ad 100644
--- a/docs/kimi3-security-recommmendations.md
+++ b/docs/kimi3-security-recommmendations.md
@@ -300,7 +300,7 @@ when" must be answerable from `dist/release-evidence/` in minutes.
 | Goal | Where we stand | The gap |
 |---|---|---|
 | G1 shrink target | Managed core is memory-safe; parsers budgeted; safe prelude exists | FFI surface (91 files / 225 symbols / 432 `unsafe`) never systematically audited (phase 5); `vendor/jsqlite` is C in the TCB; safe prelude not the default entry; import conflict undermines "safe symbol wins" confidence |
-| G2 cap blast radius | Capabilities, taint, kernel sandbox, egress policy objects, and worker facade all exist | **No native pre-exec worker backend to install kernel controls before child input**; taint opt-in; TOCTOU on paths; distributed actors unauthenticated; no memory limit story; `define-syntax` in sandbox |
+| G2 cap blast radius | Capabilities, taint, kernel sandbox, egress policy objects, worker facade, and authenticated actor transport/envelopes all exist | **No native pre-exec worker backend to install kernel controls before child input**; taint opt-in; TOCTOU on paths; no memory limit story; `define-syntax` in sandbox |
 | G3 find it first | 13 harnesses, scanner w/ rule DB, lint | No corpora, no crash regression, no scheduled fuzzing, no standing AI-red-team, no exploit-shaped regression suite |
 | G4 fail closed | Raw-fork launchers retired correctly; `allow-degraded?` explicit | New controls must keep the invariant; degraded-mode warnings must be test-locked |
 | G5 recover fast | SBOM/repro/signing gates exist | TCB accounting manual; doc drift (stale tables, undocumented modules); independent-builder reproducibility not yet routine |
@@ -439,11 +439,17 @@ compiler input after string transforms. Remaining work: complete the broader
   `distributed.ss`, `grpc.ss` either bounded or documented-trusted.
 
 ### K3-P0-05 — Authenticate distributed actor messages
-**Serves:** G2. **Effort:** 3–4 days.
-
-`security-reference.md` §13: "No message authentication for distributed
-actors — messages are still plaintext with no HMAC." A K3-class adversary
-that finds any deserialization-shape bug gets a network-reachable
+**Serves:** G2. **Effort:** 3–4 days. **Status:** initial implementation
+landed 2026-07-27. `(std actor transport)` already had HMAC-authenticated TCP
+traffic; `(std actor distributed)` now exposes HMAC'd binary envelopes with a
+timestamp and monotonic sequence, and tests cover valid, tampered, replayed,
+and wrong-key frames. Compatibility `serialize-message` / `deserialize-message`
+remain unauthenticated and should not be used directly for network-facing
+payloads.
+
+Original `security-reference.md` §13 finding: "No message authentication for
+distributed actors — messages are still plaintext with no HMAC." A K3-class
+adversary that finds any deserialization-shape bug gets a network-reachable
 eval-shaped channel; authentication shrinks who can even reach the parser.
 
 - **Where:** `lib/std/actor/distributed.ss`; reuse `rust-hmac-sha256` /
@@ -973,7 +979,9 @@ fake confidence happens.
 - Seccomp tables cover x86_64/aarch64 only; Landlock needs Linux 5.13+;
   Seatbelt/Capsicum have thinner test coverage (P1-08).
 - Taint is opt-in until P1-03; native Chez sinks never check it.
-- No message authentication for distributed actors until P0-05.
+- Distributed actor authentication is available in `(std actor transport)` and
+  `(std actor distributed)` authenticated envelopes; compatibility raw
+  serialization remains unauthenticated.
 - Path checks are TOCTOU-raceable until P1-02.
 - `define-syntax` remains in the sandbox allowlist pending P1-04's proof.
 - `run-safe-eval` output cap is on the formatted result only; no
diff --git a/docs/security-reference.md b/docs/security-reference.md
index 0fca369..1fa3ef2 100644
--- a/docs/security-reference.md
+++ b/docs/security-reference.md
@@ -433,7 +433,7 @@ Phases 1-4 are implemented and tested (42 tests in `tests/test-security2-parsers
 | 3 | Capability intersection checked type only, not permissions | HIGH | `intersect-capabilities` now ANDs boolean permissions and set-intersects list permissions. |
 | 4 | Empty network host list meant "all allowed" | HIGH | Empty list now means no hosts allowed. Explicit `"*"` required for wildcard. |
 | 5 | Path canonicalization didn't resolve symlinks | HIGH | `canonicalize-path` now uses `realpath(3)` via FFI. |
-| 6 | Distributed actors accepted remotely supplied datums without parser resource limits | HIGH | `deserialize-message` uses bounded `jerboa-read`, enforces a message-size ceiling, and validates the message shape. Chez itself rejects `#.` reader evaluation. |
+| 6 | Distributed actors accepted remotely supplied datums without parser resource limits or message authentication | HIGH | `deserialize-message` uses bounded `jerboa-read` with a message-size ceiling; network-facing payloads can use `serialize-authenticated-message` / `deserialize-authenticated-message`, which HMAC the frame and reject tamper, wrong-key, and replay attempts before payload deserialization. |
 | 7 | Seccomp/Landlock were stubs | HIGH | Both now have real implementations with actual syscalls (BPF bytecode generation, Landlock ABI detection). |
 | 8 | Taint tracking had no automatic sink enforcement | MEDIUM | Added `safe-open-input-file`, `safe-open-output-file`, `safe-system`, `safe-delete-file` that auto-reject tainted args. |
 | 9 | Restricted environment allowlist included `read` and `gensym` | MEDIUM | `read` removed (replaced by `jerboa-read`). `gensym` removed. |
@@ -595,7 +595,7 @@ These are known gaps documented as current limitations, not implementation promi
 - **Seccomp architecture coverage is limited.** The BPF bytecode generator supports x86_64 and aarch64 syscall numbers.
 - **Landlock requires Linux 5.13+.** No equivalent on macOS, BSDs, or older Linux kernels. `landlock-available?` returns `#f` on unsupported systems.
 - **Taint tracking is opt-in.** Only the `safe-*` wrappers enforce taint checks. Native Chez operations (`open-input-file`, `system`, etc.) do not check taint. No static analysis enforcement exists.
-- **No message authentication for distributed actors.** `deserialize-message` uses `jerboa-read`, but messages are still plaintext with no HMAC.
+- **Distributed actor authentication is opt-in at the serialization layer.** `(std actor transport)` authenticates TCP traffic, and `(std actor distributed)` exposes HMAC'd envelopes with timestamp and monotonic sequence replay checks. Compatibility callers that use only `serialize-message` / `deserialize-message` still get parsing limits but no peer authentication.
 - **No TOCTOU-safe path checking.** `canonicalize-path` uses `realpath(3)` before access, not `O_NOFOLLOW` + `/proc/self/fd/N` after open.
 - **`define-syntax` remains in the sandbox allowlist.** Macro definition in sandboxed code is possible. Whether this is a risk depends on the use case.
 - **Restricted-eval output is not captured.** `max-output-size` bounds only the formatted return value. It is not a stdout/stderr capture system.
diff --git a/lib/std/actor/cluster.ss b/lib/std/actor/cluster.ss
index 355455e..674706f 100644
--- a/lib/std/actor/cluster.ss
+++ b/lib/std/actor/cluster.ss
@@ -173,13 +173,14 @@
   ;; Child spec: (id proc node . restart-type)
   ;; restart-type: 'permanent | 'transient | 'temporary
 
-  (defstruct distributed-supervisor (name children strategy mutex))
-  (def make-dsup-raw make-distributed-supervisor)
-  (def dsup-name distributed-supervisor-name)
-  (def dsup-children distributed-supervisor-children)
-  (def dsup-set-children! distributed-supervisor-children-set!)
-  (def dsup-strategy distributed-supervisor-strategy)
-  (def dsup-mutex distributed-supervisor-mutex)
+  (defstruct distributed-supervisor-rec (name children strategy mutex))
+  (def make-dsup-raw make-distributed-supervisor-rec)
+  (def distributed-supervisor? distributed-supervisor-rec?)
+  (def dsup-name distributed-supervisor-rec-name)
+  (def dsup-children distributed-supervisor-rec-children)
+  (def dsup-set-children! distributed-supervisor-rec-children-set!)
+  (def dsup-strategy distributed-supervisor-rec-strategy)
+  (def dsup-mutex distributed-supervisor-rec-mutex)
 
   ;; child-info: (id proc assigned-node pid restart-type)
   (def (child-info-id   c) (list-ref c 0))
diff --git a/lib/std/actor/distributed.ss b/lib/std/actor/distributed.ss
index c54d182..281d476 100644
--- a/lib/std/actor/distributed.ss
+++ b/lib/std/actor/distributed.ss
@@ -7,7 +7,8 @@
 ;;;   - Process groups (broadcast)
 ;;;   - Distributed supervision
 ;;;   - Node failure detection / monitoring
-;;;   - Simple serialization via write/jerboa-read on string ports
+;;;   - Authenticated message envelopes for network-facing serialized payloads
+;;;   - Simple compatibility serialization via write/jerboa-read on string ports
 
 (library (std actor distributed)
   (export
@@ -48,6 +49,12 @@
     serialize-message
     deserialize-message
 
+    ;; Authenticated serialization
+    make-message-auth-state
+    message-auth-state?
+    serialize-authenticated-message
+    deserialize-authenticated-message
+
     ;; Configuration parameters
     *default-send-timeout*
     *cluster-name*
@@ -57,6 +64,9 @@
           (jerboa reader)
           (std actor core)
           (except (std actor cluster) node-alive?)
+          (only (std crypto native-rust)
+                rust-hmac-sha256 ; jerboa-security: suppress non-constant-time-secret-compare -- importing HMAC primitive, not comparing a secret
+                rust-timing-safe-equal?) ; jerboa-security: suppress non-constant-time-secret-compare -- this imported predicate is the timing-safe MAC comparison primitive
           (only (jerboa core) def defstruct))
 
   ;; ======================================================================
@@ -291,6 +301,163 @@
   ;; Maximum allowed message size (bytes) for deserialization.
   (def *max-message-size* (make-parameter (* 1 1024 1024)))  ;; 1MB default
 
+  (def AUTH_MAGIC (string->utf8 "JDAM"))
+
+  (def AUTH_VERSION 1)
+  (def AUTH_HEADER_SIZE 25)
+  (def AUTH_HMAC_SIZE 32)
+  (def DEFAULT_CLOCK_SKEW_MS 300000)
+  (def MAX_U64 (- (expt 2 64) 1))
+  (def MAX_U32 (- (expt 2 32) 1))
+
+  (defstruct auth-state-rec (key send-seq recv-seq max-clock-skew-ms mutex))
+
+  (def (message-auth-state? x)
+    (auth-state-rec? x))
+
+  (def (copy-bytevector bv)
+    (let* ([n (bytevector-length bv)]
+           [out (make-bytevector n 0)])
+      (bytevector-copy! bv 0 out 0 n)
+      out))
+
+  (def (normalize-auth-key key)
+    (let ([bv (cond
+                [(string? key) (string->utf8 key)]
+                [(bytevector? key) (copy-bytevector key)]
+                [else (error 'make-message-auth-state
+                             "key must be a string or bytevector")])])
+      (when (= (bytevector-length bv) 0)
+        (error 'make-message-auth-state "key must not be empty"))
+      bv))
+
+  (def (make-message-auth-state key . rest)
+    (let ([skew (if (null? rest) DEFAULT_CLOCK_SKEW_MS (car rest))])
+      (unless (or (not skew)
+                  (and (integer? skew) (exact? skew) (>= skew 0)))
+        (error 'make-message-auth-state
+               "clock skew must be an exact non-negative integer or #f"))
+      (make-auth-state-rec (normalize-auth-key key) 0 -1 skew (make-mutex))))
+
+  (def (check-u64 who n)
+    (unless (and (integer? n) (exact? n) (>= n 0) (<= n MAX_U64))
+      (error who "expected unsigned 64-bit integer" n)))
+
+  (def (check-u32 who n)
+    (unless (and (integer? n) (exact? n) (>= n 0) (<= n MAX_U32))
+      (error who "expected unsigned 32-bit integer" n)))
+
+  (def (u64-set! bv offset n)
+    (check-u64 'u64-set! n)
+    (let loop ([i 7] [v n])
+      (bytevector-u8-set! bv (+ offset i) (modulo v 256))
+      (when (> i 0)
+        (loop (- i 1) (quotient v 256)))))
+
+  (def (u64-ref bv offset)
+    (let loop ([i 0] [acc 0])
+      (if (= i 8)
+          acc
+          (loop (+ i 1)
+                (+ (* acc 256) (bytevector-u8-ref bv (+ offset i)))))))
+
+  (def (u32-set! bv offset n)
+    (check-u32 'u32-set! n)
+    (let loop ([i 3] [v n])
+      (bytevector-u8-set! bv (+ offset i) (modulo v 256))
+      (when (> i 0)
+        (loop (- i 1) (quotient v 256)))))
+
+  (def (u32-ref bv offset)
+    (let loop ([i 0] [acc 0])
+      (if (= i 4)
+          acc
+          (loop (+ i 1)
+                (+ (* acc 256) (bytevector-u8-ref bv (+ offset i)))))))
+
+  (def (auth-bytevector-slice bv offset n)
+    (let ([out (make-bytevector n 0)])
+      (bytevector-copy! bv offset out 0 n)
+      out))
+
+  (def (public-bytevector=? a b)
+    (and (= (bytevector-length a) (bytevector-length b))
+         (let loop ([i 0])
+           (or (= i (bytevector-length a))
+               (and (= (bytevector-u8-ref a i) (bytevector-u8-ref b i))
+                    (loop (+ i 1)))))))
+
+  (def (make-auth-prefix timestamp sequence body)
+    (let* ([body-len (bytevector-length body)]
+           [prefix (make-bytevector (+ AUTH_HEADER_SIZE body-len) 0)])
+      (check-u64 'serialize-authenticated-message timestamp)
+      (check-u64 'serialize-authenticated-message sequence)
+      (check-u32 'serialize-authenticated-message body-len)
+      (bytevector-copy! AUTH_MAGIC 0 prefix 0 4)
+      (bytevector-u8-set! prefix 4 AUTH_VERSION)
+      (u64-set! prefix 5 timestamp)
+      (u64-set! prefix 13 sequence)
+      (u32-set! prefix 21 body-len)
+      (bytevector-copy! body 0 prefix AUTH_HEADER_SIZE body-len)
+      prefix))
+
+  (def (auth-prefix-length frame)
+    (let ([frame-len (bytevector-length frame)])
+      (when (< frame-len (+ AUTH_HEADER_SIZE AUTH_HMAC_SIZE))
+        (error 'deserialize-authenticated-message "authenticated frame too short"))
+      (unless (public-bytevector=? (auth-bytevector-slice frame 0 4) AUTH_MAGIC)
+        (error 'deserialize-authenticated-message "bad authenticated frame magic"))
+      (unless (= (bytevector-u8-ref frame 4) AUTH_VERSION)
+        (error 'deserialize-authenticated-message "unsupported authenticated frame version"))
+      (let* ([body-len (u32-ref frame 21)]
+             [prefix-len (+ AUTH_HEADER_SIZE body-len)]
+             [expected-len (+ prefix-len AUTH_HMAC_SIZE)])
+        (unless (= frame-len expected-len)
+          (error 'deserialize-authenticated-message
+                 "authenticated frame length mismatch"))
+        prefix-len)))
+
+  (def (auth-header-values frame)
+    (values (u64-ref frame 5)
+            (u64-ref frame 13)
+            (u32-ref frame 21)))
+
+  (def (serialize-authenticated-message state msg)
+    (unless (message-auth-state? state)
+      (error 'serialize-authenticated-message "expected message auth state"))
+    (let ([sequence 0])
+      (with-mutex (auth-state-rec-mutex state)
+        (set! sequence (+ (auth-state-rec-send-seq state) 1))
+        (auth-state-rec-send-seq-set! state sequence))
+      (let* ([body (serialize-message msg)]
+             [prefix (make-auth-prefix (current-time-ms) sequence body)]
+             [mac (rust-hmac-sha256 (auth-state-rec-key state) prefix)]
+             [frame (make-bytevector (+ (bytevector-length prefix) AUTH_HMAC_SIZE) 0)])
+        (bytevector-copy! prefix 0 frame 0 (bytevector-length prefix))
+        (bytevector-copy! mac 0 frame (bytevector-length prefix) AUTH_HMAC_SIZE)
+        frame)))
+
+  (def (deserialize-authenticated-message state frame)
+    (unless (message-auth-state? state)
+      (error 'deserialize-authenticated-message "expected message auth state"))
+    (unless (bytevector? frame)
+      (error 'deserialize-authenticated-message "expected bytevector frame"))
+    (let* ([prefix-len (auth-prefix-length frame)]
+           [prefix (auth-bytevector-slice frame 0 prefix-len)]
+           [received-mac (auth-bytevector-slice frame prefix-len AUTH_HMAC_SIZE)]
+           [expected-mac (rust-hmac-sha256 (auth-state-rec-key state) prefix)])
+      (unless (rust-timing-safe-equal? received-mac expected-mac)
+        (error 'deserialize-authenticated-message "message authentication failed"))
+      (let-values ([(timestamp sequence body-len) (auth-header-values frame)])
+        (let ([skew (auth-state-rec-max-clock-skew-ms state)])
+          (when (and skew (> (abs (- (current-time-ms) timestamp)) skew))
+            (error 'deserialize-authenticated-message "authenticated frame timestamp outside accepted skew")))
+        (with-mutex (auth-state-rec-mutex state)
+          (when (<= sequence (auth-state-rec-recv-seq state))
+            (error 'deserialize-authenticated-message "authenticated frame replayed or out of order"))
+          (auth-state-rec-recv-seq-set! state sequence))
+        (deserialize-message (auth-bytevector-slice frame AUTH_HEADER_SIZE body-len)))))
+
   ;; Deserialize a message from a bytevector.
   ;; HARDENED: Uses jerboa-read and enforces message size limits.
   (def (deserialize-message bv)
diff --git a/tests/test-distributed.ss b/tests/test-distributed.ss
index 99465c2..bed1601 100644
--- a/tests/test-distributed.ss
+++ b/tests/test-distributed.ss
@@ -17,6 +17,16 @@
            (begin (set! pass (+ pass 1)) (printf "  ok ~a~%" name))
            (begin (set! fail (+ fail 1))
                   (printf "FAIL ~a: got ~s, expected ~s~%" name got expected)))))]))
+(define (raises? thunk)
+  (guard (exn [#t #t])
+    (thunk)
+    #f))
+
+(define (copy-bytevector bv)
+  (let* ([n (bytevector-length bv)]
+         [out (make-bytevector n 0)])
+    (bytevector-copy! bv 0 out 0 n)
+    out))
 
 (printf "--- (std actor distributed) tests ---~%")
 
@@ -68,6 +78,31 @@
 
 (let ([msg #t])
   (test "serialize boolean" (deserialize-message (serialize-message msg)) #t))
+(let* ([sender (make-message-auth-state "shared-cookie" #f)]
+       [receiver (make-message-auth-state "shared-cookie" #f)]
+       [wrong-key (make-message-auth-state "wrong-cookie" #f)]
+       [msg '(auth hello 42)]
+       [frame (serialize-authenticated-message sender msg)])
+  (test "auth frame type" (bytevector? frame) #t)
+  (test "auth valid frame" (deserialize-authenticated-message receiver frame) msg)
+  (test "auth replay rejected"
+        (raises? (lambda () (deserialize-authenticated-message receiver frame)))
+        #t)
+  (test "auth wrong key rejected"
+        (raises? (lambda () (deserialize-authenticated-message wrong-key frame)))
+        #t)
+  (let ([tampered (copy-bytevector frame)])
+    (bytevector-u8-set! tampered
+                        (- (bytevector-length tampered) 1)
+                        (bitwise-xor (bytevector-u8-ref tampered
+                                                        (- (bytevector-length tampered) 1))
+                                     #x01))
+    (test "auth tamper rejected"
+          (raises? (lambda ()
+                     (deserialize-authenticated-message
+                       (make-message-auth-state "shared-cookie" #f)
+                       tampered)))
+          #t)))
 
 ;;; ============================================================
 ;;; Cluster-wide name registration