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:
alexpaynex
2026-03-19 19:51:50 +00:00
parent 33b47f8682
commit 8a81918226
3 changed files with 78 additions and 22 deletions

View File

@@ -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)