Skip to content

Messages

Marmot uses a two-layer encryption approach for group messages: MLS for end-to-end encryption and NIP-44 for Nostr relay distribution.

Encryption Strategy

Two-Layer Encryption

  1. MLS Layer: Encrypts application data with forward secrecy and post-compromise security
  2. NIP-44 Layer: Encrypts the MLS message for Nostr relay distribution
Application Data (rumor)
  ↓ MLS Encrypt
MLSMessage
  ↓ NIP-44 Encrypt (with exporter_secret)
Nostr Event (kind 445)

Key Derivation

typescript
// Derives encryption key from current MLS epoch
exporter_secret = MLS_Exporter(clientState, "marmot group message", epoch);

// Uses exporter_secret as NIP-44 encryption key
encrypted = NIP44_Encrypt(exporter_secret, mlsMessage);

This approach:

  • Prevents key reuse across epochs
  • Provides epoch-based key rotation
  • Maintains forward secrecy
  • Compatible with Nostr event model

Privacy Features

  • Ephemeral Signing: Group events signed with ephemeral keys (not user's identity key)
  • Unlinkability: Events cannot be tied to specific users by observers
  • Rumor-Based: Application messages are unsigned events (can't be republished if leaked)

Creating Group Events

Encrypt and Create Event

typescript
import { createGroupEvent } from "@internet-privacy/marmots";

const event = await createGroupEvent({
  message: mlsMessage, // MLSMessage from MLS operations
  state: clientState, // Current MLS ClientState
  ciphersuite: ciphersuiteImpl, // Cryptographic implementation
});

// event is a fully formed Nostr event (including signature)

Ephemeral Signer

The ephemeral signer should generate a new keypair for each event:

typescript
The Marmot implementation handles per-event ephemeral signing internally.

Decrypting Group Events

Single Event Decryption

typescript
import { decryptGroupMessageEvent } from "@internet-privacy/marmots";

try {
  const mlsMessage = await decryptGroupMessageEvent(
    event, // Nostr event (kind 445)
    clientState, // Current MLS group state
    ciphersuiteImpl, // Cryptographic implementation
  );
  // mlsMessage ready for MLS processing
} catch (error) {
  // Decryption failed (wrong epoch, corrupted data, etc.)
}

Batch Decryption

For multiple events with error handling:

typescript
import { readGroupMessages } from "@internet-privacy/marmots";

const { read, unreadable } = await readGroupMessages(
  events, // Array of kind 445 events
  clientState,
  ciphersuiteImpl,
);

// `read` contains successfully decrypted `{ event, message }` pairs.
// `unreadable` contains events that could not be decrypted in the current epoch.

Commit Ordering

When multiple admins send commits for the same epoch, Marmot uses deterministic ordering to prevent conflicts (MIP-03).

Sorting Commits

typescript
import { sortGroupCommits } from "@internet-privacy/marmots";

// Sort commits by: epoch → timestamp → event ID
const sortedPairs = sortGroupCommits(messagePairs);

// Process commits in deterministic order
for (const { event, message } of sortedPairs) {
  // Process commit
}

Ordering Rules

  1. Epoch number: Lower epochs processed first
  2. Timestamp (created_at): Earlier timestamp wins
  3. Event ID: Lexicographically smallest as tiebreaker

This ensures all group members converge to the same state regardless of message arrival order.

Application Messages

Application messages are the actual content users send (chat messages, files, etc.). They're wrapped as "rumors" (unsigned Nostr events) and encrypted within MLS messages.

What are Rumors?

A rumor is an unsigned Nostr event:

typescript
interface Rumor {
  kind: number;
  content: string;
  tags: string[][];
  created_at: number;
  pubkey: string; // Sender's real pubkey
  id: string; // Required (Nostr event id), even though the rumor is unsigned
  // No 'sig' - unsigned!
}

Why unsigned?

  • Cannot be republished if leaked (no signature to verify)
  • Only valid within encrypted MLS context
  • Protects against leak exploitation

Serializing Rumors

typescript
import { serializeApplicationRumor } from "@internet-privacy/marmots";

const rumor = {
  kind: 1,
  content: "Hello, group!",
  tags: [],
  created_at: Math.floor(Date.now() / 1000),
  pubkey: senderPubkey,
  id: rumorId,
};

const serialized = serializeApplicationRumor(rumor);
// Use this as MLS application data

Deserializing Rumors

typescript
import { deserializeApplicationRumor } from "@internet-privacy/marmots";

// After processing MLS message, extract application data
const rumor = deserializeApplicationRumor(applicationData);

console.log(rumor.content); // "Hello, group!"
console.log(rumor.pubkey); // Sender's pubkey

Complete Message Flow

Sending a Message

typescript
import {
  serializeApplicationRumor,
  createGroupEvent,
  getNostrGroupIdHex,
} from "@internet-privacy/marmots";
import { createApplicationMessage } from "ts-mls";

// 1. Create rumor
const rumor = {
  kind: 1,
  content: "Hello!",
  tags: [],
  created_at: Math.floor(Date.now() / 1000),
  pubkey: myPubkey,
  id: rumorId,
};

// 2. Serialize rumor
const appData = serializeApplicationRumor(rumor);

// 3. Encrypt with MLS
const mlsMessage = createApplicationMessage(
  clientState,
  appData,
  ciphersuiteImpl,
);

// 4. Create group event
const event = await createGroupEvent({
  message: mlsMessage,
  state: clientState,
  ciphersuite: ciphersuiteImpl,
});

// 5. Publish to relays
await network.publish(relays, event);

Receiving Messages

typescript
import {
  readGroupMessages,
  sortGroupCommits,
  deserializeApplicationRumor,
} from "@internet-privacy/marmots";
import { processMessage } from "ts-mls";

// 1. Fetch events from relays
const events = await fetchGroupEvents(relays, groupId);

// 2. Decrypt all events
const { read: pairs, unreadable } = await readGroupMessages(
  events,
  clientState,
  ciphersuiteImpl,
);

// 3. Separate commits from application messages
const commits = pairs.filter((p) => isCommit(p.message));
const appMessages = pairs.filter((p) => isApplicationMessage(p.message));

// 4. Sort and process commits first
const sortedCommits = sortGroupCommits(commits);
for (const { message } of sortedCommits) {
  clientState = processMessage(clientState, message, ciphersuiteImpl);
}

// 5. Process application messages
for (const { message } of appMessages) {
  const rumor = deserializeApplicationRumor(message.message.applicationData);
  displayMessage(rumor);
}

Privacy Properties

Ephemeral Signing

Group events are signed with ephemeral keys:

  • Each event uses a different keypair
  • Events cannot be linked to sender's identity
  • Observers see random pubkeys, not real identities

Encrypted Sender Identity

  • Sender's real pubkey is in the rumor (inner event)
  • Rumor is encrypted with MLS
  • Only group members can see who sent what
  • Relays and observers see only encrypted data

Unlinkability

  • Events cannot be tied to specific users
  • Timing analysis is harder (many users, ephemeral keys)
  • Content completely opaque to non-members
  • Groups - Creating groups to send messages in
  • Client State - Managing group state for encryption
  • Protocol - MarmotGroupData and event kinds