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
- MLS Layer: Encrypts application data with forward secrecy and post-compromise security
- 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
- Epoch number: Lower epochs processed first
- Timestamp (
created_at): Earlier timestamp wins - 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 dataDeserializing 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 pubkeyComplete 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
Related
- Groups - Creating groups to send messages in
- Client State - Managing group state for encryption
- Protocol - MarmotGroupData and event kinds