Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,24 @@ to v0.3.4 are described in their annotated git tag messages.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.8.0] - 2026-10-05

### Added

- UCANs (macula's D7): `NodeKey::ucan($audience, $caps, $expS, nbf:, nnc:,
fct:, prf:)` mints a token for the node that will present it, and
`Ucan::proofId($token)` names a parent in a delegated token's `prf`.
`Pool::call` and `Pool::openStream` take `ucan:` and `proofs:` (the chain's
parents); proofs without a token are refused before anything is sent.
- Gated serving: `Pool::serve` and `Pool::serveStream` take `policy:`, a
`ServePolicy::ucanRequired($issuerNodeId)` or
`ServePolicy::realmMemberRequired($keyId, $can)`. libmacula checks each call
and open before it reaches PHP; a refused call is a `ProviderError` of code
`unauthorized`.
- The seal report: `Pool::callReport(...)` returns a `ReportedCall` (`result`
and `report`), and `Stream::report()` a caller stream's `SealReport`
(`sealed`, `provider`, `sealKeyId`) once it settled.

## [0.7.0] - 2026-10-05

### Changed
Expand Down
17 changes: 9 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@
> and ML-KEM hybrid key exchange. Calls and streams by direct dial, serving
> (under an org or in a node's own namespace), publish/subscribe, the DHT and
> node-served content are tested against in-process stations on every
> `composer test`. UCAN-gated calls are not in the PHP API yet; see [Not yet
> implemented](#not-yet-implemented). Releases before 0.7.0 bind macula-go
> v0.12.0 and cannot call a provider that advertises a KEM key.
> `composer test`, as are UCAN-gated calls and serving and the seal report.
> Releases before 0.7.0 bind macula-go v0.12.0 and cannot call a provider
> that advertises a KEM key.

## What is this?

Expand Down Expand Up @@ -128,6 +128,8 @@ compatibility layer.
| Node keys (`NodeKey`) | ✅ | ✅ | `pq_hybrid` (the fleet's) or `pq_pure`; key files readable by the owner only; `sign` and `verify`, pq_hybrid checked against the LAMPS draft's own vector and cross-verified with macula 12.7.0 |
| Pool of station links (`Pool::connect`) | ✅ | ✅ | Seeds pinned by node_id; realm keys pinned; links redialed with subscriptions and served procedures replayed |
| Calls by direct dial (`call`, `providers`) | ✅ | ✅ | Errors arrive as `ProviderError` / `RelayError`; every other failure is a `MaculaException` whose `kind` is the library's error kind |
| UCANs (`NodeKey::ucan`, `Ucan::proofId`, `ucan:`/`proofs:`, `ServePolicy`) | ✅ | ✅ | A token minted for the node that presents it, delegated by `prf`; a procedure served under `ServePolicy::ucanRequired` or `realmMemberRequired` reaches its handler only with a chain the policy accepts, refused otherwise as `ProviderError` `unauthorized` |
| The seal report (`callReport`, `Stream::report`) | ✅ | — | Whether the exchange behind a result was sealed, to which provider and key |
| End-to-end sealing (`confidential:`, `kemAdvertise:`) | ✅ | ✅ | A call or stream is sealed whenever the provider's advertisement names a KEM key; `Confidentiality::Required` fails with `ConfidentialityError` rather than go in the clear; a served `Request` says whether it came `sealed` |
| A node's own namespace (`ownProcedure`) | ✅ | ✅ | `~<node_id>/<name>`: served and called with no org and no realm key; the node's signature authorizes it |
| Streams (`openStream`, `serveStream`) | ✅ | ✅ | Server, client and bidi; a QUIC stream per session, released on every path |
Expand Down Expand Up @@ -186,10 +188,7 @@ a bounded inbox in the library until a `*_next` function takes it.

## Not yet implemented

- **UCAN-gated calls and serving.** libmacula has them
(`macula_pool_call_opts` with a "ucan", `macula_pool_serve_opts` with a
policy); the PHP API does not expose them yet.
- **The seal report** (`report`, `macula_stream_report`): not in the PHP API.
Nothing that libmacula offers a PHP node is missing today.

## Testing

Expand All @@ -204,7 +203,9 @@ composer test:live # one live station, see below
the same tag, which runs two in-process stations sharing a DHT, with a test realm that admits the test's provider nodes. It
exercises keys, calls by direct dial and their errors, providers, server and
client streams and a provider that aborts one (and that no stream is left
unreleased), a sealed call and a required call refused in the clear, pubsub, node-served content and the DHT, through the real library.
unreleased), a sealed call and a required call refused in the clear, a gated
procedure refusing a missing, misaddressed or foreign token and serving a
granted and a delegated one, the seal report of a call and a stream, pubsub, node-served content and the DHT, through the real library.
A provider a test calls runs in a PHP process of its own
(`tests/fixtures/provider.php`). No network is needed. The suite needs PHP ≥
8.3 (PHPUnit 12's floor); the library itself runs on PHP ≥ 8.1.
Expand Down
5 changes: 5 additions & 0 deletions src/Binding.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ final class Binding
const uint8_t *public_key, size_t public_key_len, const char *profile, char **err_out);
void macula_key_free(uintptr_t key);

char *macula_ucan_create(uintptr_t key, const uint8_t *audience_node_id, const char *caps_json, int64_t exp_s,
const char *options_json, char **err_out);
char *macula_ucan_proof_id(const char *token, char **err_out);

uintptr_t macula_pool_connect(uintptr_t key, const char *seeds_json, const char *options_json, uintptr_t cancel,
char **err_out);
void macula_pool_close(uintptr_t pool);
Expand Down Expand Up @@ -71,6 +75,7 @@ final class Binding
const char *payload_json, const char *options_json, int64_t deadline_ms, int64_t timeout_ms,
uintptr_t cancel, char **err_out);
char *macula_stream_request(uintptr_t stream, char **err_out);
char *macula_stream_report(uintptr_t stream, char **err_out);
void macula_stream_send_bytes(uintptr_t stream, const uint8_t *data, size_t data_len, char **err_out);
void macula_stream_send_json(uintptr_t stream, const char *value_json, char **err_out);
void macula_stream_close_send(uintptr_t stream, char **err_out);
Expand Down
31 changes: 31 additions & 0 deletions src/NodeKey.php
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,37 @@ public static function verify(string $message, string $signature, string $public
strlen($publicKey), $profile->value, $err)) === 1;
}

/**
* This identity key's UCAN for the node audience names (64 hex characters
* or 32 bytes), granting caps, a list of ['with' => <an MRI>, 'can' =>
* ...] (`mri:realm:<realm name>`, `mri:org:<realm>/<org>`,
* `mri:proc:<realm>/<org>/<name>`), until expS (Unix seconds). A delegated
* token names its parent in prf by Ucan::proofId; the audience is always
* the node that will present it. fct, when given, is null or an object
* (new \stdClass() or a decoded JSON object), never a PHP array.
*
* @param list<array{with: string, can: string}> $caps
* @param list<string> $prf
*/
public function ucan(
string $audience,
array $caps,
int $expS,
?int $nbf = null,
?string $nnc = null,
mixed $fct = null,
array $prf = [],
): string {
$h = $this->live();
$audience32 = Binding::buffer(Wire::id32($audience, 'the audience'));
$capsJson = Wire::encode($caps);
$options = array_filter(['nbf' => $nbf, 'nnc' => $nnc, 'fct' => $fct, 'prf' => $prf === [] ? null : $prf],
static fn (mixed $v) => $v !== null);
$optionsJson = $options === [] ? null : Wire::encode($options);
return Binding::takeString(Binding::call(fn ($err) => Binding::ffi()->macula_ucan_create($h, $audience32,
$capsJson, $expS, $optionsJson, $err)));
}

/** Frees the native key. The NodeKey is unusable after. */
public function free(): void
{
Expand Down
91 changes: 83 additions & 8 deletions src/Pool.php
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,11 @@ public function status(): array
* thrown as a ProviderError, a station's relay error as a RelayError. It
* is sealed whenever the provider's advertisement names a KEM key; under
* Confidentiality::Required one that cannot be sealed is a
* ConfidentialityError, never a call in the clear.
* ConfidentialityError, never a call in the clear. A gated procedure
* takes a UCAN for this node and its chain's proofs; it refuses one with
* a ProviderError of code "unauthorized".
*
* @param list<string> $proofs
*/
public function call(
string $realm,
Expand All @@ -109,23 +113,78 @@ public function call(
int $timeoutMs = Wire::DEFAULT_CALL_TIMEOUT_MS,
BytesOutput $bytes = BytesOutput::Hex,
Confidentiality $confidential = Confidentiality::Preferred,
?string $ucan = null,
array $proofs = [],
): mixed {
$h = $this->live();
$realm32 = Binding::buffer(Wire::id32($realm, 'realm'));
$json = Wire::encode($payload);
$opts = self::callOptions($provider, $confidential);
$opts = self::callOptions($provider, $confidential, $ucan, $proofs);
$result = Binding::call(fn ($err) => Binding::ffi()->macula_pool_call_opts($h, $realm32, $procedure, $json, $opts,
$timeoutMs, 0, $err));
return Wire::decodeOutput(Binding::takeString($result), $bytes);
}

/** A call's or an open's options as the library takes them. */
private static function callOptions(?string $provider, Confidentiality $confidential): string
{
/**
* call, with its seal report: whether the exchange behind the result was
* sealed, to which provider and key. An error is thrown as call throws it,
* with no report.
*
* @param list<string> $proofs
*/
public function callReport(
string $realm,
string $procedure,
mixed $payload = new \stdClass(),
?string $provider = null,
int $timeoutMs = Wire::DEFAULT_CALL_TIMEOUT_MS,
BytesOutput $bytes = BytesOutput::Hex,
Confidentiality $confidential = Confidentiality::Preferred,
?string $ucan = null,
array $proofs = [],
): ReportedCall {
$h = $this->live();
$realm32 = Binding::buffer(Wire::id32($realm, 'realm'));
$json = Wire::encode($payload);
$opts = self::callOptions($provider, $confidential, $ucan, $proofs, report: true);
$reply = Wire::decode(Binding::takeString(Binding::call(fn ($err) => Binding::ffi()->macula_pool_call_opts($h,
$realm32, $procedure, $json, $opts, $timeoutMs, 0, $err))));
return new ReportedCall(Wire::output($reply['result'], $bytes), SealReport::fromArray($reply));
}

/**
* A call's or an open's options as the library takes them: the provider,
* how it is kept, a UCAN and its chain's proofs (the parents' text), and
* whether a call reports.
*
* @param list<string> $proofs
*/
private static function callOptions(
?string $provider,
Confidentiality $confidential,
?string $ucan,
array $proofs,
bool $report = false,
): string {
$opts = ['confidential' => $confidential->value];
if ($provider !== null) {
$opts['provider'] = bin2hex(Wire::id32($provider, 'provider'));
}
if ($ucan === '') {
throw new \InvalidArgumentException('macula-php: a UCAN is never empty; pass null for none');
}
if ($ucan !== null) {
$opts['ucan'] = $ucan;
}
if ($proofs !== []) {
if ($ucan === null) {
throw new \InvalidArgumentException('macula-php: proofs go with the UCAN they prove');
}
$opts['proofs'] = array_values($proofs);
}
if ($report) {
$opts['report'] = 1;
}
return json_encode($opts, JSON_THROW_ON_ERROR);
}

Expand Down Expand Up @@ -167,16 +226,19 @@ public function subscribe(string $realm, string $topic, BytesOutput $bytes = Byt
* namespace (ownProcedure) needs neither, and another node's namespace is
* refused. Under Confidentiality::Required a call in the clear is refused;
* a pool connected with kemAdvertise names its KEM key so callers seal.
* Under a ServePolicy only callers presenting a UCAN it accepts reach the
* handler; libmacula refuses the rest before they do.
*/
public function serve(
string $realm,
string $procedure,
BytesOutput $bytes = BytesOutput::Hex,
Confidentiality $confidential = Confidentiality::Preferred,
?ServePolicy $policy = null,
): Served {
$h = $this->live();
$realm32 = Binding::buffer(Wire::id32($realm, 'realm'));
$opts = json_encode(['confidential' => $confidential->value], JSON_THROW_ON_ERROR);
$opts = self::serveOptions($confidential, $policy);
return new Served(Binding::call(fn ($err) => Binding::ffi()->macula_pool_serve_opts($h, $realm32, $procedure,
$opts, $err)), $bytes);
}
Expand All @@ -189,10 +251,11 @@ public function serveStream(
StreamMode $mode,
BytesOutput $bytes = BytesOutput::Hex,
Confidentiality $confidential = Confidentiality::Preferred,
?ServePolicy $policy = null,
): ServedStream {
$h = $this->live();
$realm32 = Binding::buffer(Wire::id32($realm, 'realm'));
$opts = json_encode(['confidential' => $confidential->value], JSON_THROW_ON_ERROR);
$opts = self::serveOptions($confidential, $policy);
return new ServedStream(Binding::call(fn ($err) => Binding::ffi()->macula_pool_serve_stream_opts($h, $realm32,
$procedure, $mode->value, $opts, $err)), $bytes);
}
Expand All @@ -209,15 +272,27 @@ public function openStream(
int $timeoutMs = Wire::DEFAULT_CALL_TIMEOUT_MS,
BytesOutput $bytes = BytesOutput::Hex,
Confidentiality $confidential = Confidentiality::Preferred,
?string $ucan = null,
array $proofs = [],
): Stream {
$h = $this->live();
$realm32 = Binding::buffer(Wire::id32($realm, 'realm'));
$json = Wire::encode($payload);
$opts = self::callOptions($provider, $confidential);
$opts = self::callOptions($provider, $confidential, $ucan, $proofs);
return new Stream(Binding::call(fn ($err) => Binding::ffi()->macula_pool_open_stream_opts($h, $realm32,
$procedure, $mode->value, $json, $opts, $deadlineMs, $timeoutMs, 0, $err)), $bytes);
}

/** A served procedure's options as the library takes them. */
private static function serveOptions(Confidentiality $confidential, ?ServePolicy $policy): string
{
$opts = ['confidential' => $confidential->value];
if ($policy !== null) {
$opts['policy'] = $policy->toArray();
}
return json_encode($opts, JSON_THROW_ON_ERROR);
}

/** The verified record under key, or null when there is none. */
public function findRecord(
string $key,
Expand Down
13 changes: 13 additions & 0 deletions src/ReportedCall.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace Macula;

/** A call's result and its seal report (Pool::callReport). */
final class ReportedCall
{
public function __construct(public readonly mixed $result, public readonly SealReport $report)
{
}
}
27 changes: 27 additions & 0 deletions src/SealReport.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace Macula;

/**
* Whether the exchange behind a result was sealed end to end, and to which
* key (macula's seal report): `sealed`, the `provider` node it was addressed
* to (hex), and the 8-byte id of the key it was sealed to (16 hex), null for
* a clear exchange. It states that sealing ran on that exchange, nothing more.
*/
final class SealReport
{
public function __construct(
public readonly bool $sealed,
public readonly string $provider,
public readonly ?string $sealKeyId,
) {
}

/** @internal */
public static function fromArray(array $r): self
{
return new self(($r['sealed'] ?? 0) === 1, (string) $r['provider'], $r['seal_key_id'] ?? null);
}
}
44 changes: 44 additions & 0 deletions src/ServePolicy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

namespace Macula;

/**
* Who may call or open a served procedure (macula's D7 UCANs): only a caller
* presenting a UCAN chain rooted at the issuer's identity key
* (ucanRequired), or at a realm key granting a can (realmMemberRequired).
* libmacula checks each call and open before it reaches PHP: a refused call
* is a ProviderError of code "unauthorized" (or "malformed_frame" for a proof
* no token names), a refused open a StreamError of the same code.
*/
final class ServePolicy
{
/** @param array<string, string> $fields */
private function __construct(private readonly array $fields)
{
}

/** A chain rooted at the identity key of the node issuerNodeId names
* (64 hex characters or 32 bytes). */
public static function ucanRequired(string $issuerNodeId): self
{
return new self(['kind' => 'ucan_required', 'issuer' => bin2hex(Wire::id32($issuerNodeId, 'the issuer'))]);
}

/** A chain rooted at the realm key whose id keyId names (64 hex
* characters or 32 bytes), granting can. */
public static function realmMemberRequired(string $keyId, string $can): self
{
if ($can === '') {
throw new \InvalidArgumentException('macula-php: a realm member policy names a can');
}
return new self(['kind' => 'realm_member_required', 'key_id' => bin2hex(Wire::id32($keyId, 'the realm key id')), 'can' => $can]);
}

/** @internal The policy as libmacula's policy_json takes it. @return array<string, string> */
public function toArray(): array
{
return $this->fields;
}
}
11 changes: 11 additions & 0 deletions src/Stream.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@ public function request(): Request
Binding::ffi()->macula_stream_request($h, $err))), $this->bytes);
}

/** A caller stream's seal report, once it settled on the provider's first
* data or reply (on a clear stream, its first data, reply or end). Before
* that, a MaculaException of kind "not_settled"; on a served stream,
* "not_a_caller". */
public function report(): SealReport
{
$h = $this->live();
return SealReport::fromArray(Wire::decode(Binding::takeString(Binding::call(fn ($err) =>
Binding::ffi()->macula_stream_report($h, $err)))));
}

/** Sends a raw chunk. */
public function send(string $chunk): void
{
Expand Down
Loading
Loading