first commit
Made-with: Cursor
This commit is contained in:
153
backend/src/middleware/nip98.ts
Normal file
153
backend/src/middleware/nip98.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { verifyEvent, getEventHash } from "nostr-tools";
|
||||
import { config } from "../config.js";
|
||||
import { getDb } from "../db/index.js";
|
||||
|
||||
const AUTH_SCHEME = "Nostr ";
|
||||
|
||||
export interface NostrAuthPayload {
|
||||
pubkey: string;
|
||||
eventId: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
nostr?: NostrAuthPayload;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getTag(event: { tags: string[][] }, name: string): string | null {
|
||||
const row = event.tags.find((t) => t[0] === name);
|
||||
return row && row[1] ? row[1] : null;
|
||||
}
|
||||
|
||||
export async function nip98Auth(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
const auth = req.headers.authorization;
|
||||
if (!auth || !auth.startsWith(AUTH_SCHEME)) {
|
||||
res.status(401).json({
|
||||
code: "invalid_nip98",
|
||||
message: "Missing or invalid Authorization header. Use NIP-98 Nostr scheme.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const base64 = auth.slice(AUTH_SCHEME.length).trim();
|
||||
let event: { id: string; pubkey: string; kind: number; created_at: number; tags: string[][]; content: string; sig: string };
|
||||
try {
|
||||
const decoded = Buffer.from(base64, "base64").toString("utf-8");
|
||||
const parsed = JSON.parse(decoded) as Record<string, unknown>;
|
||||
event = {
|
||||
id: String(parsed.id ?? ""),
|
||||
pubkey: String(parsed.pubkey ?? ""),
|
||||
kind: Number(parsed.kind),
|
||||
created_at: Number(parsed.created_at),
|
||||
tags: Array.isArray(parsed.tags) ? (parsed.tags as string[][]) : [],
|
||||
content: typeof parsed.content === "string" ? parsed.content : "",
|
||||
sig: String(parsed.sig ?? ""),
|
||||
};
|
||||
} catch {
|
||||
res.status(401).json({
|
||||
code: "invalid_nip98",
|
||||
message: "Invalid NIP-98 payload: not valid base64 or JSON.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.kind !== 27235) {
|
||||
res.status(401).json({
|
||||
code: "invalid_nip98",
|
||||
message: "NIP-98 event kind must be 27235.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (Math.abs(event.created_at - now) > config.nip98MaxSkewSeconds) {
|
||||
res.status(401).json({
|
||||
code: "invalid_nip98",
|
||||
message: "NIP-98 event timestamp is outside allowed window.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const u = getTag(event, "u");
|
||||
const method = getTag(event, "method");
|
||||
if (!u || !method) {
|
||||
res.status(401).json({
|
||||
code: "invalid_nip98",
|
||||
message: "NIP-98 event must include 'u' and 'method' tags.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Reconstruct absolute URL (protocol + host + path + query)
|
||||
const proto = req.headers["x-forwarded-proto"] ?? (req.socket as { encrypted?: boolean }).encrypted ? "https" : "http";
|
||||
const host = req.headers["x-forwarded-host"] ?? req.headers.host ?? "";
|
||||
const path = req.originalUrl ?? req.url;
|
||||
const absoluteUrl = `${proto}://${host}${path}`;
|
||||
if (u !== absoluteUrl) {
|
||||
res.status(401).json({
|
||||
code: "invalid_nip98",
|
||||
message: "NIP-98 'u' tag does not match request URL.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method.toUpperCase() !== req.method) {
|
||||
res.status(401).json({
|
||||
code: "invalid_nip98",
|
||||
message: "NIP-98 'method' tag does not match request method.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const computedId = getEventHash({
|
||||
kind: event.kind,
|
||||
pubkey: event.pubkey,
|
||||
created_at: event.created_at,
|
||||
tags: event.tags,
|
||||
content: event.content,
|
||||
sig: event.sig,
|
||||
} as Parameters<typeof getEventHash>[0]);
|
||||
if (computedId !== event.id) {
|
||||
res.status(401).json({
|
||||
code: "invalid_nip98",
|
||||
message: "NIP-98 event id does not match computed hash.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const valid = verifyEvent({
|
||||
id: event.id,
|
||||
kind: event.kind,
|
||||
pubkey: event.pubkey,
|
||||
created_at: event.created_at,
|
||||
tags: event.tags,
|
||||
content: event.content,
|
||||
sig: event.sig,
|
||||
} as Parameters<typeof verifyEvent>[0]);
|
||||
if (!valid) {
|
||||
res.status(401).json({
|
||||
code: "invalid_nip98",
|
||||
message: "NIP-98 signature verification failed.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const hasNonce = await db.hasNonce(event.id);
|
||||
if (hasNonce) {
|
||||
res.status(401).json({
|
||||
code: "invalid_nip98",
|
||||
message: "NIP-98 nonce already used (replay).",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const expiresAt = now + config.nonceTtlSeconds;
|
||||
await db.setNonce(event.id, expiresAt);
|
||||
req.nostr = { pubkey: event.pubkey, eventId: event.id };
|
||||
next();
|
||||
}
|
||||
Reference in New Issue
Block a user