From 40ac0476291a6376c4fc695b8da35ed9cc803c59 Mon Sep 17 00:00:00 2001 From: Agent Date: Mon, 14 Sep 2026 13:04:26 -0400 Subject: [PATCH 1/2] Implement NIP-98 HTTP auth for GET/PUT/DELETE /subscription/email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three browser-facing endpoints now require a kind-27235 HTTP auth event (NIP-98) proving the caller controls the pubkey: - GET /subscription/email — pubkey extracted from auth header instead of query param; returns subscription for the authed pubkey. - PUT /subscription/email — pubkey extracted from auth header instead of trusting a client-supplied body field. - DELETE /subscription/:key — verifies auth pubkey matches subscription owner (returns 403 if mismatch). Server-side: decode base64 'Nostr ' Authorization header, JSON.parse, check kind === 27235, verifyEvent (nostr-tools/pure), then check u / method / payload tags against the request URL / method / body. README updated to reflect 'implemented' auth (not 'planned'). Integration test updated to generate NIP-98 auth headers via a new helper script (script/nip98-auth-header.mjs). --- README.md | 19 +++--- script/nip98-auth-header.mjs | 34 ++++++++++ src/server.ts | 118 +++++++++++++++++++++++++++++------ test/integration.sh | 101 ++++++++++++++++++++++-------- 4 files changed, 220 insertions(+), 52 deletions(-) create mode 100644 script/nip98-auth-header.mjs mode change 100755 => 100644 test/integration.sh diff --git a/README.md b/README.md index 51c9fa7..986ec88 100644 --- a/README.md +++ b/README.md @@ -49,27 +49,30 @@ Flotilla ──HTTP──▶ Mailship (PUT /subscription/email) ### PUT /subscription/email Idempotently register or update an email subscription. Re-sends the confirmation email only when the subscription is new or the email address changed; a frequency -change keeps the existing confirmation. +change keeps the existing confirmation. The pubkey is extracted from the NIP-98 +Authorization header — the body does not include a `pubkey` field. ``` -Body: { email, frequency, pubkey } -Auth: NIP-98 (planned) +Body: { email, frequency } +Auth: NIP-98 (Nostr Authorization header) Response: { key, callback } ``` -### GET /subscription/email?pubkey=... -Look up an existing subscription, so clients can avoid re-registering (and -re-confirming) when settings haven't changed. Returns 404 if none exists. +### GET /subscription/email +Look up an existing subscription for the authenticated pubkey, so clients can +avoid re-registering (and re-confirming) when settings haven't changed. +Returns 404 if none exists. ``` +Auth: NIP-98 (Nostr Authorization header) Response: { key, callback, email, frequency, confirmed } ``` ### DELETE /subscription/:key -Unsubscribe. +Unsubscribe. Verifies the NIP-98 auth pubkey matches the subscription owner. ``` -Auth: NIP-98 (planned) +Auth: NIP-98 (Nostr Authorization header) Response: { ok: true } ``` diff --git a/script/nip98-auth-header.mjs b/script/nip98-auth-header.mjs new file mode 100644 index 0000000..75c0603 --- /dev/null +++ b/script/nip98-auth-header.mjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node +// Generates a NIP-98 Authorization header value ("Nostr ") for +// testing purposes. +// +// Usage: +// node script/nip98-auth-header.mjs [body] +// +// Example: +// export AUTH=$(node script/nip98-auth-header.mjs \ +// "$SECRET" "$BASE_URL/subscription/email" PUT '{"email":"a@b.com","frequency":"daily"}') +// curl -H "Authorization: $AUTH" ... + +import { makeHttpAuth, makeHttpAuthHeader } from '@welshman/util' +import { Nip01Signer } from '@welshman/signer' + +const [, , secret, url, method, body] = process.argv + +if (!secret || !url) { + console.error('Usage: node script/nip98-auth-header.mjs [body]') + process.exit(1) +} + +const signer = Nip01Signer.fromSecret(secret) + +// Create the unsigned auth event template +const event = await makeHttpAuth(url, method || 'GET', body || undefined) + +// Stamp (created_at, pubkey, id) and sign +const signed = await signer.sign(event) + +// Encode as "Nostr " +const header = makeHttpAuthHeader(signed) + +console.log(header) \ No newline at end of file diff --git a/src/server.ts b/src/server.ts index 5ee771e..5029290 100644 --- a/src/server.ts +++ b/src/server.ts @@ -7,9 +7,79 @@ import { confirmSubscriptionAction, unsubscribeAction, registerSubscription, Act import { getSubscriptionById, insertEvent, getSubscriptionByKey, getSubscriptionByPubkey } from './database.js' import { load } from '@welshman/net' import { getIdFilters } from '@welshman/util' +import crypto from 'crypto' import { verifyEvent } from 'nostr-tools/pure' -// Endpoints +// ── NIP-98 HTTP Auth ──────────────────────────────────────────────────── + +// Verify a NIP-98 Authorization header and return the authenticated pubkey, +// or null if the header is missing, malformed, or invalid. +// +// The client constructs the auth event via @welshman/util: +// makeHttpAuth(url, method, body) → event +// makeHttpAuthHeader(event) → "Nostr " +// +// We decode, verify kind=27235, verifyEvent, then check u / method / payload +// tags against the actual request URL / method / body. +const verifyNip98Auth = async (req: Request): Promise => { + const authHeader = req.headers.authorization + if (!authHeader) return null + + // Format: "Nostr " + const match = authHeader.match(/^Nostr\s+(.+)$/) + if (!match) return null + + // Decode base64 + let eventJson: string + try { + eventJson = Buffer.from(match[1], 'base64').toString('utf-8') + } catch { + return null + } + + // Parse event + let event: any + try { + event = JSON.parse(eventJson) + } catch { + return null + } + + // Must be kind 27235 (HTTP Auth) + if (event.kind !== 27235) return null + + // Verify event signature and id hash + if (!verifyEvent(event)) return null + + const tags = event.tags || [] + + // Find required tags + const uTag = tags.find((t: string[]) => t[0] === 'u') + const methodTag = tags.find((t: string[]) => t[0] === 'method') + const payloadTag = tags.find((t: string[]) => t[0] === 'payload') + + // Build the full URL the server received + const expectedUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}` + + // u tag must match the request URL exactly + if (!uTag || uTag[1] !== expectedUrl) return null + + // method tag must match the HTTP method (upper case) + if (!methodTag || methodTag[1] !== req.method.toUpperCase()) return null + + // For requests with a body, check payload tag is the SHA256 of the body + if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method.toUpperCase())) { + if (req.body && Object.keys(req.body).length > 0) { + const bodyStr = JSON.stringify(req.body) + const expectedPayload = crypto.createHash('sha256').update(bodyStr).digest('hex') + if (!payloadTag || payloadTag[1] !== expectedPayload) return null + } + } + + return event.pubkey as string +} + +// ── Endpoints ────────────────────────────────────────────────────────── export const server: express.Application = express() @@ -85,13 +155,15 @@ addRoute('get', '/', async (req: Request, res: Response) => { }) }) -// Look up an existing email subscription for a pubkey, so clients can avoid -// re-registering (and re-confirming) when settings haven't changed. +// Look up an existing email subscription for the authenticated pubkey, so +// clients can avoid re-registering (and re-confirming) when settings +// haven't changed. Requires NIP-98 HTTP auth proving the caller controls +// the pubkey. addRoute('get', '/subscription/email', async (req: Request, res: Response) => { - const { pubkey } = req.query + const pubkey = await verifyNip98Auth(req) - if (!pubkey || typeof pubkey !== 'string') { - return res.status(400).json({ error: 'pubkey is required' }) + if (!pubkey) { + return res.status(401).json({ error: 'NIP-98 authorization required' }) } const sub = await getSubscriptionByPubkey(pubkey) @@ -111,9 +183,17 @@ addRoute('get', '/subscription/email', async (req: Request, res: Response) => { }) }) -// Subscribe to email digests (idempotent PUT upsert) +// Subscribe to email digests (idempotent PUT upsert). Requires NIP-98 HTTP +// auth proving the caller controls the pubkey — the pubkey is extracted from +// the auth event, not from the request body. addRoute('put', '/subscription/email', async (req: Request, res: Response) => { - const { email, frequency, pubkey } = req.body + const { email, frequency } = req.body + + const pubkey = await verifyNip98Auth(req) + + if (!pubkey) { + return res.status(401).json({ error: 'NIP-98 authorization required' }) + } if (!email || !email.includes('@')) { return res.status(400).json({ error: 'A valid email address is required' }) @@ -123,15 +203,6 @@ addRoute('put', '/subscription/email', async (req: Request, res: Response) => { return res.status(400).json({ error: 'Frequency must be "daily" or "weekly"' }) } - if (!pubkey) { - return res.status(400).json({ error: 'pubkey is required' }) - } - - // TODO: Verify NIP-98 auth header - // const auth = req.headers.authorization - // if (!auth) return res.status(401).json({ error: 'NIP-98 authorization required' }) - // Verify using @welshman/util makeHttpAuth - try { const result = await registerSubscription({ pubkey, email, frequency }) res.json(result) @@ -152,17 +223,26 @@ addRoute('put', '/subscription/email', async (req: Request, res: Response) => { } }) -// Delete subscription +// Delete subscription. Requires NIP-98 HTTP auth proving the caller controls +// the pubkey that owns this subscription. addRoute('delete', '/subscription/:key', async (req: Request, res: Response) => { const { key } = req.params + const pubkey = await verifyNip98Auth(req) + + if (!pubkey) { + return res.status(401).json({ error: 'NIP-98 authorization required' }) + } + const sub = await getSubscriptionByKey(key) if (!sub) { return res.status(404).json({ error: 'Subscription not found' }) } - // TODO: Verify NIP-98 auth header matches sub.pubkey + if (sub.pubkey !== pubkey) { + return res.status(403).json({ error: 'Forbidden: you do not own this subscription' }) + } await unsubscribeAction({ token: key }) res.json({ ok: true }) diff --git a/test/integration.sh b/test/integration.sh old mode 100755 new mode 100644 index d48122a..17ed3c5 --- a/test/integration.sh +++ b/test/integration.sh @@ -38,11 +38,31 @@ if [ ! -d "dist" ]; then npx tsc fi -# Generate a random secret for the test -SECRET="$(openssl rand -hex 32)" +# Generate secrets for the test: one for the server, one for the client +SERVER_SECRET="$(openssl rand -hex 32)" +CLIENT_SECRET="$(openssl rand -hex 32)" + +# Derive the client pubkey so we can look up subscriptions later +CLIENT_PUBKEY=$(node -e " +import {Nip01Signer} from '@welshman/signer'; +const s = Nip01Signer.fromSecret('$CLIENT_SECRET'); +s.getPubkey().then(p => console.log(p)); +") + +echo "Client pubkey: $CLIENT_PUBKEY" + +# Helper to build a NIP-98 auth header +nip98_auth() { + local url="$1" method="$2" body="${3:-}" + if [ -n "$body" ]; then + node "$PROJECT_DIR/script/nip98-auth-header.mjs" "$CLIENT_SECRET" "$url" "$method" "$body" + else + node "$PROJECT_DIR/script/nip98-auth-header.mjs" "$CLIENT_SECRET" "$url" "$method" + fi +} # Export env vars for the server -export MAILSHIP_SECRET="$SECRET" +export MAILSHIP_SECRET="$SERVER_SECRET" export MAILSHIP_NAME="Mailship Test" export MAILSHIP_URL="$BASE_URL" export BASE_URL="$BASE_URL" @@ -117,11 +137,13 @@ echo "1. Health check" HEALTH=$(curl -s "$BASE_URL/") check_field "Root endpoint returns Mailship" "$HEALTH" "name" "Mailship" -# Test 2: Register subscription +# Test 2: Register subscription with NIP-98 auth echo "" -echo "2. Register subscription" +echo "2. Register subscription (NIP-98 auth)" +AUTH_PUT=$(nip98_auth "$BASE_URL/subscription/email" PUT '{"email":"test@example.com","frequency":"daily"}') REG=$(curl -s "$BASE_URL/subscription/email" -X PUT -H "Content-Type: application/json" \ - -d '{"email":"test@example.com","frequency":"daily","pubkey":"abc123"}') + -H "Authorization: $AUTH_PUT" \ + -d '{"email":"test@example.com","frequency":"daily"}') KEY=$(echo "$REG" | python3 -c "import sys,json; print(json.load(sys.stdin).get('key',''))" 2>/dev/null) CALLBACK=$(echo "$REG" | python3 -c "import sys,json; print(json.load(sys.stdin).get('callback',''))" 2>/dev/null) @@ -132,9 +154,31 @@ else fail "Registration missing key or callback (got: $REG)" fi -# Test 3: Confirm subscription +# Test 3: PUT without auth returns 401 echo "" -echo "3. Confirm subscription" +echo "3. PUT without auth returns 401" +NO_AUTH=$(curl -s "$BASE_URL/subscription/email" -X PUT -H "Content-Type: application/json" \ + -d '{"email":"test@example.com","frequency":"daily"}') +check_field "No-auth PUT returns 401" "$NO_AUTH" "error" "NIP-98 authorization required" + +# Test 4: GET /subscription/email with auth +echo "" +echo "4. GET subscription with auth" +AUTH_GET=$(nip98_auth "$BASE_URL/subscription/email" GET) +GET_RESP=$(curl -s "$BASE_URL/subscription/email" \ + -H "Authorization: $AUTH_GET") +check_field "GET returns our email" "$GET_RESP" "email" "test@example.com" +check_field "GET returns frequency" "$GET_RESP" "frequency" "daily" + +# Test 5: GET without auth returns 401 +echo "" +echo "5. GET without auth returns 401" +GET_NO_AUTH=$(curl -s "$BASE_URL/subscription/email") +check_field "No-auth GET returns 401" "$GET_NO_AUTH" "error" "NIP-98 authorization required" + +# Test 6: Confirm subscription +echo "" +echo "6. Confirm subscription" CONFIRM=$(curl -s "$BASE_URL/confirm?token=$KEY" 2>&1) if echo "$CONFIRM" | grep -qi "success"; then pass "Confirmation page shows success" @@ -142,20 +186,20 @@ else fail "Confirmation page doesn't show success" fi -# Test 4: Check SQLite state +# Test 7: Check SQLite state echo "" -echo "4. Database state" -CONFIRMED=$(sqlite3 "$DB_PATH" "SELECT confirmed_at FROM subscriptions WHERE email='test@example.com';" 2>/dev/null) +echo "7. Database state" +CONFIRMED=$(sqlite3 "$DB_PATH" "SELECT confirmed_at FROM subscriptions WHERE pubkey='$CLIENT_PUBKEY';" 2>/dev/null) if [ -n "$CONFIRMED" ] && [ "$CONFIRMED" -gt 0 ]; then pass "Subscription confirmed in DB" else fail "Subscription not confirmed in DB" fi -# Test 5: Push event to notify endpoint +# Test 8: Push event to notify endpoint echo "" -echo "5. Push event via notify" -SUB_ID=$(sqlite3 "$DB_PATH" "SELECT id FROM subscriptions WHERE email='test@example.com';" 2>/dev/null) +echo "8. Push event via notify" +SUB_ID=$(sqlite3 "$DB_PATH" "SELECT id FROM subscriptions WHERE pubkey='$CLIENT_PUBKEY';" 2>/dev/null) # Fetch a real event from a relay EVENT_ID=$(nak req -k 1 -l 1 wss://relay.primal.net 2>/dev/null | head -1 | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['id'])" 2>/dev/null) @@ -173,25 +217,25 @@ else check_field "Event stored in DB" "$NOTIFY" "stored" "True" fi -# Test 6: Dedup +# Test 9: Dedup echo "" -echo "6. Dedup" +echo "9. Dedup" if [ -n "$EVENT_ID" ]; then DEDUP=$(curl -s "$BASE_URL/notify/$SUB_ID" -X POST -H "Content-Type: application/json" \ -d "{\"id\":\"$EVENT_ID\",\"relay\":\"wss://relay.primal.net\"}") check_field "Duplicate event rejected" "$DEDUP" "stored" "False" fi -# Test 7: 404 for nonexistent subscription +# Test 10: 404 for nonexistent subscription echo "" -echo "7. 404 for nonexistent subscription" +echo "10. 404 for nonexistent subscription" NOT_FOUND=$(curl -s "$BASE_URL/notify/nonexistent-id" -X POST -H "Content-Type: application/json" \ -d '{"id":"abc","relay":"wss://relay.primal.net"}') check_field "Nonexistent subscription returns 404" "$NOT_FOUND" "error" "Subscription not found" -# Test 8: Unsubscribe +# Test 11: Unsubscribe echo "" -echo "8. Unsubscribe" +echo "11. Unsubscribe" UNSUB=$(curl -s "$BASE_URL/unsubscribe?token=$KEY") if echo "$UNSUB" | grep -qi "unsubscribed\|success"; then pass "Unsubscribe page renders" @@ -199,21 +243,28 @@ else fail "Unsubscribe page didn't render" fi -# Test 9: Notify after unsubscribe returns 404 +# Test 12: Notify after unsubscribe returns 404 echo "" -echo "9. No push after unsubscribe" +echo "12. No push after unsubscribe" if [ -n "$EVENT_ID" ]; then AFTER_UNSUB=$(curl -s "$BASE_URL/notify/$SUB_ID" -X POST -H "Content-Type: application/json" \ -d "{\"id\":\"$EVENT_ID\",\"relay\":\"wss://relay.primal.net\"}") check_field "Push after unsubscribe returns 404" "$AFTER_UNSUB" "error" "Subscription not active" fi -# Test 10: Delete subscription +# Test 13: Delete subscription with auth echo "" -echo "10. Delete subscription" -DELETE=$(curl -s "$BASE_URL/subscription/$KEY" -X DELETE 2>&1) +echo "13. Delete subscription with NIP-98 auth" +AUTH_DEL=$(nip98_auth "$BASE_URL/subscription/$KEY" DELETE) +DELETE=$(curl -s "$BASE_URL/subscription/$KEY" -X DELETE -H "Authorization: $AUTH_DEL") check_field "Delete returns ok" "$DELETE" "ok" "True" +# Test 14: Delete without auth returns 401 +echo "" +echo "14. Delete without auth returns 401" +DEL_NO_AUTH=$(curl -s "$BASE_URL/subscription/$KEY" -X DELETE) +check_field "No-auth DELETE returns 401" "$DEL_NO_AUTH" "error" "NIP-98 authorization required" + # Summary echo "" echo "=========================================" -- 2.45.2 From b94018c11efec79e0eefd25b7e0af0bab56700c8 Mon Sep 17 00:00:00 2001 From: Agent Date: Mon, 14 Sep 2026 13:07:16 -0400 Subject: [PATCH 2/2] Fix integration test: add missing CORS_ORIGIN env var MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test was failing because src/env.ts requires CORS_ORIGIN to be set, but the integration test never exported it. This is a pre-existing setup gap exposed by running the test — not a NIP-98 regression. --- test/integration.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/test/integration.sh b/test/integration.sh index 17ed3c5..c21a0d4 100644 --- a/test/integration.sh +++ b/test/integration.sh @@ -75,6 +75,7 @@ export DEFAULT_RELAYS="wss://relay.damus.io" export INDEXER_RELAYS="wss://purplepag.es" export SEARCH_RELAYS="wss://relay.nostr.band" export PORT="$PORT" +export CORS_ORIGIN="$BASE_URL" export DATA_DIR="$PROJECT_DIR/test-data" mkdir -p "$DATA_DIR" -- 2.45.2