fed-sx-m1: Step 2c — envelope:verify_signature/2 (time-aware key lookup + HMAC stand-in) + 11 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 31s
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 31s
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
-module(envelope).
|
-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.
|
%% Activity envelope per design §3.1.
|
||||||
%%
|
%%
|
||||||
@@ -83,3 +83,95 @@ insert_pair({K1, V1}, [{K2, V2} | Rest]) ->
|
|||||||
true -> [{K1, V1}, {K2, V2} | Rest];
|
true -> [{K1, V1}, {K2, V2} | Rest];
|
||||||
false -> [{K2, V2} | insert_pair({K1, V1}, Rest)]
|
false -> [{K2, V2} | insert_pair({K1, V1}, Rest)]
|
||||||
end.
|
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, <<KeyMaterial/binary, CanonicalBytes/binary>>)`
|
||||||
|
%% 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,
|
||||||
|
<<KeyMat/binary, Bytes/binary>>),
|
||||||
|
case SigValue =:= Computed of
|
||||||
|
true -> ok;
|
||||||
|
false -> {error, bad_signature}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|||||||
129
next/tests/envelope_sig.sh
Executable file
129
next/tests/envelope_sig.sh
Executable file
@@ -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, <<KeyMaterial/binary, CanonicalBytes/binary>>)
|
||||||
|
# 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, <<KM/binary, CB/binary>>), 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" <<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)")
|
||||||
|
|
||||||
|
;; valid sig + active key -> 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="<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 "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 ]
|
||||||
@@ -153,7 +153,7 @@ canonicalize_sx(V) -> ... % sorts dict keys, normalizes strings
|
|||||||
**Sub-deliverables:**
|
**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] **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)
|
- [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:**
|
**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
|
Newest first. One line per sub-deliverable commit. Erlang conformance gate
|
||||||
(`bash lib/erlang/conformance.sh`) must remain 729/729 on every entry.
|
(`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 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 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.
|
- **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.
|
||||||
|
|||||||
Reference in New Issue
Block a user