licences: deliver the refresh token by the ordinary sealed path, not a bespoke envelope
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
This commit is contained in:
@@ -25,6 +25,11 @@ create table licence (
|
||||
-- A name, not a foreign key: nodes live in another context this one may not join across
|
||||
-- (novox/hq ADR 0008).
|
||||
manager text,
|
||||
-- The module ON the manager node that runs the refresh -- the manager holder. Named alongside
|
||||
-- the manager node because a node may run the manager module AND a consuming module of the same
|
||||
-- licence (the lab co-locates both), and which holder is delivered the refresh token rather than
|
||||
-- an access token turns on the module, not the node alone. Null exactly when `manager` is.
|
||||
manager_module text,
|
||||
added_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
@@ -48,29 +53,33 @@ create table licence_holder (
|
||||
primary key (licence, node, module)
|
||||
);
|
||||
|
||||
-- The refresh token, encrypted at rest under the manager node's key.
|
||||
-- The refresh token, sealed to the manager node's key -- the same anonymous box every credential
|
||||
-- the mesh delivers uses.
|
||||
--
|
||||
-- **novox/hq ADR 0050's carve-out, and its one home.** A `refreshable-grant` licence cannot be both
|
||||
-- sealed so the mesh cannot read it and rotated centrally, because rotating means a node reads the
|
||||
-- refresh token back. So exactly one node -- the licence's manager -- holds it readably, and it is
|
||||
-- kept here as an envelope only that node can open: a symmetric data key encrypts the token
|
||||
-- (secretbox), and the data key is sealed to the manager's public key. This database on its own
|
||||
-- holds ciphertext and a wrapped key with no private half to open either (novox/hq ADR 0004).
|
||||
-- **novox/hq ADR 0050's carve-out, delivered the way the mesh delivers everything else.** A
|
||||
-- `refreshable-grant` licence cannot be both sealed so the mesh cannot read it and rotated centrally,
|
||||
-- because rotating means a node reads the refresh token back. So exactly one node -- the licence's
|
||||
-- manager -- reads it. But the earlier attempt at a bespoke at-rest envelope, and the module holding
|
||||
-- the node's private key to open it, hit a wall the mesh's own design forbids: a module is never
|
||||
-- given a node's private sealing key. So the refresh token rides the *ordinary* path instead -- it is
|
||||
-- an anonymous sealed box (secrets.Seal, `crypto_box_seal`) to the manager node's public sealing key,
|
||||
-- exactly like a consumer's db password, and the HOST unseals it and mounts the cleartext at the
|
||||
-- manager module's bound path. This database on its own holds a sealed box with no private half to
|
||||
-- open it (novox/hq ADR 0004), the same guarantee as every other sealed value here.
|
||||
--
|
||||
-- **Separate from the access tokens.** licence_holder.sealed is the ACCESS token, sealed per holder
|
||||
-- and delivered. This is the REFRESH token, one per licence, never delivered to anybody. Keeping
|
||||
-- them in different tables is what makes "the refresh token is stripped on delivery" structural:
|
||||
-- delivery reads licence_holder, and the refresh token is not in it.
|
||||
-- and delivered to consumers. This is the REFRESH token, one per licence, delivered to the manager
|
||||
-- holder alone. Keeping them apart is what makes "a consumer is never delivered the refresh token"
|
||||
-- structural: a consumer's delivery reads licence_holder, and the refresh token is not there.
|
||||
create table refresh_grant (
|
||||
-- One refresh token per licence. On delete cascade: forgetting a licence forgets its refresh
|
||||
-- token with it, the same way it forgets its holders.
|
||||
licence text primary key references licence(name) on delete cascade,
|
||||
|
||||
-- base64( nonce || secretbox(data_key, refresh_token) ) -- the token under the symmetric key.
|
||||
token text not null,
|
||||
-- base64( anonymous-box(manager_key, data_key) ) -- the data key closed to the manager node.
|
||||
wrapped_key text not null,
|
||||
-- The manager's public sealing key the data key was wrapped to. Kept so a manager that
|
||||
-- base64( anonymous-box( manager sealing key, refresh_token ) ) -- the refresh token sealed to
|
||||
-- the manager node, openable only by that node's private half, which the mesh never holds.
|
||||
sealed text not null,
|
||||
-- The manager's public sealing key the refresh token was sealed to. Kept so a manager that
|
||||
-- regenerated its key can be told it can no longer open this, rather than discovering it as a
|
||||
-- refresh that will not decrypt (the same reason licence_holder.node_key is kept).
|
||||
manager_key text not null,
|
||||
|
||||
@@ -1,19 +1,33 @@
|
||||
-- A manager, and the refresh token it holds encrypted at rest.
|
||||
-- A manager, and the refresh token it holds -- sealed to that node, the way every credential is.
|
||||
--
|
||||
-- novox/hq ADR 0050, Phase B. The consolidated schema (0001) now creates the `manager` column and
|
||||
-- the `refresh_grant` table; 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.
|
||||
-- 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.
|
||||
--
|
||||
-- **Guarded, so it is a no-op on a database created after 0001 was updated.** A fresh database
|
||||
-- already has both, and re-adding them would fail; `if not exists` on both makes the two paths --
|
||||
-- fresh and pre-existing -- end at the same schema.
|
||||
-- **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,
|
||||
token text not null,
|
||||
wrapped_key text not null,
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user