diff --git a/nip59.test.ts b/nip59.test.ts index c60a52c..8c9e4a8 100644 --- a/nip59.test.ts +++ b/nip59.test.ts @@ -1,7 +1,15 @@ import { test, expect } from 'bun:test' -import { wrapEvent, wrapManyEvents, unwrapEvent, unwrapManyEvents } from './nip59.ts' +import { + wrapEvent, + wrapManyEvents, + unwrapEvent, + unwrapManyEvents, + createRumor, + createSeal, + createWrap, +} from './nip59.ts' import { decode } from './nip19.ts' -import { NostrEvent, getPublicKey } from './pure.ts' +import { NostrEvent, getEventHash, generateSecretKey, getPublicKey } from './pure.ts' import { SimplePool } from './pool.ts' import { GiftWrap } from './kinds.ts' import { hexToBytes } from '@noble/hashes/utils.js' @@ -80,6 +88,46 @@ test('unwrapEvent', () => { expect(result.tags).toEqual(expected.tags) }) +function forgeWrap(impersonatedPublicKey: string): NostrEvent { + // an attacker seals a rumor that names someone else as its author. the seal is + // signed by the attacker and encrypted to the recipient, so it decrypts fine. + const forgedRumor = { + created_at: Math.round(Date.now() / 1000), + kind: 14, + tags: [], + content: 'trust me, I really am the sender', + pubkey: impersonatedPublicKey, + } as any + forgedRumor.id = getEventHash(forgedRumor) + + return createWrap(createSeal(forgedRumor, generateSecretKey(), recipientPublicKey), recipientPublicKey) +} + +test('unwrapEvent rejects a rumor whose pubkey does not match the seal', () => { + const wrap = forgeWrap(getPublicKey(senderPrivateKey)) + + expect(() => unwrapEvent(wrap, recipientPrivateKey)).toThrow(/does not match seal pubkey/) +}) + +test('unwrapEvent rejects a seal with an invalid signature', () => { + const rumor = createRumor(event, senderPrivateKey) + const seal = createSeal(rumor, senderPrivateKey, recipientPublicKey) + const wrap = createWrap({ ...seal, created_at: seal.created_at + 1 }, recipientPublicKey) + + expect(() => unwrapEvent(wrap, recipientPrivateKey)).toThrow(/seal signature is invalid/) +}) + +test('unwrapEvent rejects a wrap of the wrong kind', () => { + expect(() => unwrapEvent({ ...wrappedEvent, kind: 1 }, recipientPrivateKey)).toThrow(/unexpected wrap kind/) +}) + +test('unwrapManyEvents skips wraps that fail to unwrap', () => { + const results = unwrapManyEvents([forgeWrap(getPublicKey(senderPrivateKey)), wrappedEvent], recipientPrivateKey) + + expect(results.length).toEqual(1) + expect(results[0].pubkey).toEqual(getPublicKey(senderPrivateKey)) +}) + test('getWrappedEvents and unwrapManyEvents', async () => { const expected = [ { diff --git a/nip59.ts b/nip59.ts index 3db94fa..0f03fce 100644 --- a/nip59.ts +++ b/nip59.ts @@ -1,6 +1,6 @@ import { EventTemplate, UnsignedEvent, NostrEvent } from './core.ts' import { getConversationKey, decrypt, encrypt } from './nip44.ts' -import { getEventHash, generateSecretKey, finalizeEvent, getPublicKey } from './pure.ts' +import { getEventHash, generateSecretKey, finalizeEvent, getPublicKey, verifyEvent } from './pure.ts' import { Seal, GiftWrap } from './kinds.ts' type Rumor = UnsignedEvent & { id: string } @@ -90,15 +90,42 @@ export function wrapManyEvents( } export function unwrapEvent(wrap: NostrEvent, recipientPrivateKey: Uint8Array): Rumor { - const unwrappedSeal = nip44Decrypt(wrap, recipientPrivateKey) - return nip44Decrypt(unwrappedSeal, recipientPrivateKey) + if (wrap.kind !== GiftWrap) { + throw new Error(`unexpected wrap kind ${wrap.kind}, expected ${GiftWrap}`) + } + + const seal = nip44Decrypt(wrap, recipientPrivateKey) as NostrEvent + + // the seal is the only thing that proves authorship: the wrap is signed by a + // throwaway key, and the rumor isn't signed at all. so the seal must be a real + // signed event, and the rumor it carries must claim the seal's author -- otherwise + // anyone could seal a rumor bearing someone else's pubkey and have it attributed + // to them. + if (seal.kind !== Seal) { + throw new Error(`unexpected seal kind ${seal.kind}, expected ${Seal}`) + } + if (!verifyEvent(seal)) { + throw new Error('seal signature is invalid') + } + + const rumor = nip44Decrypt(seal, recipientPrivateKey) as Rumor + if (rumor.pubkey !== seal.pubkey) { + throw new Error(`rumor pubkey ${rumor.pubkey} does not match seal pubkey ${seal.pubkey}`) + } + + return rumor } export function unwrapManyEvents(wrappedEvents: NostrEvent[], recipientPrivateKey: Uint8Array): Rumor[] { let unwrappedEvents: Rumor[] = [] wrappedEvents.forEach(e => { - unwrappedEvents.push(unwrapEvent(e, recipientPrivateKey)) + try { + unwrappedEvents.push(unwrapEvent(e, recipientPrivateKey)) + } catch (_err) { + // wraps that can't be unwrapped or fail the checks above are skipped: anyone + // can send us a gift wrap, so one bad event must not discard the whole batch + } }) unwrappedEvents.sort((a, b) => a.created_at - b.created_at)