diff --git a/package-lock.json b/package-lock.json index 4270857b..6e49c83c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "imwald", - "version": "23.21.0", + "version": "23.21.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "imwald", - "version": "23.21.0", + "version": "23.21.1", "license": "MIT", "dependencies": { "@asciidoctor/core": "^3.0.4", diff --git a/package.json b/package.json index 8fde1f7e..628f01a5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "imwald", - "version": "23.21.0", + "version": "23.21.1", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery", "private": true, "type": "module", diff --git a/src/lib/index-relay-http.test.ts b/src/lib/index-relay-http.test.ts index 4ec9a090..ad87ff21 100644 --- a/src/lib/index-relay-http.test.ts +++ b/src/lib/index-relay-http.test.ts @@ -5,7 +5,7 @@ import { isIndexRelayTransportFailure, rawToIndexRelayEvent } from '@/lib/index-relay-http' -import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools' +import { finalizeEvent, generateSecretKey, getPublicKey, verifyEvent } from 'nostr-tools' import { describe, expect, it, beforeEach } from 'vitest' describe('isIndexRelayTransportFailure', () => { @@ -50,5 +50,30 @@ describe('rawToIndexRelayEvent', () => { const parsed = rawToIndexRelayEvent(mercuryRow) expect(parsed?.content).toBe('') expect(parsed?.kind).toBe(30040) + expect(verifyEvent(parsed!)).toBe(true) + }) + + it('rejects kind 30040 when tags do not match id/sig', () => { + const sk = generateSecretKey() + const pubkey = getPublicKey(sk) + const verified = finalizeEvent( + { + kind: 30040, + created_at: 1_700_000_000, + tags: [ + ['d', 'book'], + ['title', 'Test Book'], + ['a', `30041:${pubkey}:chapter-1`], + ['a', `30041:${pubkey}:chapter-2`] + ], + content: '' + }, + sk + ) + const scrambled = { + ...verified, + tags: [...verified.tags].reverse() + } as unknown as Record + expect(rawToIndexRelayEvent(scrambled)).toBeNull() }) }) diff --git a/src/lib/index-relay-http.ts b/src/lib/index-relay-http.ts index a3e70e02..5431ddcb 100644 --- a/src/lib/index-relay-http.ts +++ b/src/lib/index-relay-http.ts @@ -16,7 +16,7 @@ import { normalizeHttpRelayUrl } from '@/lib/url' import type { Filter, Event as NEvent } from 'nostr-tools' -import { validateEvent, verifyEvent } from 'nostr-tools' +import { verifyEvent } from 'nostr-tools' function trimSlash(base: string): string { return base.replace(/\/+$/, '') @@ -223,7 +223,7 @@ function rawToVerifiedEvent(raw: Record): NEvent | null { /** * Parse HTTP index relay rows for Library discovery. Kind 30040 content is always normalized to `''`. - * When verify fails (some index mirrors store stale id/sig), accept structurally valid 30040 rows. + * Signature must verify — tag order is part of the signed event (NKBIP-01 reading order). */ export function rawToIndexRelayEvent(raw: Record): NEvent | null { try { @@ -255,9 +255,7 @@ export function rawToIndexRelayEvent(raw: Record): NEvent | nul content, sig } as NEvent - if (verifyEvent(ev)) return ev - if (kind === ExtendedKind.PUBLICATION && validateEvent(ev)) return ev - return null + return verifyEvent(ev) ? ev : null } catch { return null } diff --git a/src/lib/library-index-idb-cache.ts b/src/lib/library-index-idb-cache.ts index 97e2a248..a5df2839 100644 --- a/src/lib/library-index-idb-cache.ts +++ b/src/lib/library-index-idb-cache.ts @@ -4,12 +4,20 @@ import { getLibraryIndexCacheBudget } from '@/lib/library-index-cache-config' import logger from '@/lib/logger' +import { isVerifiedPublicationIndex } from '@/lib/publication-index' import indexedDb from '@/services/indexed-db.service' import type { Event } from 'nostr-tools' export async function loadLibraryIndexCacheEvents(): Promise { try { - return await indexedDb.getLibraryPublicationIndexCacheEvents() + const cached = await indexedDb.getLibraryPublicationIndexCacheEvents() + const verified = cached.filter(isVerifiedPublicationIndex) + if (verified.length < cached.length) { + void indexedDb + .pruneUnverifiedLibraryPublicationIndexCacheEvents() + .catch(() => {}) + } + return verified } catch (e) { if (import.meta.env.DEV) { logger.warn('[Library] index IDB read failed', { diff --git a/src/lib/library-publication-index.ts b/src/lib/library-publication-index.ts index ce942632..abcc3bc6 100644 --- a/src/lib/library-publication-index.ts +++ b/src/lib/library-publication-index.ts @@ -20,6 +20,7 @@ import { hydrateNestedIndexEvents } from '@/lib/publication-index' import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' +import { verifyEvent } from 'nostr-tools' import { isEventInPinList } from '@/lib/replaceable-list-latest' import { isRelayBlockedByUser } from '@/lib/relay-blocked' import { buildComprehensiveRelayList } from '@/lib/relay-list-builder' @@ -215,7 +216,18 @@ function dedupeEventsById(events: Event[]): Event[] { const byId = new Map() for (const ev of events) { const prev = byId.get(ev.id) - if (!prev || ev.created_at > prev.created_at) byId.set(ev.id, ev) + if (!prev) { + byId.set(ev.id, ev) + continue + } + const prevVerified = verifyEvent(prev) + const nextVerified = verifyEvent(ev) + if (nextVerified && !prevVerified) { + byId.set(ev.id, ev) + continue + } + if (prevVerified && !nextVerified) continue + if (ev.created_at > prev.created_at) byId.set(ev.id, ev) } return [...byId.values()] } diff --git a/src/lib/publication-index.test.ts b/src/lib/publication-index.test.ts index 54422aa2..5c4cab6a 100644 --- a/src/lib/publication-index.test.ts +++ b/src/lib/publication-index.test.ts @@ -9,19 +9,21 @@ import { getTopLevelIndexEvents } from '@/lib/publication-index' import type { Event } from 'nostr-tools' +import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools' -const PK = 'a'.repeat(64) +const sk = generateSecretKey() +const PK = getPublicKey(sk) -function indexEvent(d: string, aTags: string[], id = d.padEnd(64, '0').slice(0, 64)): Event { - return { - id, - kind: ExtendedKind.PUBLICATION, - pubkey: PK, - created_at: 100, - content: '', - tags: [['d', d], ['title', `Title ${d}`], ...aTags.map((a) => ['a', a] as [string, string])], - sig: 'c'.repeat(128) - } +function indexEvent(d: string, aTags: string[]): Event { + return finalizeEvent( + { + kind: ExtendedKind.PUBLICATION, + created_at: 100, + content: '', + tags: [['d', d], ['title', `Title ${d}`], ...aTags.map((a) => ['a', a] as [string, string])] + }, + sk + ) } function contentEvent(d: string, id = d.padEnd(64, '1').slice(0, 64)): Event { @@ -50,7 +52,7 @@ describe('publication-index', () => { it('getTopLevelIndexEvents excludes nested 30040 children', () => { const childAddr = `30040:${PK}:part-1` const root = indexEvent('book', [childAddr, `30041:${PK}:intro`]) - const child = indexEvent('part-1', [`30041:${PK}:chapter-1`], '2'.repeat(64)) + const child = indexEvent('part-1', [`30041:${PK}:chapter-1`]) const top = getTopLevelIndexEvents([root, child]) expect(top).toHaveLength(1) expect(eventTagAddress(top[0])).toBe(`30040:${PK}:book`) @@ -60,7 +62,7 @@ describe('publication-index', () => { const childAddr = `30040:${PK}:part-1` const leafAddr = `30041:${PK}:chapter-1` const root = indexEvent('book', [childAddr, `30041:${PK}:intro`]) - const child = indexEvent('part-1', [leafAddr], '2'.repeat(64)) + const child = indexEvent('part-1', [leafAddr]) const indexByAddress = buildIndexByAddress([root, child]) const reachable = collectReachableAddressesCached(root, indexByAddress) @@ -74,7 +76,7 @@ describe('publication-index', () => { it('fetchMissingIndex resolves nested index not in initial cache', async () => { const childAddr = `30040:${PK}:part-1` const root = indexEvent('book', [childAddr]) - const child = indexEvent('part-1', [`30041:${PK}:chapter-1`], '2'.repeat(64)) + const child = indexEvent('part-1', [`30041:${PK}:chapter-1`]) const indexByAddress = buildIndexByAddress([root]) const reachable = await collectReachableAddresses(root, indexByAddress, async (addr) => { diff --git a/src/lib/publication-index.ts b/src/lib/publication-index.ts index c88eb13d..94f5d4a0 100644 --- a/src/lib/publication-index.ts +++ b/src/lib/publication-index.ts @@ -5,6 +5,23 @@ import { type PublicationSectionRef } from '@/lib/publication-section-fetch' import type { Event } from 'nostr-tools' +import { verifyEvent } from 'nostr-tools' + +/** Normalize kind-30040 rows before signature check (NKBIP-01 empty content; lowercase hex). */ +export function publicationIndexForVerify(event: Event): Event { + return { + ...event, + id: event.id.toLowerCase(), + pubkey: event.pubkey.toLowerCase(), + content: event.content ?? '' + } +} + +/** True when the event is a kind-30040 index and the signature matches the tag array. */ +export function isVerifiedPublicationIndex(event: Event): boolean { + if (event.kind !== ExtendedKind.PUBLICATION) return false + return verifyEvent(publicationIndexForVerify(event)) +} export function eventTagAddress(event: Event): string | null { const d = event.tags.find((t) => (t[0] || '').trim().toLowerCase() === 'd')?.[1] @@ -23,7 +40,7 @@ export function filterValidIndexEvents(events: Event[]): Event[] { const hasD = event.tags.some((t) => (t[0] || '').trim().toLowerCase() === 'd' && t[1]) const hasA = event.tags.some((t) => t[0] === 'a' && t[1]) const hasE = event.tags.some((t) => t[0] === 'e' && t[1]) - return hasTitle && hasD && (hasA || hasE) + return hasTitle && hasD && (hasA || hasE) && isVerifiedPublicationIndex(event) }) } diff --git a/src/services/client-events.service.ts b/src/services/client-events.service.ts index 763edccd..474f4773 100644 --- a/src/services/client-events.service.ts +++ b/src/services/client-events.service.ts @@ -1,3 +1,4 @@ +import { isVerifiedPublicationIndex } from '@/lib/publication-index' import { AUTHOR_CORE_PREFETCH_ON_INGEST_KINDS, ExtendedKind, @@ -670,6 +671,7 @@ export class EventService { */ addEventToCache(event: NEvent, ingestOpts?: ShouldDropEventOnIngestOptions): void { if (shouldDropEventOnIngest(event, ingestOpts)) return + if (event.kind === ExtendedKind.PUBLICATION && !isVerifiedPublicationIndex(event)) return const cleanEvent = { ...event } delete (cleanEvent as any).relayStatuses // REQ filters and nip19 decode use lowercase hex; some relays/clients emit uppercase ids. diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index c468f148..6cde7f79 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -27,6 +27,7 @@ import { citationPickerMatchesQuery } from '@/lib/citation-picker-search' import logger from '@/lib/logger' import { profileKind0MatchesSearchQuery } from '@/lib/profile-metadata-search' import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' +import { isVerifiedPublicationIndex } from '@/lib/publication-index' import { eventMatchesGeneralSearchQuery } from '@/lib/general-search-text-match' import { eventMatchesAnyLocalFeedFilter } from '@/lib/feed-local-event-match' import { @@ -3691,6 +3692,39 @@ class IndexedDbService { } } + async pruneUnverifiedLibraryPublicationIndexCacheEvents(): Promise { + await this.initPromise + if (!this.db?.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) return 0 + + const toDelete: string[] = [] + await new Promise((resolve, reject) => { + const tx = this.db!.transaction(StoreNames.LIBRARY_PUBLICATION_INDEX, 'readonly') + const req = tx.objectStore(StoreNames.LIBRARY_PUBLICATION_INDEX).openCursor() + req.onsuccess = () => { + const cursor = req.result as IDBCursorWithValue | null + if (!cursor) { + tx.commit() + resolve() + return + } + const row = cursor.value as TLibraryPublicationIndexCacheRow + if (row?.value?.kind === ExtendedKind.PUBLICATION && !isVerifiedPublicationIndex(row.value)) { + toDelete.push(cursor.key as string) + } + cursor.continue() + } + req.onerror = (e) => { + tx.commit() + reject(idbEventToError(e)) + } + }) + + for (const key of toDelete) { + await this.deleteStoreItem(StoreNames.LIBRARY_PUBLICATION_INDEX, key) + } + return toDelete.length + } + async getLibraryPublicationIndexCacheEvents(): Promise { await this.initPromise if (!this.db?.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) return []