From 6e12f539fd4a7acaf4a2ce210fe63e197d52e4e2 Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 28 May 2026 04:39:49 +0000 Subject: [PATCH] =?UTF-8?q?fed-sx-m1:=20Step=206d-cs=20=E2=80=94=20outbox:?= =?UTF-8?q?construct=20+=20sign=20+=20cid=5Fof=20+=2013=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- next/kernel/outbox.erl | 55 +++++++++++++++ next/tests/outbox_construct.sh | 124 +++++++++++++++++++++++++++++++++ plans/fed-sx-milestone-1.md | 4 +- 3 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 next/kernel/outbox.erl create mode 100755 next/tests/outbox_construct.sh diff --git a/next/kernel/outbox.erl b/next/kernel/outbox.erl new file mode 100644 index 00000000..ccc4bd87 --- /dev/null +++ b/next/kernel/outbox.erl @@ -0,0 +1,55 @@ +-module(outbox). +-export([construct/4, sign/2, cid_of/1]). + +%% Outbox envelope construction + signing per design §3.1. +%% +%% construct/4 builds an unsigned activity envelope from caller-supplied +%% (Type, ActorId, Published, Object). The envelope's `:id` field is +%% derived from the host `cid:to_string` BIF over a skeleton tag, so +%% recipients can address the activity by its content hash. The +%% returned property list is the canonical key-sorted form that +%% `envelope:canonical_bytes/1` operates on. +%% +%% sign/2 takes the unsigned envelope plus a KeySpec proplist that +%% mirrors a `public_keys` entry: `[{key_id, _}, {algorithm, _}, +%% {value, KeyMaterial}]`. It computes the v1 HMAC stand-in +%% `crypto:hash(sha256, <>)` +%% — the same scheme `envelope:verify_signature/2` checks — and +%% appends a `:signature` pair. +%% +%% Real Ed25519 / RSA signing arrives in milestone 2 once +%% `crypto:sign_ed25519/2` BIFs land; the API shape doesn't change. + +%% construct/4 — Type and ActorId are atoms; Published is an +%% integer timestamp the caller supplies (no clock BIF in this +%% port; the HTTP layer / outbox:publish caller injects it). +%% Object can be any term, including a property list of inner +%% fields. +construct(Type, ActorId, Published, Object) -> + Skeleton = [{actor, ActorId}, + {object, Object}, + {published, Published}, + {type, Type}], + Id = cid:to_string({activity_envelope, Skeleton}), + [{actor, ActorId}, + {id, Id}, + {object, Object}, + {published, Published}, + {type, Type}]. + +%% sign/2 — KeySpec carries key_id, algorithm, value (key material). +sign(Envelope, KeySpec) -> + {ok, KeyId} = envelope:get_field(key_id, KeySpec), + {ok, Alg} = envelope:get_field(algorithm, KeySpec), + {ok, KM} = envelope:get_field(value, KeySpec), + CB = envelope:canonical_bytes(Envelope), + SigValue = crypto:hash(sha256, <>), + Sig = [{algorithm, Alg}, {key_id, KeyId}, {value, SigValue}], + Envelope ++ [{signature, Sig}]. + +%% cid_of/1 — extract the :id field from a constructed envelope. +%% Convenience for callers that don't want to thread the CID +%% separately when both the envelope and its ID matter. +cid_of(Envelope) -> + {ok, Id} = envelope:get_field(id, Envelope), + Id. diff --git a/next/tests/outbox_construct.sh b/next/tests/outbox_construct.sh new file mode 100755 index 00000000..088fe466 --- /dev/null +++ b/next/tests/outbox_construct.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# next/tests/outbox_construct.sh — Step 6d-cs acceptance test. +# +# Exercises outbox:construct/4, outbox:sign/2, outbox:cid_of/1. +# Closes the loop by verifying that construct→sign produces an +# envelope that envelope:verify_signature/2 accepts. 11 cases. + +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 + +cat > "$TMPFILE" <<'EPOCHS' +(epoch 1) +(load "lib/erlang/tokenizer.sx") +(load "lib/erlang/parser.sx") +(load "lib/erlang/parser-core.sx") +(load "lib/erlang/parser-expr.sx") +(load "lib/erlang/parser-module.sx") +(load "lib/erlang/transpile.sx") +(load "lib/erlang/runtime.sx") +(load "lib/erlang/vm/dispatcher.sx") + +(epoch 2) +(eval "(get (erlang-load-module (file-read \"next/kernel/envelope.erl\")) :name)") +(epoch 3) +(eval "(get (erlang-load-module (file-read \"next/kernel/outbox.erl\")) :name)") + +;; construct: required fields present +(epoch 10) +(eval "(get (erlang-eval-ast \"Env = outbox:construct(create, alice, 100, nil), envelope:get_field(actor, Env) =:= {ok, alice}\") :name)") +(epoch 11) +(eval "(get (erlang-eval-ast \"Env = outbox:construct(create, alice, 100, nil), envelope:get_field(type, Env) =:= {ok, create}\") :name)") +(epoch 12) +(eval "(get (erlang-eval-ast \"Env = outbox:construct(create, alice, 100, nil), envelope:get_field(published, Env) =:= {ok, 100}\") :name)") + +;; construct: :id is a non-trivial CID +(epoch 13) +(eval "(get (erlang-eval-ast \"Env = outbox:construct(create, alice, 100, nil), {ok, Id} = envelope:get_field(id, Env), is_binary(Id) and (byte_size(Id) > 50)\") :name)") + +;; construct deterministic across calls with same args +(epoch 14) +(eval "(get (erlang-eval-ast \"E1 = outbox:construct(create, alice, 100, nil), E2 = outbox:construct(create, alice, 100, nil), outbox:cid_of(E1) =:= outbox:cid_of(E2)\") :name)") + +;; construct distinct CIDs for distinct types +(epoch 15) +(eval "(get (erlang-eval-ast \"E1 = outbox:construct(create, alice, 100, nil), E2 = outbox:construct(update, alice, 100, nil), outbox:cid_of(E1) =/= outbox:cid_of(E2)\") :name)") + +;; construct distinct CIDs for distinct timestamps +(epoch 16) +(eval "(get (erlang-eval-ast \"E1 = outbox:construct(create, alice, 100, nil), E2 = outbox:construct(create, alice, 101, nil), outbox:cid_of(E1) =/= outbox:cid_of(E2)\") :name)") + +;; sign adds a :signature field +(epoch 17) +(eval "(get (erlang-eval-ast \"KS = [{key_id, k1}, {algorithm, ed25519}, {value, <<1,2,3>>}], Unsigned = outbox:construct(create, alice, 100, nil), Signed = outbox:sign(Unsigned, KS), envelope:get_field(signature, Signed) =/= not_found\") :name)") + +;; signed envelope passes envelope:verify_signature with matching key +(epoch 18) +(eval "(get (erlang-eval-ast \"KM = <<1,2,3,4>>, KS = [{key_id, k1}, {algorithm, ed25519}, {value, KM}], Unsigned = outbox:construct(create, alice, 100, nil), Signed = outbox:sign(Unsigned, KS), AS = [{public_keys, [[{id, k1}, {created, 50}, {value, KM}]]}], envelope:verify_signature(Signed, AS) =:= ok\") :name)") + +;; signed envelope fails verify with a wrong key +(epoch 19) +(eval "(get (erlang-eval-ast \"KM = <<1,2,3,4>>, OtherKM = <<9,9,9,9>>, KS = [{key_id, k1}, {algorithm, ed25519}, {value, KM}], Unsigned = outbox:construct(create, alice, 100, nil), Signed = outbox:sign(Unsigned, KS), AS = [{public_keys, [[{id, k1}, {created, 50}, {value, OtherKM}]]}], envelope:verify_signature(Signed, AS) =:= {error, bad_signature}\") :name)") + +;; Round-trip through the full pipeline: +;; construct → sign → stage_envelope → stage_signature → ok +(epoch 20) +(eval "(get (erlang-eval-ast \"KM = <<1,2,3,4>>, KS = [{key_id, k1}, {algorithm, ed25519}, {value, KM}], Unsigned = outbox:construct(create, alice, 100, nil), Signed = outbox:sign(Unsigned, KS), AS = [{public_keys, [[{id, k1}, {created, 50}, {value, KM}]]}], envelope:validate_shape(Signed) =:= ok and envelope:verify_signature(Signed, AS) =:= ok\") :name)") +EPOCHS + +OUTPUT=$(timeout 180 "$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 "envelope module loaded" "envelope" +check 3 "outbox module loaded" "outbox" +check 10 "construct sets :actor" "true" +check 11 "construct sets :type" "true" +check 12 "construct sets :published" "true" +check 13 "construct :id is a CID" "true" +check 14 "construct deterministic" "true" +check 15 "distinct types -> distinct CIDs" "true" +check 16 "distinct ts -> distinct CIDs" "true" +check 17 "sign adds :signature" "true" +check 18 "signed verifies against key" "true" +check 19 "signed fails against wrong key" "true" +check 20 "full pipeline round-trip" "true" + +TOTAL=$((PASS+FAIL)) +if [ $FAIL -eq 0 ]; then + echo "ok $PASS/$TOTAL next/tests/outbox_construct.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 4323c9eb..9d2956c0 100644 --- a/plans/fed-sx-milestone-1.md +++ b/plans/fed-sx-milestone-1.md @@ -391,7 +391,8 @@ projection fold maintains it.) - [x] **6b-sig** — `pipeline:stage_signature/2` (direct call) + `stage_signature/1` (factory returning a context-bound stage fun). Not wired into default stage lists since ActorState isn't available at static-list build time; callers compose by `Stages = [..., pipeline:stage_signature(AS)]`. `next/tests/pipeline_signature.sh` (11 cases) covers direct + factory + composition + halt behaviour with stage_envelope. - [x] **6c-replay** — `pipeline:stage_replay/2` (direct) + `stage_replay/1` (factory closed over LogState). Checks the log entries for an existing activity with the same `:id`. Returns `{error, replay}` on duplicate, `{error, no_id}` when missing. `next/tests/pipeline_replay.sh` (12 cases). - [ ] **6c-schema** — `stage_activity_schema/1` (registry lookup of activity-type, evaluate :schema body) — blocked behind SX-source eval bridge. -- [ ] **6d** — `outbox:publish/2`: envelope construction, sign, validate_outbound, log:append, returns `{ok, #{cid, ap_id}}` +- [x] **6d-cs** — `outbox:construct/4` (skeleton + CID-derived :id via `cid:to_string`) + `outbox:sign/2` (HMAC over canonical bytes, append :signature pair from KeySpec) + `cid_of/1` accessor. Verified end-to-end: construct→sign→envelope:verify_signature passes; wrong key material fails with bad_signature. `next/tests/outbox_construct.sh` (13 cases). +- [ ] **6d-publish** — `outbox:publish/N` orchestrates construct + sign + `pipeline:validate_outbound` + `log:append`; returns `{ok, #{cid, id}, NewLogState}`. - [ ] **6e** — HTTP handler for POST /activity glue (depends on Step 8 http server) **Deliverables:** @@ -970,6 +971,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-28** — Step 6d-cs: `next/kernel/outbox.erl` — envelope construction + signing. `construct/4` takes `(Type, ActorId, Published, Object)`, builds the canonical key-sorted property list, and derives the activity `:id` from `cid:to_string({activity_envelope, Skeleton})`. `sign/2` extracts key_id/algorithm/key-material from a KeySpec proplist, computes the v1 HMAC over canonical bytes, and appends the `:signature` pair. `cid_of/1` is a convenience accessor. Round-trip end-to-end through `envelope:verify_signature/2` verified (correct key passes, wrong key returns bad_signature). Step 6d split into 6d-cs (done) + 6d-publish (orchestration). `next/tests/outbox_construct.sh` 13/13. Erlang conformance 729/729. - **2026-05-28** — Step 6c-replay: `pipeline:stage_replay/2` (direct) + `stage_replay/1` (factory closed over LogState). Linear scan of `log:entries/1` checking for an existing entry with the same `:id`. Returns ok if new, `{error, replay}` on duplicate, `{error, no_id}` when the activity has no id field. Step 6c split into 6c-replay (done) + 6c-schema (deferred — blocked behind SX-source eval bridge for the activity-type :schema body). `next/tests/pipeline_replay.sh` 12/12 covers direct + factory + composition with stage_envelope. Erlang conformance 729/729. - **2026-05-28** — Step 6b-sig: `pipeline:stage_signature/2` direct call + `stage_signature/1` factory returning a context-bound stage fun closed over ActorState. Not wired into the default `inbound_stages`/`outbound_stages` lists because actor state isn't a static-build-time value; callers prepend the factory result to a stage list (`Stages = [stage_envelope, pipeline:stage_signature(AS)]`). `next/tests/pipeline_signature.sh` 11/11 covers direct + factory + composition with stage_envelope (including halt ordering: bad envelope halts before sig; good envelope + bad sig surfaces sig error). Erlang conformance 729/729. - **2026-05-28** — Step 6b-env: `pipeline:stage_envelope/1` wraps `envelope:validate_shape/1`; wired into both `inbound_stages` and `outbound_stages` lists. `validate_inbound`/`validate_outbound` now exercises the full envelope shape contract end-to-end (missing fields, signature sub-shape, non-list input). `next/tests/pipeline_envelope.sh` 12/12; `pipeline_driver.sh` refactored to test the driver against explicit stage lists rather than depending on the now-non-empty defaults. Split 6b in the plan into 6b-env (done) + 6b-sig (needs runtime context for actor-state). Erlang conformance 729/729.