Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/marmot-protocol-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"nostream": minor
---

Add relay support for the Marmot Protocol (E2EE group messaging over Nostr).

Supported MIPs: 00 (KeyPackages), 01 (Group Construction), 02 (Welcome Events), 03 (Group Messages).

- kind 443 (legacy KeyPackage): stored as a regular event
- kind 10051 (KeyPackage relay list): stored as a replaceable event
- kind 30443 (KeyPackage): stored as a parameterized-replaceable event with `d`-tag deduplication
- kind 444 (Welcome rumor): blocked from direct publishing; must travel inside a kind 1059 gift wrap
- kind 445 (Group Event): dedicated strategy validates the required `h` tag (nostr_group_id) before storing; `#h` tag subscriptions work via the existing generic tag index
- NIP-11 relay info now advertises `supported_mips: [0, 1, 2, 3]`
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
65
],
"supportedNipExtensions": [],
"supportedMips": [0, 1, 2, 3],
"main": "src/index.ts",
"bin": {
"nostream": "./dist/src/cli/index.js"
Expand Down
5 changes: 5 additions & 0 deletions resources/default-settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,11 @@ limits:
- 39999
period: 60000
rate: 24
- description: 60 events/min for Marmot group events (kind 445)
kinds:
- 445
period: 60000
rate: 60
- description: 60 events/min for ephemeral events
kinds:
- - 20000
Expand Down
10 changes: 10 additions & 0 deletions src/constants/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ export enum EventKinds {
CHANNEL_MUTE_USER = 44,
CHANNEL_RESERVED_FIRST = 45,
CHANNEL_RESERVED_LAST = 49,
// Marmot Protocol: E2EE Group Messaging (MIPs)
MARMOT_KEY_PACKAGE_LEGACY = 443, // MIP-00: legacy KeyPackage (regular event, superseded by 30443)
MARMOT_WELCOME_RUMOR = 444, // MIP-02: Welcome rumor (must not be published directly; wraps inside gift wrap)
MARMOT_GROUP_EVENT = 445, // MIP-03: Group Event (proposals, commits, application messages)
// NIP-17: Gift Wrap
GIFT_WRAP = 1059,
// NIP-03: OpenTimestamps attestation
Expand All @@ -34,12 +38,16 @@ export enum EventKinds {
REPLACEABLE_FIRST = 10000,
// NIP-65: Relay List Metadata
RELAY_LIST = 10002,
// Marmot Protocol MIP-00: KeyPackage Relay List
MARMOT_KEY_PACKAGE_RELAY_LIST = 10051,
REPLACEABLE_LAST = 19999,
// Ephemeral events
EPHEMERAL_FIRST = 20000,
EPHEMERAL_LAST = 29999,
// Parameterized replaceable events
PARAMETERIZED_REPLACEABLE_FIRST = 30000,
// Marmot Protocol MIP-00: KeyPackage (addressable, replaces legacy 443)
MARMOT_KEY_PACKAGE = 30443,
PARAMETERIZED_REPLACEABLE_LAST = 39999,
USER_APPLICATION_FIRST = 40000,
}
Expand All @@ -58,6 +66,8 @@ export enum EventTags {
Kind = 'k',
// NIP-12: geohash tag for location-based queries
Geohash = 'g',
// Marmot Protocol MIP-03: group ID for filtering kind:445 Group Events
Group = 'h',
}

export const ALL_RELAYS = 'ALL_RELAYS'
Expand Down
4 changes: 4 additions & 0 deletions src/factories/event-strategy-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
isDeleteEvent,
isEphemeralEvent,
isGiftWrapEvent,
isMarmotGroupEvent,
isOpenTimestampsEvent,
isParameterizedReplaceableEvent,
isReplaceableEvent,
Expand All @@ -15,6 +16,7 @@ import { EphemeralEventStrategy } from '../handlers/event-strategies/ephemeral-e
import { Event } from '../@types/event'
import { Factory } from '../@types/base'
import { GiftWrapEventStrategy } from '../handlers/event-strategies/gift-wrap-event-strategy'
import { GroupEventStrategy } from '../handlers/event-strategies/group-event-strategy'
import { IEventStrategy } from '../@types/message-handlers'
import { IWebSocketAdapter } from '../@types/adapters'
import { ParameterizedReplaceableEventStrategy } from '../handlers/event-strategies/parameterized-replaceable-event-strategy'
Expand All @@ -32,6 +34,8 @@ export const eventStrategyFactory =
return new VanishEventStrategy(adapter, eventRepository, userRepository)
} else if (isGiftWrapEvent(event)) {
return new GiftWrapEventStrategy(adapter, eventRepository)
} else if (isMarmotGroupEvent(event)) {
return new GroupEventStrategy(adapter, eventRepository)
} else if (isOpenTimestampsEvent(event)) {
return new TimestampEventStrategy(adapter, eventRepository)
} else if (isRelayListEvent(event) || isReplaceableEvent(event)) {
Expand Down
5 changes: 4 additions & 1 deletion src/handlers/event-message-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
isFileMessageEvent,
isRequestToVanishEvent,
isSealEvent,
isWelcomeRumorEvent,
} from '../utils/event'
import { IEventRepository, INip05VerificationRepository, IUserRepository } from '../@types/repositories'
import { IEventStrategy, IMessageHandler } from '../@types/message-handlers'
Expand Down Expand Up @@ -238,7 +239,9 @@ export class EventMessageHandler implements IMessageHandler {
// NIP-17: kind 13 (Seal) and kind 14 (Direct Message) are inner events that
// must never be published directly to a relay. They are encrypted inside a
// kind 1059 Gift Wrap (NIP-59) before being sent here.
if (isSealEvent(event) || isDirectMessageEvent(event) || isFileMessageEvent(event)) {
// Marmot MIP-02: kind 444 (Welcome rumor) is similarly an inner event that
// must only be delivered inside a kind 1059 gift wrap.
if (isSealEvent(event) || isDirectMessageEvent(event) || isFileMessageEvent(event) || isWelcomeRumorEvent(event)) {
return `blocked: kind ${event.kind} events must not be published directly; wrap them in a kind 1059 gift wrap`
}
}
Expand Down
56 changes: 56 additions & 0 deletions src/handlers/event-strategies/group-event-strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { createCommandResult } from '../../utils/messages'
import { createLogger } from '../../factories/logger-factory'
import { Event } from '../../@types/event'
import { EventTags } from '../../constants/base'
import { IEventRepository } from '../../@types/repositories'
import { IEventStrategy } from '../../@types/message-handlers'
import { IWebSocketAdapter } from '../../@types/adapters'
import { WebSocketAdapterEvent } from '../../constants/adapter'

const logger = createLogger('group-event-strategy')

export class GroupEventStrategy implements IEventStrategy<Event, Promise<void>> {
public constructor(
private readonly webSocket: IWebSocketAdapter,
private readonly eventRepository: IEventRepository,
) {}

public async execute(event: Event): Promise<void> {
logger('received group event: %o', event)

const reason = this.validateGroupEvent(event)
if (reason) {
this.webSocket.emit(WebSocketAdapterEvent.Message, createCommandResult(event.id, false, `invalid: ${reason}`))
Comment on lines +21 to +23
return
}

const count = await this.eventRepository.create(event)
this.webSocket.emit(WebSocketAdapterEvent.Message, createCommandResult(event.id, true, count ? '' : 'duplicate:'))

if (count) {
this.webSocket.emit(WebSocketAdapterEvent.Broadcast, event)
}
}

// MIP-03: kind:445 Group Events MUST carry exactly one `h` tag whose value is the
// 64-character lowercase hex-encoded nostr_group_id from the Marmot Group Data Extension.
// The relay enforces this so that #h tag subscriptions always work correctly.
private validateGroupEvent(event: Event): string | undefined {
const groupTags = event.tags.filter((tag) => tag.length >= 2 && tag[0] === EventTags.Group)

if (groupTags.length === 0) {
return 'group event (kind 445) must have an h tag identifying the group'
}

if (groupTags.length > 1) {
return 'group event (kind 445) must have exactly one h tag'
}

const groupId = groupTags[0][1]
if (!/^[0-9a-f]{64}$/.test(groupId)) {
return 'group event (kind 445) h tag must contain a valid 64-character lowercase hex group id'
}

return undefined
}
}
1 change: 1 addition & 0 deletions src/handlers/request-handlers/root-request-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export const rootRequestHandler = (request: Request, response: Response, next: N
contact,
supported_nips: packageJson.supportedNips,
supported_nip_extensions: packageJson.supportedNipExtensions,
supported_mips: packageJson.supportedMips,
software: packageJson.repository.url,
version: packageJson.version,
...(terms_of_service !== undefined ? { terms_of_service } : {}),
Expand Down
10 changes: 10 additions & 0 deletions src/utils/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,3 +279,13 @@ export const isFileMessageEvent = (event: Event): boolean => {
export const isOpenTimestampsEvent = (event: Event): boolean => {
return event.kind === EventKinds.OPEN_TIMESTAMPS
}

// Marmot Protocol helpers

export const isWelcomeRumorEvent = (event: Event): boolean => {
return event.kind === EventKinds.MARMOT_WELCOME_RUMOR
}

export const isMarmotGroupEvent = (event: Event): boolean => {
return event.kind === EventKinds.MARMOT_GROUP_EVENT
}
21 changes: 21 additions & 0 deletions test/unit/factories/event-strategy-factory.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { EventKinds } from '../../../src/constants/base'
import { eventStrategyFactory } from '../../../src/factories/event-strategy-factory'
import { Factory } from '../../../src/@types/base'
import { GiftWrapEventStrategy } from '../../../src/handlers/event-strategies/gift-wrap-event-strategy'
import { GroupEventStrategy } from '../../../src/handlers/event-strategies/group-event-strategy'
import { IEventStrategy } from '../../../src/@types/message-handlers'
import { IWebSocketAdapter } from '../../../src/@types/adapters'
import { ParameterizedReplaceableEventStrategy } from '../../../src/handlers/event-strategies/parameterized-replaceable-event-strategy'
Expand Down Expand Up @@ -72,6 +73,26 @@ describe('eventStrategyFactory', () => {
expect(factory([event, adapter])).to.be.an.instanceOf(GiftWrapEventStrategy)
})

it('returns GroupEventStrategy given a Marmot group event (kind 445)', () => {
event.kind = EventKinds.MARMOT_GROUP_EVENT
expect(factory([event, adapter])).to.be.an.instanceOf(GroupEventStrategy)
})

it('returns ParameterizedReplaceableEventStrategy given a Marmot KeyPackage event (kind 30443)', () => {
event.kind = EventKinds.MARMOT_KEY_PACKAGE
expect(factory([event, adapter])).to.be.an.instanceOf(ParameterizedReplaceableEventStrategy)
})

it('returns ReplaceableEventStrategy given a Marmot KeyPackage relay list (kind 10051)', () => {
event.kind = EventKinds.MARMOT_KEY_PACKAGE_RELAY_LIST
expect(factory([event, adapter])).to.be.an.instanceOf(ReplaceableEventStrategy)
})

it('returns DefaultEventStrategy given a legacy Marmot KeyPackage (kind 443)', () => {
event.kind = EventKinds.MARMOT_KEY_PACKAGE_LEGACY
expect(factory([event, adapter])).to.be.an.instanceOf(DefaultEventStrategy)
})

it('returns TimestampEventStrategy given an opentimestamps (NIP-03) event', () => {
event.kind = EventKinds.OPEN_TIMESTAMPS
expect(factory([event, adapter])).to.be.an.instanceOf(TimestampEventStrategy)
Expand Down
13 changes: 13 additions & 0 deletions test/unit/handlers/event-message-handler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,19 @@ describe('EventMessageHandler', () => {
const reason = await (handler as any).isEventValid(giftWrap)
expect(reason).to.be.undefined
})

it('blocks kind 444 (Marmot Welcome rumor) with a clear rejection message', async () => {
const welcomeRumor = await makeValidEvent(EventKinds.MARMOT_WELCOME_RUMOR)
const reason = await (handler as any).isEventValid(welcomeRumor)
expect(reason).to.include('blocked')
expect(reason).to.include('444')
})

it('does not block a kind 445 (Marmot Group Event)', async () => {
const groupEvent = await makeValidEvent(EventKinds.MARMOT_GROUP_EVENT)
const reason = await (handler as any).isEventValid(groupEvent)
expect(reason).to.be.undefined
})
})
})

Expand Down
Loading
Loading