macula-rust
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
[]
= "0.4"
= { = "1", = ["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 HashMap;
use Path;
use Arc;
use Value;
use NodeKey;
use ;
use Profile;
use Publication;
let key = load_or_create?;
let mut opts = new;
opts.realm_trust = from;
let pool = connect
.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
.await?;
// Publish and subscribe; topics name a kind of fact, ids go in the payload.
let mut sub = pool.subscribe.await?;
pool.publish
.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 Offer;
use own_procedure;
use handler;
let ring = own_procedure; // ~<node_id>/ring
let served = pool
.serve
.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_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.ucan,cert_chainand 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.
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
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
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.