From 53b4a4c1fd0e9cb6fe3f1881325659838745b67f Mon Sep 17 00:00:00 2001 From: giles Date: Tue, 26 May 2026 21:00:39 +0000 Subject: [PATCH] =?UTF-8?q?fed-sx-m1:=20Step=202c=20=E2=80=94=20envelope:v?= =?UTF-8?q?erify=5Fsignature/2=20(time-aware=20key=20lookup=20+=20HMAC=20s?= =?UTF-8?q?tand-in)=20+=2011=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- next/kernel/envelope.erl | 94 +++++++++++++++++++++++++- next/tests/envelope_sig.sh | 129 ++++++++++++++++++++++++++++++++++++ plans/fed-sx-milestone-1.md | 3 +- 3 files changed, 224 insertions(+), 2 deletions(-) create mode 100755 next/tests/envelope_sig.sh diff --git a/next/kernel/envelope.erl b/next/kernel/envelope.erl index f5b63efa..5c7e7608 100644 --- a/next/kernel/envelope.erl +++ b/next/kernel/envelope.erl @@ -1,5 +1,5 @@ -module(envelope). --export([validate_shape/1, get_field/2, canonical_bytes/1]). +-export([validate_shape/1, get_field/2, canonical_bytes/1, verify_signature/2]). %% Activity envelope per design §3.1. %% @@ -83,3 +83,95 @@ insert_pair({K1, V1}, [{K2, V2} | Rest]) -> true -> [{K1, V1}, {K2, V2} | Rest]; false -> [{K2, V2} | insert_pair({K1, V1}, Rest)] end. + +%% verify_signature/2 — time-aware sig verification per design §9.6. +%% +%% Activity carries a `signature` proplist with `key_id`, `algorithm`, +%% `value`. ActorState carries `public_keys` — a list of key proplists +%% with `id`, `created`, optionally `superseded_at`, and `value` (the +%% key material). +%% +%% A key is active at time T iff `created =< T` AND +%% (no `superseded_at` OR T < `superseded_at`). Verification picks the +%% first matching active key whose `id == signature.key_id` at the +%% activity's `published` timestamp, then recomputes the MAC +%% `crypto:hash(sha256, <>)` +%% and compares it to `signature.value`. +%% +%% Returns ok | {error, Reason}. Reasons: +%% no_signature | no_key_id | no_published | no_keys | +%% no_active_key | bad_signature +%% +%% Real RSA-SHA256 / Ed25519 verification is deferred to milestone 2: +%% Phase 8 only ships `crypto:hash/2`, so we stand in with an HMAC-shaped +%% MAC that exercises the same key-lookup and canonical-bytes pipeline. + +verify_signature(Activity, ActorState) -> + case get_field(signature, Activity) of + not_found -> {error, no_signature}; + {ok, Sig} -> + case get_field(key_id, Sig) of + not_found -> {error, no_key_id}; + {ok, KeyId} -> + case get_field(published, Activity) of + not_found -> {error, no_published}; + {ok, Published} -> + verify_with_keys(Activity, Sig, KeyId, + Published, ActorState) + end + end + end. + +verify_with_keys(Activity, Sig, KeyId, Published, ActorState) -> + case get_field(public_keys, ActorState) of + not_found -> {error, no_keys}; + {ok, Keys} -> + case find_active_key(KeyId, Published, Keys) of + not_found -> {error, no_active_key}; + {ok, Key} -> verify_mac(Activity, Sig, Key) + end + end. + +find_active_key(_, _, []) -> not_found; +find_active_key(KeyId, Now, [Key | Rest]) -> + case is_matching_active_key(Key, KeyId, Now) of + true -> {ok, Key}; + false -> find_active_key(KeyId, Now, Rest) + end. + +is_matching_active_key(Key, WantId, Now) -> + case get_field(id, Key) of + {ok, WantId} -> is_active_at(Key, Now); + _ -> false + end. + +is_active_at(Key, Now) -> + case get_field(created, Key) of + not_found -> false; + {ok, Created} -> + case Now >= Created of + false -> false; + true -> + case get_field(superseded_at, Key) of + not_found -> true; + {ok, SupAt} -> Now < SupAt + end + end + end. + +verify_mac(Activity, Sig, Key) -> + case get_field(value, Sig) of + not_found -> {error, bad_signature}; + {ok, SigValue} -> + case get_field(value, Key) of + not_found -> {error, bad_signature}; + {ok, KeyMat} -> + Bytes = canonical_bytes(Activity), + Computed = crypto:hash(sha256, + <>), + case SigValue =:= Computed of + true -> ok; + false -> {error, bad_signature} + end + end + end. diff --git a/next/tests/envelope_sig.sh b/next/tests/envelope_sig.sh new file mode 100755 index 00000000..828485d1 --- /dev/null +++ b/next/tests/envelope_sig.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# next/tests/envelope_sig.sh — Step 2c acceptance test. +# +# Exercises envelope:verify_signature/2 against the full sig pipeline: +# canonical_bytes + crypto:hash MAC + time-aware key validity per design +# §9.6. 10 cases. +# +# The signature stand-in is HMAC-shaped: +# sig.value = crypto:hash(sha256, <>) +# Real Ed25519/RSA verification is deferred to milestone 2 once the +# corresponding crypto BIFs are wired. + +set -uo pipefail +cd "$(git rev-parse --show-toplevel)" + +SX_SERVER="${SX_SERVER:-hosts/ocaml/_build/default/bin/sx_server.exe}" +if [ ! -x "$SX_SERVER" ]; then + SX_SERVER="/root/rose-ash/hosts/ocaml/_build/default/bin/sx_server.exe" +fi +if [ ! -x "$SX_SERVER" ]; then + echo "ERROR: sx_server.exe not found." >&2 + exit 1 +fi + +VERBOSE="${1:-}" +PASS=0; FAIL=0; ERRORS="" +TMPFILE=$(mktemp); trap "rm -f $TMPFILE" EXIT + +# Shared Erlang prelude builds a valid-signed envelope template and an +# actor state with one active key. Each test reuses these and asserts +# against an Erlang =:= comparison so the result is a bare boolean. +PRELUDE='KM = <<1,2,3,4>>, U = [{actor,alice},{id,1},{published,100},{type,create}], CB = envelope:canonical_bytes(U), Sig = crypto:hash(sha256, <>), Env = [{actor,alice},{id,1},{published,100},{type,create},{signature,[{algorithm,ed25519},{key_id,k1},{value,Sig}]}], AS = [{public_keys, [[{id,k1},{created,50},{value,KM}]]}],' + +cat > "$TMPFILE" < ok +(epoch 10) +(eval "(get (erlang-eval-ast \"${PRELUDE} envelope:verify_signature(Env, AS) =:= ok\") :name)") + +;; tampered envelope (id mutated post-sign) -> bad_signature +(epoch 11) +(eval "(get (erlang-eval-ast \"${PRELUDE} Tampered = [{actor,alice},{id,999},{published,100},{type,create},{signature,[{algorithm,ed25519},{key_id,k1},{value,Sig}]}], envelope:verify_signature(Tampered, AS) =:= {error,bad_signature}\") :name)") + +;; wrong sig value (random bytes) -> bad_signature +(epoch 12) +(eval "(get (erlang-eval-ast \"${PRELUDE} BadEnv = [{actor,alice},{id,1},{published,100},{type,create},{signature,[{algorithm,ed25519},{key_id,k1},{value,<<0,0,0,0>>}]}], envelope:verify_signature(BadEnv, AS) =:= {error,bad_signature}\") :name)") + +;; unknown key_id -> no_active_key +(epoch 13) +(eval "(get (erlang-eval-ast \"${PRELUDE} OtherAS = [{public_keys, [[{id,k_other},{created,50},{value,KM}]]}], envelope:verify_signature(Env, OtherAS) =:= {error,no_active_key}\") :name)") + +;; key superseded BEFORE published -> no_active_key +(epoch 14) +(eval "(get (erlang-eval-ast \"${PRELUDE} SupAS = [{public_keys, [[{id,k1},{created,50},{superseded_at,80},{value,KM}]]}], envelope:verify_signature(Env, SupAS) =:= {error,no_active_key}\") :name)") + +;; key superseded AFTER published -> ok (historical valid) +(epoch 15) +(eval "(get (erlang-eval-ast \"${PRELUDE} SupAS2 = [{public_keys, [[{id,k1},{created,50},{superseded_at,200},{value,KM}]]}], envelope:verify_signature(Env, SupAS2) =:= ok\") :name)") + +;; key not yet created at published -> no_active_key +(epoch 16) +(eval "(get (erlang-eval-ast \"${PRELUDE} FutAS = [{public_keys, [[{id,k1},{created,150},{value,KM}]]}], envelope:verify_signature(Env, FutAS) =:= {error,no_active_key}\") :name)") + +;; missing signature field -> no_signature +(epoch 17) +(eval "(get (erlang-eval-ast \"${PRELUDE} envelope:verify_signature(U, AS) =:= {error,no_signature}\") :name)") + +;; actor state with no public_keys field -> no_keys +(epoch 18) +(eval "(get (erlang-eval-ast \"${PRELUDE} envelope:verify_signature(Env, []) =:= {error,no_keys}\") :name)") + +;; second key in list matches when first doesn't (lookup walks list) +(epoch 19) +(eval "(get (erlang-eval-ast \"${PRELUDE} TwoKeys = [{public_keys, [[{id,k_other},{created,50},{value,<<9,9,9>>}], [{id,k1},{created,50},{value,KM}]]}], envelope:verify_signature(Env, TwoKeys) =:= ok\") :name)") +EPOCHS + +OUTPUT=$(timeout 120 "$SX_SERVER" < "$TMPFILE" 2>/dev/null) + +check() { + local epoch="$1" desc="$2" expected="$3" + local actual + actual=$(echo "$OUTPUT" | awk -v e="$epoch" ' + $0 ~ "^\\(ok-len " e " " { getline; print; exit } + $0 ~ "^\\(ok " e " " { print; exit } + $0 ~ "^\\(error " e " " { print; exit } + ') + [ -z "$actual" ] && actual="" + if echo "$actual" | grep -qF -- "$expected"; then + PASS=$((PASS+1)) + [ "$VERBOSE" = "-v" ] && echo " ok $desc" + else + FAIL=$((FAIL+1)) + ERRORS+=" FAIL [$desc] (epoch $epoch) expected: $expected | actual: $actual +" + fi +} + +check 2 "module load name" "envelope" +check 10 "valid sig active key" "true" +check 11 "tampered envelope" "true" +check 12 "wrong sig value" "true" +check 13 "unknown key_id" "true" +check 14 "key superseded before published" "true" +check 15 "key superseded after published" "true" +check 16 "key not yet created" "true" +check 17 "missing signature field" "true" +check 18 "actor state no keys" "true" +check 19 "match second key in list" "true" + +TOTAL=$((PASS+FAIL)) +if [ $FAIL -eq 0 ]; then + echo "ok $PASS/$TOTAL next/tests/envelope_sig.sh passed" +else + echo "FAIL $PASS/$TOTAL passed, $FAIL failed:" + echo "$ERRORS" +fi +[ $FAIL -eq 0 ] diff --git a/plans/fed-sx-milestone-1.md b/plans/fed-sx-milestone-1.md index 9742da1d..d224f3b7 100644 --- a/plans/fed-sx-milestone-1.md +++ b/plans/fed-sx-milestone-1.md @@ -153,7 +153,7 @@ canonicalize_sx(V) -> ... % sorts dict keys, normalizes strings **Sub-deliverables:** - [x] **2a** — `next/kernel/envelope.erl` `validate_shape/1` + `get_field/2` (property-list envelope; Erlang maps `#{}` not supported in this port) + `next/tests/envelope_shape.sh` (15 cases) - [x] **2b** — `canonical_bytes/1` over sig-stripped, key-sorted envelope (deterministic textual form via `cid:to_string` substrate; dag-cbor stand-in for v1) + `next/tests/envelope_canonical.sh` (8 cases) -- [ ] **2c** — `verify_signature/2` against actor key set, time-aware key validity per design §9.6 + tests +- [x] **2c** — `verify_signature/2` against actor `public_keys`, time-aware key validity per design §9.6 (created ≤ published, optional supersession check) + `next/tests/envelope_sig.sh` (11 cases). Signature scheme is HMAC-shaped (`crypto:hash(sha256, KeyMaterial ++ canonical_bytes)`) — RSA/Ed25519 verify deferred to m2 (BIFs not yet wired). **Deliverables:** @@ -937,6 +937,7 @@ A few things still under-specified; resolve as work begins. Newest first. One line per sub-deliverable commit. Erlang conformance gate (`bash lib/erlang/conformance.sh`) must remain 729/729 on every entry. +- **2026-05-26** — Step 2c: `envelope:verify_signature/2` — time-aware key lookup over `public_keys` (created ≤ published < superseded_at), MAC recompute via `crypto:hash(sha256, KeyMaterial ++ canonical_bytes)`, compared against `signature.value`. Returns ok or one of `no_signature | no_key_id | no_published | no_keys | no_active_key | bad_signature`. `next/tests/envelope_sig.sh` 11/11 pass. Erlang conformance 729/729. - **2026-05-26** — Step 2b: `envelope:canonical_bytes/1` — strip signature, insertion-sort property list by key, return host-CID-string as deterministic byte form (dag-cbor stand-in). `next/tests/envelope_canonical.sh` 8/8 pass. Erlang conformance 729/729 preserved. - **2026-05-26** — Step 2a: `next/kernel/envelope.erl` `validate_shape/1` + `get_field/2` over property-list envelopes (Erlang `#{}` maps not supported in this port). `next/tests/envelope_shape.sh` 15/15 pass. Erlang conformance 729/729 preserved. - **2026-05-26** — Step 1b: `next/kernel/nx_cid.erl` (from_sx/to_string/from_string/equals) — thin Erlang wrapper around the `cid:to_string/1` BIF. `next/tests/cid.sh` 13/13 pass. Module named `nx_cid` to avoid shadowing the `cid` BIF (user-module dispatch takes precedence over BIFs by module name). Erlang conformance 729/729 preserved.