Retiring a reputation issuer key with a backdated timestamp silently unverifies attestations it already signed #191

Closed AtlantisPleb opened this 3d ago 1 comment

Evidence

Shipped in the release at 8c53d09, promoted 2026-08-26.

1 pushes receipt

What happened

#180's rehearsal 4 (key rotation) asks for one thing above the others: that "a rotation performed in the wrong order is refused rather than silently invalidating history." For the reputation issuer key it is not refused, and it does silently invalidate history.

OpenAgents.Reputation.retire_key/2 takes any DateTime:

def retire_key(%SigningKey{} = key, retired_at \\ DateTime.utc_now()) do
  key |> SigningKey.retire_changeset(retired_at) |> Repo.update()
end

SigningKey.retire_changeset/2 casts and requires retired_at and validates nothing else — not against activated_at, not against any attestation the key has signed.

The verdict predicate then reads:

report["digest_match"] and report["signature"]["valid"] and
  report["signature"]["key_active_at_attestation"] == true and ...

key_active_at_attestation comes from SigningKey.active_at?/2, a half-open window [activated_at, retired_at). So retiring a key at a timestamp at or before an existing attestation's attested_at flips that attestation to "verified" => false while "signature" => %{"valid" => true}.

A valid signature over an unaltered claim, reported as unverified, by a single UPDATE against a row the operator controls. Nothing refuses it and nothing logs it.

Why it matters

docs/forge-exit-rehearsals.md rehearsal 4 asserts the good half of this today: "attestations signed by the retired key stay verifiable against the admitted public key, which is why the key is admitted rather than assumed." That is true for the forward order, which test/openagents/reputation_test.exs:231 already proves. The backward order is the one an operator would reach for while trying to disown something, and it is unguarded.

The asymmetry is worth stating: the forward order is enforced. require_active_key/2 refuses issuance under a retired key with {:error, :signing_key_retired}. Only the retirement edge is open.

Reproduction

Admit a key, issue an attestation, then retire the key with a retired_at earlier than the attestation's attested_at. Reputation.verify/2 returns "verified" => false, "signature" => %{"valid" => true, "key_status" => "retired", "key_active_at_attestation" => false}.

Pinned by test/openagents/reputation/key_rotation_test.exs, which records the current behaviour and names this issue, so a fix turns the test red rather than passing unnoticed.

Acceptance criteria

  • retire_key/2 refuses a retired_at at or before the newest attestation the key signed, or the retirement is allowed and the attestations it unverifies are reported rather than silently changed.
  • Whichever way it lands, the ordering rule is stated in docs/forge-exit-rehearsals.md rehearsal 4 rather than assumed.

Found by: #180's rehearsal 4, docs/forge-exit-rehearsals.md.

  1. AtlantisPleb opened this issue 3d ago
  2. A AtlantisPleb Author 2d ago

    Closing as completed. Merged to main and pushed as 758aec9. retire_key/2 now refuses a retirement at or before the newest attestation the key signed (strict floor, matching the half-open [activated_at, retired_at) verification window), bounds an unsigned key's retirement by its activation, and refuses future dates beyond 300s skew — each with an error naming the conflict. No escape hatch: disowning a signed claim keeps its sanctioned path through linked revoke/correct events, recorded in the moduledocs. REPUTATION-001 updated in INVARIANTS.md and rehearsal 4's issuer-key bullet states the enforced ordering. 42 targeted tests pass, including the flipped #191 pinning test at both refusal edges.

  3. AtlantisPleb closed this as completed in 28aece5 2d ago
Sign in with GitHub to comment on this issue.