Skip to content

Repository files navigation

macula-py

CI License Python GitHub Sponsors

Macula

A Python node on the macula 12 mesh, over macula-go's C ABI


Status, 2026-09-29: on the macula 12 wire: ML-DSA-87 identities (as the ML-DSA-87 + RSA-PSS-4096 composite in pq_hybrid, the fleet's profile), ML-KEM hybrid key exchange, signed requests, and since 0.4.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. Calls and streams by direct dial, serving (under an org or in a node's own namespace, open or gated on a post-quantum UCAN), publish/subscribe, the DHT and node-served content are tested against two in-process macula 12 stations on every CI run. Device request proofs (realm join) and ownership proofs are held to their verifiers' vectors, and checked once through the realm's and mcl_om's own verifiers. 0.1.0 spoke the retired classical wire and cannot reach the current fleet.

What is this?

A Python SDK for the Macula mesh: a node's key, a pool of links to stations it pins by node_id, calls and streams that reach a provider by direct dial, serving procedures, publish/subscribe, the DHT and node-served content, all as asyncio coroutines.

Macula is a federated mesh for sovereign application networks. A station relays and holds the DHT; a node is anything else that joins, and this package is a node.

It is a binding, not a reimplementation. The protocol lives in macula-go, which exports it as a C ABI (cabi/macula.h, contract in cabi/CONTRACT.md) shared by every SDK not written in Go. macula-py loads that library with ctypes, so the wire, the post-quantum handshake and the signing rules are the ones macula-go already checks byte for byte against macula itself.

Install

pip install macula-py

Wheels exist for Linux x86-64 and arm64 (glibc 2.28 or later), macOS 13 or later on Apple silicon and Intel, and Windows x86-64. Each carries macula-go's library; nothing compiles at install time and there are no runtime Python dependencies. On any other platform pip finds no wheel.

Quick start

import asyncio

from macula_py import NodeKey, Pool, Seed

STATION = Seed("station-fi-helsinki.macula.io", 4433,
               "004d1f470097ccf8826ce291900e882fdb1f20375e53901facaec0f23eb4efd8")
REALM = "abb81b5a614b63551b400b810648c0c8a78efad845442630c94b46cc95d2fcd1"  # io.macula
REALM_KEY = "..."  # io.macula's public realm key, hex


async def main() -> None:
    key = await NodeKey.load_or_create("node.key")  # pq_hybrid; the puzzle takes a second
    async with await Pool.connect(key, [STATION], realm_trust={REALM: REALM_KEY}) as pool:
        print(await pool.call(REALM, "mcl-echo/echo", "hello"))


asyncio.run(main())

A seed is pinned: the station must prove the node_id you give. A realm's key decides which advertisements in it you trust; procedures in a node's own namespace (~<node_id>/<name>, see Pool.own_procedure) need none.

The API

NodeKey generate, load, load_or_create, save, node_id, public_key, profile, sign, verify, free; ucan, device_request_proof, ownership_proof (below)
Pool.connect seeds, realm_trust, kem_advertise (sealing, below), and the pool's tuning; async with closes it
calls call, call_report, providers; call(..., ucan=, proofs=) presents a UCAN, confidential= seals it
serving serve(realm, procedure, handler, policy=None, confidential=None): handler(request) returns the result, directly or as an awaitable; an exception reaches the caller as a ProviderError of code handler_error
streams open_stream, serve_stream; a Stream has send, send_value, close_send, reply, abort, close, recv, report, and iterates its frames
pub/sub publish, subscribe; a Subscription iterates its events, or next(timeout_ms)
content share_content, unshare_content, get_content
DHT find_record, find_records, find_records_by_type, put_record

UCANs

A procedure served with a policy 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):

from macula_py import UcanRequired
from macula_py.ucan import proof_id

served = await provider.serve(realm, procedure, handler, policy=UcanRequired(root.node_id()))

token = root.ucan(caller.node_id(), [{"with": "mri:org:io.macula/acme", "can": "invoke"}],
                  exp=int(time.time()) + 3600)
await caller.call(realm, procedure, payload, ucan=token)

# Delegated: alice hands the caller one procedure, naming her grant as its parent.
sub = alice.ucan(caller.node_id(), [{"with": "mri:proc:io.macula/acme/count_v1", "can": "invoke"}],
                 exp=int(time.time()) + 600, prf=[proof_id(to_alice)])
await caller.call(realm, procedure, payload, ucan=sub, proofs=[to_alice])

A token is minted for the node that will present it. RealmMemberRequired(key_id, can) gates on a realm key instead, named by macula_py.ucan.key_id(realm_public_key, profile) (the key as carried, bytes). 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.
provider = await Pool.connect(key, seeds, realm_trust=trust, kem_advertise=True)
served = await provider.serve(realm, procedure, handler, confidential="required")

# The caller seals to it: "preferred" (the default) whenever the provider's
# advertisement names a key, "required" never calls one that names none.
result, report = await caller.call_report(realm, procedure, payload, confidential="required")
assert report.sealed and report.provider == provider.node_id()  # seal_key_id: the key, 16 hex
  • kem_advertise 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-py 0.4 or later).
  • A served procedure is confidential="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 kem_advertise; "off" serves in the clear. request.sealed says whether a call came sealed; its payload is the opened plaintext either way.
  • A caller cannot ask for "off" (InvalidArgumentError): 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 raises ConfidentialityError, 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, stream.report(), settles on the provider's first chunk or reply; before that it raises NotSettledError, and on a served stream NotACallerError.

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

Proofs for a realm and for a service

NodeKey.device_request_proof(realm, procedure, request, rule) signs a device's request to a realm (realm proof v2): a join session's body (rule="http", a mapping or the body text exactly as sent) or a membership UCAN request over the mesh (rule="mesh"). NodeKey.ownership_proof(realm, procedure, payload) returns the 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.

Payloads are what macula's wire carries: str, int within int64, float, None, bytes, lists and dicts with str keys. There is no boolean on the wire: send 1 and 0. A Python bool is refused before it leaves, since Python treats it as an int.

Every networked method is a coroutine, and each native call runs on a thread of its own, so long waits never starve other calls. The methods that take timeout_ms, and every wait for an event, a served call or a stream frame, carry a cancel token: cancelling the task (or asyncio.wait_for timing out) ends the native call at once. The rest (publish, subscribe, serve, stop, close, and a stream's sends and ends) are short native calls without one; cancelling stops the waiting and the call runs to its end.

Errors are typed: ProviderError, RelayError, StreamError, NotSharedError, ContentUnavailableError, NoProviderError, MaculaTimeoutError (also a TimeoutError), InvalidArgumentError (also a ValueError), ClosedError, RefusedError, ConfidentialityError, NotSettledError, NotACallerError, all MaculaError.

Development

Needs Go 1.27 and a C compiler to build macula-go's library locally.

python -m venv .venv && .venv/bin/pip install -e ".[dev]"
eval "$(scripts/build_native.sh)"   # the library and teststation, from abi/MACULA_GO_REF
.venv/bin/pytest

abi/macula.h is macula-go's header at the ref in abi/MACULA_GO_REF; tests/test_abi_declarations.py holds the ctypes declarations to it function for function, and build_native.sh refuses a header that differs from the ref's.

scripts/live_check.sh runs tests/live/ against one fleet station (helsinki and io.macula by default) with keys made for the run and never saved. It publishes once, and advertises one UCAN-gated procedure in its own namespace under a throwaway realm. CI never runs it.

scripts/interop/ownership_proof.sh and scripts/interop/device_request.sh check proofs this binding 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. See scripts/interop/README.md.

A v* tag publishes to PyPI through Trusted Publishing, using only macula-go's released libraries, each checked against the release's SHA256SUMS and its build provenance attestation.

License

Apache-2.0. See LICENSE.


Built on macula-go, for Python -- sponsor the work if this saved you some time

About

Python SDK for the Macula mesh (QUIC transport, deterministic CBOR, Ed25519 identity, RPC/PubSub/content/streaming)

Resources

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Sponsor this project

Packages

Contributors

Languages