diff --git a/artifacts/api-server/src/routes/identity.ts b/artifacts/api-server/src/routes/identity.ts index 20ed82a..371361d 100644 --- a/artifacts/api-server/src/routes/identity.ts +++ b/artifacts/api-server/src/routes/identity.ts @@ -52,13 +52,22 @@ router.post("/identity/challenge", (_req: Request, res: Response) => { // event.kind — any (27235 recommended per NIP-98, but not enforced) router.post("/identity/verify", async (req: Request, res: Response) => { - const { event } = req.body as { event?: unknown }; + // Accept both { event } and { pubkey, event } shapes (pubkey is optional but asserted if present) + const { event, pubkey: explicitPubkey } = req.body as { event?: unknown; pubkey?: unknown }; if (!event || typeof event !== "object") { res.status(400).json({ error: "Body must include 'event' (Nostr signed event)" }); return; } + // If caller provided a top-level pubkey, validate it matches event.pubkey + if (explicitPubkey !== undefined) { + if (typeof explicitPubkey !== "string" || !/^[0-9a-f]{64}$/.test(explicitPubkey)) { + res.status(400).json({ error: "top-level 'pubkey' must be a 64-char hex string" }); + return; + } + } + // ── Validate event structure ────────────────────────────────────────────── const ev = event as Record; const pubkey = ev["pubkey"]; @@ -69,6 +78,12 @@ router.post("/identity/verify", async (req: Request, res: Response) => { return; } + // Assert top-level pubkey matches event.pubkey if both are provided + if (typeof explicitPubkey === "string" && explicitPubkey !== pubkey) { + res.status(400).json({ error: "top-level 'pubkey' does not match event.pubkey" }); + return; + } + if (typeof content !== "string" || content.trim().length === 0) { res.status(400).json({ error: "event.content must be the nonce string" }); return; diff --git a/artifacts/api-server/src/routes/jobs.ts b/artifacts/api-server/src/routes/jobs.ts index 4abeea5..408d39f 100644 --- a/artifacts/api-server/src/routes/jobs.ts +++ b/artifacts/api-server/src/routes/jobs.ts @@ -266,13 +266,20 @@ async function advanceJob(job: Job): Promise { // ── Resolve Nostr pubkey from token header or body ──────────────────────────── -function resolveNostrPubkey(req: Request): string | null { +/** Resolves a Nostr token from the request. + * Returns `{ pubkey, rejected }` where: + * rejected=false, pubkey=null → no token supplied (anonymous) + * rejected=true, pubkey=null → token supplied but invalid/expired → caller should 401 + * rejected=false, pubkey=str → valid token + */ +function resolveNostrPubkey(req: Request): { pubkey: string | null; rejected: boolean } { const header = req.headers["x-nostr-token"]; const bodyToken = req.body?.nostr_token; const raw = typeof header === "string" ? header : (typeof bodyToken === "string" ? bodyToken : null); - if (!raw) return null; + if (!raw) return { pubkey: null, rejected: false }; const parsed = trustService.verifyToken(raw.trim()); - return parsed?.pubkey ?? null; + if (!parsed) return { pubkey: null, rejected: true }; + return { pubkey: parsed.pubkey, rejected: false }; } router.post("/jobs", jobsLimiter, async (req: Request, res: Response) => { @@ -288,7 +295,12 @@ router.post("/jobs", jobsLimiter, async (req: Request, res: Response) => { const { request } = parseResult.data; // Optionally bind a Nostr identity — ensure row exists before FK insert - const nostrPubkey = resolveNostrPubkey(req); + const tokenResult = resolveNostrPubkey(req); + if (tokenResult.rejected) { + res.status(401).json({ error: "Invalid or expired nostr_token" }); + return; + } + const nostrPubkey = tokenResult.pubkey; if (nostrPubkey) await trustService.getOrCreate(nostrPubkey); try { diff --git a/artifacts/api-server/src/routes/sessions.ts b/artifacts/api-server/src/routes/sessions.ts index b0a1f83..4467ba3 100644 --- a/artifacts/api-server/src/routes/sessions.ts +++ b/artifacts/api-server/src/routes/sessions.ts @@ -138,13 +138,20 @@ async function advanceTopup(session: Session): Promise { // ── Resolve Nostr pubkey from token header or body ──────────────────────────── -function resolveNostrPubkey(req: Request): string | null { +/** Resolves a Nostr token from the request. + * Returns `{ pubkey, rejected }` where: + * rejected=false, pubkey=null → no token supplied (anonymous) + * rejected=true, pubkey=null → token supplied but invalid/expired → caller should 401 + * rejected=false, pubkey=str → valid token + */ +function resolveNostrPubkey(req: Request): { pubkey: string | null; rejected: boolean } { const header = req.headers["x-nostr-token"]; const bodyToken = req.body?.nostr_token; const raw = typeof header === "string" ? header : (typeof bodyToken === "string" ? bodyToken : null); - if (!raw) return null; + if (!raw) return { pubkey: null, rejected: false }; const parsed = trustService.verifyToken(raw.trim()); - return parsed?.pubkey ?? null; + if (!parsed) return { pubkey: null, rejected: true }; + return { pubkey: parsed.pubkey, rejected: false }; } // ── POST /sessions ───────────────────────────────────────────────────────────── @@ -161,7 +168,12 @@ router.post("/sessions", sessionsLimiter, async (req: Request, res: Response) => } // Optionally bind a Nostr identity — ensure row exists before FK insert - const nostrPubkey = resolveNostrPubkey(req); + const tokenResult = resolveNostrPubkey(req); + if (tokenResult.rejected) { + res.status(401).json({ error: "Invalid or expired nostr_token" }); + return; + } + const nostrPubkey = tokenResult.pubkey; if (nostrPubkey) await trustService.getOrCreate(nostrPubkey); try {