Everything is blocked on what a node presents to prove which node it is. This builds the other direction, which is not blocked: what a node believes. identity is the second of the seven contexts. It holds an Ed25519 signing key the control plane generates once, whose public half now travels in every enrolment token. A node believes a declaration because it carries a signature that key made -- pinning only the broker would make the control plane's authority transitive, and since the host applies whatever the link delivers, a compromised broker forging declarations is the whole machine. Establishing the key is idempotent, and it has to be: a second key generated by a restart is a mesh where every node holds the wrong public half, so every declaration is refused by every node with nothing visibly wrong. The guarantee is a partial unique index plus a read-back, not the check before the insert -- six processes racing to establish all agree on one key, and there is a test that runs them. Tokens are now one line of base64 carrying three of their four parts. The missing two are the broker's address and its certificate fingerprint, both step 5 of the bootstrap. The command prints the token and names what is missing rather than emitting something that looks usable. The second context also tests a claim this repository had made and never checked: that a context reaches only its own store. Two databases, two credentials, no setting that reaches both. Running migrate with one stops and names the grant it lacks -- verified, not asserted. Assembling a token needs a node record from one and a key from the other, and neither reads the other's store; the process holding both grants asks each for its part. 45 tests, none skipped. Fault injection found one test whose property is enforced somewhere other than where I injected -- idempotency comes from the database constraint, not from the early return, which is what the code comment already said.
31 lines
1.7 KiB
SQL
31 lines
1.7 KiB
SQL
-- The control plane's own signing identity.
|
|
--
|
|
-- novox/hq ADR 0004: a node takes instruction from the control plane behind the broker, and each
|
|
-- declaration is verified by its signature, every time. The public half of this key travels in
|
|
-- every enrolment token; the private half never leaves this context.
|
|
--
|
|
-- Why not pin only the broker: that would make the control plane's authority transitive. A
|
|
-- compromised broker could then forge declarations, and since the host applies whatever the link
|
|
-- delivers, that is the whole machine. The transport is verified once at connect; the instruction
|
|
-- is verified on arrival.
|
|
|
|
create table signing_key (
|
|
id uuid primary key default gen_random_uuid(),
|
|
|
|
-- Ed25519. Fixed rather than a column: a key that carries its own algorithm invites a caller
|
|
-- to be told which one to use, and the two sizes below are Ed25519's.
|
|
public bytea not null check (octet_length(public) = 32),
|
|
private bytea not null check (octet_length(private) = 64),
|
|
|
|
created timestamptz not null default now(),
|
|
|
|
-- Retiring a signing key is a fleet-wide operation with an overlapping rollover -- every node
|
|
-- holds the public half, delivered in a token it may have received months ago. So keys are
|
|
-- retired, never deleted, and more than one may be valid at a time during a rollover.
|
|
retired timestamptz
|
|
);
|
|
|
|
-- The rollover is what makes this a partial index rather than a plain unique constraint: exactly
|
|
-- one key may be signing at any moment, while any number of retired ones remain verifiable.
|
|
create unique index signing_key_one_active on signing_key ((retired is null)) where retired is null;
|