diff --git a/next/kernel/actor_state.erl b/next/kernel/actor_state.erl index 1175caeb..9e2c6a78 100644 --- a/next/kernel/actor_state.erl +++ b/next/kernel/actor_state.erl @@ -1,6 +1,7 @@ -module(actor_state). -export([fold/2, fold_fn/0, new/0, lookup/2, has/2, actors/1, - profile_type/1, profile_name/1, profile_field/2]). + profile_type/1, profile_name/1, profile_field/2, + key_history/1, active_keys_at/2, find_key_by_id/2]). %% Actor-state projection fold — Erlang-fun stand-in for the %% genesis `actor-state.sx` projection body. Tracks per-actor @@ -83,7 +84,8 @@ fold_update(Activity, State) -> {ok, Profile} -> case envelope:get_field(patch, Activity) of {ok, Patch} -> - NewProfile = merge_patch(Profile, Patch), + Published = published_seq(Activity), + NewProfile = apply_patch(Profile, Patch, Published), set_keyed(ActorId, NewProfile, State); _ -> State end; @@ -127,6 +129,86 @@ merge_patch(Profile, [{K, V} | Rest]) -> merge_patch(set_keyed(K, V, Profile), Rest); merge_patch(Profile, _) -> Profile. +%% apply_patch/3 — same as merge_patch but special-cases two +%% key-rotation patch entries per design §9.6: +%% {add_publicKey, KeyProplist} — append a new key to :public_keys, +%% defaulting :created to Published. +%% {supersede, OldKeyId} — mark the key with :id =:= OldKeyId +%% as :superseded_at = Published. +%% Other patch entries fall through to last-write-wins per key. + +apply_patch(Profile, [], _Published) -> Profile; +apply_patch(Profile, [{add_publicKey, NewKey} | Rest], Published) -> + Augmented = ensure_created(NewKey, Published), + Current = current_public_keys(Profile), + NewKeys = Current ++ [Augmented], + apply_patch(set_keyed(public_keys, NewKeys, Profile), Rest, Published); +apply_patch(Profile, [{supersede, OldKeyId} | Rest], Published) -> + Current = current_public_keys(Profile), + NewKeys = mark_superseded(OldKeyId, Published, Current), + apply_patch(set_keyed(public_keys, NewKeys, Profile), Rest, Published); +apply_patch(Profile, [{K, V} | Rest], Published) -> + apply_patch(set_keyed(K, V, Profile), Rest, Published); +apply_patch(Profile, _, _) -> Profile. + +current_public_keys(Profile) -> + case find_keyed(public_keys, Profile) of + {ok, Keys} -> Keys; + _ -> [] + end. + +ensure_created(Key, Published) -> + case find_keyed(created, Key) of + {ok, _} -> Key; + _ -> set_keyed(created, Published, Key) + end. + +mark_superseded(_, _, []) -> []; +mark_superseded(OldId, At, [Key | Rest]) -> + case find_keyed(id, Key) of + {ok, OldId} -> + case find_keyed(superseded_at, Key) of + {ok, _} -> [Key | mark_superseded(OldId, At, Rest)]; + _ -> [set_keyed(superseded_at, At, Key) | mark_superseded(OldId, At, Rest)] + end; + _ -> [Key | mark_superseded(OldId, At, Rest)] + end. + +%% Key-history view — full :public_keys list including superseded +%% entries (per §9.6: history is preserved so historical activities +%% verify against keys that were active at their :published time). + +key_history(Profile) -> + current_public_keys(Profile). + +%% active_keys_at/2 — the subset of :public_keys active at Now, +%% mirroring envelope's is_active_at semantics (local copy: envelope +%% keeps the predicate private). + +active_keys_at(Profile, Now) -> + [K || K <- current_public_keys(Profile), + key_active_at(K, Now)]. + +find_key_by_id(KeyId, Profile) -> + find_key_by_id_in(KeyId, current_public_keys(Profile)). + +find_key_by_id_in(_, []) -> not_found; +find_key_by_id_in(WantId, [K | Rest]) -> + case find_keyed(id, K) of + {ok, WantId} -> {ok, K}; + _ -> find_key_by_id_in(WantId, Rest) + end. + +key_active_at(Key, Now) -> + case find_keyed(created, Key) of + {ok, Created} when Now >= Created -> + case find_keyed(superseded_at, Key) of + {ok, SupAt} -> Now < SupAt; + _ -> true + end; + _ -> false + end. + published_seq(Activity) -> case envelope:get_field(published, Activity) of {ok, P} -> P; diff --git a/next/tests/key_rotation.sh b/next/tests/key_rotation.sh new file mode 100755 index 00000000..b942f11b --- /dev/null +++ b/next/tests/key_rotation.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# next/tests/key_rotation.sh — m2 Step 3 test. +# +# Verifies key rotation via Update + actor-state per design §9.6: +# Update{Person, patch: [{add_publicKey, K}, {supersede, OldId}]} +# augments the actor's :public_keys with the new key (carrying +# :created = activity's :published) and marks the old key with +# :superseded_at. Pre-rotation activities continue to verify against +# the old key (time-aware lookup); post-rotation activities verify +# against the new key. + +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 + +# Two key materials. Pre-rotation activities signed with K1 at +# published=1; rotation happens at published=5; post-rotation +# activities signed with K2 at published=10. +SETUP='K1Bin = <<1,2,3,4>>, K1 = [{id, k1}, {created, 0}, {value, K1Bin}], K2Bin = <<9,9,9,9>>, K2 = [{id, k2}, {value, K2Bin}], InitialPks = [K1], Profile = [{type, person}, {name, alice_n}, {preferredUsername, alice_local}, {public_keys, InitialPks}], CreateAct = [{actor, alice}, {type, create}, {object, [{type, person}, {name, alice_n}, {public_keys, InitialPks}]}, {published, 1}], RotateAct = [{actor, alice}, {type, update}, {object, <<97,108,105,99,101>>}, {patch, [{add_publicKey, K2}, {supersede, k1}]}, {published, 5}],' + +cat > "$TMPFILE" <= created=5) +(epoch 14) +(eval "(get (erlang-eval-ast \"${SETUP} S = actor_state:fold(CreateAct, actor_state:new()), S1 = actor_state:fold(RotateAct, S), {ok, P} = actor_state:lookup(alice, S1), Active = actor_state:active_keys_at(P, 5), [K] = Active, envelope:get_field(id, K) =:= {ok, k2}\") :name)") + +;; Post-rotation (T=10): only K2 is active +(epoch 15) +(eval "(get (erlang-eval-ast \"${SETUP} S = actor_state:fold(CreateAct, actor_state:new()), S1 = actor_state:fold(RotateAct, S), {ok, P} = actor_state:lookup(alice, S1), Active = actor_state:active_keys_at(P, 10), [K] = Active, envelope:get_field(id, K) =:= {ok, k2}\") :name)") + +;; key_history preserves both keys (including the superseded one) +(epoch 16) +(eval "(get (erlang-eval-ast \"${SETUP} S = actor_state:fold(CreateAct, actor_state:new()), S1 = actor_state:fold(RotateAct, S), {ok, P} = actor_state:lookup(alice, S1), Hist = actor_state:key_history(P), [Hk1, Hk2] = Hist, {ok, k1} = envelope:get_field(id, Hk1), {ok, k2} = envelope:get_field(id, Hk2), envelope:get_field(superseded_at, Hk1) =:= {ok, 5}\") :name)") + +;; envelope:verify_signature against the projection-derived AS: +;; build an actor_state proplist {public_keys, History} and verify a +;; pre-rotation activity signed with K1 (sig.value = sha256(K1Bin ++ canonical_bytes)). +(epoch 17) +(eval "(get (erlang-eval-ast \"${SETUP} S = actor_state:fold(CreateAct, actor_state:new()), S1 = actor_state:fold(RotateAct, S), {ok, P} = actor_state:lookup(alice, S1), AS = [{public_keys, actor_state:key_history(P)}], PreAct = [{actor, alice}, {type, note}, {object, [{content, hi}]}, {published, 2}], CB = envelope:canonical_bytes(PreAct), Mac = crypto:hash(sha256, <>), Signed = PreAct ++ [{signature, [{key_id, k1}, {algorithm, ed25519}, {value, Mac}]}], envelope:verify_signature(Signed, AS) =:= ok\") :name)") + +;; Post-rotation activity signed with K2: verifies +(epoch 18) +(eval "(get (erlang-eval-ast \"${SETUP} S = actor_state:fold(CreateAct, actor_state:new()), S1 = actor_state:fold(RotateAct, S), {ok, P} = actor_state:lookup(alice, S1), AS = [{public_keys, actor_state:key_history(P)}], PostAct = [{actor, alice}, {type, note}, {object, [{content, hi}]}, {published, 10}], CB = envelope:canonical_bytes(PostAct), Mac = crypto:hash(sha256, <>), Signed = PostAct ++ [{signature, [{key_id, k2}, {algorithm, ed25519}, {value, Mac}]}], envelope:verify_signature(Signed, AS) =:= ok\") :name)") + +;; Post-rotation activity signed with K1 (old key) at T=10: fails — K1 is superseded +(epoch 19) +(eval "(get (erlang-eval-ast \"${SETUP} S = actor_state:fold(CreateAct, actor_state:new()), S1 = actor_state:fold(RotateAct, S), {ok, P} = actor_state:lookup(alice, S1), AS = [{public_keys, actor_state:key_history(P)}], PostAct = [{actor, alice}, {type, note}, {object, [{content, hi}]}, {published, 10}], CB = envelope:canonical_bytes(PostAct), Mac = crypto:hash(sha256, <>), Signed = PostAct ++ [{signature, [{key_id, k1}, {algorithm, ed25519}, {value, Mac}]}], envelope:verify_signature(Signed, AS) =:= {error, no_active_key}\") :name)") + +;; Patch without rotation keys still last-write-wins on other fields (no change to key history) +(epoch 20) +(eval "(get (erlang-eval-ast \"${SETUP} S = actor_state:fold(CreateAct, actor_state:new()), MetaAct = [{actor, alice}, {type, update}, {patch, [{summary, new_bio}]}, {published, 7}], S1 = actor_state:fold(MetaAct, S), {ok, P} = actor_state:lookup(alice, S1), {actor_state:profile_field(summary, P), length(actor_state:key_history(P))} =:= {{ok, new_bio}, 1}\") :name)") + +;; add_publicKey alone (no supersede) leaves old key active +(epoch 21) +(eval "(get (erlang-eval-ast \"${SETUP} S = actor_state:fold(CreateAct, actor_state:new()), AddOnly = [{actor, alice}, {type, update}, {patch, [{add_publicKey, K2}]}, {published, 5}], S1 = actor_state:fold(AddOnly, S), {ok, P} = actor_state:lookup(alice, S1), Active = actor_state:active_keys_at(P, 10), length(Active) =:= 2\") :name)") + +;; supersede alone (no add) leaves only the marked key superseded +(epoch 22) +(eval "(get (erlang-eval-ast \"${SETUP} S = actor_state:fold(CreateAct, actor_state:new()), SupOnly = [{actor, alice}, {type, update}, {patch, [{supersede, k1}]}, {published, 5}], S1 = actor_state:fold(SupOnly, S), {ok, P} = actor_state:lookup(alice, S1), Active = actor_state:active_keys_at(P, 10), length(Active) =:= 0\") :name)") + +;; supersede with unknown key id is a no-op +(epoch 23) +(eval "(get (erlang-eval-ast \"${SETUP} S = actor_state:fold(CreateAct, actor_state:new()), SupGhost = [{actor, alice}, {type, update}, {patch, [{supersede, kx}]}, {published, 5}], S1 = actor_state:fold(SupGhost, S), {ok, P} = actor_state:lookup(alice, S1), {ok, OldKey} = actor_state:find_key_by_id(k1, P), envelope:get_field(superseded_at, OldKey) =:= not_found\") :name)") + +;; A second supersede on an already-superseded key is idempotent +(epoch 24) +(eval "(get (erlang-eval-ast \"${SETUP} S = actor_state:fold(CreateAct, actor_state:new()), S1 = actor_state:fold(RotateAct, S), Sup2 = [{actor, alice}, {type, update}, {patch, [{supersede, k1}]}, {published, 8}], S2 = actor_state:fold(Sup2, S1), {ok, P} = actor_state:lookup(alice, S2), {ok, OldKey} = actor_state:find_key_by_id(k1, P), envelope:get_field(superseded_at, OldKey) =:= {ok, 5}\") :name)") +EPOCHS + +OUTPUT=$(timeout 240 "$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 3 "actor_state loaded" "actor_state" +check 10 "rotation adds 2nd public_key" "true" +check 11 "new key :created = Published" "true" +check 12 "supersede marks :superseded_at" "true" +check 13 "pre-rotation: K1 active alone" "true" +check 14 "at T=5: K2 just active" "true" +check 15 "post-rotation: K2 active alone" "true" +check 16 "key_history preserves all keys" "true" +check 17 "pre-rotation activity verifies" "true" +check 18 "post-rotation activity verifies" "true" +check 19 "post-rotation K1 sig fails" "true" +check 20 "non-rotation patch preserves keys" "true" +check 21 "add_publicKey alone keeps old" "true" +check 22 "supersede alone empties active" "true" +check 23 "supersede unknown is no-op" "true" +check 24 "double supersede idempotent" "true" + +TOTAL=$((PASS+FAIL)) +if [ $FAIL -eq 0 ]; then + echo "ok $PASS/$TOTAL next/tests/key_rotation.sh passed" +else + echo "FAIL $PASS/$TOTAL passed, $FAIL failed:" + echo "$ERRORS" +fi +[ $FAIL -eq 0 ] diff --git a/plans/fed-sx-milestone-2.md b/plans/fed-sx-milestone-2.md index 2d5ca0b6..a8520c84 100644 --- a/plans/fed-sx-milestone-2.md +++ b/plans/fed-sx-milestone-2.md @@ -213,18 +213,29 @@ verifying activities published before the rotation. **Deliverables:** -- Update fold extension: `Update{Person, patch: {add_publicKey: K, supersede: {OldId, NewId}}}`. -- A `key-history` view on actor-state. -- `envelope:verify_signature/2` already does time-aware lookup (M1 - §Step 2c); confirm it works against the projection-driven actor-state. - -**Tests:** - -- Rotation publishes a new key; old key marked superseded. -- Pre-rotation activities verify against the old key. -- Post-rotation activities verify against the new key. -- A rotation activity must itself be signed by an active key with - appropriate purpose (`sign-activity` or `rotate-key`). +- [x] **3** — `actor_state.erl` `fold_update` now routes patches + through `apply_patch/3`, which special-cases two rotation patch + entries: + - `{add_publicKey, KeyProplist}` appends the key to `:public_keys`, + defaulting `:created` to the activity's `:published` if unset. + - `{supersede, OldKeyId}` marks the matching key with + `:superseded_at` = activity's `:published` (idempotent: existing + `:superseded_at` is preserved; unknown ids are no-ops). + Other patch entries fall through to last-write-wins per key + (preserving Step 2b semantics; verified by extra + `actor_state_pure.sh` cases). + New exports `key_history/1` (full list incl. superseded entries), + `active_keys_at/2` (subset active at time T, mirroring envelope's + `is_active_at` semantics — envelope keeps its predicate private, + so a local copy lives here), and `find_key_by_id/2`. + Rotation-purpose schema gating per §9.6 ("rotation activity must + itself be signed by an active key with `rotate-key` purpose") is + deferred to Step 5 (peer-side `stage_signature` will plumb the + purpose check through pipeline). 16 cases in `key_rotation.sh` + cover rotation arithmetic, `key_history` preservation, and live + `envelope:verify_signature/2` round-trips for pre / post / mid + rotation activities — including the negative case (post-rotation + K1-signed activity returns `{error, no_active_key}`). **Acceptance:** `bash next/tests/key_rotation.sh` passes 12+ cases. @@ -706,6 +717,26 @@ proceed. Newest first. +- **2026-06-06** — Step 3 (closes Step 3): key rotation via Update. + `actor_state.erl` `fold_update` routes patches through + `apply_patch/3` which special-cases `{add_publicKey, KeyProplist}` + (append + default `:created` to activity's `:published`) and + `{supersede, OldKeyId}` (mark `:superseded_at`, idempotent). + Other patch entries still last-write-wins per key. New exports + `key_history/1`, `active_keys_at/2`, `find_key_by_id/2` give the + projection-driven view that `envelope:verify_signature/2` + consumes for time-aware lookup. Rotation-purpose schema gating + (`rotate-key` purpose check on the rotation activity itself) + deferred to Step 5 (peer-side stage_signature). `key_rotation.sh` + 16/16 covers rotation arithmetic, key_history preservation, + active_keys_at at T=pre, T=rotation, T=post, and live + `envelope:verify_signature/2` round-trips for pre / post / cross + scenarios including the negative-case post-rotation K1 sig. + Conformance 761/761 preserved. 132/132 across 9 Step-3-adjacent + suites (key_rotation, actor_state_pure, actor_lifecycle, + envelope_sig, envelope_shape, envelope_canonical, nx_kernel_multi, + bootstrap_start, smoke_app_pure). + - **2026-06-06** — Step 2c (closes Step 2): `bootstrap_actor/4` + end-to-end `actor_lifecycle.sh`. New pure-functional export `nx_kernel:bootstrap_actor/4(ActorId, Profile, KeySpec, State)`