Skip to content

feat(secrets): the secrets key in AWS KMS; a store that cannot answer now fails the call - #271

Merged
rami-hatoum merged 4 commits into
mainfrom
feat/the-secrets-key-in-a-kms
Oct 20, 2026
Merged

rami-hatoum merged 4 commits into
mainfrom
feat/the-secrets-key-in-a-kms

Conversation

@rami-hatoum

Copy link
Copy Markdown
Contributor

A secret store whose custodian may refuse or stall reaches paths that still died, swallowed the failure or revoked tokens on a failure that is only now. A dynamic client registration the store cannot read now is a failure of the call and never "no registration": a start answers unavailable in the store's words, and a renewal fails the call and breaks nothing. A renewal whose replacement the store cannot keep now holds the renewed tokens in memory under its claim, revokes nothing, fails the call, and keeps them at the next claim without asking the vendor again; a value the store refuses revokes, as before. A start whose verifier cannot be kept, a finish whose verifier cannot be read, a finish whose tokens cannot be kept after the code is redeemed, a credential that cannot be kept, and a removal whose tokens cannot be read each answer unavailable in the store's words, and the finish says the person starts again, since the code is spent. The store answers each call within ten seconds, shorter than the fifteen a renewal's claim keeps for the write, and the log's cause is keyed by the failure's tag and the key's id.

The secrets key can now be held in AWS KMS. SECRETS_KEY_KMS names a symmetric key or an alias by its ARN; the custodian wraps each value's data key with Encrypt under the context on.auto/runtime: secret data key and unwraps it with Decrypt, over the same proxy, credential chain and waits as the object store's client, and the server loads it through the @on.auto/secrets/aws subpath only where the setting names it, so a runtime without it loads no part of the KMS client. At start the custodian is probed with one data key wrapped, unwrapped and zeroed, and a key that is disabled, pending deletion, unknown or denied stops the start in words that name the setting, the key's id and the service's own words, never a credential or the account. The start line says which key the ledger's secrets are sealed under, and rotation from a key in the environment or a file to a key in KMS, and back, is one restart with the previous setting. A key disabled while the runtime runs fails each call as connection_unavailable and breaks nothing, and the calls succeed again once it is enabled. The package's fake answers the KMS protocol for the unit tests, and CI runs the same suite and a runtime started twice against moto's server, checking that no key, data key or credential reaches the output. The self-host pages say how to hold the key in a KMS, what the identity needs, what disabling the key does, and that KMS's own rotation of its key material needs nothing.

…ll in its words

The paths a refusing or silent custodian reached still died, read a failure as nothing or revoked
what the runtime had been given; each now fails the call in the store's words and keeps what it can.

- ClientRegistrations.latestAt, named and keep fail with { _tag, detail }, the store's tag and
  words: makeClientRegistrations no longer reads a row it cannot read now as no registration, nor
  dies on a keep. The consent client answers both as registration_unavailable: a start and a
  finish as unavailable, "The client the runtime registered for the connection drive cannot be
  kept or read now: <the store's words>", a renewal as a failed call that breaks nothing, and a
  removal by logging that the tokens were not revoked.
- A registration made at the authorization server that the store cannot keep now is logged at WARN
  by its issuer and client id, never its secret, held in memory by the consent client, and kept at
  the next start at that issuer before anything is read or registered, so no second client is
  registered there while the process runs. One the store refuses, secret_refused, is never held,
  since it will never be kept: it is logged as staying registered there unused, and the start fails
  as registration_refused, conflict in the refusal's words.
- SecretKeeping's failures carry their tag. A renewal whose replacement the store cannot make now
  revokes nothing, holds the renewed tokens in memory and fails the call in the store's words; while
  its claim lasts it serves the access token it holds, and the next renewal claims the row again and
  keeps those tokens without asking the server, or takes the value another process kept. A value
  the store refuses, secret_refused, still revokes. The renewals at the vendor and through Auto's
  consent service keep through the same replace.
- start answers unavailable where its verifier cannot be kept; finish where the verifier cannot be
  read, redeeming nothing, and where the tokens cannot be kept once the code is redeemed, revoking
  them and saying the person starts again, since the code is spent; give_credential where the value
  cannot be kept, leaving the link open. A removal logs tokens it cannot read now and goes on.
- The log of a credential that cannot be read now keys its cause by the failure's tag and kid, not
  by its words.
- valuesInTheClear of @on.auto/secrets/testing refuses a value past its bound as a SecretRefused.
- Tests over a recording custodian told to refuse: registrations read and kept with the store's
  tag, a start, a finish before and after the code is redeemed, give_credential, two removals,
  renewals at the vendor and through the service, a row another process replaced before the next
  claim, a row that cannot be read now, and the log of each cause; and a registration the store
  refuses, registered again at the next start and answered as conflict.
- The connections and tool-servers READMEs, the HTTP reference's responses of a connection, and the
  descriptions of give_credential and finish, with docs/assets/openapi.json written again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Comment thread packages/secrets/src/testing/fake-kms.ts Fixed
rami-hatoum and others added 3 commits October 20, 2026 20:37
…ts probe and a 10 second bound

A data key can now be wrapped by a key that never leaves AWS KMS: each value keeps its own data key,
KMS wraps it at each keep and unwraps it at each read, and each use is an entry in the account's
trail under a context that names the purpose.

- @on.auto/secrets/aws, a subpath of its own over @aws-sdk/client-kms pinned to 3.1149.0, the
  version of the other AWS clients, so that only a setting that names a key in KMS loads the KMS
  client. kmsCustodian({ keyId, region, proxy, credentials, endpoint?, waits? }) makes the
  custodian as an effect: for the ARN of an alias it asks DescribeKey once, within the store's
  bound, and pins the key the alias names, so an alias pointed at another key makes the custodian
  of another key; for the ARN of a key it asks nothing. A DescribeKey refused or not answered
  fails the making in the service's words, with the alias as its kid.
- The custodian wraps with Encrypt under the pinned key's ARN, the data key as Plaintext and the
  context { 'on.auto/runtime': 'secret data key' }, answering the ciphertext blob as base64, and
  unwraps with Decrypt under the same key and context. Its kid is the key's id, the last path
  segment of the key's ARN.
- Its client takes the settings of awsClientSettingsOf(proxy) as the S3 client does, and waits 5
  seconds to connect and the store's 10 for an answer. A refusal that is the caller's fault,
  DisabledException, KMSInvalidStateException, AccessDeniedException, NotFoundException,
  InvalidCiphertextException or IncorrectKeyException, is custodian_refused in the service's words
  with the kid. A request not answered, a throttled one, which is anything isThrottlingError of
  @smithy/core/retry judges so, ThrottlingException and LimitExceededException among them, a
  fault of the service or a credential not found is keeper_unavailable with the kid, "The
  custodian of the key <kid> did not answer a request to wrap a data key: <words>", so the log of
  each cause tells custodians apart. The account of every ARN is taken out of those words.
- The store's bound of a hold and a reveal is 10 seconds, the one rule in answer-bound.ts, shorter
  than the fifteen seconds a renewal's claim keeps for its write.
- custodianProbed(custodian) wraps and unwraps one data key within that bound and zeroes both
  copies, for the server to probe a custodian at start.
- serveFakeKms({ keys, aliases? }) of @on.auto/secrets/testing answers Encrypt, Decrypt and
  DescribeKey of the KMS JSON protocol under X-Amz-Target, with a blob sealed under a key of its
  own, which holds no plaintext in the clear and opens only under its key and context; it is told
  a key is disabled, pending deletion, denied or stalling, made to answer an error such as a
  throttling one, points an alias at another key, and records each call with its key id.
- storeBehaviour takes the custodians it runs over, local ones by default, and recordingCustodian
  wraps any custodian; the suite proves no row holds a data key in the clear.
- Tests: the custodian's kid, its Encrypt and Decrypt with their key and context; an alias
  resolved once and its key pinned, a custodian of another key once the alias names another, an
  alias unknown, denied or silent; each refusal in the words of KMS without the account or a
  credential; ThrottlingException, LimitExceededException and an HTTP 429 as keeper_unavailable
  with the kid; a KMS that stalls or cannot be reached given up on within 100 ms waits;
  storeBehaviour over keys in the fake; the probe answering, refused and silent at 10 seconds of
  TestClock; the fake's blobs, contexts, descriptions and refusals; and storeBehaviour, an alias
  pointed at another key, an unknown key and the context against a real server of the KMS API
  when SECRETS_TEST_KMS_URL names one, skipped otherwise, which passes against moto's server mode.
- The README sets out the custodian of a key in KMS and of an alias, the bound and its words, the
  fake and the server suite, and that only the aws subpath loads any part of the AWS SDK.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…bed at start and rotated to

A runtime can name a key in AWS KMS in place of a key in hand: SECRETS_KEY_KMS and
SECRETS_KEY_KMS_PREVIOUS join the settings of the secrets key, the server makes the KMS custodian
where one names it, probes it before it reads anything, and rotates to it and from it as it rotates
between keys in hand.

- The value is the ARN of a symmetric key or of an alias, arn:aws:kms:<region>:<account>:key/<id>,
  a multi-Region mrk- id among them, or arn:aws:kms:<region>:<account>:alias/<name>, refused in
  words otherwise; the region is the ARN's. One of SECRETS_KEY, SECRETS_KEY_FILE and SECRETS_KEY_KMS
  at most, the same for the previous key, which stands only beside a current one; a ledger in
  PostgreSQL takes any of the three. secretsVariables lists all six, so connections.variables
  refuses them.
- database/secret-store.ts makes a localCustodian of a key in hand, and database/kms-custodians.ts
  loads kmsCustodian through import('@on.auto/secrets/aws') where a setting names a key in KMS,
  with the region, the proxy settings and awsCredentialChain(proxy), so a runtime without the
  setting loads no part of @aws-sdk/client-kms. ledgerStoresOpenedBy takes the loader, which the
  tests replace. An alias is resolved to its key there, and a resolution that fails stops the
  start, "The secrets settings are invalid. SECRETS_KEY_KMS: The KMS alias alias/<name> it names
  could not be resolved to its key at start: <the custodian's words>".
- Before the rows open, a current key in KMS is probed with custodianProbed: a data key wrapped,
  unwrapped and zeroed. A probe that fails stops the start, "The secrets settings are invalid.
  SECRETS_KEY_KMS: The KMS key <id> it names could not wrap and unwrap a data key at start:
  <the custodian's words>", never a credential or the account.
- The start's line gains its third form, "... sealed under the key <id> that the KMS key <arn>
  holds, and <count> sealed under the previous key were sealed again under it", annotated
  secrets_key_from: kms, and the words of an own key that cannot be read name
  SECRETS_KEY_KMS_PREVIOUS. secretsKeysOf gives way to keyInHandOf, of one key in hand.
- Tests: the six variables, the ARNs of a key, a multi-Region key and an alias, eight values that
  are no such ARN, the kinds of key refused side by side; no module of @aws-sdk/client-kms loaded
  by a server whose key is in hand, from the environment or from the file, and the client loaded
  once a setting names a key in KMS; the loader called for a current or a previous key in KMS with
  its region, the credential chain and the proxy settings; the probe and its context, and the start
  stopped for a key KMS does not know, disabled, pending deletion, denied or silent, against the
  fake; an alias resolved before the probe, the start stopped for an alias KMS does not know, and
  the rows of an alias pointed at another key wrapped again under it with the key before as the
  previous one; the rotation from a key in hand to a key in KMS and back, two each way, with
  nothing of a key, a data key or a credential in the log; a connection of a server whose key is
  disabled meanwhile failing its call as connection_unavailable in the words of KMS and called
  again once the key is enabled, through the SDK's own endpoint and credential variables; a
  spawned server logging the KMS line at two starts and stopping for a disabled key; and the
  page's settings, policy and quoted refusal.
- docs/self-host/production.md gains "Hold the secrets key in a KMS", with kms:DescribeKey for an
  alias, how to move to another key, and what a first read and a renewal answer under a disabled
  key; configuration.md has its rows, security.md's "Secrets at rest" its sentence and the wrapping
  KMS makes, postgresql.md, docker.md, connections.md and the npm README the third setting where
  they named two, and auto-runtime.example.yaml and .env.example SECRETS_KEY_KMS beside the others.
  The server README sets out the secrets key, and the outbound README the custodian among its AWS
  clients.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… under a key it makes there

The custodian of a key in KMS is tried against a server of the KMS API on every pull request, and
so is a runtime whose secrets that key holds.

- The job kms starts moto's server mode, motoserver/moto 5.2.4 pinned by its digest, on the port
  19760, and makes a key there through the KMS API, CreateKey, before anything else runs.
- It runs the tests of @on.auto/secrets with SECRETS_TEST_KMS_URL, and checks that the suite
  against a server of the KMS API ran, that vitest's Test Files and Tests summary lines name
  nothing skipped, and that the credential is in no line of their output.
- It starts the runtime twice under SECRETS_KEY_KMS, the ARN of that key, on the port 19761, and
  checks, as the PostgreSQL smoke checks its own, that each start logs "Secrets are kept in the
  ledger's database, sealed under the key <id> that the KMS key <arn> holds, and 0 sealed under the
  previous key", and that neither the secret access key nor the data key of the row the runtime
  keeps for itself, unwrapped by moto under the purpose's context and masked in both its forms
  before it is used, reaches its log. A step that fails ends the runtime it started and shows the
  log of each start.
- ci-ok needs the job, and turbo passes SECRETS_TEST_KMS_URL to the tests, as it passes the
  variables of PostgreSQL and the object store.
- scripts/test-groups.test.ts judges the groups that start PostgreSQL and the object store by the
  variables the test job sets, and checks that the job against moto runs the tests of exactly the
  packages that read the variables it sets, and that one of the two jobs sets each variable turbo
  passes to the tests.
- The secrets README says what CI runs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@rami-hatoum
rami-hatoum force-pushed the feat/the-secrets-key-in-a-kms branch from 6f27cc9 to 5b132fe Compare October 20, 2026 19:37
@rami-hatoum
rami-hatoum merged commit 374c1b2 into main Oct 20, 2026
20 checks passed
@rami-hatoum
rami-hatoum deleted the feat/the-secrets-key-in-a-kms branch October 20, 2026 21:17
rami-hatoum pushed a commit that referenced this pull request Oct 21, 2026
🤖 I have created a release *beep* *boop*
---


##
[0.16.0](v0.15.0...v0.16.0)
(2026-10-21)


### Features

* **api:** an agent asked to connect a service knows the runtime does it
([#277](#277))
([d4e9d1f](d4e9d1f))
* **secrets:** secrets kept in AWS Parameter Store, moved there and back
at start ([#275](#275))
([eae2d70](eae2d70))
* **secrets:** the secrets key in AWS KMS; a store that cannot answer
now fails the call
([#271](#271))
([374c1b2](374c1b2))
* **server:** the agent's relay reaches its runtime again within
seconds, on the protocol's means
([#278](#278))
([92da8a1](92da8a1))


### Bug Fixes

* **server:** a ledger of another version says where it is, what to do
and where to ask
([#273](#273))
([168a756](168a756))
* **tool-servers:** a path argument that holds a slash is sent encoded
as one segment, not refused
([#276](#276))
([9942eda](9942eda))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: beonautobot[bot] <298957281+beonautobot[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants