task/29: fix vouch idempotency + replay guard — unique constraints + DB push
## 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
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user