first commit

Made-with: Cursor
This commit is contained in:
Michaël
2026-02-26 18:33:00 -03:00
commit 3734365463
76 changed files with 14133 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
import { Request, Response, NextFunction } from "express";
import { verifyJwt } from "../auth/jwt.js";
import { nip98Auth } from "./nip98.js";
const BEARER_PREFIX = "Bearer ";
/**
* Accept either Bearer JWT or NIP-98. Sets req.nostr = { pubkey }.
*/
export function authOrNip98(req: Request, res: Response, next: NextFunction): void {
const auth = req.headers.authorization;
if (auth?.startsWith(BEARER_PREFIX)) {
const token = auth.slice(BEARER_PREFIX.length).trim();
const payload = verifyJwt(token);
if (payload) {
req.nostr = { pubkey: payload.pubkey, eventId: "" };
next();
return;
}
}
nip98Auth(req, res, next);
}

View File

@@ -0,0 +1,40 @@
import { Request, Response, NextFunction } from "express";
import { createHmac } from "crypto";
import { config } from "../config.js";
declare global {
namespace Express {
interface Request {
ipHash?: string;
}
}
}
/**
* Resolve client IP: X-Forwarded-For (first hop) when TRUST_PROXY, else socket remoteAddress.
*/
function getClientIp(req: Request): string {
if (config.trustProxy) {
const forwarded = req.headers["x-forwarded-for"];
if (forwarded) {
const first = typeof forwarded === "string" ? forwarded.split(",")[0] : forwarded[0];
const ip = first?.trim();
if (ip) return ip;
}
}
const addr = req.socket?.remoteAddress;
return addr ?? "0.0.0.0";
}
/**
* HMAC-SHA256(ip, HMAC_IP_SECRET) as hex.
*/
function hmacIp(ip: string): string {
return createHmac("sha256", config.hmacIpSecret).update(ip).digest("hex");
}
export function ipHashMiddleware(req: Request, _res: Response, next: NextFunction): void {
const ip = getClientIp(req);
req.ipHash = hmacIp(ip);
next();
}

View 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();
}