Rust SDK for the Macula mesh, with Kotlin and Swift bindings
Status, 2026-10-20: on wire 1.0 (macula
docs/design/WIRE_1_0.md), the wire an operator can run for years on security fixes alone. Every class of macula's wire 1.0 conformance vectors passes on everycargo test: the frame envelope, frames, enums, records for readers and custodians, the check order, tbs bytes,_dht.*shapes and the handshake transcripts. The live fleet has run wire 1.0 since 19 October 2026, and 0.11.0 is the final release that reaches it; 0.9.x and earlier speak the retired wire. Subscribing and serving return once the station has accepted the SUBSCRIBE or ADVERTISE (macula#12). Post-quantum ML-DSA-87 identities (in pq_hybrid, the fleet's profile, the ML-DSA-87 + RSA-PSS-4096 composite), ML-KEM hybrid key exchange, and signed requests. Calls and streams by direct dial, serving (under an org or in a node's own namespace), publish/subscribe, node-served content and the DHT are tested against in-process wire 1.0 stations. Every link runs handshake version 5, bound to its TLS session, with proof V3 and session proof V2, and never completes below the version it last completed with a station. A call or stream to a provider whose advertisement names a KEM key is sealed end to end to that key (macula 13's seal scheme 1), and a node withkem_advertiseon names its key, opens sealed requests and answers them sealed, and a caller's seal report says whether the exchange behind a result was sealed and to which key; both directions are held to macula's vectors and run live against macula 15.6.0 withconfidential => required(scripts/interop/sealed.sh). A call or an open presents a UCAN, and a procedure served under a UCAN policy answers only what it authorizes, every verdict held to macula's UCAN_V1 vectors and every minted token authorized by macula's ownmacula_ucan(scripts/interop/ucan.sh). A group's events are published and read sealed under the group's current epoch key, which the org's distributor hands out over a sealed call under the org's grant, and an Erlang member and this crate's member open each other's sealed event (scripts/interop/groups.sh). Releases before 0.10.0-rc.1 speak an earlier wire and cannot reach the current fleet.
A native Rust implementation of a Macula node: its identity key, a pool of links to the stations it pins by node_id, calls and streams that reach a provider at its own station, serving procedures, publish/subscribe, and the DHT. It speaks the same wire as macula (the Erlang/OTP reference) and macula-go, over QUIC (quinn) with the post-quantum TLS of macula-pqc, ML-DSA-87 from macula-mldsa, and RSA-PSS-4096 from aws-lc-rs.
macula-rust-ffi wraps it for Kotlin and
Swift. The core crate has no FFI dependency and no FFI-shaped types.
[dependencies]
macula-rust = "0.12"
tokio = { version = "1", features = ["full"] }A node needs a station to link to, pinned by its node_id, and the key of each realm it trusts, which the realm publishes. Its own key is created on first use and kept in a file its owner alone can read, one per name and profile under the account's identity directory, as every Macula SDK keeps it.
use std::collections::HashMap;
use std::sync::Arc;
use macula_rust::cbor::Value;
use macula_rust::frame::Payload;
use macula_rust::node_key::{self, NodeKey};
use macula_rust::pool::{Call, Opts, Pool, Seed};
use macula_rust::profile::Profile;
use macula_rust::station_link::Publication;
/// The realm's id and key, and the station's node_id: the realm and the
/// station publish them.
async fn quickstart(
realm: [u8; 32],
realm_key: Vec<u8>,
station_id: [u8; 32],
) -> Result<(), Box<dyn std::error::Error>> {
// This program's identity under the account's identity directory
// (~/.local/share/macula/identity/default.pq_hybrid.key on Linux), made the
// first time. Log the path and node_id: another name or profile is another node.
let dir = node_key::default_identity_dir().expect("HOME is set");
let (key, path) = NodeKey::stored_identity(&dir, node_key::DEFAULT_IDENTITY_NAME, Profile::PqHybrid)?;
println!("identity {} node {:02x?}", path.display(), key.node_id()?);
let mut opts = Opts::new(Arc::new(key));
opts.realm_trust = HashMap::from([(realm, realm_key)]);
let pool = Pool::connect(
vec![Seed { host: "station-fi-helsinki.macula.io".into(), port: 4433, node_id: station_id }],
opts,
)
.await?;
// A call reaches a provider by direct dial: its advertisement from the DHT,
// trusted only when the realm key authorizes it, and its station dialed.
let answer = pool
.call(Call { realm, procedure: "mcl-echo/echo".into(), payload: Value::text("hello"), ..Call::default() })
.await?;
// Publish and subscribe; topics name a kind of fact, ids go in the payload.
let mut sub = pool.subscribe(&realm, "acme/demo/greeting_sent_v1").await?;
pool.publish(Publication {
realm,
topic: "acme/demo/greeting_sent_v1".into(),
payload: Payload::Clear(Value::Map(vec![(Value::text("text"), Value::text("hi"))])),
ttl_ms: None,
})
.await?;
let event = sub.recv().await;
pool.close().await;
println!("{answer:?} {event:?}");
Ok(())
}Serving a procedure in the node's own namespace needs no org and no realm key:
use macula_rust::pool::{Offer, Pool, Served};
use macula_rust::record::own_procedure;
use macula_rust::station_link::handler;
/// Serves until the returned `Served` is stopped.
async fn serve_ring(pool: &Pool, realm: [u8; 32]) -> Result<Served, Box<dyn std::error::Error>> {
let ring = own_procedure(&pool.node_id(), "ring"); // ~<node_id>/ring
let served = pool
.serve(Offer::unary(realm, &ring, handler(|request| async move { Ok(request.payload) })))
.await?;
Ok(served)
}Runnable versions are in examples/: quickstart, serve,
publish_subscribe and content, each reading the environment described at the top of
examples/common/mod.rs.
Everything moved to the macula 12 wire, and the API with it. There is no compatibility layer.
- New identities. A macula 12 node_id derives from an ML-DSA-87 key (or
the LAMPS composite in
pq_hybrid), so no Ed25519 identity carries over.NodeKey::load_or_createmakes a new key file. Re-join your realms and re-trust your agents: anything that named your old node_id must be redone with the new one. identity::KeyPairis nownode_key::NodeKey;connection::Sessionispool::Pool(orstation_link::Linkfor one station), whose seeds carry the station's node_id and whoserealm_trustpins realm keys;direct_dial::callis simplyPool::call;resolveisPool::providers;serve_one_callisPool::servewith a handler;Trust::WebPkiis gone: every station is pinned by its node_id.- The 10.x content transfer (
content,manifest,put_direct/get_direct) is replaced by node-served content:Pool::share_content/get_content, with macula 12's SHA-384manifest. The 10.xucanis replaced by macula 12's post-quantum UCAN (ucan, below);cert_chainis gone. - Serving an org procedure needs the realm's org directory and the org's delegation to your node in the DHT: a realm admits orgs through a human.
| Primitive | Caller | Provider | Notes |
|---|---|---|---|
Node keys (node_key::NodeKey) |
✅ | ✅ | pq_hybrid (the fleet's) or pq_pure; on unix key files readable by the owner only, stored by name and profile under the account's identity directory (identity layout v1, checked against macula's vectors), or a key store (keystore); on Windows Credential Manager only (a key file is refused); a key store keeps the private part only; pq_hybrid checked against the LAMPS draft's own vector and cross-verified with macula 12.8.0 |
Pool of station links (pool::Pool) |
✅ | ✅ | Seeds pinned by node_id; realm keys pinned; links redialed with subscriptions and served procedures replayed |
One station link (station_link::Link) |
✅ | ✅ | The wire 1.0 handshake: negotiated versions and profiles, downgrades detected, bound to the TLS session, a per-station version floor; status statements both ways, a liveness probe; every frame read through the wire 1.0 envelope (unknown frame types and extensions read past and counted, wire_counters), and the station's REGISTER_ACK, whose refusal ends what it refused |
Calls by direct dial (call, providers) |
✅ | ✅ | Candidates tried freshest first; errors arrive as LinkError::Provider / LinkError::Relay |
A node's own namespace (record::own_procedure) |
✅ | ✅ | ~<node_id>/<name>: served and called with no org and no realm key |
Streams (open_stream, Offer::stream) |
✅ | ✅ | Server, client and bidi; a QUIC stream per session, released on every path |
| Publish/subscribe | ✅ | ✅ | Signed publications, delivered once across links; subscribe returns once the station has acknowledged the SUBSCRIBE (REGISTER_ACK), so a publication sent after it is heard |
End-to-end sealing (seal, Confidentiality) |
✅ | ✅ | Seal scheme 1 for calls and streams: a caller seals to the key an advertisement names; a provider with kem_advertise names its keyring's key (rotated daily, a replaced key kept 30 minutes), refuses what it cannot open sealed_refused and, when required, every clear request sealed_required; call_report and Stream::report give the caller's seal report |
UCAN (ucan, Call::token, Offer::policy) |
✅ | ✅ | macula 12's tokens (D7) in the node's profile: ucan::create mints, a call or open presents a token and its chain's proofs, and an Offer gated on Policy::UcanRequired or RealmMemberRequired refuses what ucan::authorize does not accept unauthorized (a stray proof malformed_frame) before its handler runs; held to macula v13.6.0's ucan_v1.json |
Sealed groups (group, join_group, publish_group, subscribe_group) |
✅ | — | The member side of sealed pubsub (macula 13.2, DESIGN_E2E_SEALED_PUBSUB.md): a group is a topic prefix whose events are sealed once under its current epoch key, which the org's distributor hands out over a sealed call to a member holding the org's grant; epochs are re-pulled at a random instant in the ahead window and erased once no event under them is accepted, and keys never touch disk. Held to macula's group_keys_v1 payloads byte for byte, the org's grant to macula's own verdicts, and a cross-test with macula's macula_group_keys distributor. This crate serves no distributor. A clear event under a required group is refused; under any other policy it is delivered as it arrives, so a subscriber that needs every event sealed checks Event::seal_key_id itself |
Node-served content (share_content, unshare_content, get_content) |
✅ | ✅ | Shared on the node's own ~<node_id>/content_v1 and announced; a fetch checks the block, the manifest and every chunk against the content id, bounded (ContentOptions), with no realm key; manifests match macula's byte for byte (manifest) |
DHT (find_record, find_records, find_records_by_type, put_record) |
✅ | — | The versioned _dht.* forms; every record found verified as a reader verifies it before it is handed on; record::check_as_custodian runs the checks a station stores any record by |
| Mobile bindings (Kotlin, Swift) | ✅ | ✅ | macula-rust-ffi, below |
The link and the pool are ported from macula-go v0.12.0's stationlink and
pool, and every wire format is checked against macula-go's and macula's own
vectors (tests/vectors/). unsafe_code = "forbid" holds across the
workspace; the unsafe code is inside dependencies (quinn, aws-lc-rs).
A payload is what macula's wire CBOR carries: Value::Null, Int, Float,
Text, Bytes, List and Map. There is no boolean: write 1 or 0. A
decoded payload obeys macula 12's decoding rule (depth 64, 131,072 elements,
integers within ±2^63, text or integer map keys, no duplicates).
macula-rust-ffi wraps the pool with UniFFI
proc macros: FfiNodeKey, FfiPool, FfiSubscription, FfiStream, and two
handlers the app implements, FfiCallHandler and FfiStreamHandler
(suspend fun in Kotlin, async throws in Swift). A node key mints UCANs
(mintUcan), a call or an open presents one (FfiUcan), and serve takes an
FfiPolicy to gate a procedure on them. FfiPool also shares and
fetches content (shareContent, getContent, FfiContentOptions). Every id
crosses as bytes and is checked for its length (32 bytes, or 50 for a content
id).
cargo build -p macula-rust-ffi --release
cargo run -p macula-rust-ffi --release --bin uniffi-bindgen -- generate \
--library target/release/libmacula_rust_ffi.so \
--language kotlin --out-dir bindings-kotlinclass Echo : FfiCallHandler {
override suspend fun handle(request: FfiRequest): FfiValue = request.payload
}
val key = try {
FfiNodeKey.loadFromKeystore("io.macula.myapp", "node-identity", FfiProfile.PQ_HYBRID)
} catch (e: FfiException.KeystoreNotFound) {
FfiNodeKey.generate(FfiProfile.PQ_HYBRID).also { it.saveToKeystore("io.macula.myapp", "node-identity") }
}
val pool = FfiPool.connect(key, listOf(FfiSeed(host, 4433.toUShort(), stationId)),
FfiPoolOptions(realmTrust = listOf(FfiRealmKey(realm, realmKey))))
pool.serve(realm, ownProcedure(pool.nodeId(), "ring"), Echo(), FfiConfidentiality.PREFERRED, null)
val answer = pool.call(realm, "mcl-echo/echo", FfiValue.Text("hello"), null, 5_000uL,
FfiConfidentiality.PREFERRED, null)
// A UCAN for another node, presented on its calls to a gated procedure.
val token = key.mintUcan(otherNodeId, listOf(FfiCapability("mri:org:io.macula/acme", "invoke")),
FfiUcanOptions(exp = nowSeconds + 3600, nbf = null, nnc = null, fctJson = null, parent = null))On Android the platform keystore needs one call at app start,
Keyring.initializeNdkContext(applicationContext); see the keystore
module's documentation. iOS needs nothing extra.
CI generates both bindings on every push to master and every pull request; the apps that use them compile them. The FFI crate needs Rust 1.91, the core crate 1.89.
- Resealing on a bare link. A pool call or stream refused
sealed_refusedafter the provider's key rotated is sealed once more to the key the provider names (#20, #21); one made directly on aLinkfails closed (LinkError::SealedRefused, orLinkError::Streamwithsealed_refused), naming that key. - Station discovery beyond the seeds. macula discovers stations through mcl-stations/list_stations; this pool does not call it, so give it its seeds.
./scripts/build-teststation.sh # macula-go's in-process stations, to target/teststation
cargo test --workspaceThe integration tests (tests/station_link.rs, tests/pool.rs,
macula-rust-ffi/tests/pool_ffi.rs) run against tests/teststation, a Go
helper around macula-go's teststation. It starts in-process wire 1.0
stations, realms and orgs as each test asks, and reports what a station sees
(who is connected, what is advertised or subscribed, how many streams it
relays). A test fails, not skips, when the helper is missing. No network is
needed. Go ≥ 1.27 builds the helper.
tests/live.rs runs against one real station and is ignored unless asked:
MACULA_RUST_LIVE_SEED=station-fi-helsinki.macula.io:4433 \
MACULA_RUST_LIVE_STATION_ID=<64 hex> MACULA_RUST_LIVE_REALM=<64 hex> \
MACULA_RUST_LIVE_REALM_KEY=<hex> cargo test --test live -- --ignoredWith a key generated for the run and never saved, it reads the DHT, calls
mcl-echo/echo by direct dial and hears its own publication.
scripts/cross-verify-macula.sh renews the pq_hybrid signatures that crossed
both ways with macula (tests/vectors/identity/macula_12_cross).
tests/interop_groups.rs runs against macula's own distributor
(macula_group_keys) in an Erlang peer, and is ignored unless asked:
MACULA_BUILD=<a compiled macula 15.0.0-rc.1 or later> \
scripts/interop/groups.sh [pq_hybrid|pq_pure]An Erlang member and the Rust member here subscribe under one group, each
pulling the epoch from that distributor over a sealed call with the org's
grant, and each publishes sealed under it; each must open the other's event.
Then a node the distributor's removed set names must be refused
not_a_member at this crate's public subscribe.
| Repo | Approach |
|---|---|
| macula | The reference SDK (Erlang/OTP) |
| macula-go | Go port; this crate's link and pool follow it |
| macula-ts | FFI binding over macula-go, for Node.js |
| macula-php | FFI binding over macula-go, for PHP |
| macula-station | The station: DHT, SWIM, routing, peering |
| macula-realm | Managed-realm identity + certificate authority |
Licensed under the Apache License, Version 2.0 (LICENSE or http://www.apache.org/licenses/LICENSE-2.0).
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this crate by you shall be licensed as above, without any additional terms or conditions.
Built with the BEAM's protocol, ported to Rust — sponsor the work if this saved you some time