Refuse a backdated issuer key retirement instead of unverifying history

28aece52cfe1 · AtlantisPleb · · parent f6723c80c858

Refuse a backdated issuer key retirement instead of unverifying history

Fixes #191. SigningKey.active_at?/2 reads a half-open window, so one
UPDATE with a retired_at at or before an attestation's attested_at
flipped that already-signed attestation to unverified while its
signature stayed valid. retire_changeset/3 now refuses a retired_at at
or before the newest attestation the key signed, naming the conflicting
attestation time; a key that signed nothing is bounded by its own
activated_at; and a retired_at more than 300 seconds in the future is
refused. Reputation.retire_key/2 queries the newest attested_at and
passes it as the floor. There is deliberately no override: disowning a
signed claim is a linked revocation, never a rewrite of the key's
validity window.

The pinning test in key_rotation_test.exs turns from recording the
rewrite to asserting the refusal, docs/forge-exit-rehearsals.md
rehearsal 4 states the ordering rule, and REPUTATION-001 carries the
enforced invariant with this test as evidence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoYpb8FEmdxVErsv7ABCYi
Co-Authored-By
Claude Fable 5 <noreply@anthropic.com>
Fixes
#191

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

pushed
by user · WAL seq 302 · 2026-08-24T20:17:08.140201Z

Changed files

  • modified INVARIANTS.md
  • modified docs/forge-exit-rehearsals.md
  • modified lib/openagents/reputation.ex
  • modified lib/openagents/reputation/signing_key.ex
  • modified test/openagents/forge/key_rotation_test.exs

Diff

5 files changed, +175 -23

INVARIANTS.md modified +10 -2

@@ -1385,7 +1385,14 @@ subject, the outcome, and the verifier, a valid attestation presented for

1385 1385
another issue, revision, verifier, or actor fails its binding. A reversed or
1386 1386
invalidated outcome produces a linked invalidating attestation, and the revoked
1387 1387
claim and signature stay readable. Retiring a key never invalidates the history
1388
it signed, and a private key never enters the database.
1388
it signed, and that is enforced rather than assumed: because the key's active
1389
window is half-open at `retired_at`, a retirement at or before the newest
1390
attestation the key signed is refused with the conflicting attestation time
1391
named, a key that signed nothing retires no earlier than its `activated_at`,
1392
and a `retired_at` beyond a small clock-skew allowance in the future is
1393
refused. There is no override — disowning a signed claim is a linked
1394
revocation, never a rewrite of the key's validity window. A private key never
1395
enters the database.
1389 1396
1390 1397
Disclosure follows repository authority: an attestation is `public` only where
1391 1398
the repository is public or its transparency level admits ledger disclosure

@@ -1411,6 +1418,7 @@ Evidence: `OpenAgents.Reputation`, `OpenAgents.Reputation.Claim`,

1411 1418
the append-only and uniqueness constraints on `reputation_attestations`, the
1412 1419
per-kind reference constraint on `reputation_subject_claims`,
1413 1420
`OpenAgentsWeb.ReputationController`, `test/openagents/reputation_test.exs`,
1421
`test/openagents/forge/key_rotation_test.exs`,
1414 1422
`test/openagents/reputation/subject_claim_test.exs`,
1415 1423
`test/openagents_web/controllers/reputation_subject_claim_controller_test.exs`,
1416 1424
and `test/openagents_web/controllers/reputation_controller_test.exs`.

@@ -4866,7 +4874,7 @@ contract; the invariant prose above defines the assertion, not the filename.

4866 4874
| COLLECTIVE-002 | `test/openagents/collective_generalizer_test.exs` |
4867 4875
| COLLECTIVE-003 | `test/openagents/collective_publication_test.exs` |
4868 4876
| COMPENSATION-001 | `test/openagents/compensation_test.exs` |
4869
| REPUTATION-001 | `test/openagents/reputation_test.exs`, `test/openagents_web/controllers/reputation_controller_test.exs` |
4877
| REPUTATION-001 | `test/openagents/reputation_test.exs`, `test/openagents/forge/key_rotation_test.exs`, `test/openagents_web/controllers/reputation_controller_test.exs` |
4870 4878
| SETTLEMENT-001 | `test/openagents/settlement_test.exs` |
4871 4879
| MODULE-001 | `test/openagents/modules/registry_test.exs`, `test/openagents/tool_step_persistence_test.exs` |
4872 4880
| MODULE-002 | `test/openagents/modules/discovery_test.exs`, `test/openagents/modules/lifecycle_test.exs` |
docs/forge-exit-rehearsals.md modified +10 -1

@@ -216,7 +216,16 @@ The forge holds several key-like secrets and they rotate differently:

216 216
- **The reputation issuer key.** `OpenAgents.Reputation.admit_key/1` admits a
217 217
  public key with a validity window. Rotation is issuing under a new key;
218 218
  attestations signed by the retired key stay verifiable against the admitted
219
  public key, which is why the key is admitted rather than assumed.
219
  public key, which is why the key is admitted rather than assumed. The
220
  ordering rule is enforced rather than assumed: `retire_key/2` refuses a
221
  `retired_at` at or before the newest attestation the key signed — the
222
  refusal names the conflicting attestation time — refuses one earlier than
223
  the key's `activated_at` when it signed nothing, and refuses one beyond a
224
  small clock-skew allowance in the future. There is deliberately no
225
  override: a backdated retirement is exactly the single-`UPDATE` move that
226
  silently unverifies published signatures (#191), and an issuer who must
227
  disown a claim publishes a linked revocation instead of rewriting the
228
  key's validity window.
220 229
- **The three hand-rolled vaults.** Each takes its key from the operator's own
221 230
  environment, so rotation is an operator action with no separation of duties.
222 231
  `docs/forge-operator-independence.md` records that plainly.
lib/openagents/reputation.ex modified +23 -2

@@ -141,10 +141,31 @@ defmodule OpenAgents.Reputation do

141 141
    |> Repo.insert()
142 142
  end
143 143
144
  @doc "Retires an issuer key. Attestations it already signed keep verifying."
144
  @doc """
145
  Retires an issuer key. Attestations it already signed keep verifying.
146
147
  That sentence is enforced, not assumed: `active_at?/2`'s window is half-open
148
  at `retired_at`, so a backdated retirement would flip every attestation at or
149
  after that instant to unverified while its signature stays valid. The
150
  changeset refuses a `retired_at` at or before the newest attestation the key
151
  signed, earlier than the key's `activated_at` when it signed nothing, or more
152
  than `SigningKey.max_future_skew_seconds/0` in the future. There is no
153
  override — an operator who wants to disown signed history gets the refusal
154
  and files a revocation instead (#191).
155
  """
145 156
  @spec retire_key(SigningKey.t(), DateTime.t()) :: {:ok, SigningKey.t()} | {:error, term()}
146 157
  def retire_key(%SigningKey{} = key, retired_at \\ DateTime.utc_now()) do
147
    key |> SigningKey.retire_changeset(retired_at) |> Repo.update()
158
    key
159
    |> SigningKey.retire_changeset(retired_at, latest_attested_at: latest_attested_at(key))
160
    |> Repo.update()
161
  end
162
163
  defp latest_attested_at(%SigningKey{key_id: key_id}) do
164
    Repo.one(
165
      from attestation in Attestation,
166
        where: attestation.issuer_key_id == ^key_id,
167
        select: max(attestation.attested_at)
168
    )
148 169
  end
149 170
150 171
  @doc "Every admitted issuer key, for independent verification."
lib/openagents/reputation/signing_key.ex modified +75 -1

@@ -13,6 +13,7 @@ defmodule OpenAgents.Reputation.SigningKey do

13 13
  @timestamps_opts [type: :utc_datetime_usec, updated_at: false]
14 14
15 15
  @algorithms ~w(ed25519)
16
  @max_future_skew_seconds 300
16 17
17 18
  schema "reputation_signing_keys" do
18 19
    field :key_id, :string

@@ -28,6 +29,9 @@ defmodule OpenAgents.Reputation.SigningKey do

28 29
29 30
  def algorithms, do: @algorithms
30 31
32
  @doc "How far into the future a `retired_at` may sit, to absorb clock skew."
33
  def max_future_skew_seconds, do: @max_future_skew_seconds
34
31 35
  def changeset(record, attributes) do
32 36
    record
33 37
    |> cast(attributes, ~w(key_id algorithm public_key issuer activated_at retired_at)a)

@@ -40,10 +44,80 @@ defmodule OpenAgents.Reputation.SigningKey do

40 44
    |> unique_constraint(:public_key)
41 45
  end
42 46
43
  def retire_changeset(record, retired_at) do
47
  @doc """
48
  Retires the key at `retired_at`, refusing a timestamp that would rewrite
49
  history.
50
51
  `active_at?/2` reads the half-open window `[activated_at, retired_at)`, so
52
  a `retired_at` at or before an attestation's `attested_at` flips that
53
  already-signed attestation to unverified without touching its row. The
54
  changeset therefore refuses a `retired_at`:
55
56
    * at or before the newest attestation the key signed
57
      (`:latest_attested_at`, queried by the caller), naming the conflicting
58
      attestation time in the error;
59
    * earlier than the key's own `activated_at` when it signed nothing —
60
      `activated_at` rather than `inserted_at`, because `activated_at` is the
61
      required field that opens the window `active_at?/2` reads, while
62
      `inserted_at` is only the row's insertion time;
63
    * more than #{@max_future_skew_seconds} seconds in the future, so a
64
      retirement records when the key actually stopped rather than a claim
65
      about a time that has not happened.
66
67
  There is deliberately no override: an operator who needs an earlier
68
  boundary is asking to unverify signatures that were already published as
69
  valid, and #191 exists because that must be a refusal, not an option.
70
  """
71
  def retire_changeset(record, retired_at, opts \\ []) do
44 72
    record
45 73
    |> cast(%{retired_at: retired_at}, ~w(retired_at)a)
46 74
    |> validate_required(~w(retired_at)a)
75
    |> validate_not_future()
76
    |> validate_after_signed_history(Keyword.get(opts, :latest_attested_at))
77
  end
78
79
  defp validate_not_future(changeset) do
80
    horizon = DateTime.add(DateTime.utc_now(), @max_future_skew_seconds, :second)
81
82
    validate_change(changeset, :retired_at, fn :retired_at, retired_at ->
83
      if DateTime.compare(retired_at, horizon) == :gt do
84
        [
85
          retired_at: "cannot sit more than #{@max_future_skew_seconds} seconds in the future"
86
        ]
87
      else
88
        []
89
      end
90
    end)
91
  end
92
93
  defp validate_after_signed_history(changeset, %DateTime{} = latest_attested_at) do
94
    validate_change(changeset, :retired_at, fn :retired_at, retired_at ->
95
      if DateTime.compare(retired_at, latest_attested_at) == :gt do
96
        []
97
      else
98
        [
99
          retired_at:
100
            "cannot be at or before the newest attestation this key signed " <>
101
              "(attested at #{DateTime.to_iso8601(latest_attested_at)})"
102
        ]
103
      end
104
    end)
105
  end
106
107
  defp validate_after_signed_history(changeset, nil) do
108
    activated_at = changeset.data.activated_at
109
110
    validate_change(changeset, :retired_at, fn :retired_at, retired_at ->
111
      if is_nil(activated_at) or DateTime.compare(retired_at, activated_at) != :lt do
112
        []
113
      else
114
        [
115
          retired_at:
116
            "cannot be earlier than the key's activation " <>
117
              "(activated at #{DateTime.to_iso8601(activated_at)})"
118
        ]
119
      end
120
    end)
47 121
  end
48 122
49 123
  @doc "Whether the key was admitted and not yet retired at `instant`."
test/openagents/forge/key_rotation_test.exs modified +57 -17

@@ -8,17 +8,17 @@ defmodule OpenAgents.Forge.KeyRotationTest do

8 8
  rotation performed in the wrong order is refused rather than silently
9 9
  invalidating history.
10 10
11
  The first holds everywhere. The second holds in one of the four families,
12
  and the two places it does not are pinned here with the issues that carry
13
  them, so a fix turns a test red instead of passing unnoticed. A rehearsal
14
  that recorded only the half that works would be the kind of claim `EXIT-006`
15
  exists to prevent.
11
  The first holds everywhere. The second now holds in two of the four
12
  families, and the places it does not are pinned here with the issues that
13
  carry them, so a fix turns a test red instead of passing unnoticed. A
14
  rehearsal that recorded only the half that works would be the kind of claim
15
  `EXIT-006` exists to prevent.
16 16
17 17
  | Family | Rotation loses nothing | Wrong order refused |
18 18
  | --- | --- | --- |
19 19
  | Forge operator token | yes — the principal is a literal, not a derivation | not applicable; there is no order |
20 20
  | Account `oa_pat_` tokens | yes — digest-only, no key under them | not applicable |
21
  | Reputation issuer key | forward, yes | **no** — #191 |
21
  | Reputation issuer key | yes | yes — #191 made the backdate a refusal |
22 22
  | GitHub token vault | yes — key id in the envelope, keyring for the old ones | yes |
23 23
  | Machine pairing vault | **no** — #192 | no |
24 24
  | Voice recording vault | **no** — no key id, no keyring | no |

@@ -41,7 +41,7 @@ defmodule OpenAgents.Forge.KeyRotationTest do

41 41
  alias OpenAgents.Forge.{Verification, WAL}
42 42
  alias OpenAgents.Machines.TokenVault, as: MachineVault
43 43
  alias OpenAgents.Reputation
44
  alias OpenAgents.Reputation.Claim
44
  alias OpenAgents.Reputation.{Claim, SigningKey}
45 45
  alias OpenAgents.Voice.RecordingVault
46 46
47 47
  describe "forge receipts depend on no key, so no rotation can invalidate one" do

@@ -114,22 +114,62 @@ defmodule OpenAgents.Forge.KeyRotationTest do

114 114
      assert {:error, :signing_key_retired} = issue(context)
115 115
    end
116 116
117
    test "retiring backward silently unverifies what the key already signed (#191)", context do
117
    test "retiring backward is refused, naming what it would have unverified (#191)", context do
118 118
      assert {:ok, attestation} = issue(context)
119 119
      assert Reputation.verify(attestation)["verified"]
120 120
121
      backdated = DateTime.add(attestation.attested_at, -1, :second)
122
      assert {:ok, _retired} = Reputation.retire_key(context.key, backdated)
121
      # `active_at?/2`'s window is half-open at `retired_at`, so at-or-before
122
      # the attestation's own instant is the whole rewriting range: both edges
123
      # must be refused, and the refusal names the attestation time so the
124
      # operator sees exactly which history the backdate collided with.
125
      for retired_at <- [
126
            DateTime.add(attestation.attested_at, -1, :second),
127
            attestation.attested_at
128
          ] do
129
        assert {:error, %Ecto.Changeset{} = changeset} =
130
                 Reputation.retire_key(context.key, retired_at)
131
132
        assert [message] = errors_on(changeset).retired_at
133
        assert message =~ "at or before the newest attestation"
134
        assert message =~ DateTime.to_iso8601(attestation.attested_at)
135
      end
123 136
137
      # A refusal leaves no residue: the key is still active, the attestation
138
      # still verified, and the next attestation still issues.
124 139
      report = Reputation.verify(attestation)
140
      assert report["verified"]
141
      assert report["signature"]["key_status"] == "active"
142
      assert {:ok, _next} = issue(context)
143
    end
125 144
126
      # The signature is still valid over an unaltered claim. Only the window
127
      # moved, and it moved because one UPDATE against a row the operator
128
      # controls said so. Nothing refused it and nothing recorded it.
129
      assert report["digest_match"]
130
      assert report["signature"]["valid"]
131
      refute report["signature"]["key_active_at_attestation"]
132
      refute report["verified"]
145
    test "a key that signed nothing is bounded by its own activation", context do
146
      before_activation = DateTime.add(context.key.activated_at, -1, :second)
147
148
      assert {:error, %Ecto.Changeset{} = changeset} =
149
               Reputation.retire_key(context.key, before_activation)
150
151
      assert [message] = errors_on(changeset).retired_at
152
      assert message =~ "earlier than the key's activation"
153
      assert message =~ DateTime.to_iso8601(context.key.activated_at)
154
155
      # Retiring at exactly `activated_at` leaves an empty window, which
156
      # invalidates nothing because nothing was signed inside it.
157
      assert {:ok, retired} = Reputation.retire_key(context.key, context.key.activated_at)
158
      assert retired.retired_at == context.key.activated_at
159
    end
160
161
    test "a future-dated retirement beyond clock skew is refused", context do
162
      future =
163
        DateTime.add(DateTime.utc_now(), SigningKey.max_future_skew_seconds() + 60, :second)
164
165
      assert {:error, %Ecto.Changeset{} = changeset} =
166
               Reputation.retire_key(context.key, future)
167
168
      assert [message] = errors_on(changeset).retired_at
169
      assert message =~ "in the future"
170
171
      within_skew = DateTime.add(DateTime.utc_now(), 30, :second)
172
      assert {:ok, _retired} = Reputation.retire_key(context.key, within_skew)
133 173
    end
134 174
135 175
    test "the forward edge is the one that is guarded", context do

This page updates live while a promote is in flight · changelog