fed-sx-m1: Step 6d-cs — outbox:construct + sign + cid_of + 13 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 32s

This commit is contained in:
2026-05-28 04:39:49 +00:00
parent 8c592c41b8
commit 6e12f539fd
3 changed files with 182 additions and 1 deletions

55
next/kernel/outbox.erl Normal file
View File

@@ -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, <<KeyMaterial/binary, CanonicalBytes/binary>>)`
%% — 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, <<KM/binary, CB/binary>>),
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.

124
next/tests/outbox_construct.sh Executable file
View File

@@ -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="<no output for epoch $epoch>"
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 ]