marmot_dart 0.0.1
marmot_dart: ^0.0.1 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.
Platform support #
| Platform | Support |
|---|---|
| Android | Yes |
| iOS | Yes |
| macOS | Yes |
| Linux | Yes |
| Windows | Yes |
Features #
- Identity — generate or import Nostr keypairs (nsec/npub). Pure functions, no MDK state needed.
- Key packages — create MLS key packages for group invitations (kind:30443 events).
- Groups — full MLS group lifecycle: create, process welcomes, list, members, add/remove, edit metadata (name/description/relays/admins) and encrypted group image.
- Messages — encrypt plain-text and structured group messages; decrypt incoming events.
- Media (MIP-04) — encrypt files for group sharing and decrypt downloaded blobs with content hashing and blurhash/thumbhash metadata.
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 #
Named factory constructors — one call creates an initialised Marmot instance.
import 'dart:math';
import 'dart:typed_data';
import 'package:marmot_dart/marmot_dart.dart';
// Host-supplied 32-byte encryption key
Uint8List random32Bytes() {
final r = Random.secure();
return Uint8List.fromList(List.generate(32, (_) => r.nextInt(256)));
}
final marmot = await Marmot.sqliteWithKey(
dbPath: '/path/to/marmot.db',
dbKey: random32Bytes(),
);
Three factory constructors:
| Constructor | Backend |
|---|---|
Marmot.memory(dbPath:) |
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
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']);
// Build the kind:30443 event with these pieces:
// content: kp.content — base64-encoded MLS key package
// tags: kp.tags30443 — modern kind:30443 tags
// kp.tags443 — legacy kind:443 tags (optional)
// d-tag: kp.dTag — reuse this value when rotating
// Sign the event yourself (or use signEvent as a convenience):
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 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. Three steps to set, then publish the commit.
// 1. Encrypt the image
final prep = await Marmot.prepareGroupImage(imageBytes, 'image/png');
// 2. Upload prep.encryptedData to Blossom, authenticating with prep.uploadNsec
// (NOT the user's nsec). The blob is addressed by its hash.
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!); // by hash
final imageBytes = await Marmot.decryptGroupImage(
encryptedData: blob,
imageHash: group.imageHash!,
imageKey: group.imageKey!,
imageNonce: group.imageNonce!,
);
// render imageBytes
}
Messages #
// Build an unsigned rumor, then encrypt for the group
final rumorJson = await buildUnsignedRumor(npub, 'hello world');
final eventJson = await marmot.sendMessage(rumorJson, group.id);
// Publish eventJson to the group's relays
// Structured payloads — any JSON works as the rumor content
final rumor2 = await buildUnsignedRumor(npub, jsonEncode({
'type': 'com.myapp.reaction',
'payload': {'emoji': '👍'},
}));
await marmot.sendMessage(rumor2, group.id);
// Decrypt an incoming Nostr event
final MarmotMessage? msg = await marmot.processIncoming(nostrEventJson);
// msg.text, msg.senderNpub, msg.timestampSecs, msg.payloadJson, msg.contentType
// msg.media — List<MarmotMediaRef>, the MIP-04 attachments (see below)
Media (MIP-04) #
Send: encrypt → upload the encrypted blob to Blossom → send a kind-9 message
carrying a NIP-92 imeta tag describing the media. The imeta tag is built by
the protocol's own serializer.
// 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 (goes into imeta `x`)
// enc.nonce — encryption nonce (goes into imeta `n`)
// enc.blurhash, enc.thumbhash, enc.dimensionsWidth, enc.dimensionsHeight — metadata
// 2. Upload enc.encryptedData to Blossom yourself → blob URL (<server>/<sha256>)
final url = await uploadToBlossom(enc.encryptedData);
// 3. Build the media message (caption optional) 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);
// Publish eventJson to the group's relays
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 — it is part of the key derivation
filename: ref.filename, // must match — it is part of the key derivation
schemeVersion: ref.schemeVersion,
nonce: ref.nonce,
));
// save `decrypted` to disk
}
Lifecycle #
marmot.dispose(); // remove session, release resources
API overview #
Marmot class #
All operations live on the Marmot instance. dbPath is set once at construction.
Factories:
Marmot.memory(), Marmot.sqlite(), Marmot.sqliteWithKey(), Marmot.initKeyringStore()
Groups:
createGroup(), processWelcome(), getPendingWelcomes(), acceptWelcome(), listGroups(), getMembers(), addMember(), removeMember(), updateGroupMetadata(), prepareGroupImage(), setGroupImage(), clearGroupImage(), decryptGroupImage()
Key packages:
createKeyPackage(), createSignedKeyPackage()
Messages:
sendMessage(), buildMediaRumor(), processIncoming()
Media:
encryptMedia(), decryptMedia()
Lifecycle:
dispose()
MarmotIdentity class #
Static pure functions: generate(), importFromNsec(), validateNsec(), npubFromNsec(), pubkeyHexFromNpub()
Pure functions #
buildUnsignedRumor(npub, content) — build an unsigned Nostr rumor event.
signEvent(nsec, unsignedEventJson) — sign any Nostr event with an nsec.
Models #
NostrKeypair, KeyPackageEventData, CreateGroupParams, GroupCreateResult, MarmotGroup, MarmotMember, PendingWelcome, MemberChangeResult, MarmotMessage, 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.