marmot_dart 0.0.1 copy "marmot_dart: ^0.0.1" to clipboard
marmot_dart: ^0.0.1 copied to clipboard

retracted

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_storage or equivalent)
  • UI or app-specific logic

License #

MIT — see LICENSE.

2
likes
0
points
131
downloads

Publisher

verified publishercodeswot.dev

Weekly Downloads

End-to-end encrypted group messaging for Flutter, built on the Marmot protocol (MLS over Nostr) via MDK.

Repository (GitHub)
View/report issues

Topics

#nostr #mls #encryption #messaging #marmot

License

unknown (license)

Dependencies

flutter, flutter_rust_bridge, freezed_annotation

More

Packages that depend on marmot_dart

Packages that implement marmot_dart