Files
XC_VM/tests/Unit/EncryptionPreviousExtraTest.php
T
Claude 6c391f25d9 fix(servers): harden the OPENSSL_EXTRA repair after review
server:sync-openssl-extra now refuses to send when the main's own
published fingerprint differs from the value it read. Run as root, it
could read a config/openssl_extra that xc_vm cannot read, while the
main's php-fpm runs on the default, and push a value that breaks every
LB. The command and cron:root_signals now share one contract:
OpensslExtra::SIGNAL_ACTION, signal() and applySignal(), with
publish() for cron:servers.

Encryption::readToken now tries the sealed format with both values
before the legacy one. A wrong-value legacy decrypt() could return
garbage (about 1 in 256), so a sealed token minted with the replaced
value was refused. Legacy tokens keep that inherent miss rate.

proxy_api trusts the posted server_id and handed any registered proxy
an LB's signal rows, including the main's OPENSSL_EXTRA. It now skips
set_openssl_extra rows until the source-IP binding lands.

Tests cover sync() with TestDb, the signal contract and the case's own
break. The docs cover the check and the repair.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQHeypQpfAXTdCysmi7b6a
2026-09-25 00:56:05 +00:00

99 lines
4.0 KiB
PHP

<?php
use PHPUnit\Framework\TestCase;
use XcVm\Core\Config\OpensslExtra;
use XcVm\Core\Util\Encryption;
/**
* Encryption::readToken — after a node is moved onto MAIN's OPENSSL_EXTRA,
* the tokens it minted with the value it replaced (HLS segment and key links,
* VOD links reused for seeks) keep opening until the replaced value's window
* closes. Only the OPENSSL_EXTRA context falls back, and only a token that
* fails with the current value tries it.
*
* tests/bootstrap.php defines OPENSSL_EXTRA as 'test-openssl-extra'.
*/
final class EncryptionPreviousExtraTest extends TestCase {
private const PLAIN = 'live/user/pass/1/ts';
private string $rFile;
protected function setUp(): void {
$this->rFile = sys_get_temp_dir() . '/xcvm_openssl_extra_prev_' . uniqid();
OpensslExtra::usePrevFile($this->rFile);
}
protected function tearDown(): void {
try {
OpensslExtra::usePrevFile(null);
} finally {
@unlink($this->rFile);
}
}
private function previous(string $rValue, int $rValidUntil): void {
file_put_contents($this->rFile, json_encode(['value' => $rValue, 'valid_until' => $rValidUntil]));
OpensslExtra::usePrevFile($this->rFile);
}
public function testATokenMintedWithTheReplacedValueOpensOnlyWhileItIsAccepted(): void {
$rToken = Encryption::seal(self::PLAIN, 'k', 'old-extra');
$this->assertFalse(Encryption::readToken($rToken, 'k', OPENSSL_EXTRA, false), 'no previous value');
$this->previous('old-extra', time() + 600);
$this->assertSame(self::PLAIN, Encryption::readToken($rToken, 'k', OPENSSL_EXTRA, false));
$this->previous('old-extra', time() - 1);
$this->assertFalse(Encryption::readToken($rToken, 'k', OPENSSL_EXTRA, false), 'window closed');
}
public function testALegacyTokenFallsBackOnlyWhereLegacyTokensAreAccepted(): void {
$rToken = Encryption::encrypt(self::PLAIN, 'k', 'old-extra');
$this->previous('old-extra', time() + 600);
$this->assertSame(self::PLAIN, Encryption::readToken($rToken, 'k', OPENSSL_EXTRA, true));
$this->assertFalse(Encryption::readToken($rToken, 'k', OPENSSL_EXTRA, false));
}
/**
* Where legacy tokens are accepted, a sealed token minted with the replaced
* value must not be read as a legacy one with the current value first: that
* decrypt() returns garbage instead of false about once in 256 tokens (when
* the padding happens to hold). 48- and 64-byte tokens can pass CBC's length
* check; 2000 of them miss none only when both sealed attempts come first.
*/
public function testASealedTokenWithTheReplacedValueOpensWhereLegacyTokensAreAccepted(): void {
$this->previous('old-extra', time() + 600);
$rMissed = 0;
for ($i = 0; $i < 2000; $i++) {
$rPlain = str_pad('live/' . $i . '/', ($i % 2) ? 20 : 36, 'x');
if (Encryption::readToken(Encryption::seal($rPlain, 'k', 'old-extra'), 'k', OPENSSL_EXTRA, true) !== $rPlain) {
$rMissed++;
}
}
$this->assertSame(0, $rMissed);
}
public function testOnlyTheOpensslExtraContextFallsBack(): void {
$this->previous('old-extra', time() + 600);
$this->assertFalse(Encryption::readToken(Encryption::seal(self::PLAIN, 'k', 'old-extra'), 'k', 'd', false));
$this->assertFalse(Encryption::readToken(Encryption::encrypt(self::PLAIN, 'k', 'old-extra'), 'k', 'd', true));
$this->assertFalse(Encryption::readToken(Encryption::seal(self::PLAIN, 'other-key', OPENSSL_EXTRA), 'k', OPENSSL_EXTRA, false), 'another key is still refused');
}
public function testATokenMintedWithTheCurrentValueStillOpens(): void {
$rSealed = Encryption::mintToken(self::PLAIN, 'k', OPENSSL_EXTRA, true);
$rLegacy = Encryption::mintToken(self::PLAIN, 'k', OPENSSL_EXTRA, false);
$this->assertSame(self::PLAIN, Encryption::readToken($rSealed, 'k', OPENSSL_EXTRA, false));
$this->assertSame(self::PLAIN, Encryption::readToken($rLegacy, 'k', OPENSSL_EXTRA, true));
$this->previous('old-extra', time() + 600);
$this->assertSame(self::PLAIN, Encryption::readToken($rSealed, 'k', OPENSSL_EXTRA, false));
$this->assertSame(self::PLAIN, Encryption::readToken($rLegacy, 'k', OPENSSL_EXTRA, true));
}
}