AXIAM PHP SDK

ReactorProtocol
in package

FinalYes

The reactor wire protocol — CONTRACT.md §22.2, §22.3, §22.4.

Both directions are signed with the same §8 v2 primitives and the same tenant subkey: the server signs the event, the reactor signs the reply. A reply is an instruction to change a token or refuse a login, so an unsigned reply is not a weak reply — it is not a reply at all, and the server discards it as though the reactor had never answered.

THE ONE CANONICALIZATION DIFFERENCE FROM §8. On a reactor event and a reactor reply, hmac_signature is serialized as JSON null inside the signed bytes. It is NOT omitted, which is what §8's own two message types (AuthzRequest, AuditEventMessage — see Hmac) do. Getting this wrong produces a MAC that never verifies in either direction, so the §22.13 vectors carry canonical_signed_json for every message and tests/ReactorVectorsTest.php asserts against them byte-for-byte rather than against anyone's memory of this paragraph.

THE PHP-SPECIFIC TRAPS, all of which this class encodes once:

  • json_encode() escapes forward slashes and non-ASCII by default and serde_json escapes neither, so every call here passes JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE (the same Pitfall 1 the §8 Hmac class documents).
  • Bodies are decoded to stdClass, not to associative arrays. An empty JSON object decoded into an array re-encodes as [] rather than }, which silently changes the canonical bytes of any event whose payload is empty.
  • A reply's patch is written through a stdClass and key-sorted with SORT_STRING, because the server's BTreeMap<String, String> emits its keys in byte order while a PHP array emits them in insertion order.

Table of Contents

Constants

CHAIN_PATCH_KEY  : mixed = '_reactor_patch'
The payload key under which the server inserts the patch accumulated by earlier reactors in the chain (§22.3), so a later reactor decides against the state that will actually be committed.
FRESHNESS_SKEW_SECONDS  : mixed = \Axiam\Sdk\Amqp\ReplayGuard::DEFAULT_SKEW_SECONDS
The ±freshness window, in seconds, applied to `issued_at` in **both** directions. A future timestamp is not "extra fresh", it is the shape of a captured message held for later.
KEY_VERSION  : mixed = 2
The §8 v2 key version both directions carry. A body carrying less than this is refused **before anything else about it is considered** — including its signature (§22.2, §22.4 row 4).

Methods

buildReply()  : string
Renders and signs the reply for one handler answer (§22.2, §22.4).
canonicalize()  : string
Serializes `$message` to the exact bytes HMAC runs over.
decodeEvent()  : ReactorEvent
Parses and fully verifies one delivery body, in the order §22.3 fixes: reject `key_version < 2`, verify the MAC, check freshness, check the nonce.
sign()  : string
HMAC-SHA256 of `$canonical` under the tenant's HKDF-derived AMQP subkey (§8.1, §22.2), hex-encoded. There is no second key and no asymmetric variant in v1.
verify()  : bool
Constant-time verification of a hex-encoded MAC. Never throws: a non-hex or wrong-length signature verifies as false.

Constants

CHAIN_PATCH_KEY

The payload key under which the server inserts the patch accumulated by earlier reactors in the chain (§22.3), so a later reactor decides against the state that will actually be committed.

public mixed CHAIN_PATCH_KEY = '_reactor_patch'

FRESHNESS_SKEW_SECONDS

The ±freshness window, in seconds, applied to `issued_at` in **both** directions. A future timestamp is not "extra fresh", it is the shape of a captured message held for later.

public mixed FRESHNESS_SKEW_SECONDS = \Axiam\Sdk\Amqp\ReplayGuard::DEFAULT_SKEW_SECONDS

KEY_VERSION

The §8 v2 key version both directions carry. A body carrying less than this is refused **before anything else about it is considered** — including its signature (§22.2, §22.4 row 4).

public mixed KEY_VERSION = 2

Methods

buildReply()

Renders and signs the reply for one handler answer (§22.2, §22.4).

public static buildReply(string $signingKey, ReactorEvent $event, ReactorAnswer $answer, string $nonce, int $now) : string

Field order is the server's struct declaration order: correlation_id, tenant_id, event, decision, reason (omitted when absent), patch (omitted when absent), require_mfa (omitted when false), key_version, nonce, issued_at, hmac_signature (null while signing).

The three conditional omissions are load-bearing: a reply that serializes "require_mfa": false rather than omitting it produces different canonical bytes and therefore a different MAC. An SDK must reproduce the omission rule, not merely the values.

Two answers are refused here rather than put on the wire, and each raises ReactorRejection so the caller publishes nothing and the registration's failure_policy decides:

  • require_mfa on any event other than login.post_auth. §22.13 permits rejecting this client-side or sending it and surfacing the server's rejection; rejecting names the author's mistake at the place it was made.
  • a mutate answer with an empty patch, which the server rejects as malformed_mutation.

It does NOT refuse a patch key outside the event's allow-list. That is the one case where sending the wrong thing is required: the server names the offending key in its audit record, and filtering it out here would hide the mistake from everyone (§22.4 rule 1).

Parameters
$signingKey : string
$event : ReactorEvent
$answer : ReactorAnswer
$nonce : string

A FRESH UUIDv4 per reply. The server keeps no durable nonce-dedup store for replies — its protection is the freshness window plus the correlation_id binding — but the nonce is inside the signed bytes, and a unique one is the only thing that keeps two replies from being byte-identical.

$now : int

The instant to stamp issued_at with.

Tags
throws
ReactorRejection

when the answer is one this SDK refuses to send.

Return values
string

canonicalize()

Serializes `$message` to the exact bytes HMAC runs over.

public static canonicalize(stdClass $message) : string

The caller is responsible for property order — PHP preserves the order properties were assigned in, which is how the server's serde field-declaration order is reproduced without a sorting helper.

Parameters
$message : stdClass
Return values
string

decodeEvent()

Parses and fully verifies one delivery body, in the order §22.3 fixes: reject `key_version < 2`, verify the MAC, check freshness, check the nonce.

public static decodeEvent(string $signingKey, string $body, string $expectedTenantId, ReplayGuard $guard, int $now) : ReactorEvent

Only then is the payload decoded and handed on.

Identity and registry membership are checked after the MAC: neither is cryptography, and spending them on unauthenticated bytes would tell an unauthenticated party what this reactor accepts.

Parameters
$signingKey : string

The tenant's HKDF-derived AMQP subkey.

$body : string

The raw delivery bytes, exactly as received.

$expectedTenantId : string

This reactor's configured tenant. An event naming another one is refused outright.

$guard : ReplayGuard

The shared §8 v2 freshness + nonce gate. ONE instance must serve every delivery — a fresh guard per message would defeat replay dedup entirely.

$now : int

The instant to measure freshness and the dispatch deadline against; must be the same clock $guard reads.

Tags
throws
ReactorRejection

on any refusal, carrying a fixed-vocabulary reason.

Return values
ReactorEvent

sign()

HMAC-SHA256 of `$canonical` under the tenant's HKDF-derived AMQP subkey (§8.1, §22.2), hex-encoded. There is no second key and no asymmetric variant in v1.

public static sign(string $signingKey, string $canonical) : string
Parameters
$signingKey : string
$canonical : string
Return values
string

verify()

Constant-time verification of a hex-encoded MAC. Never throws: a non-hex or wrong-length signature verifies as false.

public static verify(string $signingKey, string $canonical, string $signatureHex) : bool
Parameters
$signingKey : string
$canonical : string
$signatureHex : string
Return values
bool
On this page

Search results