Files
XC_VM/tests/Unit/ClusterTicketsTest.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

87 lines
4.4 KiB
PHP

<?php
use PHPUnit\Framework\TestCase;
use XcVm\Core\Cluster\Crypto\FileDigest;
use XcVm\Core\Cluster\Crypto\NodeSig;
use XcVm\Core\Cluster\Crypto\RelayAuth;
use XcVm\Core\Cluster\Crypto\Ticket;
use XcVm\Tests\Support\ClusterReference as Ref;
/**
* Data-plane credentials: panel-signed relay and file tickets, the child's
* per-connect relay proof, and owner-signed file digests.
*/
final class ClusterTicketsTest extends TestCase {
private string $rSeed;
private string $rPanelPub;
protected function setUp(): void {
$this->rSeed = random_bytes(32);
$this->rPanelPub = Ref::panelPub($this->rSeed);
}
private function relayTicket(int $rIat = 1000, int $rExp = 2000): string {
$rDoc = Ticket::document('rly', 'tid_0123456789', $rIat, $rExp, ['child_sid' => 5, 'child_gen' => 2, 'parent_sid' => 1, 'stream_id' => 77]);
return Ticket::wire($rDoc, Ref::panelSign($this->rSeed, 'rly', $rDoc));
}
public function testRelayTicketVerifies(): void {
$rDoc = Ticket::verify($this->rPanelPub, 'rly', $this->relayTicket(), 1500);
$this->assertSame(['child_gen' => 2, 'child_sid' => 5, 'exp' => 2000, 'iat' => 1000, 'parent_sid' => 1, 'stream_id' => 77, 'tid' => 'tid_0123456789', 'typ' => 'xcvm-relay', 'v' => 1], $rDoc);
}
public function testTicketTimeKindAndSignatureAreChecked(): void {
$this->assertNull(Ticket::verify($this->rPanelPub, 'rly', $this->relayTicket(), 2000), 'expired');
$this->assertNull(Ticket::verify($this->rPanelPub, 'rly', $this->relayTicket(), 879), 'not yet valid');
$this->assertNotNull(Ticket::verify($this->rPanelPub, 'rly', $this->relayTicket(), 880), 'skew allowed');
$this->assertNull(Ticket::verify($this->rPanelPub, 'fil', $this->relayTicket(), 1500), 'a relay ticket is not a file ticket');
$this->assertNull(Ticket::verify(Ref::panelPub(random_bytes(32)), 'rly', $this->relayTicket(), 1500), 'other panel');
[$rDocPart, $rSig] = explode('.', $this->relayTicket());
$rForged = rtrim(strtr(base64_encode(str_replace('"stream_id":77', '"stream_id":78', base64_decode(strtr($rDocPart, '-_', '+/')))), '+/', '-_'), '=');
$this->assertNull(Ticket::verify($this->rPanelPub, 'rly', $rForged . '.' . $rSig, 1500), 'tampered');
}
public function testTicketLifetimeIsCapped(): void {
$this->expectException(InvalidArgumentException::class);
Ticket::document('fil', 'tid_0123456789', 0, 21601, []);
}
public function testRelayAuthProvesTheChildKey(): void {
$rPair = sodium_crypto_sign_keypair();
$rSk = sodium_crypto_sign_secretkey($rPair);
$rPub = sodium_crypto_sign_publickey($rPair);
$rTicket = $this->relayTicket();
$rNow = 1800000000000;
$rHeader = RelayAuth::header($rSk, $rTicket, 'GET', '/relay/k/77.ts', $rNow);
$rOk = RelayAuth::verify($rPub, $rHeader, $rTicket, 'GET', '/relay/k/77.ts', $rNow + 1000);
$this->assertSame($rNow, $rOk['ts_ms']);
$this->assertNull(RelayAuth::verify($rPub, $rHeader, $rTicket, 'GET', '/relay/k/78.ts', $rNow), 'other target');
$this->assertNull(RelayAuth::verify($rPub, $rHeader, $this->relayTicket(1000, 2001), 'GET', '/relay/k/77.ts', $rNow), 'other ticket');
$this->assertNull(RelayAuth::verify($rPub, $rHeader, $rTicket, 'GET', '/relay/k/77.ts', $rNow + 90001), 'stale');
$this->assertNull(RelayAuth::verify(sodium_crypto_sign_publickey(sodium_crypto_sign_keypair()), $rHeader, $rTicket, 'GET', '/relay/k/77.ts', $rNow), 'other key');
}
public function testFileDigestFromMainOrANode(): void {
$rFile = tempnam(sys_get_temp_dir(), 'xcvm-dig');
file_put_contents($rFile, 'offair video bytes');
try {
$rDoc = FileDigest::document('tid_file_0001', 1, filesize($rFile), hash_file('sha256', $rFile), 1000);
$rMain = FileDigest::header($rDoc, Ref::panelSign($this->rSeed, 'dig', $rDoc));
$rDigest = FileDigest::verify($rMain, 'tid_file_0001', $this->rPanelPub);
$this->assertNotNull($rDigest);
$this->assertTrue(FileDigest::matches($rDigest, $rFile));
$this->assertNull(FileDigest::verify($rMain, 'tid_other_0001', $this->rPanelPub), 'bound to its ticket');
$rPair = sodium_crypto_sign_keypair();
$rNode = FileDigest::header($rDoc, NodeSig::sign(sodium_crypto_sign_secretkey($rPair), 'digest', $rDoc));
$this->assertNotNull(FileDigest::verify($rNode, 'tid_file_0001', null, sodium_crypto_sign_publickey($rPair)));
$this->assertNull(FileDigest::verify($rNode, 'tid_file_0001', $this->rPanelPub), 'a node cannot sign as MAIN');
file_put_contents($rFile, 'swapped');
$this->assertFalse(FileDigest::matches($rDigest, $rFile));
} finally {
unlink($rFile);
}
}
}