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

56 lines
2.5 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
use PHPUnit\Framework\TestCase;
use XcVm\Core\Cluster\Crypto\ClusterCrypto;
use XcVm\Core\Cluster\Crypto\ClusterCryptoFactory;
use XcVm\Core\Cluster\Crypto\ClusterUnavailableException;
/**
* No extension, or one outside the API range, means no cluster crypto at all:
* the factory throws and the node stays legacy. There is no PHP fallback.
*/
final class ClusterCryptoFailClosedTest extends TestCase {
protected function tearDown(): void {
ClusterCryptoFactory::useProbe(null);
}
/** @return iterable<string, array{0: ?array, 1: string}> */
public static function refusals(): iterable {
yield 'no extension' => [null, 'NO_EXTENSION'];
yield 'no api field' => [['ext_version' => '3.0.0'], 'API_OUT_OF_RANGE'];
yield 'too old' => [['api' => ClusterCryptoFactory::API_MIN - 1], 'API_OUT_OF_RANGE'];
yield 'too new' => [['api' => ClusterCryptoFactory::API_MAX + 1], 'API_OUT_OF_RANGE'];
}
/** @dataProvider refusals */
public function testRefuses(?array $rInfo, string $rReason): void {
ClusterCryptoFactory::useProbe(fn() => $rInfo);
$this->assertFalse(ClusterCryptoFactory::available());
$this->assertSame($rReason, ClusterCryptoFactory::status()['reason']);
$this->expectException(ClusterUnavailableException::class);
ClusterCryptoFactory::create();
}
public function testInRange(): void {
ClusterCryptoFactory::useProbe(fn() => ['api' => ClusterCryptoFactory::API_MAX, 'ext_version' => '3.1.0']);
$this->assertSame(['available' => true, 'reason' => 'OK', 'api' => 1, 'ext_version' => '3.1.0', 'range' => '1–1'], ClusterCryptoFactory::status());
$this->assertInstanceOf(ClusterCrypto::class, ClusterCryptoFactory::create());
}
public function testRealProbeWithoutTheExtension(): void {
if (class_exists('XC_VM', false) && method_exists('XC_VM', 'cluster_session')) {
$this->markTestSkipped('xcvm_core with the cluster API is loaded here');
}
$this->assertSame('NO_EXTENSION', ClusterCryptoFactory::status()['reason']);
}
public function testNoReferenceCryptoShips(): void {
$rSrc = dirname(__DIR__, 2) . '/src/';
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($rSrc . 'Core/Cluster', FilesystemIterator::SKIP_DOTS)) as $rFile) {
$rCode = (string) file_get_contents($rFile->getPathname());
$this->assertStringNotContainsString('sodium_crypto_sign_seed_keypair', $rCode, $rFile->getPathname() . ' must not sign as the panel');
$this->assertStringNotContainsString('xcvm-node-token', $rCode, $rFile->getPathname() . ' must not compute tokens');
}
}
}