#!/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 ]