marmot_dart 0.0.4
marmot_dart: ^0.0.4 copied to clipboard
End-to-end encrypted group messaging for Flutter, built on the Marmot protocol (MLS over Nostr) via MDK.
marmot_dart #
End-to-end encrypted group messaging for Flutter, built on the Marmot protocol (MLS over Nostr) via MDK.
marmot_dart is an app-agnostic Flutter FFI plugin — it handles identity, MLS crypto, group lifecycle, message encryption, and media encryption. You own transport (Nostr relay WebSocket connections, Blossom uploads/downloads) and UI.
Status: early development, built on MDK
0.8. Identity, key packages, groups, messages, and MIP-04 media are implemented.
Install #
flutter pub add marmot_dart
This is an FFI plugin — it compiles and links the MDK Rust crate via flutter_rust_bridge and cargokit.
Usage #
Init #
Three factory constructors — one call creates an initialised Marmot instance bound to a single encrypted database.
import 'package:marmot_dart/marmot_dart.dart';
// Host-supplied 32-byte encryption key
final marmot = await Marmot.sqliteWithKey(
dbPath: '/path/to/marmot.db',
dbKey: Uint8List.fromList(List.generate(32, (_) => 0x42)),
);
| Constructor | Backend |
|---|---|
Marmot.memory(dbPath:) |
In-memory — ephemeral, for testing |
Marmot.sqlite(dbPath:, serviceId:, keyId:) |
Keyring-managed encryption |
Marmot.sqliteWithKey(dbPath:, dbKey:) |
You supply a 32-byte encryption key |
// Keyring-managed (macOS/iOS Keychain, Android Keystore, etc.)
await Marmot.initKeyringStore();
final marmot = await Marmot.sqlite(
dbPath: '/path/to/marmot.db',
serviceId: 'com.myapp',
keyId: 'marmot-db-key',
);
Identity #
// Generate a fresh keypair
final keypair = await MarmotIdentity.generate();
// keypair.npub — npub1...
// keypair.nsec — nsec1... (only returned here — persist it yourself)
// keypair.pubkeyHex — hex pubkey
// Import an existing keypair from nsec
final imported = await MarmotIdentity.importFromNsec('nsec1...');
// Validate an nsec string
final valid = await MarmotIdentity.validateNsec('nsec1...');
// Derive npub from nsec
final npub = await MarmotIdentity.npubFromNsec('nsec1...');
// Convert npub to hex pubkey (useful for relay filters)
final hex = await MarmotIdentity.pubkeyHexFromNpub('npub1...');
nsec is returned only from generate() and importFromNsec(). Your app persists it (e.g. via flutter_secure_storage). marmot_dart holds keys in memory only for the current session.
Key packages #
Publish a key package so others can invite you to groups. Two paths:
Path 1 — Signed (simplest): pass your nsec, get back a signed kind:30443 event ready to publish.
final signedJson = await marmot.createSignedKeyPackage(
nsec,
['wss://relay.example.com'],
);
// signedJson is a signed Nostr event — publish it to your relay
Path 2 — Unsigned: mints the MLS key package, returns the pieces. You assemble, sign, and publish the Nostr event yourself (useful when the signer is external).
final kp = await marmot.createKeyPackage(npub, ['wss://relay.example.com']);
// kp.content — base64-encoded MLS key package
// kp.tags30443 — modern kind:30443 tags
// kp.tags443 — legacy kind:443 tags
// kp.dTag — reuse this value when rotating
// kp.hashRef — content hash reference
// Build the kind:30443 event with these pieces, then sign:
final signed = await signEvent(nsec, unsignedEventJson);
Groups #
Sending an invite. createGroup returns the group plus one welcome rumor per member. Gift-wrap (NIP-59) each rumor and publish over nostr.
final result = await marmot.createGroup(creatorNpub, CreateGroupParams(
name: 'My Group',
description: 'A private group',
relayUrls: ['wss://relay.example.com'],
memberKeyPackageEventJsons: keyPackageEvents,
));
// result.group — MarmotGroup (id, name, adminNpubs, memberCount, ...)
// result.welcomeRumors — List<String>, one per invited member
Accepting an invite. Three steps: unwrap the gift-wrap to get the welcome rumor, process it, then accept.
// 1. Parse the welcome rumor — validates and stores it as pending
await marmot.processWelcome(wrapperEventId, welcomeRumorJson);
// 2. Inspect pending welcomes (e.g. show group name before joining)
final pending = await marmot.getPendingWelcomes();
// 3. Accept and join the group
await marmot.acceptWelcome(pending.first.id);
Managing groups.
final groups = await marmot.listGroups();
final members = await marmot.getMembers(group.id);
// addMember / removeMember return evolution event + new welcome rumors
final change = await marmot.addMember(group.id, keyPackageEventJson);
await marmot.removeMember(group.id, npub);
Editing group metadata. Name, description, relays, and admins live in the MLS group's Marmot data extension (MIP-01). Updating any of them produces a kind:445 commit event — publish it to the group's relays. Other members apply the change when they processIncoming that event. Pass only the fields you want to change.
final commitJson = await marmot.updateGroupMetadata(
group.id,
name: 'New name',
description: 'New description',
// relayUrls: [...], // optional
// adminNpubs: [...], // optional
);
// Publish commitJson to the group's relays
Group image. The image is encrypted (like MIP-04 media), uploaded to Blossom under a one-time derived keypair, and its hash/key/nonce stored in group metadata.
// 1. Encrypt the image
final prep = await Marmot.prepareGroupImage(imageBytes, 'image/png');
// 2. Upload prep.encryptedData to Blossom, authenticating with prep.uploadNsec
await uploadToBlossom(nsec: prep.uploadNsec, data: prep.encryptedData);
// 3. Store the image in group metadata → kind:445 commit to publish
final commitJson = await marmot.setGroupImage(
group.id,
imageHash: prep.imageHash,
imageKey: prep.imageKey,
imageNonce: prep.imageNonce,
imageUploadKey: prep.imageUploadKey,
);
// Remove it later:
await marmot.clearGroupImage(group.id);
Display it: each MarmotGroup carries imageHash / imageKey / imageNonce (null when unset). Download the blob by hash, then decrypt.
if (group.imageHash != null) {
final blob = await downloadFromBlossom(group.imageHash!);
final imageBytes = await Marmot.decryptGroupImage(
encryptedData: blob,
imageHash: group.imageHash!,
imageKey: group.imageKey!,
imageNonce: group.imageNonce!,
);
}
Messages #
Sending.
// Plain text
final rumor = await buildUnsignedRumor(npub: npub, content: 'hello world');
final eventJson = await marmot.sendMessage(rumor, group.id);
// Publish eventJson to the group's relays
// Structured payloads (JSON)
final rumor = await buildUnsignedRumor(
npub: npub,
content: jsonEncode({'type': 'com.myapp.reaction', 'emoji': '👍'}),
contentType: 'application/json',
);
await marmot.sendMessage(rumor, group.id);
// Shortcut for structured messages — same as the two calls above
final eventJson = await marmot.sendStructured(npub, group.id, {
'type': 'com.myapp.reaction',
'emoji': '👍',
});
Receiving.
final MarmotMessage? msg = await marmot.processIncoming(nostrEventJson);
// msg.text — set for plain text, null for structured
// msg.payloadJson — set when content-type tag is present (structured)
// msg.contentType — the content-type tag value (e.g. "application/json")
// msg.senderNpub — author npub
// msg.timestampSecs — sender timestamp
// msg.media — List<MarmotMediaRef>, MIP-04 attachments
Retrieving stored messages. sendMessage and processIncoming both persist to the encrypted local DB. Use these to read them back:
// List messages, newest first
final messages = await marmot.getMessages(group.id);
// Paginate
final page2 = await marmot.getMessages(group.id,
params: MessageListParams(limit: 50, offset: 50));
// Sort by local reception time instead of sender timestamp
final byArrival = await marmot.getMessages(group.id,
params: MessageListParams(sortByProcessedAt: true));
// Single message by Nostr event ID hex
final msg = await marmot.getMessage(group.id, eventIdHex);
// Most recent message (useful for group list previews)
final last = await marmot.getLastMessage(group.id);
Each MarmotGroup also carries lastMessageId, lastMessageAtSecs, and lastMessageProcessedAtSecs — updated automatically on every send or receive. Sort your group list by recent activity without fetching messages:
final groups = await marmot.listGroups();
groups.sort((a, b) =>
(b.lastMessageAtSecs ?? 0).compareTo(a.lastMessageAtSecs ?? 0));
Media (MIP-04) #
Send: encrypt → upload encrypted blob to Blossom → send a kind-9 message carrying an imeta tag.
// 1. Encrypt a file for group sharing
final enc = await marmot.encryptMedia(group.id, fileBytes, 'image/png', 'photo.png');
// enc.encryptedData — Uint8List to upload to Blossom
// enc.originalHash — content hash (imeta `x` tag)
// enc.nonce — encryption nonce (imeta `n` tag)
// enc.blurhash, enc.thumbhash, enc.dimensionsWidth, enc.dimensionsHeight
// 2. Upload enc.encryptedData to Blossom yourself → blob URL
final url = await uploadToBlossom(enc.encryptedData);
// 3. Build the media message and send it
final rumor = await marmot.buildMediaRumor(
npub: npub,
groupId: group.id,
caption: 'check this out',
url: url,
originalHash: enc.originalHash,
mimeType: enc.mimeType,
filename: enc.filename,
nonce: enc.nonce,
blurhash: enc.blurhash,
thumbhash: enc.thumbhash,
dimensionsWidth: enc.dimensionsWidth,
dimensionsHeight: enc.dimensionsHeight,
);
final eventJson = await marmot.sendMessage(rumor, group.id);
Receive: processIncoming parses each imeta tag into msg.media. For each ref, download the blob from ref.url and decrypt.
final msg = await marmot.processIncoming(nostrEventJson);
for (final ref in msg!.media) {
final encryptedBlob = await downloadFromBlossom(ref.url);
final decrypted = await marmot.decryptMedia(group.id, encryptedBlob, MediaRefInput(
url: ref.url,
originalHash: ref.originalHash,
mimeType: ref.mimeType, // must match — part of key derivation
filename: ref.filename, // must match — part of key derivation
schemeVersion: ref.schemeVersion,
nonce: ref.nonce,
));
}
Lifecycle #
marmot.dispose(); // remove session, release resources
API overview #
Marmot class #
One instance per database. All operations that need the encrypted store are instance methods.
Factories (static): Marmot.memory(), Marmot.sqlite(), Marmot.sqliteWithKey(), Marmot.initKeyringStore()
Groups: createGroup, processWelcome, getPendingWelcomes, acceptWelcome, listGroups, getMembers, addMember, removeMember, updateGroupMetadata, prepareGroupImage (static), setGroupImage, clearGroupImage, decryptGroupImage (static), leaveGroup, deleteMessagesForGroup, deleteGroup
Key packages: createKeyPackage, createSignedKeyPackage
Messages: sendMessage, buildMediaRumor, sendStructured, processIncoming, getMessages, getMessage, getLastMessage
Media: encryptMedia, decryptMedia
Lifecycle: dispose()
Top-level functions (no Marmot instance needed) #
buildUnsignedRumor({npub, content, contentType?})— build an unsigned kind-9 rumorsignEvent(nsec, unsignedEventJson)— sign any Nostr event with an nsec
MarmotIdentity class #
Static pure functions: generate(), importFromNsec(nsec), validateNsec(nsec), npubFromNsec(nsec), pubkeyHexFromNpub(npub)
Models #
StorageConfig, NostrKeypair, KeyPackageEventData, CreateGroupParams, GroupCreateResult, MarmotGroup, MarmotMember, PendingWelcome, MemberChangeResult, GroupMetadataUpdate, GroupImagePrepared, MarmotMessage, MarmotMediaRef, MessageListParams, EncryptedMediaOutput, MediaRefInput, MarmotError
Architecture #
Your Flutter app
│ (relay WebSocket + Blossom HTTP — your code)
▼
marmot_dart (Dart API)
│ flutter_rust_bridge (FFI)
▼
MDK (Rust) → MLS crypto + encrypted SQLite storage
marmot_dart is transport-agnostic. You subscribe to Nostr events on your relays, feed raw JSON into processIncoming() / processWelcome(), and publish the encrypted JSON these methods return.
What this package does NOT do #
- Nostr relay WebSocket connections
- Blossom server upload/download
- nsec persistence (use
flutter_secure_storageor equivalent) - UI or app-specific logic
License #
MIT — see LICENSE.