fed-sx-m2: Step 3 — key rotation via Update + actor_state + 16 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 32s
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 32s
actor_state.erl fold_update routes patches through apply_patch/3
which special-cases two rotation patch entries per design §9.6:
{add_publicKey, KeyProplist}
Append to :public_keys; default :created to activity's
:published if unset.
{supersede, OldKeyId}
Mark the matching key with :superseded_at = activity's
:published. Existing :superseded_at preserved (idempotent);
unknown :id no-op.
Other patch entries still last-write-wins per key (Step 2b semantics
preserved; verified by actor_state_pure 19/19 unchanged).
New exports:
key_history/1 — full :public_keys list (preserves superseded)
active_keys_at/2 — subset active at time T (mirrors envelope's
is_active_at; envelope keeps that predicate
private, so a local copy lives here)
find_key_by_id/2 — lookup by :id in the history
Rotation-purpose schema gating per §9.6 (rotation must be signed
by a key with :rotate-key purpose) is deferred to Step 5 (peer-side
stage_signature will plumb purpose through the pipeline).
16/16 in next/tests/key_rotation.sh covering:
- rotation arithmetic (add_publicKey + supersede combined)
- new key :created = rotation activity's :published
- supersede marks :superseded_at correctly
- key_history preserves all keys (superseded included)
- active_keys_at semantics at T=pre / T=rotation / T=post
- live envelope:verify_signature/2 round-trips:
pre-rotation activity signed with K1 -> ok
post-rotation activity signed with K2 -> ok
post-rotation activity signed with K1 -> {error, no_active_key}
- non-rotation Update patches preserve key history
- add_publicKey alone (no supersede) keeps old key active
- supersede alone empties active set
- supersede with unknown id is a no-op
- second supersede on superseded key is idempotent
Conformance 761/761. 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).
This commit is contained in:
@@ -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;
|
||||
|
||||
156
next/tests/key_rotation.sh
Executable file
156
next/tests/key_rotation.sh
Executable file
@@ -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" <<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/actor_state.erl\")) :name)")
|
||||
|
||||
;; add_publicKey appends new key with :created = activity's :published
|
||||
(epoch 10)
|
||||
(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), length(Hist) =:= 2\") :name)")
|
||||
|
||||
;; New key carries :created = 5 (the rotation's :published)
|
||||
(epoch 11)
|
||||
(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), {ok, NewKey} = actor_state:find_key_by_id(k2, P), envelope:get_field(created, NewKey) =:= {ok, 5}\") :name)")
|
||||
|
||||
;; supersede marks old key with :superseded_at = activity's :published
|
||||
(epoch 12)
|
||||
(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), {ok, OldKey} = actor_state:find_key_by_id(k1, P), envelope:get_field(superseded_at, OldKey) =:= {ok, 5}\") :name)")
|
||||
|
||||
;; Pre-rotation: only K1 is active at T=1
|
||||
(epoch 13)
|
||||
(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, 1), length(Active) =:= 1\") :name)")
|
||||
|
||||
;; At T=5 (the rotation moment), K1 is no longer active (Now < superseded_at means Now < 5 is false), K2 is just becoming active (Now >= 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, <<K1Bin/binary, CB/binary>>), 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, <<K2Bin/binary, CB/binary>>), 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, <<K1Bin/binary, CB/binary>>), 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="<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 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 ]
|
||||
@@ -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)`
|
||||
|
||||
Reference in New Issue
Block a user