Issuing a node credential by hand required writing the key into a 0600 file on the controller and having a person copy it onto another machine. That key landed in shell history and a terminal buffer, and it made the controller a courier: nothing could be enrolled without me in the middle, which is the opposite of what a fleet of heterogeneous nodes needs. `POST /api/compute/pairing-codes` (admin session) mints a six-digit code for a *named* node. `POST /api/node/pair` exchanges it, once, for a credential returned in the response. The node never sees a secret typed by a person, and the controller is no longer in the path. ## Why a six-digit code is defensible 1,000,000 possibilities is nothing on its own. What makes it work is everything around it, and any one of these alone would be too thin: - **Single use.** Redemption is `SELECT ... FOR UPDATE` then `UPDATE ... WHERE used_at IS NULL`, in one transaction. - **Expiring.** Default ten minutes, hard-bounded to 60-1800s. Long enough to walk to a machine; short enough that a code left in a terminal buffer is not a credential for the day. - **Bound to a node name.** A code read out of a terminal buffer cannot pair a machine called something else. Without this, "confirm it is the machine" is decoration. - **Argon2id at rest.** A database read or a support transcript yields nothing replayable. - **Rate limited per source address**, charged *before* any code lookup, so guessing across many codes costs the same as guessing at one. Twenty per quarter-hour against a million-code space is about 21 years. This is the control that makes the length defensible; the others are cheap additions to it. Every rejection is worded identically - "no such code", "wrong code", "expired", "already used" all read the same - so the response is not an oracle for the rate limiter to work against. The one exception is a name mismatch, which says so plainly: it is not secret, and an operator staring at a failure needs to be told the code is for a different machine rather than left guessing. A name mismatch does *not* spend the code, because the obvious implementation spends it and then the retry with the right name cannot work. ## Why not a SECURITY DEFINER function The obvious shape is a plpgsql function that verifies and redeems, so the node surface never touches the tables. It cannot be done in SQL here: the codebase hashes with Argon2id in Python, and the only hashing PostgreSQL offers in this database is `crypt()` (bcrypt and MD5). Writing pairing against bcrypt would claim an algorithm choice the rest of the system does not make. So verification is in the repository, which means the pairing tables need grants for the application role when G2 lands, and the path needs re-testing as a non-owner role. Recorded in the migration header. ## Three defects found by writing the tests - **Redemption order.** Claiming the code before inserting the node it references violates the foreign key, so *every* redemption failed. Found by nine failing tests with an FK error that had nothing to do with pairing logic. - **A race that produced a 500.** Two nodes verifying one code simultaneously both created a node, and the loser hit a unique-constraint violation on `(organization_id, name)` - an unhandled error for an entirely ordinary condition. Fixed with `SELECT ... FOR UPDATE` on the code row before the insert, so the second caller blocks, re-evaluates `used_at IS NULL`, and gets the ordinary refusal. The concurrency test fails when the lock is removed and passes when it is present. - **A test that could not fail.** Removing the `used_at IS NULL` from the *claim* left all tests passing, because the candidate query's `used_at IS NULL` filter masks it. Sequential redemption cannot distinguish the two guards; only a concurrent one can. That is now `test_a_concurrent_redemption_produces_exactly_one_node`, and `test_a_spent_code_is_not_offered_as_a_candidate` pins the first guard directly. The reuse trigger needed a correction too. It originally refused any change to `expires_at`, which would have made it impossible to revoke an unused code - the normal way to withdraw a code issued to the wrong person. It now refuses identity changes and expiry *extension*, and permits expiry to move earlier only. Two existing tests flagged the new unauthenticated route, correctly: `test_the_node_surface_is_exactly_what_a_node_may_do` and `test_every_node_route_uses_node_credential_authentication` both assumed every `/api/node` route needs a credential. Rather than weaken either, `/pair` is recorded as a named exception in both, with a check that the named route still exists - so a second unauthenticated node route has to be added deliberately with a reason. 509 passed, 27 skipped. Deployment: 0004 applied after a backup, health 200, and the live endpoint verified to refuse a bogus code with 403 and a wrong node name with a `pairing_name_mismatch` that does not spend the code. G2 is still open, and the pairing tables will need grants when it closes.
179 lines
9.3 KiB
PL/PgSQL
179 lines
9.3 KiB
PL/PgSQL
-- Pairing codes: a node proves it is the machine a person authorised, without that
|
|
-- person ever typing a 32-byte credential into a terminal on the far end.
|
|
--
|
|
-- The problem this solves. Issuing a node credential the way it was done for
|
|
-- umbraaudio-m4 meant writing the key into a 0600 file on the controller and having a
|
|
-- human copy it onto another machine by hand. That key landed in shell history, in a
|
|
-- terminal buffer, and in whatever it was pasted into. It also made the controller a
|
|
-- courier: nothing could be enrolled without me in the middle, which is the opposite of
|
|
-- what a fleet of heterogeneous nodes needs.
|
|
--
|
|
-- The flow this supports:
|
|
--
|
|
-- 1. An administrator requests a pairing code for a *named* node.
|
|
-- 2. The code is shown once, expires, and is single-use.
|
|
-- 3. The node calls POST /api/node/pair with that code and its own name.
|
|
-- 4. The controller verifies the code, creates the node, and issues a credential
|
|
-- once, in the response.
|
|
--
|
|
-- Two properties make this worth having over "send the operator a secret":
|
|
--
|
|
-- * **The code is bound to a node name.** A code read out of a terminal buffer
|
|
-- cannot pair a machine called something else, so it cannot register a node under
|
|
-- a name no administrator approved.
|
|
-- * **The code never becomes a credential.** It is exchanged once, for a credential
|
|
-- shown once and stored only as an Argon2id hash. There is no path by which the
|
|
-- code itself is usable against the node surface afterwards.
|
|
--
|
|
-- ## Why there is no SECURITY DEFINER function here
|
|
--
|
|
-- The obvious shape for this is a plpgsql function that verifies and redeems, so the
|
|
-- node surface never touches the tables. It cannot be done in SQL here: the codebase's
|
|
-- hasher is Argon2id, in Python, and the only hashing PostgreSQL offers in this
|
|
-- database is `crypt()` (bcrypt and MD5). Writing pairing against bcrypt would be
|
|
-- claiming an algorithm choice the rest of the system does not make, and MD5 would be
|
|
-- indefensible. So the verification happens in Python, in the repository, alongside
|
|
-- `issue_node_credential` - which means the node endpoint authenticates as whatever
|
|
-- role the application uses and needs that role to hold EXECUTE on nothing and direct
|
|
-- table rights on these two tables.
|
|
--
|
|
-- That is a deliberate consequence of G2 still being open: the application connects as
|
|
-- the table owner, so a paired node's credential is issued with owner-level authority
|
|
-- rather than a scoped one. The tables below are additive and hold no secrets other
|
|
-- than hashed codes, so nothing sensitive is exposed by that gap today. It is recorded
|
|
-- here because when G2 closes, these tables need grants for the application role, and
|
|
-- the pairing path needs re-testing as a non-owner role - the same treatment
|
|
-- test_rls_tenancy.py gives everything else.
|
|
|
|
BEGIN;
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- 1. Pairing codes
|
|
-- ---------------------------------------------------------------------------
|
|
CREATE TABLE IF NOT EXISTS public.node_pairing_codes (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
-- The node this code may pair. An administrator names the machine they are
|
|
-- standing at; the node claims that name or nothing.
|
|
node_name text NOT NULL CHECK (btrim(node_name) <> '' AND length(node_name) <= 120),
|
|
organization_id uuid NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE,
|
|
locality text,
|
|
trust_domain text NOT NULL DEFAULT 'local'
|
|
CHECK (trust_domain IN ('local', 'remote', 'cloud')),
|
|
-- Argon2id, never the code. A database read must not yield something replayable
|
|
-- against the node surface, and a support transcript must not hand over a live
|
|
-- credential.
|
|
code_hash text NOT NULL,
|
|
-- Shown to the administrator so two codes are distinguishable in a conversation
|
|
-- ("the one ending 41"). Deliberately not a lookup key: verifying by hash over
|
|
-- every live code avoids a prefix lookup that would make a truncated or misread
|
|
-- code valid.
|
|
code_hint text NOT NULL,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
expires_at timestamptz NOT NULL,
|
|
-- Set on successful redemption, and the single-use guard: redemption is one
|
|
-- UPDATE ... WHERE used_at IS NULL, so two nodes racing on a single code produce
|
|
-- one winner rather than two nodes.
|
|
used_at timestamptz,
|
|
used_by_node_id uuid REFERENCES public.compute_nodes(id) ON DELETE SET NULL,
|
|
attempts smallint NOT NULL DEFAULT 0 CHECK (attempts >= 0),
|
|
CHECK (expires_at > created_at)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS node_pairing_codes_live_idx
|
|
ON public.node_pairing_codes (organization_id, node_name)
|
|
WHERE used_at IS NULL;
|
|
|
|
COMMENT ON TABLE public.node_pairing_codes IS
|
|
'Single-use, expiring, node-name-bound codes that exchange for a node credential. '
|
|
'A code is never itself usable against the node surface.';
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- 2. Rate limiting
|
|
-- ---------------------------------------------------------------------------
|
|
-- A per-code attempt counter alone is not enough. An attacker who never generated a
|
|
-- code can still spray: pick a code, guess until it is right. Without a per-address
|
|
-- limit the six-digit space is reachable at whatever rate the network allows, and the
|
|
-- per-code counter only helps once a guess happens to hit a real code.
|
|
--
|
|
-- The counter is per address over a window, so guessing across many codes costs the
|
|
-- same as guessing repeatedly at one. This is the control that makes a six-digit code
|
|
-- defensible; without it the code length would not be.
|
|
CREATE TABLE IF NOT EXISTS public.node_pairing_attempts (
|
|
source_address text PRIMARY KEY,
|
|
window_started_at timestamptz NOT NULL DEFAULT now(),
|
|
attempts integer NOT NULL DEFAULT 0 CHECK (attempts >= 0)
|
|
);
|
|
|
|
COMMENT ON TABLE public.node_pairing_attempts IS
|
|
'Rolling per-address attempt counter for node pairing. Separate from the per-code '
|
|
'counter: one bounds guessing at a known code, this bounds guessing across all of '
|
|
'them.';
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- 3. Row-level security
|
|
-- ---------------------------------------------------------------------------
|
|
-- Enabled now so the tables cannot be reached by a role that is not the owner once G2
|
|
-- lands, and so `test_rls_coverage.py` sees them. No policy is created: pairing is an
|
|
-- administrative operation on a table that holds no tenant data, and the read path is
|
|
-- the repository's. A permissive policy here would widen the surface for no gain.
|
|
ALTER TABLE public.node_pairing_codes ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE public.node_pairing_attempts ENABLE ROW LEVEL SECURITY;
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- 4. Immutability of the redemption record
|
|
-- ---------------------------------------------------------------------------
|
|
-- `used_at` and `used_by_node_id` are the evidence that a code was spent and on what.
|
|
-- An UPDATE that clears them would make a single-use code reusable, and nothing else
|
|
-- would notice: the redemption itself is already committed.
|
|
CREATE OR REPLACE FUNCTION app.prevent_pairing_reuse_mutation()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
AS $body$
|
|
BEGIN
|
|
-- The redemption columns are immutable; the attempt counter is not, because the
|
|
-- rate limiter updates it. Guarding both would break the limiter, and guarding
|
|
-- nothing would let a spent code be revived.
|
|
IF OLD.used_at IS NOT NULL THEN
|
|
-- Spent. Nothing about this row may change afterwards, or a code could be
|
|
-- revived by clearing the redemption.
|
|
RAISE EXCEPTION
|
|
'a pairing code is single-use; a redeemed code cannot be changed';
|
|
END IF;
|
|
|
|
-- While a code is still live, its *identity* is fixed: the hash it was minted
|
|
-- under, and the organization and node name it was authorised for. Rewriting any
|
|
-- of those would let an unspent code be re-pointed at another tenant's node,
|
|
-- which is the one thing the name binding exists to prevent.
|
|
--
|
|
-- `expires_at` is deliberately NOT guarded. Expiry is how a code is revoked before
|
|
-- it is used - an operator who issued a code to the wrong person shortens or ages
|
|
-- it out - so it must be adjustable. It can only ever move *earlier*, never later:
|
|
-- extending a live code is not revocation, it is a second lease on a secret that
|
|
-- was issued for ten minutes.
|
|
IF NEW.code_hash IS DISTINCT FROM OLD.code_hash
|
|
OR NEW.node_name IS DISTINCT FROM OLD.node_name
|
|
OR NEW.organization_id IS DISTINCT FROM OLD.organization_id
|
|
OR NEW.expires_at > OLD.expires_at
|
|
THEN
|
|
RAISE EXCEPTION
|
|
'a pairing code''s identity is fixed and its expiry may only be brought forward';
|
|
END IF;
|
|
RETURN NEW;
|
|
END
|
|
$body$;
|
|
|
|
DO $$
|
|
BEGIN
|
|
EXECUTE 'DROP TRIGGER IF EXISTS node_pairing_codes_redeem_once ON public.node_pairing_codes';
|
|
EXECUTE 'CREATE TRIGGER node_pairing_codes_redeem_once BEFORE UPDATE ON public.node_pairing_codes '
|
|
'FOR EACH ROW EXECUTE FUNCTION app.prevent_pairing_reuse_mutation()';
|
|
END
|
|
$$;
|
|
|
|
-- Rollback, in dependency order:
|
|
-- DROP TRIGGER IF EXISTS node_pairing_codes_redeem_once ON public.node_pairing_codes;
|
|
-- DROP FUNCTION IF EXISTS app.prevent_pairing_reuse_mutation();
|
|
-- DROP TABLE IF EXISTS public.node_pairing_attempts;
|
|
-- DROP TABLE IF EXISTS public.node_pairing_codes;
|
|
|
|
COMMIT; |