fix(nip59): bind the rumor to the seal when unwrapping

unwrapEvent() decrypted the wrap and then the seal and returned whatever
rumor came out, without ever checking that the rumor's `pubkey` was the
same as the seal's. Decrypting the seal only proves that its author holds
the key for `seal.pubkey`; the rumor inside is unsigned, so its `pubkey`
field is just a claim.

That means anyone can seal a rumor naming an arbitrary author, wrap it to
a recipient, and have the recipient's client attribute the message to that
author. NIP-17 private DMs inherit this, since nip17 re-exports unwrapEvent
as-is.

Verify that the seal is a kind:13 event with a valid signature, and that
the rumor claims the seal's author, before returning it. Also check the
wrap's kind while we're at it.

unwrapManyEvents() now skips wraps that fail these checks instead of
throwing: gift wraps are unsolicited by nature, so a single bad one
shouldn't wipe out the whole batch.
This commit is contained in:
Alex Gleason
2026-08-19 17:03:40 -03:00
committed by fiatjaf
parent 5ae314f43e
commit 2ec294b6c1
2 changed files with 81 additions and 6 deletions
+50 -2
View File
@@ -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 = [
{
+31 -4
View File
@@ -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)