Give the pairing vault its own key, and record what rotation costs each vault

7023ed4ea42a · AtlantisPleb · · parent 5c745a1d944a

Give the pairing vault its own key, and record what rotation costs each vault

OpenAgents.Machines.TokenVault sealed under the GitHub vault's active key
(:github_token_encryption_key), so the documented GitHub rotation would have
silently made every outstanding machine_pairings.token_ciphertext unopenable,
and no document recorded the coupling (#192).

The vault now seals only under its own :machine_token_encryption_key and
answers {:error, :machine_token_vault_not_configured} when it is absent —
a typed refusal, never a borrowed key. Opening tries the dedicated key first
and falls back to the GitHub keyring (active key plus
:github_token_decryption_keys), the only key material historical records were
sealed under. The fallback never rewraps, and that is a decision rather than
an omission: the only reader, claim_locked_pairing/1, nulls token_ciphertext
in the same transaction as a successful open, so no record survives a read to
be rewrapped, and every record dies within one ten-minute pairing lifetime,
which also makes an eager migration sweep empty work.

config/runtime.exs reads MACHINE_TOKEN_ENCRYPTION_KEY; a set-but-invalid
value fails startup, and an unset value bridges to the GitHub key — recorded
in place — so the deploy that introduces the variable boots unattended before
the operator provisions the secret. OpenAgents.RuntimeConfig validates the
key beside the keyring it historically borrowed from.

INVARIANTS.md gains VAULT-001: each vault seals under its own key, so
rotating one never unreads another. Rehearsal 4 of
docs/forge-exit-rehearsals.md now states which of the three vaults rotates
without loss (GitHub: lossless; pairing: at most ten minutes of unclaimed
pairings; voice recordings: permanent), docs/github-auth-plan.md states the
rotation no longer touches pairings, and the staging secret inventory names
the new secret.

Closes #192.

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>
Closes
#192

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 306 · 2026-08-24T20:39:05.323175Z

Changed files

  • modified INVARIANTS.md
  • modified config/config.exs
  • modified config/dev.exs
  • modified config/runtime.exs
  • modified config/test.exs
  • modified docs/forge-exit-rehearsals.md
  • modified docs/github-auth-plan.md
  • modified docs/security/secrets-and-log-handling.md
  • modified lib/openagents/machines/token_vault.ex
  • modified lib/openagents/runtime_config.ex
  • modified test/openagents/machines/token_vault_test.exs
  • modified test/openagents/runtime_config_test.exs

Diff

12 files changed, +319 -15

INVARIANTS.md modified +41

@@ -2965,6 +2965,46 @@ Evidence: `OpenAgents.GitHubOAuth.RuntimeConfig`,

2965 2965
`.dockerignore`, the `openagents-oauth-callback-requests` logging exclusion, and
2966 2966
`ops/ci/release-smoke.sh`.
2967 2967
2968
### VAULT-001 — Each vault seals under its own key, so rotating one never unreads another
2969
2970
Status: Current
2971
2972
The application holds three hand-rolled encryption vaults —
2973
`OpenAgents.Accounts.TokenVault` for GitHub access tokens,
2974
`OpenAgents.Machines.TokenVault` for computer tokens awaiting pairing claim,
2975
and `OpenAgents.Voice.RecordingVault` for call audio — and each seals under
2976
its own configured key. Rotating one vault's key never makes another vault's
2977
records unreadable or unverifiable, because no vault reads another vault's
2978
key to seal. A vault whose own key is absent fails with a typed configuration
2979
error at its boundary rather than silently borrowing key material that
2980
rotates on someone else's schedule; that silent borrow is exactly what #192
2981
found, where the pairing vault read the GitHub vault's active key and the
2982
documented GitHub rotation would have unread every outstanding pairing.
2983
2984
Decrypt-side compatibility is narrower than sealing and stays explicit: the
2985
pairing vault opens with its dedicated key first and falls back to the GitHub
2986
keyring — active key plus `:github_token_decryption_keys` — because that is
2987
the only key material its historical records were sealed under, and because
2988
`config/runtime.exs` deliberately bridges an unset
2989
`MACHINE_TOKEN_ENCRYPTION_KEY` to the GitHub key until the operator
2990
provisions the dedicated secret. The fallback never rewraps: the only reader
2991
nulls `token_ciphertext` in the same transaction as a successful open
2992
(IDENTITY-011), so no record survives a read, and every fallback-sealed
2993
record is claimed or expired within one ten-minute pairing lifetime.
2994
2995
Rotation posture per vault, as rehearsal 4 of `docs/forge-exit-rehearsals.md`
2996
requires: the GitHub vault rotates losslessly through its keyed envelope and
2997
keyring rewrap; the pairing vault's own rotation loses at most one ten-minute
2998
window of unclaimed pairings, which retry; the recording vault has no keyring,
2999
so rotating its key strands prior recordings — a bounded, recorded loss, not a
3000
silent one.
3001
3002
Evidence: `OpenAgents.Machines.TokenVault`, `OpenAgents.Accounts.TokenVault`,
3003
`OpenAgents.Voice.RecordingVault`, `OpenAgents.RuntimeConfig.validate/1`,
3004
`config/runtime.exs`, `test/openagents/machines/token_vault_test.exs`,
3005
`test/openagents/accounts/token_vault_test.exs`, and
3006
`test/openagents/runtime_config_test.exs`.
3007
2968 3008
### RELEASE-003 — Every published hostname can establish LiveView
2969 3009
2970 3010
Status: Current

@@ -4945,6 +4985,7 @@ contract; the invariant prose above defines the assertion, not the filename.

4945 4985
| OBSERVABILITY-001 | `test/openagents/observability_test.exs` |
4946 4986
| RELEASE-001 | `ops/ci/release-smoke.sh`, `test/openagents_web/controllers/health_controller_test.exs` |
4947 4987
| RELEASE-002 | `test/openagents/github_oauth/runtime_config_test.exs`, `ops/ci/reference-check.sh` |
4988
| VAULT-001 | `test/openagents/machines/token_vault_test.exs`, `test/openagents/runtime_config_test.exs` |
4948 4989
| RELEASE-003 | `test/openagents_web/allowed_origins_test.exs`, `ops/ci/release-smoke.sh` |
4949 4990
| RELEASE-004 | `ops/ci/gate.sh`, `test/openagents/forge/gate_receipt_test.exs`, `test/openagents/hosted_ci_absence_test.exs` |
4950 4991
| RELEASE-005 | `test/openagents/forge/relup_deployment_test.exs`, `test/openagents/forge/relup_node_test.exs`, `test/openagents/release/appup_test.exs`, `test/openagents/cluster/code_change_test.exs`, `test/openagents/forge/rolling_replacement_test.exs` |
config/config.exs modified +1

@@ -278,6 +278,7 @@ config :openagents,

278 278
  github_token_encryption_key: nil,
279 279
  github_token_encryption_key_id: nil,
280 280
  github_token_decryption_keys: %{},
281
  machine_token_encryption_key: nil,
281 282
  voice_recording_encryption_key: nil,
282 283
  inference_proxy_url: nil,
283 284
  inference_grant_max_total_tokens: 2_000_000,
config/dev.exs modified +7

@@ -42,6 +42,13 @@ config :openagents,

42 42
config :openagents, :github_token_encryption_key_id, "development-2026-08"
43 43
config :openagents, :github_token_decryption_keys, %{}
44 44
45
# The machine pairing vault's own key, distinct from the GitHub vault's so
46
# development exercises the key independence VAULT-001 requires.
47
config :openagents,
48
       :machine_token_encryption_key,
49
       System.get_env("MACHINE_TOKEN_ENCRYPTION_KEY") ||
50
         Base.encode64("openagents-dev-machine-vault-key")
51
45 52
# For development, we disable any cache and enable
46 53
# debugging and code reloading.
47 54
#
config/runtime.exs modified +34

@@ -629,6 +629,40 @@ if runtime_role == :web do

629 629
      github_token_encryption_key_id: token_encryption_key_id,
630 630
      github_token_decryption_keys: token_decryption_keys
631 631
  end
632
633
  # The machine pairing vault's own key (#192, VAULT-001). A set-but-invalid
634
  # value fails startup; an unset value bridges to the GitHub vault's active
635
  # key so the deploy that introduces this variable boots unattended, before
636
  # the operator provisions the secret. The bridge is this expression and
637
  # nothing else — `OpenAgents.Machines.TokenVault` refuses to read the GitHub
638
  # key itself — and it ends the moment `MACHINE_TOKEN_ENCRYPTION_KEY` is set.
639
  # While the bridge is active, a GitHub key rotation moves the pairing
640
  # vault's key too; the vault's decrypt-side GitHub-keyring fallback is what
641
  # keeps the at-most-ten-minute pairing population readable across that.
642
  machine_token_encryption_key = optional_text.("MACHINE_TOKEN_ENCRYPTION_KEY")
643
644
  valid_machine_token_key? =
645
    is_binary(machine_token_encryption_key) and
646
      match?(
647
        {:ok, key} when byte_size(key) == 32,
648
        Base.decode64(machine_token_encryption_key)
649
      )
650
651
  if config_env() == :prod and machine_token_encryption_key != nil and
652
       not valid_machine_token_key? do
653
    raise "environment variable MACHINE_TOKEN_ENCRYPTION_KEY must be a base64-encoded 32-byte key"
654
  end
655
656
  cond do
657
    valid_machine_token_key? ->
658
      config :openagents, machine_token_encryption_key: machine_token_encryption_key
659
660
    valid_token_key? ->
661
      config :openagents, machine_token_encryption_key: token_encryption_key
662
663
    true ->
664
      :ok
665
  end
632 666
end
633 667
634 668
if parse_optional_boolean.("PHX_SERVER") do
config/test.exs modified +6

@@ -43,6 +43,12 @@ config :openagents,

43 43
config :openagents, :github_token_encryption_key_id, "test-2026-08"
44 44
config :openagents, :github_token_decryption_keys, %{}
45 45
46
# Distinct from the GitHub vault key above so the suite catches any vault
47
# that quietly borrows another vault's key (VAULT-001).
48
config :openagents,
49
       :machine_token_encryption_key,
50
       Base.encode64("openagents-test-machine-vault-32")
51
46 52
# Test fakes for providers and voice sideband so the suite never reaches the network.
47 53
config :openagents, :provider, OpenAgents.Providers.Test
48 54
config :openagents, :openrouter_provider, OpenAgents.Providers.Test
docs/forge-exit-rehearsals.md modified +18 -1

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

228 228
  key's validity window.
229 229
- **The three hand-rolled vaults.** Each takes its key from the operator's own
230 230
  environment, so rotation is an operator action with no separation of duties.
231
  `docs/forge-operator-independence.md` records that plainly.
231
  `docs/forge-operator-independence.md` records that plainly. Each vault seals
232
  under its own key (`INVARIANTS.md`, VAULT-001), and their rotation outcomes
233
  differ:
234
  - `OpenAgents.Accounts.TokenVault` (GitHub tokens) rotates without loss:
235
    the envelope names its key, the retired key joins
236
    `GITHUB_TOKEN_DECRYPTION_KEYS_JSON`, and `rotate_github_tokens!/0` rewraps
237
    every row under the active key.
238
  - `OpenAgents.Machines.TokenVault` (pairing tokens,
239
    `MACHINE_TOKEN_ENCRYPTION_KEY`) rotates with bounded loss: at most one
240
    ten-minute window of unclaimed pairings becomes unreadable, and a person
241
    retries the pairing. There is no keyring because no record outlives the
242
    window. Rotating the GitHub key no longer touches this vault; #192
243
    records that it once did, silently.
244
  - `OpenAgents.Voice.RecordingVault` (call audio,
245
    `VOICE_RECORDING_ENCRYPTION_KEY`) rotates with permanent loss: one key,
246
    no key id, no keyring, so recordings sealed under the retired key never
247
    open again. Rotate it only when stranding prior recordings is the intent
248
    or an acceptable cost of suspected exposure.
232 249
233 250
**What a rehearsal must establish:** that a rotation of each of these leaves
234 251
every already-issued receipt verifiable, and that a rotation performed in the
docs/github-auth-plan.md modified +9

@@ -97,6 +97,15 @@ Rotate without losing access to existing ciphertext:

97 97
4. Verify `users.github_token_key_id` contains no prior ID, retain the rotation
98 98
   receipt/count, and remove the prior key in the following deploy.
99 99
100
This procedure rotates only the GitHub vault. The machine pairing vault seals
101
under its own `MACHINE_TOKEN_ENCRYPTION_KEY` and its records survive this
102
rotation unread and unmoved (`INVARIANTS.md`, VAULT-001; #192 records the
103
release where that was not true). Keep step 2's prior key in
104
`GITHUB_TOKEN_DECRYPTION_KEYS_JSON` until one pairing lifetime after the
105
rotation deploy: the pairing vault's decrypt fallback reads that keyring for
106
any pairing sealed while `config/runtime.exs` still bridged its key to the
107
GitHub key.
108
100 109
The rewrap is one database transaction and reports only a count. Any
101 110
unsealable row rolls the transaction back and emits no credential material.
102 111
docs/security/secrets-and-log-handling.md modified +1

@@ -35,6 +35,7 @@ use distinct names and values and remains locked.

35 35
| `GITHUB_CLIENT_SECRET` | `openagents-staging-github-client-secret` | web, fleet | OAuth app rotation |
36 36
| `GITHUB_TOKEN_ENCRYPTION_KEY` | `openagents-staging-github-vault-active` | web, fleet, migrator | Scheduled vault rotation or suspected exposure |
37 37
| `GITHUB_TOKEN_DECRYPTION_KEYS_JSON` | `openagents-staging-github-vault-previous` | web, fleet, migrator, only during rewrap | Delete after every row uses the active key ID |
38
| `MACHINE_TOKEN_ENCRYPTION_KEY` | `openagents-staging-machine-vault-active` | web, fleet | Scheduled vault rotation or suspected exposure; rotation strands at most one ten-minute window of unclaimed pairings (VAULT-001). Until this secret exists, `config/runtime.exs` bridges the machine pairing vault to `GITHUB_TOKEN_ENCRYPTION_KEY` |
38 39
| `OPENAI_API_KEY` | `openagents-staging-openai-api-key` | web, fleet | Provider rotation or suspected prompt/log exposure |
39 40
| `VOICE_RECORDING_ENCRYPTION_KEY` | `openagents-staging-voice-recording-key` | web and fleet when recording is admitted | Scheduled recording-key procedure or suspected exposure |
40 41
| `OPENAGENTS_FORGE_OPERATOR_TOKEN` | `openagents-staging-forge-operator-token` | web, fleet, builder | Scheduled rotation, builder replacement, or suspected URL/argv/log exposure |
lib/openagents/machines/token_vault.ex modified +87 -12

@@ -15,6 +15,36 @@ defmodule OpenAgents.Machines.TokenVault do

15 15
16 16
  The `openagents.machine_token.v2` AAD keeps its `machine` spelling for the
17 17
  opposite reason: it is bound into ciphertext this release did not write.
18
19
  ## Key independence
20
21
  Sealing uses `:machine_token_encryption_key` and nothing else. Until #192
22
  this vault read `:github_token_encryption_key` — the GitHub vault's active
23
  key — so the documented GitHub rotation in `docs/github-auth-plan.md` would
24
  have silently made every outstanding `machine_pairings.token_ciphertext`
25
  unopenable, and no document recorded the coupling. A missing dedicated key
26
  is a typed `{:error, :machine_token_vault_not_configured}` at this boundary;
27
  the vault never borrows another vault's key to seal. See `INVARIANTS.md`,
28
  VAULT-001.
29
30
  Opening tries the dedicated key first and then each key in the GitHub
31
  keyring — the active `:github_token_encryption_key` plus every entry in
32
  `:github_token_decryption_keys` — because that keyring is the only key
33
  material any historical record was sealed under, and because the GitHub
34
  rotation procedure moves a retired key into that keyring rather than
35
  deleting it. The fallback covers two bounded populations: pairings sealed by
36
  the previous release during a deploy, and pairings sealed while
37
  `config/runtime.exs` still bridges `:machine_token_encryption_key` to the
38
  GitHub key pending `MACHINE_TOKEN_ENCRYPTION_KEY` provisioning.
39
40
  The fallback deliberately never rewraps. The only reader is
41
  `OpenAgents.Machines.claim_locked_pairing/1`, which nulls
42
  `token_ciphertext` in the same transaction as a successful open — a record
43
  that opens does not survive to be read again, so lazy rewrap-on-read is a
44
  branch no execution reaches. An eager migration sweep is equally empty
45
  work: every record sealed under the historical key is claimed or expired
46
  within one `@pairing_lifetime_seconds` window, which is shorter than any
47
  operator response to the sweep's outcome.
18 48
  """
19 49
20 50
  @version 2

@@ -25,7 +55,7 @@ defmodule OpenAgents.Machines.TokenVault do

25 55
26 56
  @spec seal(String.t()) :: {:ok, binary()} | {:error, atom()}
27 57
  def seal(token) when is_binary(token) and byte_size(token) in 1..@maximum_token_bytes do
28
    with {:ok, key} <- key() do
58
    with {:ok, key} <- dedicated_key() do
29 59
      nonce = :crypto.strong_rand_bytes(@nonce_bytes)
30 60
31 61
      {ciphertext, tag} =

@@ -43,23 +73,68 @@ defmodule OpenAgents.Machines.TokenVault do

43 73
          ciphertext::binary>>
44 74
      )
45 75
      when version == @version do
46
    with {:ok, key} <- key() do
47
      case :crypto.crypto_one_time_aead(:aes_256_gcm, key, nonce, ciphertext, @aad, tag, false) do
48
        token when is_binary(token) -> {:ok, token}
49
        :error -> {:error, :token_unsealable}
76
    with {:ok, key} <- dedicated_key() do
77
      case decrypt(key, nonce, ciphertext, tag) do
78
        {:ok, token} -> {:ok, token}
79
        {:error, :token_unsealable} -> open_with_fallback(key, nonce, ciphertext, tag)
50 80
      end
51 81
    end
52 82
  end
53 83
54 84
  def open(_sealed), do: {:error, :token_unsealable}
55 85
56
  defp key do
57
    with encoded when is_binary(encoded) <-
58
           Application.get_env(:openagents, :github_token_encryption_key),
59
         {:ok, key} when byte_size(key) == 32 <- Base.decode64(encoded) do
60
      {:ok, key}
61
    else
62
      _missing -> {:error, :token_vault_not_configured}
86
  defp open_with_fallback(dedicated, nonce, ciphertext, tag) do
87
    dedicated
88
    |> historical_keys()
89
    |> Enum.find_value({:error, :token_unsealable}, fn key ->
90
      case decrypt(key, nonce, ciphertext, tag) do
91
        {:ok, token} -> {:ok, token}
92
        {:error, :token_unsealable} -> nil
93
      end
94
    end)
95
  end
96
97
  defp decrypt(key, nonce, ciphertext, tag) do
98
    case :crypto.crypto_one_time_aead(:aes_256_gcm, key, nonce, ciphertext, @aad, tag, false) do
99
      token when is_binary(token) -> {:ok, token}
100
      :error -> {:error, :token_unsealable}
101
    end
102
  end
103
104
  defp dedicated_key do
105
    case decode_key(Application.get_env(:openagents, :machine_token_encryption_key)) do
106
      {:ok, key} -> {:ok, key}
107
      :error -> {:error, :machine_token_vault_not_configured}
63 108
    end
64 109
  end
110
111
  # The GitHub keyring, decoded and deduplicated, minus the dedicated key that
112
  # already failed. Decrypt-side only: `seal/1` never sees these.
113
  defp historical_keys(dedicated) do
114
    active = Application.get_env(:openagents, :github_token_encryption_key)
115
116
    previous =
117
      :openagents
118
      |> Application.get_env(:github_token_decryption_keys, %{})
119
      |> Map.values()
120
121
    [active | previous]
122
    |> Enum.flat_map(fn encoded ->
123
      case decode_key(encoded) do
124
        {:ok, key} -> [key]
125
        :error -> []
126
      end
127
    end)
128
    |> Enum.uniq()
129
    |> List.delete(dedicated)
130
  end
131
132
  defp decode_key(encoded) when is_binary(encoded) do
133
    case Base.decode64(encoded) do
134
      {:ok, key} when byte_size(key) == 32 -> {:ok, key}
135
      _invalid -> :error
136
    end
137
  end
138
139
  defp decode_key(_missing), do: :error
65 140
end
lib/openagents/runtime_config.ex modified +11

@@ -277,6 +277,7 @@ defmodule OpenAgents.RuntimeConfig do

277 277
    token_key = Map.get(settings, :github_token_encryption_key)
278 278
    token_key_id = Map.get(settings, :github_token_encryption_key_id)
279 279
    decryption_keys = Map.get(settings, :github_token_decryption_keys)
280
    machine_token_key = Map.get(settings, :machine_token_encryption_key)
280 281
281 282
    with :ok <- ensure(present?(client_id), :github_oauth_client_id, "is required"),
282 283
         :ok <- ensure(present?(client_secret), :github_oauth_client_secret, "is required"),

@@ -304,6 +305,16 @@ defmodule OpenAgents.RuntimeConfig do

304 305
             decryption_keyring?(decryption_keys, token_key_id, environment),
305 306
             :github_token_decryption_keys,
306 307
             "must contain only bounded identifiers and base64-encoded 32-byte keys"
308
           ),
309
         # The machine pairing vault's own key, validated beside the keyring
310
         # it historically borrowed from. `config/runtime.exs` bridges an
311
         # unset `MACHINE_TOKEN_ENCRYPTION_KEY` to the GitHub key, so an
312
         # absent value here means both are missing (VAULT-001, #192).
313
         :ok <-
314
           ensure(
315
             encryption_key?(machine_token_key),
316
             :machine_token_encryption_key,
317
             "must be a base64-encoded 32-byte key"
307 318
           ) do
308 319
      :ok
309 320
    end
test/openagents/machines/token_vault_test.exs modified +97 -2

@@ -1,13 +1,91 @@

1 1
defmodule OpenAgents.Machines.TokenVaultTest do
2
  use ExUnit.Case, async: true
2
  # These tests rebind the vault key configuration, which is global
3
  # application environment, so they cannot run beside async tests that read
4
  # the same keys.
5
  use ExUnit.Case, async: false
3 6
4 7
  alias OpenAgents.Machines.TokenVault
5 8
9
  @machine_key_setting :machine_token_encryption_key
10
  @github_key_setting :github_token_encryption_key
11
  @github_keyring_setting :github_token_decryption_keys
12
13
  setup do
14
    original = %{
15
      @machine_key_setting => Application.get_env(:openagents, @machine_key_setting),
16
      @github_key_setting => Application.get_env(:openagents, @github_key_setting),
17
      @github_keyring_setting => Application.get_env(:openagents, @github_keyring_setting)
18
    }
19
20
    on_exit(fn ->
21
      Enum.each(original, fn {setting, value} ->
22
        Application.put_env(:openagents, setting, value)
23
      end)
24
    end)
25
26
    :ok
27
  end
28
6 29
  test "seals new tokens with the OpenAgents version" do
7 30
    assert {:ok, <<2, _rest::binary>> = sealed} = TokenVault.seal("smct_current")
8 31
    assert {:ok, "smct_current"} = TokenVault.open(sealed)
9 32
  end
10 33
34
  # VAULT-001: the pairing vault reads its own key. This test turns red if
35
  # either side of the vault quietly reaches for the GitHub key again.
36
  test "seals and opens with the GitHub vault absent entirely" do
37
    Application.put_env(:openagents, @github_key_setting, nil)
38
    Application.put_env(:openagents, @github_keyring_setting, %{})
39
40
    assert {:ok, sealed} = TokenVault.seal("smct_independent")
41
    assert {:ok, "smct_independent"} = TokenVault.open(sealed)
42
  end
43
44
  test "a missing dedicated key is a typed configuration error, never a borrowed key" do
45
    Application.put_env(:openagents, @machine_key_setting, nil)
46
47
    assert {:error, :machine_token_vault_not_configured} = TokenVault.seal("smct_orphan")
48
49
    assert {:error, :machine_token_vault_not_configured} =
50
             TokenVault.open(<<2, :crypto.strong_rand_bytes(60)::binary>>)
51
  end
52
53
  # The historical population: every record sealed before #192 used the GitHub
54
  # vault's active key. The decrypt-side fallback keeps it readable for the one
55
  # pairing lifetime it can exist; nothing rewraps it, because the only reader
56
  # nulls the ciphertext in the same transaction as a successful open.
57
  test "a record sealed under the GitHub active key still opens" do
58
    github_key = Application.fetch_env!(:openagents, @github_key_setting)
59
60
    assert {:ok, "smct_historical"} =
61
             "smct_historical" |> sealed_under(github_key) |> TokenVault.open()
62
  end
63
64
  # Rehearsal 4 of docs/forge-exit-rehearsals.md: rotating the GitHub key —
65
  # new active key, old key moved into the decryption keyring — leaves every
66
  # outstanding pairing record readable.
67
  test "a GitHub key rotation leaves pairing records readable" do
68
    retired = Application.fetch_env!(:openagents, @github_key_setting)
69
    sealed = sealed_under("smct_survives_rotation", retired)
70
71
    Application.put_env(
72
      :openagents,
73
      @github_key_setting,
74
      Base.encode64(:crypto.strong_rand_bytes(32))
75
    )
76
77
    Application.put_env(:openagents, @github_keyring_setting, %{"test-prior" => retired})
78
79
    assert {:ok, "smct_survives_rotation"} = TokenVault.open(sealed)
80
  end
81
82
  test "a record sealed under no known key fails closed" do
83
    unknown = Base.encode64(:crypto.strong_rand_bytes(32))
84
85
    assert {:error, :token_unsealable} =
86
             "smct_stranger" |> sealed_under(unknown) |> TokenVault.open()
87
  end
88
11 89
  # The retired `sarah.machine_token.v1` AAD is gone, not kept as a legacy
12 90
  # entry. Nothing seals a version-1 blob, and a sealed token cannot outlive
13 91
  # the ten-minute pairing window that `MachinesTest` pins, so the branch had

@@ -15,7 +93,7 @@ defmodule OpenAgents.Machines.TokenVaultTest do

15 93
  test "refuses retired Sarah version-1 tokens" do
16 94
    token = "smct_legacy"
17 95
    nonce = :crypto.strong_rand_bytes(12)
18
    {:ok, key} = Base.decode64(Application.fetch_env!(:openagents, :github_token_encryption_key))
96
    {:ok, key} = Base.decode64(Application.fetch_env!(:openagents, @machine_key_setting))
19 97
20 98
    {ciphertext, tag} =
21 99
      :crypto.crypto_one_time_aead(

@@ -34,4 +112,21 @@ defmodule OpenAgents.Machines.TokenVaultTest do

34 112
  test "refuses unknown versions" do
35 113
    assert {:error, :token_unsealable} = TokenVault.open(<<3, 0::256>>)
36 114
  end
115
116
  defp sealed_under(token, encoded_key) do
117
    {:ok, key} = Base.decode64(encoded_key)
118
    nonce = :crypto.strong_rand_bytes(12)
119
120
    {ciphertext, tag} =
121
      :crypto.crypto_one_time_aead(
122
        :aes_256_gcm,
123
        key,
124
        nonce,
125
        token,
126
        "openagents.machine_token.v2",
127
        true
128
      )
129
130
    <<2, nonce::binary, tag::binary, ciphertext::binary>>
131
  end
37 132
end
test/openagents/runtime_config_test.exs modified +7

@@ -248,6 +248,13 @@ defmodule OpenAgents.RuntimeConfigTest do

248 248
             |> RuntimeConfig.validate()
249 249
  end
250 250
251
  test "the machine pairing vault key is required and named on its own" do
252
    settings = staging_settings() |> Map.put(:machine_token_encryption_key, nil)
253
254
    assert {:error, %{setting: :machine_token_encryption_key}} =
255
             RuntimeConfig.validate(settings)
256
  end
257
251 258
  test "forge mirror remotes refuse credential-bearing URLs" do
252 259
    for url <- [
253 260
          "https://operator:secret@mirror.example/openagents.com.git",

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