diff --git a/worker/admin/allowlist.ts b/worker/admin/allowlist.ts index 18a8080c..338fb481 100644 --- a/worker/admin/allowlist.ts +++ b/worker/admin/allowlist.ts @@ -3,56 +3,107 @@ * session. Checked on every admin request, after (never instead of) cryptographic * verification of the identity. * - * SHIPS EMPTY AND FAILS CLOSED. Zero admins exist until identities are deliberately - * committed here and deployed; there is no self-registration, no environment - * override, and no dev/test bypass. An empty list rejects everyone — including a - * request carrying a perfectly valid signature. + * FAILS CLOSED. There is no self-registration, no environment override, and no + * dev/test bypass. An identity absent from this list is rejected — including a + * request carrying a perfectly valid signature — and removal revokes any live + * session on its next request (per-request enforcement in worker/admin/router.ts). * - * Downstream deployers (this is AGPL software — run your own instance): constitute - * your own admin set by editing the two arrays below in your fork and deploying. - * - nostrPubkeys: 64-char lowercase HEX pubkeys (NOT npub…). To convert an npub, - * use any NIP-19 decoder (e.g. `nostr-tools/nip19` decode) or your signer's - * settings page, which usually shows both forms. - * - blueskyDids: full DIDs (did:plc:… or did:web:…), NOT handles. Handles can be - * re-registered by someone else; DIDs are stable. Find yours at - * https://bsky.social settings or by resolving your handle. + * GOVERNANCE (the role field is genesis DATA, not a runtime capability switch): + * - role: "superadmin" — governance authority over THIS list, exercised through + * repository merge rights (add/remove admin = PR; revocation = commit + + * deploy). Never an in-app power: there is NO runtime write-path to the admin + * set. Also the highest capability tier when Phases 5–6 (catalog management, + * moderation) differentiate capabilities. + * - role: "admin" — assigned by superadmins via PR. + * TODAY all allowlisted identities pass the same gate; differentiated + * enforcement lands with the features that need it — nothing speculative here. + * + * Downstream deployers (this is AGPL software — run your own instance): + * constitute your own admin set by editing the two arrays below in your fork. + * - nostr: 64-char lowercase HEX pubkeys (NOT npub…). Convert an npub with any + * NIP-19 decoder (e.g. `nostr-tools/nip19` decode) or your signer's settings. + * - bluesky: full DIDs (did:plc:… / did:web:…), NOT handles — handles are + * mutable pointers someone else can later hold; DIDs are stable. * Review additions like code (they are code): one identity per line, a comment - * saying who it is, and two-person review before merge. + * saying who it is, and review before merge. */ +export type AdminRole = 'superadmin' | 'admin'; + +export interface AdminNostrEntry { + /** 64-hex Nostr pubkey (lowercase). */ + readonly pubkey: string; + readonly role: AdminRole; +} + +export interface AdminBlueskyEntry { + /** AT-Proto DID (did:plc:… / did:web:…). */ + readonly did: string; + readonly role: AdminRole; +} + export interface AdminAllowlist { - /** 64-hex Nostr pubkeys (lowercase). */ - readonly nostrPubkeys: readonly string[]; - /** AT-Proto DIDs (did:plc:… / did:web:…). */ - readonly blueskyDids: readonly string[]; + readonly nostr: readonly AdminNostrEntry[]; + readonly bluesky: readonly AdminBlueskyEntry[]; } -/** The committed allowlist. EMPTY by design — the operator adds real identities in - * their own reviewed commit; the code never invents or defaults any. */ +/** The committed allowlist — the constituted admin set of THIS deployment + * (wecanjustbuildthings.dev). Every entry is owner-declared and multi-source + * verified before landing; changes enter only through reviewed commits. */ export const ADMIN_ALLOWLIST: AdminAllowlist = { - nostrPubkeys: [ - // '<64-hex admin pubkey>', // who this is + nostr: [ + // Martin Montero (site operator). npub1kdve3xh5l4xnuwx87zff9pyrq044kx9pd6lsxwdfgpy0xklhv5qs96rgqv + // Verified three ways: in-repo nip19 decode (round-tripped), independent decode, + // and the NIP-98-proven subject of his live sign-in in the production auth store. + { pubkey: 'b359989af4fd4d3e38c7f09292848303eb5b18a16ebf0339a94048f35bf76501', role: 'superadmin' }, ], - blueskyDids: [ - // 'did:plc:', // who this is + bluesky: [ + // Martin Montero (site operator). Handle monteroxr.bsky.social — the DID (not the + // mutable handle) is stored, taken from his completed AT-Proto OAuth session in + // the production auth store and confirmed via live resolveHandle. + { did: 'did:plc:nli4gouip2eswhsrzqbiatyz', role: 'superadmin' }, ], }; const HEX_PUBKEY = /^[0-9a-f]{64}$/; const DID = /^did:(plc|web):[a-zA-Z0-9._:%-]+$/; +const ROLES: readonly AdminRole[] = ['superadmin', 'admin']; -/** True only for a well-formed pubkey present in the list. Malformed ENTRIES are - * ignored (a typo in the allowlist must never accidentally match), and matching is +/** True only for a well-formed entry (malformed entries can never match — a typo + * in the allowlist must never accidentally grant access). */ +export function isWellFormedNostrEntry(entry: AdminNostrEntry): boolean { + return HEX_PUBKEY.test(entry.pubkey) && ROLES.includes(entry.role); +} +export function isWellFormedBlueskyEntry(entry: AdminBlueskyEntry): boolean { + return DID.test(entry.did) && ROLES.includes(entry.role); +} + +/** True only for a well-formed pubkey present in the list. Matching is * case-insensitive on hex. */ export function isAllowedNostrPubkey(list: AdminAllowlist, pubkey: string): boolean { if (typeof pubkey !== 'string' || !HEX_PUBKEY.test(pubkey.toLowerCase())) return false; const needle = pubkey.toLowerCase(); - return list.nostrPubkeys.some((entry) => HEX_PUBKEY.test(entry) && entry === needle); + return list.nostr.some((entry) => isWellFormedNostrEntry(entry) && entry.pubkey === needle); } /** True only for a well-formed DID present in the list (exact match — DIDs are * identifiers, not user input to normalize). */ export function isAllowedBlueskyDid(list: AdminAllowlist, did: string): boolean { if (typeof did !== 'string' || !DID.test(did)) return false; - return list.blueskyDids.some((entry) => DID.test(entry) && entry === did); + return list.bluesky.some((entry) => isWellFormedBlueskyEntry(entry) && entry.did === did); +} + +/** The role recorded for an allowlisted identity, or null if not a member. Today + * this is informational (whoami/audit); Phases 5–6 gate capabilities on it. */ +export function adminRoleFor( + list: AdminAllowlist, + provider: 'nostr' | 'bluesky', + subject: string, +): AdminRole | null { + if (provider === 'nostr') { + if (!isAllowedNostrPubkey(list, subject)) return null; + return list.nostr.find((e) => e.pubkey === subject.toLowerCase())?.role ?? null; + } + if (!isAllowedBlueskyDid(list, subject)) return null; + return list.bluesky.find((e) => e.did === subject)?.role ?? null; } diff --git a/worker/tests/admin-auth.test.ts b/worker/tests/admin-auth.test.ts index 97ddfa81..481aa36b 100644 --- a/worker/tests/admin-auth.test.ts +++ b/worker/tests/admin-auth.test.ts @@ -4,16 +4,20 @@ * AdminCoordinator class behind a hand-fake DurableObjectNamespace — no miniflare, * no new dependencies. * - * The allowlist used here is injected through routeAdmin's compile-time deps seam; - * the COMMITTED allowlist stays empty and its fail-closed behavior is asserted - * explicitly (a valid signature with the shipped config must be rejected). + * The allowlist used here is injected through routeAdmin's compile-time deps seam. + * Fails-closed is proven against an EMPTY FIXTURE (EMPTY_ALLOWLIST below) so the + * proof is invariant to whichever identities the operator has constituted in the + * committed list; the committed list itself is validated for well-formedness. */ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { routeAdmin } from '../admin/router.ts'; import { AdminCoordinator } from '../admin/coordinator.ts'; -import { ADMIN_ALLOWLIST, isAllowedNostrPubkey, isAllowedBlueskyDid } from '../admin/allowlist.ts'; +import { + ADMIN_ALLOWLIST, isAllowedNostrPubkey, isAllowedBlueskyDid, adminRoleFor, + isWellFormedNostrEntry, isWellFormedBlueskyEntry, type AdminAllowlist, +} from '../admin/allowlist.ts'; import { ADMIN_SESSION_COOKIE, ADMIN_IDLE_SECONDS, ADMIN_ABSOLUTE_SECONDS, putAdminSession, resolveAdminSessionId, adminSessionCookie, newAdminSessionId, @@ -163,47 +167,80 @@ const sessionIdOf = (res: Response) => /__Host-wcjbt_admin=([0-9a-f]{64})/.exec( const withAdminCookie = (path: string, id: string, init?: RequestInit) => req(path, { ...init, headers: { ...(init?.headers as Record ?? {}), cookie: `${ADMIN_SESSION_COOKIE}=${id}` } }); -// ---- the committed allowlist is EMPTY and fails closed ---- +// ---- fails-closed, proven against an EMPTY FIXTURE ---- +// The fixture — not the committed list — carries the fails-closed proof, so these +// tests stay valid regardless of which identities the operator has constituted. -test('the COMMITTED allowlist ships empty', () => { - assert.equal(ADMIN_ALLOWLIST.nostrPubkeys.length, 0); - assert.equal(ADMIN_ALLOWLIST.blueskyDids.length, 0); -}); +const EMPTY_ALLOWLIST: AdminAllowlist = { nostr: [], bluesky: [] }; -test('EMPTY allowlist rejects a perfectly VALID signature (fail closed, generic 401)', async () => { +test('an EMPTY allowlist rejects a perfectly VALID signature (fail closed, generic 401)', async () => { const env = fakeEnv(); - const res = await nostrLogin(env, generateSecretKey()); // default deps = committed empty list + const res = await nostrLogin(env, generateSecretKey(), { allowlist: EMPTY_ALLOWLIST }); assert.equal(res.status, 401); assert.deepEqual(await res.json(), { error: 'authentication failed' }); // no oracle assert.equal(cookieOf(res), ''); // and no cookie }); +test('routeAdmin DEFAULTS to the committed allowlist: an uncommitted key is rejected', async () => { + // No deps passed → the committed ADMIN_ALLOWLIST is in force. A fresh random key + // is (probabilistically) never a member, so this pins the default wiring without + // depending on whether the operator has constituted identities yet. + const env = fakeEnv(); + const res = await nostrLogin(env, generateSecretKey()); + assert.equal(res.status, 401); +}); + +test('every COMMITTED allowlist entry is well-formed with a valid role', () => { + for (const entry of ADMIN_ALLOWLIST.nostr) { + assert.ok(isWellFormedNostrEntry(entry), `malformed nostr entry: ${entry.pubkey}`); + } + for (const entry of ADMIN_ALLOWLIST.bluesky) { + assert.ok(isWellFormedBlueskyEntry(entry), `malformed bluesky entry: ${entry.did}`); + } +}); + test('a NON-allowlisted pubkey is rejected exactly like a bad signature', async () => { const env = fakeEnv(); const admin = generateSecretKey(); const intruder = generateSecretKey(); - const deps = { allowlist: { nostrPubkeys: [getPublicKey(admin)], blueskyDids: [] } }; + const deps = { allowlist: { nostr: [{ pubkey: getPublicKey(admin), role: 'admin' }], bluesky: [] } }; const res = await nostrLogin(env, intruder, deps); assert.equal(res.status, 401); assert.deepEqual(await res.json(), { error: 'authentication failed' }); }); test('allowlist matchers reject malformed subjects and malformed ENTRIES', () => { - const list = { nostrPubkeys: ['ABC', 'f'.repeat(64)], blueskyDids: ['not-a-did', 'did:plc:ok123'] }; + const list: AdminAllowlist = { + nostr: [{ pubkey: 'ABC', role: 'admin' }, { pubkey: 'f'.repeat(64), role: 'admin' }], + bluesky: [{ did: 'not-a-did', role: 'admin' }, { did: 'did:plc:ok123', role: 'admin' }], + }; assert.equal(isAllowedNostrPubkey(list, 'F'.repeat(64)), true); // hex case-insensitive assert.equal(isAllowedNostrPubkey(list, 'abc'), false); // malformed subject - assert.equal(isAllowedNostrPubkey({ nostrPubkeys: ['ABC'], blueskyDids: [] }, 'abc'), false); // malformed entry never matches + assert.equal(isAllowedNostrPubkey({ nostr: [{ pubkey: 'ABC', role: 'admin' }], bluesky: [] }, 'abc'), false); // malformed entry never matches assert.equal(isAllowedBlueskyDid(list, 'did:plc:ok123'), true); assert.equal(isAllowedBlueskyDid(list, 'not-a-did'), false); }); +test('adminRoleFor returns the recorded role for members, null otherwise (role is genesis DATA)', () => { + const pk = 'a'.repeat(64); + const list: AdminAllowlist = { + nostr: [{ pubkey: pk, role: 'superadmin' }], + bluesky: [{ did: 'did:plc:member1', role: 'admin' }], + }; + assert.equal(adminRoleFor(list, 'nostr', pk), 'superadmin'); + assert.equal(adminRoleFor(list, 'nostr', pk.toUpperCase()), 'superadmin'); // hex case-insensitive + assert.equal(adminRoleFor(list, 'bluesky', 'did:plc:member1'), 'admin'); + assert.equal(adminRoleFor(list, 'nostr', 'b'.repeat(64)), null); + assert.equal(adminRoleFor(list, 'bluesky', 'did:plc:stranger'), null); +}); + // ---- Nostr login happy path + session cookie contract ---- test('allowlisted NIP-98 login mints a Strict __Host- admin session + csrf', async () => { const env = fakeEnv(); const sk = generateSecretKey(); const pk = getPublicKey(sk); - const deps = { allowlist: { nostrPubkeys: [pk], blueskyDids: [] } }; + const deps = { allowlist: { nostr: [{ pubkey: pk, role: 'admin' }], bluesky: [] } }; const res = await nostrLogin(env, sk, deps); assert.equal(res.status, 200); const body = (await res.json()) as { identity: string; method: string; csrf: string }; @@ -230,11 +267,11 @@ test('mid-session REVOCATION (Nostr): de-listing an identity kills its live cook const env = fakeEnv(); const sk = generateSecretKey(); const pk = getPublicKey(sk); - const allowed = { allowlist: { nostrPubkeys: [pk], blueskyDids: [] } }; + const allowed = { allowlist: { nostr: [{ pubkey: pk, role: 'admin' }], bluesky: [] } }; const id = sessionIdOf(await nostrLogin(env, sk, allowed)); // While the session is still well within its idle+absolute bounds, the identity // is removed from the allowlist (simulating a revocation commit+deploy). - const revoked = { allowlist: { nostrPubkeys: [], blueskyDids: [] } }; + const revoked = { allowlist: EMPTY_ALLOWLIST }; assert.equal((await routeAdmin(withAdminCookie('/api/admin/whoami', id), env, revoked)).status, 401); // …and the session is destroyed, not merely rejected: even the ORIGINAL (still- // allowlisted) deps can't resurrect it. @@ -244,14 +281,14 @@ test('mid-session REVOCATION (Nostr): de-listing an identity kills its live cook test('mid-session REVOCATION (Bluesky): de-listing a DID kills its elevated cookie session next request', async () => { const env = fakeEnv(); const did = 'did:plc:revokeme'; - const allowed = { allowlist: { nostrPubkeys: [], blueskyDids: [did] } }; + const allowed = { allowlist: { nostr: [], bluesky: [{ did: did, role: 'admin' }] } }; const sid = await blueskyUserSession(env, did); const elevated = await routeAdmin(req('/api/admin/elevate/bluesky', { method: 'POST', headers: { cookie: `${SESSION_COOKIE}=${sid}` }, }), env, allowed); const id = sessionIdOf(elevated); assert.equal((await routeAdmin(withAdminCookie('/api/admin/whoami', id), env, allowed)).status, 200); - const revoked = { allowlist: { nostrPubkeys: [], blueskyDids: [] } }; + const revoked = { allowlist: EMPTY_ALLOWLIST }; assert.equal((await routeAdmin(withAdminCookie('/api/admin/whoami', id), env, revoked)).status, 401); assert.equal((await routeAdmin(withAdminCookie('/api/admin/whoami', id), env, allowed)).status, 401); // destroyed }); @@ -259,7 +296,7 @@ test('mid-session REVOCATION (Bluesky): de-listing a DID kills its elevated cook test('a replayed login (same challenge + token) fails: challenges are single-use', async () => { const env = fakeEnv(); const sk = generateSecretKey(); - const deps = { allowlist: { nostrPubkeys: [getPublicKey(sk)], blueskyDids: [] } }; + const deps = { allowlist: { nostr: [{ pubkey: getPublicKey(sk), role: 'admin' }], bluesky: [] } }; const cRes = await routeAdmin(req('/api/admin/login/nostr/challenge', { method: 'POST' }), env, deps); const { challenge } = (await cRes.json()) as { challenge: string }; const body = JSON.stringify({ challenge }); @@ -349,7 +386,7 @@ test('the final <60 s before the absolute bound skips the rewrite (KV min window test('logout demands the coordinator-held CSRF token and a same-site request', async () => { const env = fakeEnv(); const sk = generateSecretKey(); - const deps = { allowlist: { nostrPubkeys: [getPublicKey(sk)], blueskyDids: [] } }; + const deps = { allowlist: { nostr: [{ pubkey: getPublicKey(sk), role: 'admin' }], bluesky: [] } }; const login = await nostrLogin(env, sk, deps); const id = sessionIdOf(login); const { csrf } = (await login.json()) as { csrf: string }; @@ -391,7 +428,7 @@ test('per-request NIP-98 whoami: allowlisted GET signature works once, replay di const env = fakeEnv(); const sk = generateSecretKey(); const pk = getPublicKey(sk); - const deps = { allowlist: { nostrPubkeys: [pk], blueskyDids: [] } }; + const deps = { allowlist: { nostr: [{ pubkey: pk, role: 'admin' }], bluesky: [] } }; const token = tokenFor(sk, { url: `${ORIGIN}/api/admin/whoami`, method: 'GET' }); // bodyless: no payload tag const call = () => routeAdmin(req('/api/admin/whoami', { headers: { authorization: token } }), env, deps); const first = await call(); @@ -404,7 +441,7 @@ test('per-request NIP-98 negatives: bad sig, wrong url, wrong method, stale, fut const env = fakeEnv(); const sk = generateSecretKey(); const pk = getPublicKey(sk); - const deps = { allowlist: { nostrPubkeys: [pk], blueskyDids: [] } }; + const deps = { allowlist: { nostr: [{ pubkey: pk, role: 'admin' }], bluesky: [] } }; const url = `${ORIGIN}/api/admin/whoami`; const call = (authorization: string, d = deps) => routeAdmin(req('/api/admin/whoami', { headers: { authorization } }), env, d); @@ -436,7 +473,7 @@ async function blueskyUserSession(env: ReturnType, did: string) test('an allowlisted DID elevates its user session into an admin session', async () => { const env = fakeEnv(); const did = 'did:plc:adminxyz'; - const deps = { allowlist: { nostrPubkeys: [], blueskyDids: [did] } }; + const deps = { allowlist: { nostr: [], bluesky: [{ did: did, role: 'admin' }] } }; const sid = await blueskyUserSession(env, did); const res = await routeAdmin(req('/api/admin/elevate/bluesky', { method: 'POST', headers: { cookie: `${SESSION_COOKIE}=${sid}` }, @@ -453,7 +490,7 @@ test('an allowlisted DID elevates its user session into an admin session', async test('elevation rejects: no user session, non-allowlisted DID, nostr-only user, cross-site', async () => { const env = fakeEnv(); - const deps = { allowlist: { nostrPubkeys: [], blueskyDids: ['did:plc:someoneelse'] } }; + const deps = { allowlist: { nostr: [], bluesky: [{ did: 'did:plc:someoneelse', role: 'admin' }] } }; // no session cookie assert.equal((await routeAdmin(req('/api/admin/elevate/bluesky', { method: 'POST' }), env, deps)).status, 401); // session exists but the DID is not allowlisted → same generic 401