The refreshable-grant refresh token no longer rides a custom at-rest envelope that a
module opens with a node private key. A module is never given a node's private sealing
key, so that path could not exist -- the gap Phase C hit.
Instead the refresh token is a credential sealed to the MANAGER holder with the same
anonymous box (secrets.Seal / crypto_box_seal) every credential uses, stored as one
sealed blob, and delivered by the existing host-unseal-and-mount: the host opens it with
the node's real key and mounts the cleartext at the manager module's bound path, exactly
as a consumer's db password is delivered.
- refresh_grant now stores { sealed, manager_key }, dropping the AtRest token/wrapped_key
columns; internal/secrets/atrest.go is retired (nothing else used it).
- the licence records its manager as (node, module); KeyFor delivers the refresh token to
the manager holder and the access token to consumers, disambiguated by module so the two
can co-locate. Accept and the reseal skip the manager holder.
- the manager holder is delivered the node's PUBLIC sealing key in its bound facts, so the
module can re-seal a rotated refresh token with no private key of its own; the
declaration tolerates its empty pre-adoption secret rather than refusing.
- SubmitRefresh / set-grant take a sealed blob, never a refresh token in the clear.
The invariant holds unchanged: the control plane never reads the refresh token, and no node
but the manager holds it. A committed cross-language test proves the TypeScript module seal
opens under Go box.OpenAnonymous (the host's Unseal) -- both are NaCl crypto_box_seal.
Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
34 lines
2.0 KiB
SQL
34 lines
2.0 KiB
SQL
-- A manager, and the refresh token it holds -- sealed to that node, the way every credential is.
|
|
--
|
|
-- novox/hq ADR 0050. The consolidated schema (0001) creates the `manager` and `manager_module`
|
|
-- columns and the `refresh_grant` table in its final shape; this migration carries an existing
|
|
-- database the same distance, so a database that predates the carve-out gains exactly what a fresh
|
|
-- one is created with.
|
|
--
|
|
-- **Idempotent, and it converges rather than assumes.** An early cut of this carve-out kept the
|
|
-- refresh token as a bespoke at-rest envelope (`token` + `wrapped_key`) so the manager MODULE could
|
|
-- open it with the node's private key. That was retired before release: a module is never given a
|
|
-- node's private sealing key, so the refresh token now rides the ordinary sealed-delivery path --
|
|
-- one anonymous sealed box to the manager node's public key, unsealed by the HOST. This migration
|
|
-- therefore also drops those columns and adds `sealed` for any database that ran the earlier shape,
|
|
-- so both a pristine database and one carried through the early cut end at the same schema.
|
|
|
|
alter table licence add column if not exists manager text;
|
|
alter table licence add column if not exists manager_module text;
|
|
|
|
create table if not exists refresh_grant (
|
|
licence text primary key references licence(name) on delete cascade,
|
|
sealed text not null,
|
|
manager_key text not null,
|
|
updated_at timestamptz not null default now()
|
|
);
|
|
|
|
-- Converge a database that created refresh_grant in the retired at-rest shape. There is nothing to
|
|
-- preserve: an unreleased carve-out held no production refresh tokens, and a refresh token cannot be
|
|
-- re-derived from a wrapped envelope this migration cannot open. The manager re-adopts.
|
|
alter table refresh_grant add column if not exists sealed text;
|
|
alter table refresh_grant drop column if exists token;
|
|
alter table refresh_grant drop column if exists wrapped_key;
|
|
update refresh_grant set sealed = '' where sealed is null;
|
|
alter table refresh_grant alter column sealed set not null;
|