From 918dfee0d26c934ffac9b9dddbe2a8374f94b1bc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 00:33:22 +0000 Subject: [PATCH] feat(stream): accept a node's previous OPENSSL_EXTRA for ten minutes A load balancer that runs on another OPENSSL_EXTRA than its main has to be moved onto the main's value. Every stream link the node minted with its old value (HLS segment and key tokens, VOD links a player reuses to seek, tokens from direct-to-LB auth) would stop opening at that moment. OpensslExtra::previous() reads config/openssl_extra.prev, the value a node replaced and when it stops being accepted, and returns it while it is still valid and differs from the value in use. Encryption::readToken tries a token that fails with OPENSSL_EXTRA once more with that value. Only the OPENSSL_EXTRA context falls back and only on the failure path, so a valid token costs nothing extra. The file is read once per process; until something writes it (the repair signal that follows), nothing changes. encrypt/decrypt/seal/open/mintToken are untouched: new tokens, hmac_keys and image names use only the current value. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TQHeypQpfAXTdCysmi7b6a --- src/Core/Config/OpensslExtra.php | 63 +++++++++++++++++- src/Core/Util/Encryption.php | 16 +++++ tests/Unit/EncryptionPreviousExtraTest.php | 75 ++++++++++++++++++++++ tests/Unit/OpensslExtraTest.php | 47 ++++++++++++++ 4 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/EncryptionPreviousExtraTest.php diff --git a/src/Core/Config/OpensslExtra.php b/src/Core/Config/OpensslExtra.php index cf0f097a..133fc618 100644 --- a/src/Core/Config/OpensslExtra.php +++ b/src/Core/Config/OpensslExtra.php @@ -13,7 +13,9 @@ namespace XcVm\Core\Config; * provisioning shipped the file runs on the default. * * Each node publishes a fingerprint of its value (never the value) in - * servers.server_hardware, so server:diagnose can compare them. + * servers.server_hardware, so server:diagnose can compare them. After a node + * switches to another value, Encryption::readToken() still opens, for a short + * window, tokens made with the value it replaced (previous()). * * @package XC_VM_Core_Config * @author Divarion_D @@ -25,6 +27,17 @@ final class OpensslExtra { /** The server_hardware key cron:servers publishes the fingerprint under. */ public const HARDWARE_KEY = 'openssl_extra_fp'; + /** Test seam: the previous-value file to read instead of CONFIG_PATH's. */ + private static ?string $rPrevFile = null; + + /** + * The previous-value file as read once per process: null until read, false + * when there is none, else ['value' => string, 'valid_until' => int]. + * + * @var array{value:string,valid_until:int}|false|null + */ + private static $rPrevious = null; + /** * A fingerprint of $rValue that can be compared across nodes: an HMAC keyed * by the value, cut to 64 bits. It does not reveal the value. @@ -43,4 +56,52 @@ final class OpensslExtra { return (is_string($rPrint) && $rPrint !== '') ? $rPrint : null; } + + /** + * The value this node replaced last (config/openssl_extra.prev), while it + * is still accepted: null once the window has closed, when there is none, + * or when it is the value in use now. + */ + public static function previous(?int $rNow = null): ?string { + if (self::$rPrevious === null) { + self::$rPrevious = self::readPrevious(); + } + if (self::$rPrevious === false || ($rNow ?? time()) > self::$rPrevious['valid_until']) { + return null; + } + if (defined('OPENSSL_EXTRA') && self::$rPrevious['value'] === OPENSSL_EXTRA) { + return null; + } + + return self::$rPrevious['value']; + } + + /** Test seam: read the previous value from $rPath (null restores CONFIG_PATH's). */ + public static function usePrevFile(?string $rPath): void { + self::$rPrevFile = $rPath; + self::$rPrevious = null; + } + + /** @return array{value:string,valid_until:int}|false */ + private static function readPrevious() { + $rFile = self::$rPrevFile; + if ($rFile === null) { + if (defined('CONFIG_PATH')) { + $rFile = CONFIG_PATH . 'openssl_extra.prev'; + } elseif (defined('MAIN_HOME')) { + $rFile = MAIN_HOME . 'config/openssl_extra.prev'; + } else { + return false; + } + } + if (!is_file($rFile)) { + return false; + } + $rData = json_decode((string) @file_get_contents($rFile), true); + if (!is_array($rData) || !isset($rData['value'], $rData['valid_until']) || !is_string($rData['value']) || $rData['value'] === '') { + return false; + } + + return ['value' => $rData['value'], 'valid_until' => intval($rData['valid_until'])]; + } } diff --git a/src/Core/Util/Encryption.php b/src/Core/Util/Encryption.php index 6918dc77..c8e8ab11 100644 --- a/src/Core/Util/Encryption.php +++ b/src/Core/Util/Encryption.php @@ -2,6 +2,8 @@ namespace XcVm\Core\Util; +use XcVm\Core\Config\OpensslExtra; + /** * Encryption Utilities * @@ -125,9 +127,23 @@ class Encryption { * $rAcceptLegacy — the secure_stream_tokens setting is off, or the token * only carries credentials the caller checks against the database again. * + * A token made for the OPENSSL_EXTRA context that does not open is tried once + * more with the value this node replaced, while that is still accepted + * (OpensslExtra::previous()), so switching a node onto MAIN's value does not + * break the links it has just handed out. A token that opens pays nothing extra. + * * @return string|false */ public static function readToken($token, $key, $deviceId, bool $rAcceptLegacy) { + $rPlain = self::readTokenWith($token, $key, $deviceId, $rAcceptLegacy); + if ($rPlain === false && defined('OPENSSL_EXTRA') && $deviceId === OPENSSL_EXTRA && ($rPrevious = OpensslExtra::previous()) !== null) { + return self::readTokenWith($token, $key, $rPrevious, $rAcceptLegacy); + } + return $rPlain; + } + + /** @return string|false */ + private static function readTokenWith($token, $key, $deviceId, bool $rAcceptLegacy) { $rPlain = self::open($token, $key, $deviceId); if ($rPlain !== false || !$rAcceptLegacy || !is_string($token)) { return $rPlain; diff --git a/tests/Unit/EncryptionPreviousExtraTest.php b/tests/Unit/EncryptionPreviousExtraTest.php new file mode 100644 index 00000000..9b2fa7a1 --- /dev/null +++ b/tests/Unit/EncryptionPreviousExtraTest.php @@ -0,0 +1,75 @@ +rFile = sys_get_temp_dir() . '/xcvm_openssl_extra_prev_' . uniqid(); + OpensslExtra::usePrevFile($this->rFile); + } + + protected function tearDown(): void { + OpensslExtra::usePrevFile(null); + @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)); + } + + 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)); + } +} diff --git a/tests/Unit/OpensslExtraTest.php b/tests/Unit/OpensslExtraTest.php index 5b4531b9..b38b792c 100644 --- a/tests/Unit/OpensslExtraTest.php +++ b/tests/Unit/OpensslExtraTest.php @@ -10,9 +10,31 @@ use XcVm\Core\Config\OpensslExtra; * fingerprint / reportedFingerprint: what each node publishes in * servers.server_hardware, so the values can be compared without leaving the * node. + * + * previous: after a node switches to another value, the one it replaced is + * kept for a short window, so tokens minted with it still open + * (Encryption::readToken). */ final class OpensslExtraTest extends TestCase { + private string $rDir; + + protected function setUp(): void { + $this->rDir = sys_get_temp_dir() . '/xcvm_openssl_extra_' . uniqid() . '/'; + mkdir($this->rDir, 0700, true); + } + + protected function tearDown(): void { + OpensslExtra::usePrevFile(null); + exec('rm -rf ' . escapeshellarg($this->rDir)); + } + + /** Write a previous-value file (JSON unless given raw) and point previous() at it. */ + private function writePrev($rData): void { + file_put_contents($this->rDir . 'openssl_extra.prev', is_string($rData) ? $rData : json_encode($rData)); + OpensslExtra::usePrevFile($this->rDir . 'openssl_extra.prev'); + } + public function testFingerprintIsAShortStableDigestThatHidesTheValue(): void { $rSecret = 'fNiu3XD448xTDa27xoY4'; $rPrint = OpensslExtra::fingerprint($rSecret); @@ -36,4 +58,29 @@ final class OpensslExtraTest extends TestCase { $this->assertNull(OpensslExtra::reportedFingerprint(['server_hardware' => null])); $this->assertNull(OpensslExtra::reportedFingerprint([])); } + + public function testThePreviousValueIsAcceptedUntilItsWindowCloses(): void { + $this->writePrev(['value' => 'old-extra', 'valid_until' => 1600]); + + $this->assertSame('old-extra', OpensslExtra::previous(1500)); + $this->assertSame('old-extra', OpensslExtra::previous(1600)); + $this->assertNull(OpensslExtra::previous(1601)); + } + + public function testThereIsNoPreviousValueWithoutAUsableFile(): void { + OpensslExtra::usePrevFile($this->rDir . 'missing'); + $this->assertNull(OpensslExtra::previous(1000), 'no file'); + + $this->writePrev('{"value": "old-extra", '); + $this->assertNull(OpensslExtra::previous(1000), 'corrupt JSON'); + + $this->writePrev(['value' => '', 'valid_until' => 1600]); + $this->assertNull(OpensslExtra::previous(1000), 'empty value'); + + $this->writePrev(['value' => 'old-extra']); + $this->assertNull(OpensslExtra::previous(1000), 'no expiry'); + + $this->writePrev(['value' => OPENSSL_EXTRA, 'valid_until' => 1600]); + $this->assertNull(OpensslExtra::previous(1000), 'the value in use now'); + } }