From 8a819182264fef84d18180b683866ec04db3b264 Mon Sep 17 00:00:00 2001 From: alexpaynex <55271826-alexpaynex@users.noreply.replit.com> Date: Thu, 19 Mar 2026 19:51:50 +0000 Subject: [PATCH] =?UTF-8?q?task/29:=20fix=20vouch=20idempotency=20+=20repl?= =?UTF-8?q?ay=20guard=20=E2=80=94=20unique=20constraints=20+=20DB=20push?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- artifacts/api-server/src/routes/identity.ts | 46 +++++++++++++++---- .../migrations/0006_timmy_economic_peer.sql | 14 ++++-- lib/db/src/schema/nostr-trust-vouches.ts | 40 +++++++++++----- 3 files changed, 78 insertions(+), 22 deletions(-) diff --git a/artifacts/api-server/src/routes/identity.ts b/artifacts/api-server/src/routes/identity.ts index 66be46e..842d7dd 100644 --- a/artifacts/api-server/src/routes/identity.ts +++ b/artifacts/api-server/src/routes/identity.ts @@ -289,19 +289,49 @@ router.post("/identity/vouch", async (req: Request, res: Response) => { 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); - await db.insert(nostrTrustVouches).values({ - id: randomUUID(), - voucherPubkey: parsed.pubkey, - voucheePubkey, - vouchEventJson: JSON.stringify(event), - trustBoost: VOUCH_TRUST_BOOST, - }); + // 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 }); - // Apply trust boost: read current score then write boosted value + 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) diff --git a/lib/db/migrations/0006_timmy_economic_peer.sql b/lib/db/migrations/0006_timmy_economic_peer.sql index f1fa8b0..6abc8cd 100644 --- a/lib/db/migrations/0006_timmy_economic_peer.sql +++ b/lib/db/migrations/0006_timmy_economic_peer.sql @@ -22,18 +22,26 @@ CREATE INDEX IF NOT EXISTS idx_timmy_nostr_events_recipient ON timmy_nostr_events(recipient_pubkey); -- ── nostr_trust_vouches ─────────────────────────────────────────────────────── --- Records elite-tier co-signing events. Each row grants a trust boost to the --- vouchee and stores the raw Nostr event for auditability. +-- Records elite-tier co-signing events. Each row grants a one-time trust boost +-- to the vouchee. Two uniqueness guards prevent trust fraud: +-- 1. (voucher_pubkey, vouchee_pubkey) — one vouch per pair, ever. +-- 2. event_id — a specific signed event cannot be replayed. CREATE TABLE IF NOT EXISTS nostr_trust_vouches ( id TEXT PRIMARY KEY, voucher_pubkey TEXT NOT NULL REFERENCES nostr_identities(pubkey), vouchee_pubkey TEXT NOT NULL, + event_id TEXT NOT NULL, vouch_event_json TEXT NOT NULL, trust_boost INTEGER NOT NULL DEFAULT 20, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT uq_nostr_trust_vouches_event_id UNIQUE (event_id) ); +CREATE UNIQUE INDEX IF NOT EXISTS uq_nostr_trust_vouches_pair + ON nostr_trust_vouches(voucher_pubkey, vouchee_pubkey); + CREATE INDEX IF NOT EXISTS idx_nostr_trust_vouches_voucher ON nostr_trust_vouches(voucher_pubkey); diff --git a/lib/db/src/schema/nostr-trust-vouches.ts b/lib/db/src/schema/nostr-trust-vouches.ts index 47acd69..76f1a4f 100644 --- a/lib/db/src/schema/nostr-trust-vouches.ts +++ b/lib/db/src/schema/nostr-trust-vouches.ts @@ -1,20 +1,38 @@ -import { pgTable, text, integer, timestamp } from "drizzle-orm/pg-core"; +import { pgTable, text, integer, timestamp, uniqueIndex } from "drizzle-orm/pg-core"; import { nostrIdentities } from "./nostr-identities"; -export const nostrTrustVouches = pgTable("nostr_trust_vouches", { - id: text("id").primaryKey(), +// ── nostr_trust_vouches ─────────────────────────────────────────────────────── +// One row per elite co-signing event. Two uniqueness guards prevent trust fraud: +// 1. (voucher_pubkey, vouchee_pubkey) — each elite may vouch for a given target +// only once; subsequent calls return existing state without additional boost. +// 2. event_id — a specific signed event cannot be replayed against different +// target pubkeys. - voucherPubkey: text("voucher_pubkey") - .notNull() - .references(() => nostrIdentities.pubkey), +export const nostrTrustVouches = pgTable( + "nostr_trust_vouches", + { + id: text("id").primaryKey(), - voucheePubkey: text("vouchee_pubkey").notNull(), + voucherPubkey: text("voucher_pubkey") + .notNull() + .references(() => nostrIdentities.pubkey), - vouchEventJson: text("vouch_event_json").notNull(), + voucheePubkey: text("vouchee_pubkey").notNull(), - trustBoost: integer("trust_boost").notNull().default(20), + // NIP-01 event id (sha256 of serialised event) — prevents replay of the same + // signed event against a different voucheePubkey. + eventId: text("event_id").notNull().unique(), - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), -}); + vouchEventJson: text("vouch_event_json").notNull(), + + trustBoost: integer("trust_boost").notNull().default(20), + + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + // Enforce one vouch per (voucher, vouchee) pair — idempotency guard. + uniqueIndex("uq_nostr_trust_vouches_pair").on(table.voucherPubkey, table.voucheePubkey), + ], +); export type NostrTrustVouch = typeof nostrTrustVouches.$inferSelect;