mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 15:31:38 +02:00
The admin authorization data model. The server stores only public keys; an admin is authorized by presence in admin_keys, and each row carries an HMAC (ADMIN_KEY_INTEGRITY_SECRET, separate from DATABASE_URL) over id|pubkey|scope|created_at|activated_at|revoked_at. Because the MAC covers the revocation/activation state, a DB-write attacker who clears revoked_at (un-revoke) or revives a key produces a mismatch the guard rejects — the MAC proves a pubkey is *currently* authorized, not merely once. - migration 20260625000002: admin_keys (partial unique index = one active row per pubkey; soft revoke keeps history) + admin_sessions (separate table/secret from user sessions; cascade-on-key-delete). - core::crypto::admin_mac: compute/verify over a JSON-canonical field set, timestamps as microsecond epochs; constant-time compare; KATs bind revoked/activated state. - db::admin_keys: create / get_active (re-verifies MAC, fail-closed + logs tamper) / list / count_live / activate / revoke. Every covered-field write recomputes the MAC centrally; timestamps truncated to microseconds so the signed value round-trips; revoke/activate are FOR UPDATE transactions. - 5 L1 tests incl. the load-bearing raw-SQL un-revoke -> MAC mismatch -> rejected.
45 lines
2.6 KiB
SQL
45 lines
2.6 KiB
SQL
-- Admin authentication & authorization (operator §0.3 / §1.2).
|
|
--
|
|
-- The server stores ONLY public keys — an admin proves control of a private key
|
|
-- (NIP-98 signed action) and is authorized by presence in this allowlist. Each
|
|
-- row carries an integrity MAC keyed by ADMIN_KEY_INTEGRITY_SECRET (separate from
|
|
-- DATABASE_URL): it covers id|pubkey|scope|created_at|activated_at|revoked_at, so
|
|
-- a DB-write attacker who flips revoked_at back to NULL (un-revoke) or revives a
|
|
-- key produces a MAC mismatch that the guard rejects. Revocation is a soft
|
|
-- revoke (the audit trail survives); the partial unique index keeps at most one
|
|
-- ACTIVE row per pubkey while allowing historical revoked rows.
|
|
CREATE TABLE admin_keys (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
pubkey TEXT NOT NULL, -- x-only secp256k1 hex, 64 lowercase
|
|
label TEXT,
|
|
scope TEXT NOT NULL DEFAULT 'admin',
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
created_by_kind TEXT NOT NULL, -- 'admin' | 'cli' | 'bootstrap' (coarse; no social graph)
|
|
activated_at TIMESTAMPTZ, -- NULL = pending (first login not yet completed)
|
|
activation_deadline TIMESTAMPTZ, -- pending key auto-expires after this
|
|
revoked_at TIMESTAMPTZ, -- soft revoke
|
|
last_used_at TIMESTAMPTZ,
|
|
integrity_mac TEXT NOT NULL -- HMAC(integrity_secret, id|pubkey|scope|created_at|activated_at|revoked_at)
|
|
);
|
|
|
|
-- At most one ACTIVE allowlist entry per pubkey; revoked rows are unconstrained.
|
|
CREATE UNIQUE INDEX admin_keys_active_pubkey ON admin_keys (pubkey) WHERE revoked_at IS NULL;
|
|
|
|
-- Admin sessions are a SEPARATE table from user `sessions` (cryptographically
|
|
-- distinct tokens, distinct secret). Revoking the key cascades to its sessions.
|
|
CREATE TABLE admin_sessions (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
admin_key_id UUID NOT NULL REFERENCES admin_keys (id) ON DELETE CASCADE,
|
|
pubkey TEXT NOT NULL,
|
|
refresh_token_hash TEXT NOT NULL, -- peppered at rest (never the token)
|
|
access_jti TEXT NOT NULL, -- the live access token's id
|
|
device_info TEXT,
|
|
ip_address INET, -- real TCP peer, never XFF
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
revoked_at TIMESTAMPTZ
|
|
);
|
|
|
|
CREATE INDEX idx_admin_sessions_key ON admin_sessions (admin_key_id);
|
|
CREATE INDEX idx_admin_sessions_jti ON admin_sessions (access_jti) WHERE revoked_at IS NULL;
|