mirror of
https://github.com/Vateron-Media/XC_VM.git
synced 2026-10-25 11:03:14 +01:00
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
87 lines
4.4 KiB
PHP
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);
|
|
}
|
|
}
|
|
}
|