Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 78 additions & 27 deletions worker/admin/allowlist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<id>', // 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;
}
87 changes: 62 additions & 25 deletions worker/tests/admin-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, string> ?? {}), 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 };
Expand All @@ -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.
Expand All @@ -244,22 +281,22 @@ 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
});

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 });
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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();
Expand All @@ -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);
Expand Down Expand Up @@ -436,7 +473,7 @@ async function blueskyUserSession(env: ReturnType<typeof fakeEnv>, 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}` },
Expand All @@ -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
Expand Down
Loading