Files
XC_VM/tests/Unit/CanonicalRequestTest.php
T
Claude 69d706b395 feat(cluster): cluster crypto contract on the panel side (Phase 1)
src/Core/Cluster/Crypto implements the wire formats of the extension's
cluster API v1 (xcvm_core ADR-002), in plain PHP with sodium and OpenSSL:

- Enc: the lp()/u64 canonical encoding.
- Box: XCVM-BOX-v1 session bodies.
- Seal: XCVM-SEAL-v1 to an X25519 key; refuses the all-zero shared secret
  and binds purpose and context.
- PanelSig: verifies panel signatures against the closed tag registry.
- NodeSig: the node key's own signatures, under a separate domain string
  so they can never pass as panel records.
- Canonical: the panel-owned request/response serialisation. It is the
  BOX context and the MAC input, with a ±90 s window and header parsing.
- Ticket, RelayAuth, FileDigest: relay and file tickets, the child's
  per-connect relay proof, and owner-signed file digests.
- ClusterCrypto: a facade over XC_VM::cluster_*. Refusals throw
  ClusterRefusedException with the extension's reason code.
- ClusterCryptoFactory: fails closed (ClusterUnavailableException) without
  the extension, outside API 1–1, or without sodium/AES-GCM. No PHP
  fallback ships.

Tests:
- The panel matches the extension's shared vectors byte for byte
  (tests/Support/cluster_vectors.json, copied from xcvm_core): SEAL, BOX,
  panel signatures, the licence binding and CK_B, and the whole token
  chain an agent runs.
- tests/Support/cluster_canonical_vectors.json freezes the canonical
  request/response bytes for the Go agent.
- Also added: SignTagSeparationTest, ClusterTicketsTest,
  ClusterCryptoFailClosedTest (including a check that nothing under
  Core/Cluster signs as the panel or computes tokens) and
  ClusterCryptoBenchTest.
- Timing: 64 KB is ~0.25 ms p99 against the 1 ms budget; 8 MB is ~41 ms
  here against a 40 ms target, guarded at 2x.

The reference crypto that only xcvm_core or the Go agent computes
(binding, token MAC, session keys, panel signing with a seed) lives in
tests/Support and never ships.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Es8UhyPS8MNjuYTagqfGWi
2026-09-25 10:28:53 +00:00

104 lines
4.7 KiB
PHP

<?php
use PHPUnit\Framework\TestCase;
use XcVm\Core\Cluster\Crypto\Canonical;
/**
* The canonical request/response bytes are the panel's half of the wire
* contract: frozen here by a vector file the Go agent also checks.
*/
final class CanonicalRequestTest extends TestCase {
private static array $rV;
public static function setUpBeforeClass(): void {
self::$rV = json_decode((string) file_get_contents(dirname(__DIR__) . '/Support/cluster_canonical_vectors.json'), true);
}
private function request(array $rOver = []): array {
$r = self::$rV['request'];
return array_merge([
'proto' => $r['proto'], 'agent' => $r['agent'], 'method' => $r['method'], 'path' => $r['path'], 'query' => $r['query'],
'content_type' => $r['content_type'], 'content_encoding' => $r['content_encoding'], 'node' => $r['node'],
'epoch' => $r['epoch'], 'ts_ms' => $r['ts_ms'], 'nonce' => hex2bin($r['nonce']),
], $rOver);
}
public function testRequestAndResponseMatchTheVectors(): void {
$r = self::$rV['request'];
$rCtx = Canonical::request($this->request());
$this->assertSame($r['context'], bin2hex($rCtx));
$this->assertSame($r['canonical_query'], Canonical::query($r['query']));
$this->assertSame($r['mac'], bin2hex(Canonical::mac(hex2bin($r['mac_key']), $rCtx, $r['body'])));
$s = self::$rV['response'];
$rRes = Canonical::response($rCtx, $s['status'], $s['content_type'], $s['ts_ms'], hex2bin($s['nonce']));
$this->assertSame($s['context'], bin2hex($rRes));
$this->assertSame($s['mac'], bin2hex(Canonical::mac(hex2bin($s['mac_key']), $rRes, $s['body'])));
}
public function testEveryCoveredFieldChangesTheMac(): void {
$rKey = random_bytes(32);
$rBase = Canonical::mac($rKey, Canonical::request($this->request()), 'body');
foreach ([
'proto' => 2, 'agent' => 'xc_agent/0.1.1', 'method' => 'GET', 'path' => '/cluster/v1/hello', 'query' => 'b=3',
'content_type' => 'application/json', 'content_encoding' => 'gzip', 'node' => 'sid:12', 'epoch' => 4,
'ts_ms' => 1800000000124, 'nonce' => str_repeat("\x01", 16),
] as $rField => $rValue) {
$this->assertNotSame($rBase, Canonical::mac($rKey, Canonical::request($this->request([$rField => $rValue])), 'body'), $rField);
}
$this->assertNotSame($rBase, Canonical::mac($rKey, Canonical::request($this->request()), 'bodY'), 'body');
$this->assertTrue(Canonical::verifyMac($rKey, Canonical::request($this->request()), 'body', $rBase));
$this->assertFalse(Canonical::verifyMac(random_bytes(32), Canonical::request($this->request()), 'body', $rBase));
}
public function testNormalisationThatMustNotChangeTheMac(): void {
$rA = Canonical::request($this->request(['method' => 'POST', 'content_type' => 'application/octet-stream', 'query' => 'a=%41&b=2&a=x%20y']));
$this->assertSame(Canonical::request($this->request()), $rA);
}
public function testResponseIsBoundToItsRequest(): void {
$rNonce = random_bytes(16);
$rOne = Canonical::response(Canonical::request($this->request()), 200, 'x', 1, $rNonce);
$rTwo = Canonical::response(Canonical::request($this->request(['nonce' => random_bytes(16)])), 200, 'x', 1, $rNonce);
$this->assertNotSame($rOne, $rTwo);
}
public function testWindowIsNinetySeconds(): void {
$rNow = 1800000000000;
$this->assertTrue(Canonical::withinWindow($rNow - 90000, $rNow));
$this->assertTrue(Canonical::withinWindow($rNow + 90000, $rNow));
$this->assertFalse(Canonical::withinWindow($rNow - 90001, $rNow));
$this->assertFalse(Canonical::withinWindow($rNow + 90001, $rNow));
}
public function testPathsOutsideTheApiAreRefused(): void {
$this->expectException(InvalidArgumentException::class);
Canonical::request($this->request(['path' => '/api']));
}
public function testHeaderParsing(): void {
$rOk = [
'x-xcvm-proto' => '1', 'X-XCVM-Agent' => 'xc_agent/0.1.0', 'X-XCVM-Node' => '0f8fad5b-d9cb-469f-a165-70867728950e',
'X-XCVM-Epoch' => '3', 'X-XCVM-Ts' => '1800000000123', 'X-XCVM-Nonce' => str_repeat('ab', 16), 'X-XCVM-Sig' => str_repeat('cd', 32),
];
$rParsed = Canonical::parseHeaders($rOk);
$this->assertSame(3, $rParsed['epoch']);
$this->assertSame(16, strlen($rParsed['nonce']));
$this->assertSame(32, strlen($rParsed['sig']));
$this->assertNotNull(Canonical::parseHeaders(['X-XCVM-Node' => 'sid:12'] + $rOk), 'enrolment by code');
foreach ([
'X-XCVM-Node' => 'sid:0', 'X-XCVM-Epoch' => '-1', 'X-XCVM-Ts' => '12', 'X-XCVM-Nonce' => 'zz', 'X-XCVM-Sig' => 'ab',
'X-XCVM-Proto' => '0', 'X-XCVM-Agent' => "bad agent",
] as $rName => $rBad) {
$rHeaders = $rOk;
unset($rHeaders['x-xcvm-proto']);
$rHeaders['X-XCVM-Proto'] = '1';
$rHeaders[$rName] = $rBad;
$this->assertNull(Canonical::parseHeaders($rHeaders), $rName);
}
$rMissing = $rOk;
unset($rMissing['X-XCVM-Nonce']);
$this->assertNull(Canonical::parseHeaders($rMissing));
}
}