macula-rust 0.4.0

Rust SDK for the macula 12 mesh: ML-DSA-87 and LAMPS composite node keys, a post-quantum QUIC transport — mobile first, not mobile-only.
Documentation

macula-rust

CI License Rust memory safety GitHub Sponsors


Status, 2026-09-26: on the macula 12 wire. That means 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 and the DHT are tested against in-process macula 12 stations on every cargo test, and live against the fleet. Not here yet: UCAN-gated calls and node-served content; see Not yet implemented. Releases before 0.4.0 speak the retired 10.x wire and cannot reach the current fleet.

What is this?

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.

Quick start

[dependencies]
macula-rust = "0.4"
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.

use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;

use macula_rust::cbor::Value;
use macula_rust::node_key::NodeKey;
use macula_rust::pool::{Call, Opts, Pool, Seed};
use macula_rust::profile::Profile;
use macula_rust::station_link::Publication;

let key = NodeKey::load_or_create(Path::new("node.key"), Profile::PqHybrid)?;
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: Value::Map(vec![(Value::text("text"), Value::text("hi"))]),
    ttl_ms: None,
})
.await?;
let event = sub.recv().await;

pool.close().await;

Serving a procedure in the node's own namespace needs no org and no realm key:

use macula_rust::pool::Offer;
use macula_rust::record::own_procedure;
use macula_rust::station_link::handler;

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?;

Runnable versions are in examples/: quickstart, serve and publish_subscribe, each reading the environment described at the top of examples/common/mod.rs.

Coming from 0.3 and earlier

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_create makes 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::KeyPair is now node_key::NodeKey; connection::Session is pool::Pool (or station_link::Link for one station), whose seeds carry the station's node_id and whose realm_trust pins realm keys; direct_dial::call is simply Pool::call; resolve is Pool::providers; serve_one_call is Pool::serve with a handler; Trust::WebPki is gone: every station is pinned by its node_id.
  • ucan, cert_chain and the content-transfer modules are gone until macula 12's own arrive (see Not yet implemented).
  • 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.

What's implemented

Primitive Caller Provider Notes
Node keys (node_key::NodeKey) ✅ ✅ pq_hybrid (the fleet's) or pq_pure; key files readable by the owner only, or the platform's secure store (keystore); 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 v4 handshake, status statements both ways, neighbour signatures in pq_hybrid, a liveness probe
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
DHT (find_record, find_records, find_records_by_type, put_record) ✅ — Records verified before they are handed on
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).

Payloads

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).

Mobile bindings (Kotlin and Swift)

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). Every 32-byte id crosses as bytes and is checked.

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-kotlin
class 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())
val answer = pool.call(realm, "mcl-echo/echo", FfiValue.Text("hello"), null, 5_000uL)

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.

Not yet implemented

  • UCAN-gated calls and serving. macula 12 uses post-quantum UCANs; calls carry no token yet, and a gated procedure cannot be served.
  • Node-served content (macula 12's D27): planned for 0.5.0.
  • Station discovery beyond the seeds. macula's discovery call is not served by the fleet today (macula-io/macula#31); give the pool its seeds.

Testing

./scripts/build-teststation.sh    # macula-go's in-process stations, to target/teststation
cargo test --workspace

The 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 macula 12 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 -- --ignored

With 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).

Sibling SDKs

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

License

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.