Skip to content

feat(secrets): each secret under its own key, wrapped by a custodian, in rows the ledger keeps - #267

Merged
rami-hatoum merged 3 commits into
mainfrom
feat/secrets-under-a-custodian-and-a-keeper
Oct 20, 2026
Merged

rami-hatoum merged 3 commits into
mainfrom
feat/secrets-under-a-custodian-and-a-keeper

Conversation

@rami-hatoum

Copy link
Copy Markdown
Contributor

The runtime holds secrets for others: the keys people paste, the client secrets whoever runs it registers, the registrations it makes for itself and the tokens a person's consent gives it. Each was a row sealed as one JWE whose content key the secrets key wrapped, in a module of the ledger that knew the key. Now each value is sealed under a data key made for it alone, as a dir JWE with A256GCM, and the row keeps that data key wrapped by the secrets key, A256KW, with the id of the key that wrapped it, so a new secrets key rewraps the data keys at start and never touches a value; the row also says which keeper holds the value, which is the ledger's own database for every row today. The package @on.auto/secrets holds the store, its three ports, the rows the ledger implements over SQLite and PostgreSQL, the custodian that holds the wrapping key and the keeper that holds the value, with the local custodian and the sealed keeper as their first kinds; the ledger keeps the table, kept_secrets, and its SQL, and seals nothing and knows no key. The behaviour of the rows and of the store is written once, in the package's testing entry, and run there over rows kept in memory and in the ledger over both databases; those in-memory rows are also the one lease the consent tests and the connections harness hold their tokens in.

The secrets key comes from SECRETS_KEY as before, or from a file SECRETS_KEY_FILE names, which a container platform mounts as a secret and the runtime reads and never makes; secrets.key beside a SQLite ledger stays the default of it, made at the first start when neither is set. The key being replaced is SECRETS_KEY_PREVIOUS or SECRETS_KEY_FILE_PREVIOUS, at most one, only beside a current key. Both of a pair set, a previous key alone, a named file that is missing, that others may read or that holds anything but a key, and a ledger in PostgreSQL with no key stop the start, naming the setting and the file. The start logs where the key is held and how many rows the previous key sealed were sealed again, never a key or a value. The self-host pages and the configuration reference say the row, the settings and the rotation by either pair.

rami-hatoum and others added 3 commits October 20, 2026 12:07
…each value under its own key

@on.auto/secrets holds the secret store the connections take and the three seams behind it,
so the rows know no key and the connections know no database.

- SecretRows is the port of the table the ledger will keep: keep, read, replace under the
  version read, claim, endingsOf and forget, plus heldBy and reheld for the start. A row
  carries a holding, { keptBy, held, wrappedKey?, kid? }, in place of the sealed string.
- A Custodian, { kid, wrap, unwrap }, holds the key that wraps each value's data key.
  localCustodian takes a key of 32 bytes, refused at any other length in the words the
  ledger used, names it by the first 16 hex characters of its SHA-256, and wraps a data key
  as a compact JWE, A256KW and A256GCM, unwrapping with those two algorithms alone.
- A Keeper, { name, hold, reveal, release }, holds the value. sealedKeeper, named ledger,
  seals each value as a compact JWE { alg: 'dir', enc: 'A256GCM' } under 32 random bytes of
  its own, has the current custodian wrap them, and keeps the wrapped key and the kid beside
  the value, out of its protected header, so a rotation rewrites two columns and no value.
  Every data key made or unwrapped is filled with zeros once it is used.
- secretStoreOn(rows, keeper, custodians) refuses an empty value and one past 8 KiB before
  anything is sealed, releases a forgotten row's holding, and rotates at start: the rows of
  the previous kid, 100 at a time, unwrapped by the previous custodian, wrapped by the
  current one and written only while their version and holding are the ones read.
  SecretRefused and SecretKeyUnknown keep their tags and words.
- @on.auto/secrets/testing holds memoryRows, the rows under the rule of the lease in memory,
  which fail on an id they keep already as a table does; valuesInTheClear, values a test
  holds in the clear over them under a bound of characters; recordingCustodian, which counts
  its wraps and unwraps, holds the data keys it saw and refuses when told; rowsBehaviour,
  every claim on the rows; and storeBehaviour, what the store adds over any rows. Both suites
  run here over memoryRows.

The package runs in the CI group without services.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…l nothing and know no key

The ledger keeps the rows of @on.auto/secrets alone: sealing, keys and rotation leave it for that
package, and the store's suites run here over both databases.

- kept_secrets holds id, version, kept_at, claimed_until, ends_with, kept_by, held, wrapped_key
  and kid, created by the schema step and its shape checked as the content tables' are.
- sqliteSecretRows({ fileName }) of the sqlite3 entry and postgresqlSecretRows({ pools }) of the
  postgresql entry open the SecretRows port where the two stores were opened. Each operation is
  one statement: forget returns the holding it deleted, heldBy reads a keeper's rows under a kid
  in the order of their ids, and reheld writes a holding only while the version and the holding
  read still stand, compared through COALESCE so a holding without a wrapped key compares alike.
- The ledger's entry no longer exports the store, its keys or its failures. The ledger takes
  only the port's types from @on.auto/secrets, a dev dependency, and jose leaves it.
- ledger.test.ts and ledger-on-postgresql.test.ts run rowsBehaviour and storeBehaviour of
  @on.auto/secrets/testing with the refusal of an older table; postgresql-secret-rows.test.ts
  checks each statement and what it binds without a database.
- The tool-servers' memoryTokens and the connections harness keep their values in
  valuesInTheClear of @on.auto/secrets/testing, so the lease and the bound of a value are
  written once; leasedRows goes, and the harness takes its ids from a counter.
- The server takes the store from @on.auto/secrets, composed over the rows, a custodian of each
  key it reads and the ledger's keeper, with its settings and its log line as they were, and the
  image copies the package.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…TS_KEY_FILE names

The server reads the custodian's key from the environment or from a file a platform mounts, and
says at start where the key is.

- SECRETS_KEY or SECRETS_KEY_FILE gives the current key, and SECRETS_KEY_PREVIOUS or
  SECRETS_KEY_FILE_PREVIOUS the key it replaces; a pair of either set both ways stops the start
  naming both, and a previous key without a current one stops it naming the setting given.
- With neither current one, a SQLite ledger keeps its key in SECRETS_KEY_FILE at its default,
  secrets.key beside the ledger file, made at the first start with the mode 0600; a ledger in
  memory makes one at each start; a ledger in PostgreSQL stops the start naming both settings.
- A file a setting names is read as the platform mounts it, and the file the server makes is its
  owner's alone. A named file that does not exist, a key file the server cannot read or that holds
  anything but the key, and a made file others may read stop the start naming its setting and the
  file; only the default file is ever made.
- The start line says where the key is: Secrets are kept in the ledger's database, sealed under
  the key <kid> that SECRETS_KEY holds, or that the file <path> holds, and <count> sealed under the
  previous key were sealed again under it; secrets_key_from joins secrets_kid and
  secrets_resealed. The CI smoke on PostgreSQL greps the new line.
- The four variables are the runtime's own, so connections.variables refuses each.
- The retry-state key is derived from the current key's bytes as before.
- The pages say the row, the data key per value and its wrapping beside it, the four settings
  and the file that is mounted or made, and rotation by either pair.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@rami-hatoum rami-hatoum changed the title feat(secrets): each secret sealed under a key of its own, wrapped by a custodian, in rows the ledger keeps feat(secrets): each secret under its own key, wrapped by a custodian, in rows the ledger keeps Oct 20, 2026
@rami-hatoum rami-hatoum reopened this Oct 20, 2026
@rami-hatoum
rami-hatoum merged commit 45e0bc6 into main Oct 20, 2026
33 of 35 checks passed
@rami-hatoum
rami-hatoum deleted the feat/secrets-under-a-custodian-and-a-keeper branch October 20, 2026 11:37
SamHatoum pushed a commit that referenced this pull request Oct 20, 2026
🤖 I have created a release *beep* *boop*
---


##
[0.15.0](v0.14.0...v0.15.0)
(2026-10-20)


### Features

* **connections:** a runtime with no client of its own takes consent
through Auto's client
([#268](#268))
([b2f38ea](b2f38ea))
* **providers:** github, slack and profound take a sign-in
([#261](#261))
([0655d3c](0655d3c))
* **providers:** microsoft, amazon and adobe's six providers
([#264](#264))
([f536378](f536378))
* **providers:** the thirteen providers of Google, with their documents
translated from Discovery
([#262](#262))
([99f0f05](99f0f05))
* **providers:** zoom takes a sign-in, over its HTTP API
([#263](#263))
([2803ca1](2803ca1))
* **secrets:** an unreadable credential fails the call; the retry-state
key is the runtime's own
([#269](#269))
([53bd32a](53bd32a))
* **secrets:** each secret under its own key, wrapped by a custodian, in
rows the ledger keeps
([#267](#267))
([45e0bc6](45e0bc6))


### Bug Fixes

* **api:** the runtime's page and icon show the Auto mark with its color
bands, beside its name
([#270](#270))
([be7fc82](be7fc82))

---
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.

1 participant