security: authenticate distributed actor frames
ober
635fd700ddf564db83e086b2daa11ba2d1798120
--- 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 --- 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. --- 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)) --- 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) --- 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