## Code review round 2 issues resolved
### Vouch replay / duplicate boost vulnerability — FIXED
- `nostr-trust-vouches.ts` schema: added `eventId` column + two unique guards:
1. `UNIQUE(event_id)` — same signed event cannot be replayed for any pair
2. `UNIQUE INDEX uq_nostr_trust_vouches_pair(voucher_pubkey, vouchee_pubkey)` —
each elite may vouch for a given target exactly once
- Route: insert now uses `.onConflictDoNothing().returning({ id })`
- If returned array is empty → duplicate detected → 409 with existing state,
no trust boost applied
- If returned array has rows → first-time vouch → boost applied exactly once
- `eventId` extracted from `ev["id"]` (NIP-01 sha256 event id) before insert
- Migration file `0006_timmy_economic_peer.sql` updated to include both
unique constraints (UNIQUE + CREATE UNIQUE INDEX)
- Schema pushed to production — all three indexes confirmed in DB:
`nostr_trust_vouches_event_id_unique`, `uq_nostr_trust_vouches_pair`, `pkey`
### Previously fixed (round 1)
- LNURL-pay resolution in ZapService (full NIP-57 §4 flow)
- Vouch event made required with p-tag vouchee binding
- DB migration file 0006 created for both new tables + lightning_address column
- GET /identity/timmy now returns relayUrl field
### Verified
- TypeScript: 0 errors (tsc --noEmit clean)
- DB: all constraints confirmed live in production
- API: /identity/timmy 200, /identity/challenge nonce, /identity/vouch 401/400
410 lines
15 KiB
TypeScript
410 lines
15 KiB
TypeScript
import { Router, type Request, type Response } from "express";
|
|
import { randomBytes, randomUUID } from "crypto";
|
|
import { verifyEvent, validateEvent } from "nostr-tools";
|
|
import { db, nostrTrustVouches, nostrIdentities, timmyNostrEvents } from "@workspace/db";
|
|
import { eq, count } from "drizzle-orm";
|
|
import { trustService } from "../lib/trust.js";
|
|
import { timmyIdentityService } from "../lib/timmy-identity.js";
|
|
import { makeLogger } from "../lib/logger.js";
|
|
|
|
const logger = makeLogger("identity");
|
|
const router = Router();
|
|
|
|
// ── In-memory nonce store (TTL = 5 minutes) ───────────────────────────────────
|
|
// Nonces are single-use: consumed on first successful verify.
|
|
|
|
const NONCE_TTL_MS = 5 * 60 * 1000;
|
|
|
|
interface ChallengeEntry {
|
|
nonce: string;
|
|
expiresAt: number;
|
|
}
|
|
|
|
const challenges = new Map<string, ChallengeEntry>();
|
|
|
|
// Cleanup stale nonces periodically
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [key, entry] of challenges) {
|
|
if (now > entry.expiresAt) challenges.delete(key);
|
|
}
|
|
}, 60_000);
|
|
|
|
// ── POST /identity/challenge ──────────────────────────────────────────────────
|
|
// Returns a time-limited nonce the client must sign with their Nostr key.
|
|
|
|
router.post("/identity/challenge", (_req: Request, res: Response) => {
|
|
const nonce = randomBytes(32).toString("hex");
|
|
const expiresAt = Date.now() + NONCE_TTL_MS;
|
|
challenges.set(nonce, { nonce, expiresAt });
|
|
|
|
res.json({
|
|
nonce,
|
|
expiresAt: new Date(expiresAt).toISOString(),
|
|
});
|
|
});
|
|
|
|
// ── POST /identity/verify ─────────────────────────────────────────────────────
|
|
// Accepts a NIP-01 signed event whose `content` field contains the nonce.
|
|
// Verifies the signature, consumes the nonce, upserts the identity row,
|
|
// and returns a signed `nostr_token` valid for 24 h.
|
|
//
|
|
// Body: { event: NostrEvent }
|
|
// event.pubkey — 64-char hex pubkey
|
|
// event.content — the nonce returned by /identity/challenge
|
|
// event.kind — any (27235 recommended per NIP-98, but not enforced)
|
|
// event.tags — optional: include ["lud16", "user@domain.com"] to store
|
|
// a lightning address for future zap-out payments
|
|
|
|
router.post("/identity/verify", async (req: Request, res: Response) => {
|
|
// 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<string, unknown>;
|
|
const pubkey = ev["pubkey"];
|
|
const content = ev["content"];
|
|
|
|
if (typeof pubkey !== "string" || !/^[0-9a-f]{64}$/.test(pubkey)) {
|
|
res.status(400).json({ error: "event.pubkey must be a 64-char hex string" });
|
|
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;
|
|
}
|
|
|
|
const nonce = content.trim();
|
|
|
|
// ── Check nonce ──────────────────────────────────────────────────────────
|
|
const entry = challenges.get(nonce);
|
|
if (!entry) {
|
|
res.status(401).json({ error: "Nonce not found or already consumed. Request a new challenge." });
|
|
return;
|
|
}
|
|
if (Date.now() > entry.expiresAt) {
|
|
challenges.delete(nonce);
|
|
res.status(401).json({ error: "Nonce expired. Request a new challenge." });
|
|
return;
|
|
}
|
|
|
|
// ── Verify Nostr signature ────────────────────────────────────────────────
|
|
if (!validateEvent(ev as Parameters<typeof validateEvent>[0])) {
|
|
res.status(401).json({ error: "Invalid Nostr event structure" });
|
|
return;
|
|
}
|
|
|
|
let valid = false;
|
|
try {
|
|
valid = verifyEvent(ev as Parameters<typeof verifyEvent>[0]);
|
|
} catch {
|
|
valid = false;
|
|
}
|
|
|
|
if (!valid) {
|
|
res.status(401).json({ error: "Nostr signature verification failed" });
|
|
return;
|
|
}
|
|
|
|
// ── Consume nonce (single-use) ────────────────────────────────────────────
|
|
challenges.delete(nonce);
|
|
|
|
// ── Upsert identity & issue token ────────────────────────────────────────
|
|
try {
|
|
const identity = await trustService.getOrCreate(pubkey);
|
|
const token = trustService.issueToken(pubkey);
|
|
const tierInfo = await trustService.getIdentityWithDecay(pubkey);
|
|
|
|
// ── Optional: persist lightning address from lud16 tag ────────────────
|
|
// Allows ZapService to resolve LNURL-pay invoices for this identity.
|
|
const tags = Array.isArray(ev["tags"]) ? ev["tags"] as unknown[][] : [];
|
|
const lud16Tag = tags.find(t => Array.isArray(t) && t[0] === "lud16" && typeof t[1] === "string");
|
|
if (lud16Tag) {
|
|
const lightningAddress = lud16Tag[1] as string;
|
|
const lnAddrRe = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
|
if (lnAddrRe.test(lightningAddress)) {
|
|
await db
|
|
.update(nostrIdentities)
|
|
.set({ lightningAddress, updatedAt: new Date() })
|
|
.where(eq(nostrIdentities.pubkey, pubkey));
|
|
logger.info("lightning address stored", { pubkey: pubkey.slice(0, 8), lightningAddress });
|
|
}
|
|
}
|
|
|
|
logger.info("identity verified", {
|
|
pubkey: pubkey.slice(0, 8),
|
|
tier: identity.tier,
|
|
score: identity.trustScore,
|
|
});
|
|
|
|
res.json({
|
|
pubkey,
|
|
nostr_token: token,
|
|
trust: {
|
|
tier: tierInfo?.tier ?? identity.tier,
|
|
score: tierInfo?.trustScore ?? identity.trustScore,
|
|
interactionCount: identity.interactionCount,
|
|
memberSince: identity.createdAt.toISOString(),
|
|
},
|
|
});
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : "Failed to upsert identity";
|
|
logger.error("identity verify failed", { error: message });
|
|
res.status(500).json({ error: message });
|
|
}
|
|
});
|
|
|
|
// ── GET /identity/timmy ───────────────────────────────────────────────────────
|
|
// Returns Timmy's own Nostr identity (npub) and outbound zap statistics.
|
|
// Public endpoint — no authentication required.
|
|
|
|
router.get("/identity/timmy", async (_req: Request, res: Response) => {
|
|
try {
|
|
const zapRows = await db
|
|
.select({ count: count() })
|
|
.from(timmyNostrEvents)
|
|
.where(eq(timmyNostrEvents.kind, 9734));
|
|
|
|
const zapCount = zapRows[0]?.count ?? 0;
|
|
|
|
res.json({
|
|
npub: timmyIdentityService.npub,
|
|
pubkeyHex: timmyIdentityService.pubkeyHex,
|
|
zapCount,
|
|
relayUrl: process.env["NOSTR_RELAY_URL"] ?? null,
|
|
});
|
|
} catch (err) {
|
|
res.status(500).json({ error: err instanceof Error ? err.message : "Failed to fetch Timmy identity" });
|
|
}
|
|
});
|
|
|
|
// ── POST /identity/vouch ──────────────────────────────────────────────────────
|
|
// Allows an elite-tier identity to co-sign a new pubkey, granting it a trust
|
|
// boost. The voucher MUST provide a signed Nostr event that explicitly tags the
|
|
// voucheePubkey in a "p" tag, proving the co-signing is intentional.
|
|
//
|
|
// Requires: X-Nostr-Token with elite tier.
|
|
// Body: {
|
|
// voucheePubkey: string, — 64-char hex pubkey of the identity to vouch for
|
|
// event: NostrSignedEvent — REQUIRED: voucher's signed kind-1 (or 9735) event
|
|
// containing a ["p", voucheePubkey] tag
|
|
// }
|
|
|
|
const VOUCH_TRUST_BOOST = 20;
|
|
|
|
router.post("/identity/vouch", async (req: Request, res: Response) => {
|
|
const raw = req.headers["x-nostr-token"];
|
|
const token = typeof raw === "string" ? raw.trim() : null;
|
|
|
|
if (!token) {
|
|
res.status(401).json({ error: "X-Nostr-Token header required" });
|
|
return;
|
|
}
|
|
|
|
const parsed = trustService.verifyToken(token);
|
|
if (!parsed) {
|
|
res.status(401).json({ error: "Invalid or expired nostr_token" });
|
|
return;
|
|
}
|
|
|
|
const tier = await trustService.getTier(parsed.pubkey);
|
|
if (tier !== "elite") {
|
|
res.status(403).json({ error: "Vouching requires elite trust tier" });
|
|
return;
|
|
}
|
|
|
|
const { voucheePubkey, event } = req.body as {
|
|
voucheePubkey?: unknown;
|
|
event?: unknown;
|
|
};
|
|
|
|
if (typeof voucheePubkey !== "string" || !/^[0-9a-f]{64}$/.test(voucheePubkey)) {
|
|
res.status(400).json({ error: "voucheePubkey must be a 64-char hex pubkey" });
|
|
return;
|
|
}
|
|
|
|
if (voucheePubkey === parsed.pubkey) {
|
|
res.status(400).json({ error: "Cannot vouch for yourself" });
|
|
return;
|
|
}
|
|
|
|
// ── event is REQUIRED — voucher must produce a signed Nostr event ─────────
|
|
if (!event || typeof event !== "object") {
|
|
res.status(400).json({
|
|
error: "event is required: include a signed Nostr event with a [\"p\", voucheePubkey] tag",
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!validateEvent(event as Parameters<typeof validateEvent>[0])) {
|
|
res.status(400).json({ error: "Invalid Nostr event structure" });
|
|
return;
|
|
}
|
|
|
|
let valid = false;
|
|
try { valid = verifyEvent(event as Parameters<typeof verifyEvent>[0]); } catch { valid = false; }
|
|
if (!valid) {
|
|
res.status(401).json({ error: "Nostr signature verification failed" });
|
|
return;
|
|
}
|
|
|
|
const ev = event as Record<string, unknown>;
|
|
|
|
// ── event.pubkey must match the authenticated voucher ────────────────────
|
|
if (ev["pubkey"] !== parsed.pubkey) {
|
|
res.status(400).json({ error: "event.pubkey does not match X-Nostr-Token identity" });
|
|
return;
|
|
}
|
|
|
|
// ── event must contain a ["p", voucheePubkey] tag — binding co-signature ─
|
|
// This proves the voucher intentionally named the vouchee in their signed event.
|
|
const tags = Array.isArray(ev["tags"]) ? ev["tags"] as unknown[][] : [];
|
|
const pTag = tags.find(
|
|
t => Array.isArray(t) && t[0] === "p" && t[1] === voucheePubkey
|
|
);
|
|
if (!pTag) {
|
|
res.status(400).json({
|
|
error: `event must include a ["p", "${voucheePubkey}"] tag to bind the co-signature`,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// ── Extract event ID for replay guard ────────────────────────────────────
|
|
const eventId = typeof ev["id"] === "string" ? ev["id"] : randomUUID();
|
|
|
|
try {
|
|
// Upsert vouchee identity so it exists before applying boost
|
|
await trustService.getOrCreate(voucheePubkey);
|
|
|
|
// Idempotent insert — unique constraints on (voucher, vouchee) pair AND
|
|
// event_id prevent both duplicate vouches and event replay attacks.
|
|
// onConflictDoNothing().returning() returns [] on conflict: no boost applied.
|
|
const inserted = await db
|
|
.insert(nostrTrustVouches)
|
|
.values({
|
|
id: randomUUID(),
|
|
voucherPubkey: parsed.pubkey,
|
|
voucheePubkey,
|
|
eventId,
|
|
vouchEventJson: JSON.stringify(event),
|
|
trustBoost: VOUCH_TRUST_BOOST,
|
|
})
|
|
.onConflictDoNothing()
|
|
.returning({ id: nostrTrustVouches.id });
|
|
|
|
if (inserted.length === 0) {
|
|
// Duplicate — either same pair already vouched or event replayed.
|
|
// Return existing state without applying another boost.
|
|
const voucheeIdentity = await trustService.getIdentityWithDecay(voucheePubkey);
|
|
logger.info("vouch duplicate — no additional boost applied", {
|
|
voucher: parsed.pubkey.slice(0, 8),
|
|
vouchee: voucheePubkey.slice(0, 8),
|
|
});
|
|
res.status(409).json({
|
|
ok: false,
|
|
duplicate: true,
|
|
message: "Already vouched for this identity — co-signature recorded but no additional boost applied",
|
|
voucheePubkey,
|
|
currentScore: voucheeIdentity?.trustScore,
|
|
currentTier: voucheeIdentity?.tier,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// First-time vouch: apply the one-time trust boost
|
|
const currentRows = await db
|
|
.select({ score: nostrIdentities.trustScore })
|
|
.from(nostrIdentities)
|
|
.where(eq(nostrIdentities.pubkey, voucheePubkey));
|
|
const currentScore = currentRows[0]?.score ?? 0;
|
|
|
|
await db
|
|
.update(nostrIdentities)
|
|
.set({ trustScore: currentScore + VOUCH_TRUST_BOOST, updatedAt: new Date() })
|
|
.where(eq(nostrIdentities.pubkey, voucheePubkey));
|
|
|
|
logger.info("vouch recorded", {
|
|
voucher: parsed.pubkey.slice(0, 8),
|
|
vouchee: voucheePubkey.slice(0, 8),
|
|
boost: VOUCH_TRUST_BOOST,
|
|
});
|
|
|
|
const voucheeIdentity = await trustService.getIdentityWithDecay(voucheePubkey);
|
|
|
|
res.json({
|
|
ok: true,
|
|
voucheePubkey,
|
|
trustBoost: VOUCH_TRUST_BOOST,
|
|
newScore: voucheeIdentity?.trustScore,
|
|
newTier: voucheeIdentity?.tier,
|
|
});
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : "Failed to record vouch";
|
|
logger.error("vouch failed", { error: message });
|
|
res.status(500).json({ error: message });
|
|
}
|
|
});
|
|
|
|
// ── GET /identity/me ──────────────────────────────────────────────────────────
|
|
// Look up the trust profile for a verified nostr_token.
|
|
|
|
router.get("/identity/me", async (req: Request, res: Response) => {
|
|
const raw = req.headers["x-nostr-token"];
|
|
const token = typeof raw === "string" ? raw.trim() : null;
|
|
|
|
if (!token) {
|
|
res.status(401).json({ error: "Missing X-Nostr-Token header" });
|
|
return;
|
|
}
|
|
|
|
const parsed = trustService.verifyToken(token);
|
|
if (!parsed) {
|
|
res.status(401).json({ error: "Invalid or expired nostr_token" });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const identity = await trustService.getIdentityWithDecay(parsed.pubkey);
|
|
if (!identity) {
|
|
res.status(404).json({ error: "Identity not found" });
|
|
return;
|
|
}
|
|
|
|
res.json({
|
|
pubkey: identity.pubkey,
|
|
trust: {
|
|
tier: identity.tier,
|
|
score: identity.trustScore,
|
|
interactionCount: identity.interactionCount,
|
|
satsAbsorbedToday: identity.satsAbsorbedToday,
|
|
memberSince: identity.createdAt.toISOString(),
|
|
lastSeen: identity.lastSeen.toISOString(),
|
|
},
|
|
});
|
|
} catch (err) {
|
|
res.status(500).json({ error: err instanceof Error ? err.message : "Failed to fetch identity" });
|
|
}
|
|
});
|
|
|
|
export default router;
|