Skip to content

About

Native .NET (C#) client for the Macula mesh wire protocol (QUIC/CBOR, no FFI)

Resources

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

macula-dotnet

CI NuGet License .NET GitHub Sponsors

Macula

The Macula mesh from .NET
written in C#, usable from any .NET language


Wire 1.0, 0.9.0 (2026-10-19): built on libmacula from macula-go v0.27.0, it speaks wire 1.0 (macula 15) and nothing older, so it reaches only a wire 1.0 station. The fleet moved to wire 1.0 on 19 Oct 2026 at 18:03 UTC, and from then on 0.8.0 and everything before it can no longer connect. A plain dotnet add package Macula resolves 0.9.0.

Status of 0.8.0, 2026-09-29: on the macula 12 wire: TLS 1.3 with a hybrid post-quantum key exchange, ML-DSA-87 identities (as the ML-DSA-87 + RSA-PSS-4096 composite in pq_hybrid, the fleet's profile), and signed calls, replies and publications. Since 0.7.0, handshake v5 (the session bound to its TLS channel) and macula 13's end-to-end sealing with the caller's seal report, checked live against macula 13.2.2 both ways. Every feature is tested on each push against the in-process stations at libmacula.version on Linux, macOS and Windows. Checked on the public fleet with throwaway keys: connecting, the DHT, publish/subscribe, calling mcl-echo in io.macula, serving calls and a stream in a node's own namespace, and serving a procedure gated on a post-quantum UCAN. Device request proofs (realm join) and ownership proofs are held to their verifiers' vectors, and checked through the realm's and mcl_om's own verifiers.

What it is

A .NET client for the Macula mesh: node keys, a pool of links to stations, calls and streams by direct dial, serving (in a node's own namespace or under an org), publish/subscribe, node-served content, and the DHT. It is a thin, idiomatic C# layer over libmacula, macula-go behind a C ABI (macula-go's cabi). The package carries libmacula for every supported platform, so dotnet add package Macula is all it takes.

Why a binding, not a native port

macula-dotnet 0.4 was a native C# implementation on System.Net.Quic. It cannot reach a macula 12 station, and no .NET release can today:

  • macula 12 stations accept only hybrid post-quantum key exchange (SecP384r1MLKEM1024, SecP256r1MLKEM768) and refuse a classical-only client.
  • On Linux and macOS, MsQuic's OpenSSL backend fixes the groups every connection offers in code: SSL_set1_groups_list(TlsContext->Ssl, "secp256r1:x25519") in src/platform/tls_openssl.c, still so on MsQuic's main as of 2026-09-25. It is set per connection, so no OpenSSL configuration can change it.
  • MsQuic's QUIC_TLS_GROUP enum lists the ML-KEM groups, but only to report the negotiated one; there is no setting to choose them, and QuicConnectionOptions has none either. On Windows, .NET gives no control over the groups at all.
  • The NuGet Unofficial.MsQuic package 0.4 shipped embeds OpenSSL 1.1.1, which has no ML-KEM.

So instead of a fifth implementation of QUIC, TLS 1.3 with a hybrid post-quantum key exchange, deterministic CBOR and signed frames, .NET uses macula-go's, the same choice macula-php, macula-ts and macula-py make. There is one implementation of the wire to keep correct, and the C ABI is the same for every binding.

Install

dotnet add package Macula
Runtime Needs
linux-x64, linux-arm64 glibc 2.28 or later
osx-x64, osx-arm64 macOS 13.0 or later
win-x64 Windows x64

.NET 10. Each libmacula in the package is the file of a macula-go release, checked against its SHA256SUMS and its GitHub build provenance attestation before it was packed (scripts/fetch_libmacula.sh); libmacula.version names the release.

Quick start

using System.Text.Json.Nodes;
using Macula;

// A node key: ML-DSA-87 + RSA-PSS-4096 (pq_hybrid), its node id solving the
// admission puzzle. Saved readable by its owner only, reused next time.
using var key = await NodeKey.LoadOrCreateAsync("node.key");

// Stations are pinned by the node id they must prove, never by name alone.
var seeds = new[]
{
    new Seed("station-de-frankfurt.macula.io", 4433,
        MeshId.Parse("00cd0008ec2e72b6572b7bf6fc8b048d7fe83993faf1fc544370f2bc1eb71f85")),
};
await using var pool = await Pool.ConnectAsync(key, seeds, new PoolOptions
{
    RealmTrust = new Dictionary<MeshId, byte[]> { [realm] = realmKey },
});

// Serve a procedure in this node's own namespace: only this node can.
var greet = pool.OwnProcedure("greet");
await using var served = pool.Serve(realm, greet, (request, cancellationToken) =>
    ValueTask.FromResult<JsonNode?>(new JsonObject { ["hello"] = request.Caller.ToString() }));

// Call a service under an org, by direct dial to a provider the realm trusts.
var reply = await pool.CallAsync(realm, "mcl-echo/echo", new JsonObject { ["message"] = "hi" });

// Publish and subscribe.
await using var subscription = pool.Subscribe(realm, "news");
pool.Publish(realm, "news", new JsonObject { ["headline"] = "macula 12" });
await foreach (var e in subscription.ReadAllAsync(cancellationToken))
{
    Console.WriteLine($"{e.Publisher}: {e.Payload}");
}

The API

Type What it does
NodeKey GenerateAsync, Load, LoadOrCreateAsync, Save, NodeId, PublicKey, Sign, Verify; CreateUcan, DeviceRequestProof, OwnershipProof (below)
Pool ConnectAsync (PoolOptions.KemAdvertise, below), NodeId, OwnProcedure, Status, EventsAsync, DisposeAsync
calls Pool.CallAsync (CallOptions.Ucan presents a UCAN, CallOptions.Confidential seals it), Pool.CallReportAsync, Pool.ProvidersAsync
serving Pool.Serve (a handler per call), Pool.ServeStream (a handler per session), each a Served to dispose, each optionally gated on an AuthPolicy and sealed (ServedConfidential)
streams Pool.OpenStreamAsync (its ucan presents one, its confidential seals it), and MeshStream: Send, CloseSend, Reply, Abort, Close, ReadAllAsync, Report
UCANs Ucan.ProofId, Ucan.KeyId, Capability, UcanOptions, UcanPresentation, UcanRequired, RealmMemberRequired
proofs DeviceRequestProofs.Message, OwnershipProofs.Message: the exact bytes each signs
publish/subscribe Pool.Publish, Pool.Subscribe, and Subscription.ReadAllAsync, Dropped
sealed groups Pool.PublishGroupAsync, Pool.SubscribeGroupAsync, Group, and MeshEvent.Sealed, SealKeyId, Unopened (below)
content Pool.ShareContentAsync, UnshareContentAsync, GetContentAsync
the DHT Pool.FindRecordAsync, FindRecordsAsync, FindRecordsByTypeAsync, PutRecordAsync

Every call that waits takes a CancellationToken, and cancelling it ends the wait in libmacula itself, not just the await. Failures are .NET's own where one fits (OperationCanceledException, TimeoutException, ArgumentException, ObjectDisposedException), and otherwise a MaculaException with its Kind: ProviderErrorException (with the provider's Code and Detail), RelayErrorException, NotSharedException, ContentUnavailableException (with each sharer's failure), ConfidentialityException (with its Reason, Named and Found), GroupException (with its Reason and Prefix), and RefusedException (a station's verdict on a serve or subscribe: its Reason, the Registration and the Station's node_id).

Payloads

A payload is a System.Text.Json.Nodes.JsonNode, mapped to and from the mesh's CBOR:

  • No booleans. The mesh's CBOR has none: send 0 and 1. A boolean is refused with an ArgumentException before anything is sent.
  • Integers are exact over the int64 range, the wire's; read them with GetValue<long>(). One outside int64 is refused.
  • Bytes are Payload.Bytes(span) going out and Payload.TryGetBytes(node, out bytes) coming back: the object {"$bytes": "<base64>"} either way.
  • Map keys come back in the wire's deterministic order.

Serving

A served procedure's handler gets the Request (the verified caller, realm, procedure, payload, deadline, and whether it came sealed) and a CancellationToken that ends at the call's deadline. Its result is the reply; an exception it throws answers the caller with a handler_error whose detail is the exception's message. Calls run concurrently, each on its own task. A streaming handler's stream is closed when the handler returns, and aborted with handler_error when it throws.

A procedure in a node's own namespace (~<node id>/<name>, Pool.OwnProcedure) needs no org or realm to vouch for it. One under an org (<org>/<name>) needs the org's delegation to this node in the realm, and the realm's key in PoolOptions.RealmTrust.

UCANs

A procedure served with an AuthPolicy answers only callers presenting a UCAN (macula 12's post-quantum capability token) the policy accepts. The provider checks each call and stream open before the handler sees it, as macula does, and answers the rest unauthorized (or malformed_frame for a proof no token in the chain names):

await using var served = provider.Serve(realm, procedure, handler, new UcanRequired(root.NodeId));

var token = root.CreateUcan(caller.NodeId, [new Capability("mri:org:io.macula/acme", "invoke")],
    DateTimeOffset.UtcNow.AddHours(1));
await caller.CallAsync(realm, procedure, payload, new CallOptions { Ucan = new UcanPresentation(token) });

// Delegated: alice hands the caller one procedure, naming her grant as its parent.
var sub = alice.CreateUcan(caller.NodeId, [new Capability("mri:proc:io.macula/acme/count_v1", "invoke")],
    DateTimeOffset.UtcNow.AddMinutes(10), new UcanOptions { Parent = Ucan.ProofId(toAlice) });
await caller.CallAsync(realm, procedure, payload, new CallOptions { Ucan = new UcanPresentation(sub, [toAlice]) });

A token is minted for the node that will present it. RealmMemberRequired(keyId, can) gates on a realm key instead, named by Ucan.KeyId(realmPublicKey, profile). A UcanPresentation never prints its token, or any proof of its chain: the token is the credential, so what it prints is what the token carries in the clear — its audience, when it expires, how many capabilities it grants and how long its chain is — and (unreadable) for a token that is not a UCAN. A Group's grant is kept out of what it prints for the same reason. macula's test/vectors/UCAN_V1.md is the contract.

Sealing

macula 13 seals a call's or a stream's payload end to end to the provider's KEM key (E2E seal scheme 1): stations route what they cannot read. It is off until a provider opts in, and a caller seals whenever it can:

// The provider names its KEM key in its advertisements.
await using var provider = await Pool.ConnectAsync(key, seeds, new PoolOptions { RealmTrust = trust, KemAdvertise = true });
await using var served = provider.Serve(realm, procedure, handler, confidential: ServedConfidential.Required);

// The caller seals to it: Preferred (the default) whenever the provider's advertisement names a key,
// Required never calls one that names none.
var (result, report) = await caller.CallReportAsync(realm, procedure, payload,
    new CallOptions { Confidential = Confidential.Required });
// report.Sealed, report.Provider, report.SealKeyId (the key, 16 hex)
  • KemAdvertise is off by default. Enable it only once every station runs macula 12.11 or later and every caller can seal (macula 13, macula-go 0.18, Macula .NET 0.7 or later).
  • A served procedure is ServedConfidential.Preferred by default: it names the key when the pool advertises one, and still takes a clear call while its last keyless advertisement could be served. Required refuses every clear call (sealed_required) and needs KemAdvertise; Off serves in the clear. Request.Sealed says whether a call came sealed; its payload is the opened plaintext either way.
  • A caller has no Off: only an advertisement naming no key is called in the clear, and a sealed call never falls back to the clear. What could not be kept confidential throws a ConfidentialityException, its Reason one of no_kem_key, key_mismatch, reply_not_opened, clear_answer_to_sealed, kem_advertise_disabled.
  • The seal report states that sealing ran on the exchange behind a result, nothing more. A stream's, MeshStream.Report(), settles on the provider's first chunk or reply; before that it throws a MaculaException of kind NotSettled, and on a served stream of kind NotACaller.

What stays visible: a request's UCAN and proofs, sizes, timing and routing. Content (ShareContentAsync) is public by design and travels in the clear. macula-go's cabi/CONTRACT.md ("Confidentiality", "The seal report") is the contract.

Sealed groups

macula 13.2 seals a group: a topic prefix whose second segment is its org (macula's docs/design/DESIGN_E2E_SEALED_PUBSUB.md). The org's distributor hands the group's epoch keys to members over a sealed call, and a member presents the org's grant — a UCAN with can group_keys, which the org mints for it. Every station between a publisher and a subscriber routes an event it cannot read:

// The group: its prefix, the org's grant, and the distributor, when the group is a ~<node id> one.
var group = new Group("io.macula/acme/chat", orgGrant);

// Publishing joins the group first, which pulls its epoch from the org's distributor, so it takes a
// timeout like a call.
await pool.PublishGroupAsync(realm, "io.macula/acme/chat/said_v1", new JsonObject { ["said"] = "hush" },
    group, timeout: TimeSpan.FromSeconds(10));

// Subscribing does too, and opens every event the group seals.
await using var subscription = await pool.SubscribeGroupAsync(realm, "io.macula/acme/chat/said_v1", group);
await foreach (var e in subscription.ReadAllAsync(cancellationToken))
{
    // Check e.Sealed unless the group's policy is required: a clear event under the prefix is
    // delivered as it is. e.SealKeyId is the epoch's key id (16 hex), named whether or not it opened.
    if (!e.Sealed)
    {
        continue;
    }
}
  • A refused join, or a publication that cannot be sealed, throws a GroupException with the reason macula gave — not_a_member, membership_unknown, unknown_epoch, epoch_expired, no_distributor, no_group, tag_invalid — and group_held for a clear Publish under a prefix this node holds a key for, which also names the Prefix.
  • A clear event under the prefix is not refused for you. The library refuses one only when the group's own policy is required, and the distributor sets that policy, not the subscriber; under any other policy — and for a node that holds no group at all — a clear event that any realm member published under the prefix is delivered as it is, with MeshEvent.Sealed false and a payload. A subscriber that wants sealed events alone checks MeshEvent.Sealed; nothing in the binding can decide that for it.
  • An event that arrived sealed but cannot be opened arrives once with a null payload and MeshEvent.Unopened naming the same closed set, and still carries SealKeyId, the epoch it arrived under; a plain Subscribe under a group prefix hears every sealed event as no_group.
  • A join stays held once it succeeded, also when the SubscribeGroupAsync after it fails, so the pool keeps refusing clear publishes under the prefix from then on.
  • The group's Ucan is a bearer credential and is kept out of what a Group prints.
  • Epoch keys stay inside libmacula, in memory: nothing of them crosses the C ABI, and rotation runs on its own.

What stays visible: a topic and its prefix, who published, timing, sizes and routing. Writing the distributor itself is not part of this package; the org already runs one (macula's macula_group_keys).

Proofs for a realm and for a service

NodeKey.DeviceRequestProof signs a device's request to a realm (realm proof v2): a join session's body (the body text exactly as sent, or a JsonObject under the HTTP rule) or a membership UCAN request over the mesh (DeviceRequestRule.Mesh). NodeKey.OwnershipProof returns a payload with the asserted_by block that authorises its fields to a service such as mcl_om; send it as the payload. Neither signs a "caller": the caller is the verified signer, so a request or payload carrying one is refused. Signing runs on the calling thread.

Content

ShareContentAsync keeps the bytes in this node, serves them on its own ~<node id>/content_v1 and announces them, for as long as the pool is open; stations keep no content. GetContentAsync fetches an MCID from the nodes that share it and checks every block against it, so no sharer is trusted.

Examples

examples/ has one short, complete program per feature. They connect fresh nodes (throwaway keys) to the public fleet and the io.macula realm, or to the stations MACULA_STATIONS names (host:port@<node id hex>, comma separated) and the realm in MACULA_REALM and MACULA_REALM_KEY:

dotnet run --project examples -- quickstart   # a pool and its links
dotnet run --project examples -- call         # mcl-echo in io.macula, by direct dial
dotnet run --project examples -- serve        # serve in the own namespace, call it
dotnet run --project examples -- gated        # serve gated on a UCAN, call it with and without one
dotnet run --project examples -- pubsub       # publish, hear it back
dotnet run --project examples -- stream       # a server stream
dotnet run --project examples -- content      # share, fetch verified
dotnet run --project examples -- dht          # every station's endpoint record
dotnet run --project examples -- errors       # what each failure looks like

content needs every station a node links to to admit own-namespace advertisements (macula-station 0.6.4 or later).

The F# examples of 0.4 were retired with its API; F# uses this one as it is.

Building and testing

scripts/fetch_libmacula.sh                 # libmacula of libmacula.version, verified
# or, for an unreleased macula-go revision, for this machine only:
scripts/build_libmacula.sh <macula-go checkout>

dotnet test                                # runs macula-go's teststation harness
MACULA_GO_DIR=<macula-go checkout> dotnet test   # the harness from a checkout

The tests run against in-process wire 1.0 stations (macula-go's teststation/cmd/teststation, at libmacula.version, which needs Go), in both profiles for UCANs. CI runs them on Linux, macOS and Windows.

scripts/interop/ownership_proof.sh and scripts/interop/device_request.sh check proofs this package signs against the verifiers themselves: mcl_om's (in macula's pinned CI image), after the payload has crossed a station as a provider receives it, and the realm's. scripts/interop/v5.sh and scripts/interop/sealed.sh run handshake v5 against a macula station, and sealed calls and streams with their seal reports both ways against a macula node. scripts/interop/groups.sh runs sealed groups both ways against macula's own Erlang distributor: an Erlang member and a .NET member open each other's sealed event under one epoch, and a node the distributor removed is refused not_a_member. See scripts/interop/README.md.

License

Licensed under the Apache License, Version 2.0: see LICENSE.

The .NET emblem in this README's header logo is Microsoft's official .NET logo (from the dotnet/brand repository), licensed CC0 1.0 Universal, a public-domain dedication, credited here anyway. It is used (in assets/macula-dotnet-full-{dark,light}.svg) only to identify the platform this SDK targets, as the sibling macula-go and macula-rust badges use the Go gopher and the Rust gear, and macula-php the PHP logo. It is not an endorsement by Microsoft.

About

Native .NET (C#) client for the Macula mesh wire protocol (QUIC/CBOR, no FFI)

Resources

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Releases

Sponsor this project

Packages

Used by

Contributors

Languages