From 56a107c3d623d000c9a7a48dbd524f2c9811cd24 Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 22:14:26 +0330 Subject: [PATCH 01/66] feat(format): add domain-separated stable package cryptography --- app/Project/StablePackageCrypto.php | 76 +++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 app/Project/StablePackageCrypto.php diff --git a/app/Project/StablePackageCrypto.php b/app/Project/StablePackageCrypto.php new file mode 100644 index 00000000..b694b8e4 --- /dev/null +++ b/app/Project/StablePackageCrypto.php @@ -0,0 +1,76 @@ + 1024) { + throw new RuntimeException('Invalid key derivation request.'); + } + return hash_hkdf('sha256', $root, StablePackageDescriptor::KEY_BYTES, + "SPKG/" . $purpose . "\0" . $context, $packageId); + } + + public static function chunkAad(string $coreHash, string $packageId, string $fileId, int $sequence, int $size): string + { + if (strlen($coreHash) !== 32 || strlen($packageId) !== StablePackageDescriptor::PACKAGE_ID_BYTES + || strlen($fileId) !== StablePackageDescriptor::FILE_ID_BYTES || $sequence < 0 || $size < 0) { + throw new RuntimeException('Invalid authenticated chunk context.'); + } + return "SPKG/CHUNK\0" . $coreHash . $packageId . $fileId . pack('N', $sequence) + . StablePackageDescriptor::packUint64($size); + } + + public static function manifestAad(string $coreHash): string + { + if (strlen($coreHash) !== 32) { + throw new RuntimeException('Invalid authenticated manifest context.'); + } + return "SPKG/MANIFEST\0" . $coreHash; + } + + public static function encodeRecoveryKey(string $raw): string + { + if (strlen($raw) !== StablePackageDescriptor::KEY_BYTES) { + throw new RuntimeException('Invalid recovery key.'); + } + return rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); + } + + public static function decodeRecoveryKey(string $encoded): string + { From 1b9539126fa3d37a41e1013c3d1f8bde048ad84d Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 22:16:53 +0330 Subject: [PATCH 02/66] feat(format): complete stable package cryptography helpers --- app/Project/StablePackageCrypto.php | 67 +++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/app/Project/StablePackageCrypto.php b/app/Project/StablePackageCrypto.php index b694b8e4..26cc45c0 100644 --- a/app/Project/StablePackageCrypto.php +++ b/app/Project/StablePackageCrypto.php @@ -74,3 +74,70 @@ public static function encodeRecoveryKey(string $raw): string public static function decodeRecoveryKey(string $encoded): string { + if ($encoded === '' || strlen($encoded) > 128 || !preg_match('/^[A-Za-z0-9_-]+$/D', $encoded)) { + throw new RuntimeException('Unable to open package.'); + } + $padded = strtr($encoded, '-_', '+/'); + $padded .= str_repeat('=', (4 - strlen($padded) % 4) % 4); + $raw = base64_decode($padded, true); + if (!is_string($raw) || strlen($raw) !== StablePackageDescriptor::KEY_BYTES + || !hash_equals(self::encodeRecoveryKey($raw), $encoded)) { + throw new RuntimeException('Unable to open package.'); + } + return $raw; + } + + public static function unwrapRootKey(array $header, string $password, string $pattern, ?string $recoveryKey): string + { + $credentialKey = null; + $recoveryRaw = null; + $rootKey = null; + $macKey = null; + try { + if ($recoveryKey !== null && $recoveryKey !== '') { + if (!$header['has_recovery']) { + throw new RuntimeException('Unable to open package.'); + } + $recoveryRaw = self::decodeRecoveryKey($recoveryKey); + $rootKey = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt( + $header['recovery_cipher'], + "SPKG/KEYWRAP/RECOVERY\0" . $header['core'], + $header['recovery_nonce'], + $recoveryRaw + ); + } else { + \SecurePackage\Security\Validator::validatePassword($password); + \SecurePackage\Security\Validator::validatePattern($pattern); + $credentialKey = self::deriveCredentialKey($password, $pattern, $header['salt'], + $header['package_id'], $header['ops'], $header['memory_bytes']); + $rootKey = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt( + $header['primary_cipher'], + "SPKG/KEYWRAP/PRIMARY\0" . $header['core'], + $header['primary_nonce'], + $credentialKey + ); + } + if (!is_string($rootKey) || strlen($rootKey) !== StablePackageDescriptor::KEY_BYTES) { + throw new RuntimeException('Unable to open package.'); + } + $macKey = self::deriveSubkey($rootKey, 'HEADER-MAC', $header['package_id']); + $expected = hash_hmac('sha256', substr($header['raw'], 0, StablePackageDescriptor::HEADER_WITHOUT_MAC_BYTES), $macKey, true); + if (!hash_equals($expected, $header['header_mac'])) { + sodium_memzero($rootKey); + $rootKey = null; + throw new RuntimeException('Unable to open package.'); + } + return $rootKey; + } catch (\Throwable $e) { + if (is_string($rootKey)) sodium_memzero($rootKey); + // Collapse credential/authentication failures to the same external message. + throw new RuntimeException('Unable to open package.', 0, $e); + } finally { + if (is_string($credentialKey)) sodium_memzero($credentialKey); + if (is_string($recoveryRaw)) sodium_memzero($recoveryRaw); + if (is_string($macKey)) sodium_memzero($macKey); + $password = ''; + $pattern = ''; + } + } +} \ No newline at end of file From a94959154371eba64cc9cac12f531f222e2fd803 Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 22:17:50 +0330 Subject: [PATCH 03/66] feat(format): define stable binary package descriptor and manifest codec --- app/Project/StablePackageDescriptor.php | 457 ++++++++++++++++++++++++ 1 file changed, 457 insertions(+) create mode 100644 app/Project/StablePackageDescriptor.php diff --git a/app/Project/StablePackageDescriptor.php b/app/Project/StablePackageDescriptor.php new file mode 100644 index 00000000..9ae073c3 --- /dev/null +++ b/app/Project/StablePackageDescriptor.php @@ -0,0 +1,457 @@ + self::MAX_KDF_OPS + || $memoryBytes < self::MIN_KDF_MEM_BYTES || $memoryBytes > self::MAX_KDF_MEM_BYTES + || ($memoryBytes % 1024) !== 0) { + throw new RuntimeException('Unsupported or unsafe password-hashing parameters.'); + } + } + + public static function packUint64(int $value): string + { + if ($value < 0 || PHP_INT_SIZE < 8) { + throw new RuntimeException('Invalid unsigned 64-bit integer.'); + } + $base = 4_294_967_296; + $high = intdiv($value, $base); + $low = $value % $base; + return pack('N2', $high, $low); + } + + public static function unpackUint64(string $bytes): int + { + if (strlen($bytes) !== 8 || PHP_INT_SIZE < 8) { + throw new RuntimeException('Invalid unsigned 64-bit integer.'); + } + $parts = unpack('Nhigh/Nlow', $bytes); + if (!is_array($parts)) { + throw new RuntimeException('Invalid unsigned 64-bit integer.'); + } + $value = ((int) $parts['high'] * 4_294_967_296) + (int) $parts['low']; + if ($value < 0 || $value > PHP_INT_MAX) { + throw new RuntimeException('Unsigned 64-bit integer exceeds this runtime.'); + } + return $value; + } + + public static function writeAll($handle, string $bytes): void + { + $offset = 0; + $length = strlen($bytes); + while ($offset < $length) { + $written = fwrite($handle, substr($bytes, $offset)); + if ($written === false || $written === 0) { + throw new RuntimeException('Unable to write package data.'); + } + $offset += $written; + } + } + + public static function readExact($handle, int $length): string + { + if ($length < 0) { + throw new RuntimeException('Invalid read length.'); + } + $data = ''; + while (strlen($data) < $length && !feof($handle)) { + $chunk = fread($handle, $length - strlen($data)); + if ($chunk === false || $chunk === '') { + break; + } + $data .= $chunk; + } + if (strlen($data) !== $length) { + throw new RuntimeException('Package is truncated.'); + } + return $data; + } + + /** Parse the fixed header and reject unsafe algorithm or KDF parameters before derivation. */ + public static function parseHeader(string $raw): array + { + self::assertRuntime(); + if (strlen($raw) !== self::HEADER_BYTES || !hash_equals(self::MAGIC, substr($raw, 0, 8))) { + throw new RuntimeException('Unable to open package.'); + } + + $flags = ord($raw[8]); + $kdfId = ord($raw[9]); + $aeadId = ord($raw[10]); + $streamId = ord($raw[11]); + $hashId = ord($raw[12]); + $ops = unpack('Nvalue', substr($raw, 13, 4))['value'] ?? 0; + $memoryBytes = unpack('Nvalue', substr($raw, 17, 4))['value'] ?? 0; + $packageId = substr($raw, 21, self::PACKAGE_ID_BYTES); + $salt = substr($raw, 45, self::SALT_BYTES); + $primaryNonce = substr($raw, 77, self::NONCE_BYTES); + $primaryCipher = substr($raw, 101, self::WRAPPED_KEY_BYTES); + $recoveryNonce = substr($raw, 149, self::NONCE_BYTES); + $recoveryCipher = substr($raw, 173, self::WRAPPED_KEY_BYTES); + $headerMac = substr($raw, 221, self::HEADER_MAC_BYTES); + + if (($flags & ~self::FLAG_RECOVERY_SLOT) !== 0 + || $kdfId !== self::KDF_ARGON2ID13 + || $aeadId !== self::AEAD_XCHACHA20POLY1305 + || $streamId !== self::STREAM_SECRETSTREAM_XCHACHA20POLY1305 + || $hashId !== self::HASH_SHA256) { + throw new RuntimeException('Unsupported package cryptographic parameters.'); + } + self::assertKdfParameters((int) $ops, (int) $memoryBytes); + if (self::isZeroBytes($packageId) || self::isZeroBytes($salt) + || self::isZeroBytes($primaryNonce) || self::isZeroBytes($primaryCipher)) { + throw new RuntimeException('Unable to open package.'); + } + + $hasRecovery = ($flags & self::FLAG_RECOVERY_SLOT) !== 0; + if ($hasRecovery) { + if (self::isZeroBytes($recoveryNonce) || self::isZeroBytes($recoveryCipher)) { + throw new RuntimeException('Unable to open package.'); + } + } elseif (!self::isZeroBytes($recoveryNonce) || !self::isZeroBytes($recoveryCipher)) { + throw new RuntimeException('Unable to open package.'); + } + + return [ + 'raw' => $raw, + 'core' => substr($raw, 0, self::CORE_BYTES), + 'core_hash' => hash('sha256', substr($raw, 0, self::CORE_BYTES), true), + 'flags' => $flags, + 'has_recovery' => $hasRecovery, + 'kdf_id' => $kdfId, + 'aead_id' => $aeadId, + 'stream_id' => $streamId, + 'hash_id' => $hashId, + 'ops' => (int) $ops, + 'memory_bytes' => (int) $memoryBytes, + 'package_id' => $packageId, + 'package_id_hex' => bin2hex($packageId), + 'salt' => $salt, + 'primary_nonce' => $primaryNonce, + 'primary_cipher' => $primaryCipher, + 'recovery_nonce' => $recoveryNonce, + 'recovery_cipher' => $recoveryCipher, + 'header_mac' => $headerMac, + ]; + } + + /** Build a manifest with no application or format-version fields. */ + public static function encodeManifest(array $entries): string + { + if (count($entries) > self::MAX_NODES) { + throw new RuntimeException('Package contains too many entries.'); + } + $bytes = pack('N', count($entries)); + $files = 0; + $directories = 0; + foreach ($entries as $entry) { + $type = $entry['type'] ?? 0; + $id = $entry['id'] ?? ''; + $parent = $entry['parent_id'] ?? ''; + $name = $entry['name'] ?? ''; + if (!in_array($type, [1, 2], true) || !is_string($id) || strlen($id) !== self::FILE_ID_BYTES + || self::isZeroBytes($id) || !is_string($parent) || strlen($parent) !== self::FILE_ID_BYTES + || !is_string($name)) { + throw new RuntimeException('Invalid manifest entry.'); + } + self::assertName($name); + $nameLength = strlen($name); + if ($nameLength < 1 || $nameLength > self::MAX_FILENAME_BYTES) { + throw new RuntimeException('Invalid manifest entry name.'); + } + $bytes .= chr($type) . $id . $parent . pack('n', $nameLength) . $name; + if ($type === 2) { + $size = $entry['size'] ?? -1; + $chunks = $entry['chunks'] ?? 0; + if (!is_int($size) || $size < 0 || $size > self::MAX_FILE_BYTES || !is_int($chunks) + || $chunks !== self::expectedChunkCount($size)) { + throw new RuntimeException('Invalid manifest file metadata.'); + } + $bytes .= self::packUint64($size) . pack('N', $chunks); + $files++; + } else { + $directories++; + } + if (strlen($bytes) > self::MAX_MANIFEST_BYTES) { + throw new RuntimeException('Manifest exceeds the stable format limit.'); + } + } + if ($files < 1) { + throw new RuntimeException('A package must contain at least one regular file.'); + } + return $bytes; + } + + /** Decode the binary manifest, validate the graph, and return canonical entries with paths. */ + public static function decodeManifest(string $plain): array + { + $length = strlen($plain); + if ($length < 4 || $length > self::MAX_MANIFEST_BYTES) { + throw new RuntimeException('Manifest length is invalid.'); + } + $count = unpack('Nvalue', substr($plain, 0, 4))['value'] ?? 0; + if ($count < 1 || $count > self::MAX_NODES) { + throw new RuntimeException('Manifest entry count is invalid.'); + } + + $offset = 4; + $entries = []; + $byId = []; + $files = 0; + $directories = 0; + $declaredTotal = 0; + for ($i = 0; $i < $count; $i++) { + if ($offset + 35 > $length) { + throw new RuntimeException('Manifest is truncated.'); + } + $type = ord($plain[$offset]); + $offset++; + $id = substr($plain, $offset, self::FILE_ID_BYTES); + $offset += self::FILE_ID_BYTES; + $parentId = substr($plain, $offset, self::FILE_ID_BYTES); + $offset += self::FILE_ID_BYTES; + $nameLength = unpack('nvalue', substr($plain, $offset, 2))['value'] ?? 0; + $offset += 2; + if (!in_array($type, [1, 2], true) || self::isZeroBytes($id) + || isset($byId[bin2hex($id)]) || $nameLength < 1 || $nameLength > self::MAX_FILENAME_BYTES + || $offset + $nameLength > $length) { + throw new RuntimeException('Manifest entry is invalid.'); + } + $name = substr($plain, $offset, $nameLength); + $offset += $nameLength; + self::assertName($name); + $entry = ['type' => $type, 'id' => $id, 'id_hex' => bin2hex($id), 'parent_id' => $parentId, + 'parent_hex' => bin2hex($parentId), 'name' => $name, 'size' => null, 'chunks' => null]; + if ($type === 2) { + if ($offset + 12 > $length) { + throw new RuntimeException('Manifest file metadata is truncated.'); + } + $size = self::unpackUint64(substr($plain, $offset, 8)); + $offset += 8; + $chunks = unpack('Nvalue', substr($plain, $offset, 4))['value'] ?? 0; + $offset += 4; + if ($size > self::MAX_FILE_BYTES || $chunks !== self::expectedChunkCount($size)) { + throw new RuntimeException('Manifest file metadata is invalid.'); + } + $entry['size'] = $size; + $entry['chunks'] = (int) $chunks; + $declaredTotal += $size; + if ($declaredTotal > self::MAX_TOTAL_PLAIN_BYTES) { + throw new RuntimeException('Manifest exceeds the total data limit.'); + } + $files++; + } else { + $directories++; + } + $byId[$entry['id_hex']] = count($entries); + $entries[] = $entry; + } + if ($offset !== $length || $files < 1 || count($entries) !== $count) { + throw new RuntimeException('Manifest contains trailing or missing data.'); + } + + $states = []; + $paths = []; + $foldedNames = []; + foreach ($entries as $entry) { + $parentHex = $entry['parent_hex']; + if ($parentHex !== str_repeat('0', self::FILE_ID_BYTES * 2)) { + if (!isset($byId[$parentHex])) { + throw new RuntimeException('Manifest references a missing parent.'); + } + $parent = $entries[$byId[$parentHex]]; + if ($parent['type'] !== 1 || $parent['id_hex'] === $entry['id_hex']) { + throw new RuntimeException('Manifest parent reference is invalid.'); + } + } + $fold = self::foldName($entry['name']); + $siblingKey = $parentHex . "\0" . $fold; + if (isset($foldedNames[$siblingKey])) { + throw new RuntimeException('Manifest contains colliding names.'); + } + $foldedNames[$siblingKey] = true; + } + + foreach ($entries as $entry) { + $idHex = $entry['id_hex']; + $paths[$idHex] = self::resolvePath($idHex, $entries, $byId, $states, $paths, 1); + } + $previousPath = null; + foreach ($entries as &$entry) { + $path = $paths[$entry['id_hex']]; + if ($previousPath !== null && strcmp($previousPath, $path) >= 0) { + throw new RuntimeException('Manifest entries are not in canonical path order.'); + } + $previousPath = $path; + $entry['path'] = $path; + $entry['depth'] = substr_count($path, '/') + 1; + } + unset($entry); + + return ['entries' => $entries, 'file_count' => $files, 'directory_count' => $directories, + 'total_plain_bytes' => $declaredTotal]; + } + + public static function expectedChunkCount(int $size): int + { + if ($size < 0) { + throw new RuntimeException('Invalid file size.'); + } + return max(1, (int) ceil($size / self::CHUNK_BYTES)); + } + + public static function assertName(string $name): void + { + if ($name === '' || $name === '.' || $name === '..' || strlen($name) > self::MAX_FILENAME_BYTES + || preg_match('//u', $name) !== 1 || str_contains($name, "\0") + || str_contains($name, '/') || str_contains($name, '\\') + || preg_match('/[\x00-\x1F\x7F:<>"|?*]/u', $name) === 1 + || str_ends_with($name, ' ') || str_ends_with($name, '.') + || preg_match('/^(CON|PRN|AUX|NUL|CONIN\$|CONOUT\$|COM[1-9]|LPT[1-9])(?:\..*)?$/i', $name) === 1) { + throw new RuntimeException('Unsafe or unsupported filesystem name.'); + } + self::foldName($name); // Ensure collision checking is safe on this runtime. + } + + public static function foldName(string $name): string + { + $hasNonAscii = preg_match('/[^\x00-\x7F]/', $name) === 1; + if ($hasNonAscii) { + // Unicode normalization and case-folding vary across filesystems. To avoid + // generating packages whose paths collide after transfer, require both APIs + // for non-ASCII names and accept only already-normalized NFC names. + if (!function_exists('mb_strtolower') || !class_exists('Normalizer')) { + throw new RuntimeException('Non-ASCII names require mbstring and intl for portable path validation.'); + } + $normalized = \Normalizer::normalize($name, \Normalizer::FORM_C); + if (!is_string($normalized) || !hash_equals($normalized, $name)) { + throw new RuntimeException('Non-ASCII filenames must use Unicode NFC normalization.'); + } + } + if (function_exists('mb_convert_case') && defined('MB_CASE_FOLD')) { + return mb_convert_case($name, MB_CASE_FOLD, 'UTF-8'); + } + if (function_exists('mb_strtolower')) { + return mb_strtolower($name, 'UTF-8'); + } + return strtolower($name); + } + + private static function resolvePath(string $idHex, array $entries, array $byId, array &$states, array &$paths, int $depth): string + { + if (($states[$idHex] ?? 0) === 1) { + throw new RuntimeException('Manifest directory graph contains a cycle.'); + } + if (($states[$idHex] ?? 0) === 2) { + return $paths[$idHex]; + } + if ($depth > self::MAX_DEPTH || !isset($byId[$idHex])) { + throw new RuntimeException('Manifest directory depth is invalid.'); + } + $states[$idHex] = 1; + $entry = $entries[$byId[$idHex]]; + $zeroParent = str_repeat('0', self::FILE_ID_BYTES * 2); + if ($entry['parent_hex'] === $zeroParent) { + $path = $entry['name']; + } else { + $parentPath = self::resolvePath($entry['parent_hex'], $entries, $byId, $states, $paths, $depth + 1); + $path = $parentPath . '/' . $entry['name']; + } + if (strlen($path) > self::MAX_PATH_BYTES) { + throw new RuntimeException('Manifest path exceeds the supported limit.'); + } + $states[$idHex] = 2; + $paths[$idHex] = $path; + return $path; + } +} \ No newline at end of file From d82d92dc5256ebf492976f56091fbf630028a583 Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 22:19:31 +0330 Subject: [PATCH 04/66] feat(format): implement stable binary package writer --- app/Project/StablePackageWriter.php | 462 ++++++++++++++++++++++++++++ 1 file changed, 462 insertions(+) create mode 100644 app/Project/StablePackageWriter.php diff --git a/app/Project/StablePackageWriter.php b/app/Project/StablePackageWriter.php new file mode 100644 index 00000000..5c2ea95e --- /dev/null +++ b/app/Project/StablePackageWriter.php @@ -0,0 +1,462 @@ +canonicalSourceDirectory($sourceDir); + $entries = $this->scanTree($sourceRoot); + if (count($entries) < 1 || count($entries) > StablePackageDescriptor::MAX_NODES) { + throw new RuntimeException('Source tree is empty or exceeds the package entry limit.'); + } + usort($entries, static fn(array $a, array $b): int => strcmp($a['path'], $b['path'])); + + $fileCount = 0; + $directoryCount = 0; + $totalPlainBytes = 0; + foreach ($entries as &$entry) { + if ($entry['type'] === 2) { + $fileCount++; + $totalPlainBytes += $entry['size']; + if ($totalPlainBytes > StablePackageDescriptor::MAX_TOTAL_PLAIN_BYTES) { + throw new RuntimeException('Source tree exceeds the total package size limit.'); + } + $entry['chunks'] = StablePackageDescriptor::expectedChunkCount($entry['size']); + } else { + $directoryCount++; + } + } + unset($entry); + if ($fileCount < 1) { + throw new RuntimeException('A package must contain at least one regular file.'); + } + + $manifestEntries = []; + foreach ($entries as $entry) { + $manifestEntries[] = [ + 'type' => $entry['type'], + 'id' => $entry['id'], + 'parent_id' => $entry['parent_id'], + 'name' => $entry['name'], + 'size' => $entry['type'] === 2 ? $entry['size'] : null, + 'chunks' => $entry['type'] === 2 ? $entry['chunks'] : null, + ]; + } + $manifestPlain = StablePackageDescriptor::encodeManifest($manifestEntries); + // Validate the writer's own output against the same strict graph and canonical-order rules. + $checkedManifest = StablePackageDescriptor::decodeManifest($manifestPlain); + if ($checkedManifest['file_count'] !== $fileCount || $checkedManifest['directory_count'] !== $directoryCount + || $checkedManifest['total_plain_bytes'] !== $totalPlainBytes) { + throw new RuntimeException('Internal manifest validation failed.'); + } + + $packageId ??= PackageId::generate(); + PackageId::assert($packageId); + $packageIdRaw = hex2bin($packageId); + if (!is_string($packageIdRaw) || strlen($packageIdRaw) !== StablePackageDescriptor::PACKAGE_ID_BYTES) { + throw new RuntimeException('Invalid package identifier.'); + } + + $destination = $this->canonicalDestinationFile($destinationFile); + if (file_exists($destination) || is_link($destination)) { + throw new RuntimeException('Package destination already exists.'); + } + + $salt = null; + $rootKey = null; + $credentialKey = null; + $manifestKey = null; + $headerMacKey = null; + $recoveryKeyRaw = null; + $temporaryFile = $destination . '.partial.' . bin2hex(random_bytes(16)); + $out = null; + $published = false; + + try { + $salt = random_bytes(StablePackageDescriptor::SALT_BYTES); + $rootKey = random_bytes(StablePackageDescriptor::KEY_BYTES); + $recoveryKeyRaw = $createRecovery ? random_bytes(StablePackageDescriptor::KEY_BYTES) : null; + $ops = StablePackageDescriptor::DEFAULT_KDF_OPS; + $memory = StablePackageDescriptor::DEFAULT_KDF_MEM_BYTES; + StablePackageDescriptor::assertKdfParameters($ops, $memory); + $flags = $createRecovery ? StablePackageDescriptor::FLAG_RECOVERY_SLOT : 0; + $core = StablePackageDescriptor::MAGIC + . chr($flags) + . chr(StablePackageDescriptor::KDF_ARGON2ID13) + . chr(StablePackageDescriptor::AEAD_XCHACHA20POLY1305) + . chr(StablePackageDescriptor::STREAM_SECRETSTREAM_XCHACHA20POLY1305) + . chr(StablePackageDescriptor::HASH_SHA256) + . pack('N2', $ops, $memory) + . $packageIdRaw + . $salt; + if (strlen($core) !== StablePackageDescriptor::CORE_BYTES) { + throw new RuntimeException('Internal stable header layout mismatch.'); + } + $coreHash = hash('sha256', $core, true); + $credentialKey = StablePackageCrypto::deriveCredentialKey($password, $pattern, $salt, $packageIdRaw, $ops, $memory); + + $primaryNonce = random_bytes(StablePackageDescriptor::NONCE_BYTES); + $primaryCipher = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt( + $rootKey, + "SPKG/KEYWRAP/PRIMARY\0" . $core, + $primaryNonce, + $credentialKey + ); + if (strlen($primaryCipher) !== StablePackageDescriptor::WRAPPED_KEY_BYTES) { + throw new RuntimeException('Unable to wrap package key.'); + } + + if ($recoveryKeyRaw !== null) { + $recoveryNonce = random_bytes(StablePackageDescriptor::NONCE_BYTES); + $recoveryCipher = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt( + $rootKey, + "SPKG/KEYWRAP/RECOVERY\0" . $core, + $recoveryNonce, + $recoveryKeyRaw + ); + } else { + $recoveryNonce = str_repeat("\0", StablePackageDescriptor::NONCE_BYTES); + $recoveryCipher = str_repeat("\0", StablePackageDescriptor::WRAPPED_KEY_BYTES); + } + $headerWithoutMac = $core . $primaryNonce . $primaryCipher . $recoveryNonce . $recoveryCipher; + if (strlen($headerWithoutMac) !== StablePackageDescriptor::HEADER_WITHOUT_MAC_BYTES) { + throw new RuntimeException('Internal stable header layout mismatch.'); + } + $headerMacKey = StablePackageCrypto::deriveSubkey($rootKey, 'HEADER-MAC', $packageIdRaw); + $headerMac = hash_hmac('sha256', $headerWithoutMac, $headerMacKey, true); + $header = $headerWithoutMac . $headerMac; + if (strlen($header) !== StablePackageDescriptor::HEADER_BYTES) { + throw new RuntimeException('Internal stable header length mismatch.'); + } + + $manifestKey = StablePackageCrypto::deriveSubkey($rootKey, 'MANIFEST', $packageIdRaw); + $out = @fopen($temporaryFile, 'x+b'); + if ($out === false) { + $out = null; + throw new RuntimeException('Unable to create temporary package file.'); + } + @chmod($temporaryFile, 0600); + StablePackageDescriptor::writeAll($out, $header); + + $fileRootKey = StablePackageCrypto::deriveSubkey($rootKey, 'FILE-ROOT', $packageIdRaw); + try { + foreach ($entries as $entry) { + if ($entry['type'] !== 2) { + continue; + } + $this->encryptFileEntry($out, $entry, $fileRootKey, $packageIdRaw, $coreHash); + } + } finally { + sodium_memzero($fileRootKey); + } + + $manifestNonce = random_bytes(StablePackageDescriptor::NONCE_BYTES); + $manifestCipher = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt( + $manifestPlain, + "SPKG/MANIFEST\0" . $coreHash, + $manifestNonce, + $manifestKey + ); + $this->writeRecord($out, StablePackageDescriptor::RECORD_MANIFEST, $manifestNonce . $manifestCipher, + StablePackageDescriptor::MAX_MANIFEST_RECORD_BYTES); + + if (!fflush($out)) { + throw new RuntimeException('Unable to flush the package file.'); + } + if (function_exists('fsync') && !fsync($out)) { + throw new RuntimeException('Unable to synchronize the package file.'); + } + if (!fclose($out)) { + $out = null; + throw new RuntimeException('Unable to close the package file.'); + } + $out = null; + $this->publishWithoutReplace($temporaryFile, $destination); + $published = true; + + return [ + 'package_id' => $packageId, + 'files' => $fileCount, + 'directories' => $directoryCount, + 'nodes' => count($entries), + 'total_plain_bytes' => $totalPlainBytes, + 'recovery_key' => $recoveryKeyRaw === null ? null : StablePackageCrypto::encodeRecoveryKey($recoveryKeyRaw), + ]; + } catch (Throwable $e) { + if (is_resource($out)) { + fclose($out); + } + if (!$published && (is_file($temporaryFile) || is_link($temporaryFile))) { + @unlink($temporaryFile); + } + throw $e; + } finally { + if (is_string($salt)) sodium_memzero($salt); + if (is_string($rootKey)) sodium_memzero($rootKey); + if (is_string($credentialKey)) sodium_memzero($credentialKey); + if (is_string($manifestKey)) sodium_memzero($manifestKey); + if (is_string($headerMacKey)) sodium_memzero($headerMacKey); + if (is_string($recoveryKeyRaw)) sodium_memzero($recoveryKeyRaw); + if (is_string($manifestPlain)) sodium_memzero($manifestPlain); + $password = ''; + $pattern = ''; + } + } + + private function encryptFileEntry($out, array $entry, string $fileRootKey, string $packageId, string $coreHash): void + { + $path = $entry['source_path']; + if (is_link($path) || !is_file($path)) { + throw new RuntimeException('Source entry changed during package creation.'); + } + $real = realpath($path); + $root = $entry['source_root']; + $rootPrefix = rtrim(str_replace('\\', '/', $root), '/') . '/'; + $realNormalized = $real === false ? '' : str_replace('\\', '/', $real); + if ($real === false || !str_starts_with($realNormalized, $rootPrefix)) { + throw new RuntimeException('Source entry escaped the source directory.'); + } + $input = @fopen($path, 'rb'); + if ($input === false) { + throw new RuntimeException('Unable to open source file.'); + } + $before = fstat($input); + if (!is_array($before) || (int) ($before['size'] ?? -1) !== $entry['size']) { + fclose($input); + throw new RuntimeException('Source file changed during package creation.'); + } + + $fileKey = StablePackageCrypto::deriveSubkey($fileRootKey, 'FILE', $packageId, bin2hex($entry['id'])); + $state = null; + try { + [$state, $streamHeader] = sodium_crypto_secretstream_xchacha20poly1305_init_push($fileKey); + $startBody = $entry['id'] . StablePackageDescriptor::packUint64($entry['size']) . $streamHeader; + $this->writeRecord($out, StablePackageDescriptor::RECORD_FILE_START, $startBody, + StablePackageDescriptor::MAX_FILE_CHUNK_RECORD_BYTES); + + $remaining = $entry['size']; + $sequence = 0; + if ($remaining === 0) { + $cipher = sodium_crypto_secretstream_xchacha20poly1305_push( + $state, + '', + StablePackageCrypto::chunkAad($coreHash, $packageId, $entry['id'], $sequence, 0), + SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_FINAL + ); + $this->writeChunkRecord($out, $entry['id'], $sequence, $cipher); + $sequence++; + } else { + while ($remaining > 0) { + $wanted = min(StablePackageDescriptor::CHUNK_BYTES, $remaining); + $plain = $this->readExactFromFile($input, $wanted); + $remaining -= strlen($plain); + if (strlen($plain) !== $wanted) { + sodium_memzero($plain); + throw new RuntimeException('Source file changed during package creation.'); + } + $tag = $remaining === 0 + ? SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_FINAL + : SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_MESSAGE; + $cipher = sodium_crypto_secretstream_xchacha20poly1305_push( + $state, + $plain, + StablePackageCrypto::chunkAad($coreHash, $packageId, $entry['id'], $sequence, $entry['size']), + $tag + ); + $this->writeChunkRecord($out, $entry['id'], $sequence, $cipher); + sodium_memzero($plain); + $sequence++; + } + } + if ($sequence !== $entry['chunks']) { + throw new RuntimeException('Internal file chunk count mismatch.'); + } + $extra = fread($input, 1); + if ($extra === false || $extra !== '') { + throw new RuntimeException('Source file changed during package creation.'); + } + $after = fstat($input); + if (!is_array($after) || (int) ($after['size'] ?? -1) !== $entry['size'] + || (int) ($after['mtime'] ?? -1) !== (int) ($before['mtime'] ?? -1) + || (int) ($after['ctime'] ?? -1) !== (int) ($before['ctime'] ?? -1)) { + throw new RuntimeException('Source file changed during package creation.'); + } + } finally { + fclose($input); + sodium_memzero($fileKey); + if (is_string($state)) sodium_memzero($state); + } + } + + private function scanTree(string $root): array + { + $entries = []; + $seenIds = []; + $nodeCount = 0; + $totalBytes = 0; + $walk = function (string $directory, string $parentId, string $relative, int $depth) use (&$walk, &$entries, &$seenIds, &$nodeCount, &$totalBytes, $root): void { + $names = @scandir($directory); + if ($names === false) { + throw new RuntimeException('Unable to inspect the source directory.'); + } + $names = array_values(array_diff($names, ['.', '..'])); + sort($names, SORT_STRING); + $siblings = []; + foreach ($names as $name) { + StablePackageDescriptor::assertName($name); + $fold = StablePackageDescriptor::foldName($name); + if (isset($siblings[$fold])) { + throw new RuntimeException('Source directory contains colliding names.'); + } + $siblings[$fold] = true; + + $path = $directory . DIRECTORY_SEPARATOR . $name; + if (is_link($path)) { + throw new RuntimeException('Symbolic links are not supported in package sources.'); + } + $real = realpath($path); + $rootPrefix = rtrim(str_replace('\\', '/', $root), '/') . '/'; + $realNormalized = $real === false ? '' : str_replace('\\', '/', $real); + if ($real === false || !str_starts_with($realNormalized, $rootPrefix)) { + throw new RuntimeException('Source entry escaped the source directory.'); + } + $isDirectory = is_dir($path); + $isFile = is_file($path); + if (!$isDirectory && !$isFile) { + throw new RuntimeException('Special filesystem entries are not supported.'); + } + $nodeDepth = $depth; + if ($nodeDepth > StablePackageDescriptor::MAX_DEPTH) { + throw new RuntimeException('Source directory nesting exceeds the package limit.'); + } + if (++$nodeCount > StablePackageDescriptor::MAX_NODES) { + throw new RuntimeException('Source tree contains too many entries.'); + } + do { + $id = random_bytes(StablePackageDescriptor::FILE_ID_BYTES); + $idHex = bin2hex($id); + } while (StablePackageDescriptor::isZeroBytes($id) || isset($seenIds[$idHex])); + $seenIds[$idHex] = true; + $pathName = $relative === '' ? $name : $relative . '/' . $name; + if (strlen($pathName) > StablePackageDescriptor::MAX_PATH_BYTES) { + throw new RuntimeException('Source path exceeds the package limit.'); + } + $entry = [ + 'type' => $isDirectory ? 1 : 2, + 'id' => $id, + 'parent_id' => $parentId, + 'name' => $name, + 'path' => $pathName, + 'source_path' => $path, + 'source_root' => $root, + 'size' => 0, + 'chunks' => null, + ]; + if ($isFile) { + $stat = @stat($path); + if (!is_array($stat) || !isset($stat['size']) || (int) $stat['size'] < 0 + || (int) $stat['size'] > StablePackageDescriptor::MAX_FILE_BYTES) { + throw new RuntimeException('Source file exceeds the supported size limit.'); + } + $entry['size'] = (int) $stat['size']; + $totalBytes += $entry['size']; + if ($totalBytes > StablePackageDescriptor::MAX_TOTAL_PLAIN_BYTES) { + throw new RuntimeException('Source tree exceeds the total package size limit.'); + } + } + $entries[] = $entry; + if ($isDirectory) { + $walk($path, $id, $pathName, $depth + 1); + } + } + }; + $walk($root, StablePackageDescriptor::zeroId(), '', 1); + return $entries; + } + + private function canonicalSourceDirectory(string $sourceDir): string + { + if (is_link($sourceDir)) { + throw new RuntimeException('Source directory must not be a symbolic link.'); + } + $real = realpath($sourceDir); + if ($real === false || !is_dir($real)) { + throw new RuntimeException('Source directory does not exist.'); + } + return rtrim($real, DIRECTORY_SEPARATOR); + } + + private function canonicalDestinationFile(string $path): string + { + $parent = realpath(dirname($path)); + $name = basename($path); + if ($parent === false || !is_dir($parent) || $name === '' || $name === '.' || $name === '..') { + throw new RuntimeException('Package destination parent is invalid.'); + } + return rtrim($parent, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $name; + } + + private function writeChunkRecord($out, string $fileId, int $sequence, string $cipher): void + { + $body = $fileId . pack('N', $sequence) . $cipher; + $this->writeRecord($out, StablePackageDescriptor::RECORD_FILE_CHUNK, $body, + StablePackageDescriptor::MAX_FILE_CHUNK_RECORD_BYTES); + } + + private function writeRecord($out, int $type, string $body, int $maximum): void + { + $length = strlen($body); + if ($length < 1 || $length > $maximum || $length > 0xFFFFFFFF) { + throw new RuntimeException('Package record length is invalid.'); + } + StablePackageDescriptor::writeAll($out, chr($type) . pack('N', $length) . $body); + } + + private function readExactFromFile($handle, int $length): string + { + return StablePackageDescriptor::readExact($handle, $length); + } + + private function publishWithoutReplace(string $temporary, string $destination): void + { + if (file_exists($destination) || is_link($destination)) { + throw new RuntimeException('Package destination already exists.'); + } + // Hard-link publication is atomic and fails if the destination exists. The temporary + // file is in the same directory/filesystem by construction. + if (!@link($temporary, $destination)) { + throw new RuntimeException('Unable to publish package without overwriting an existing destination.'); + } + if (!@unlink($temporary)) { + // Only remove the just-published name if it still points at our temporary inode. + $a = @stat($temporary); + $b = @stat($destination); + if (is_array($a) && is_array($b) && ($a['dev'] ?? null) === ($b['dev'] ?? null) + && ($a['ino'] ?? null) === ($b['ino'] ?? null)) { + @unlink($destination); + } + throw new RuntimeException('Package publication cleanup failed.'); + } + } +} From 57799ec9422e703acd35229b1b7f01cc3ec46cce Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 22:20:30 +0330 Subject: [PATCH 05/66] feat(format): implement strict stable binary package reader --- app/Project/StablePackageReader.php | 527 ++++++++++++++++++++++++++++ 1 file changed, 527 insertions(+) create mode 100644 app/Project/StablePackageReader.php diff --git a/app/Project/StablePackageReader.php b/app/Project/StablePackageReader.php new file mode 100644 index 00000000..f71fd4ad --- /dev/null +++ b/app/Project/StablePackageReader.php @@ -0,0 +1,527 @@ +canonicalPackageFile($packageFile); + $destination = $this->canonicalDestinationDirectory($destinationDir); + if (file_exists($destination) || is_link($destination)) { + throw new RuntimeException('Restore destination already exists.'); + } + $input = @fopen($source, 'rb'); + if ($input === false) { + throw new RuntimeException('Unable to open package.'); + } + + $stage = null; + $active = null; + $rootKey = null; + $manifestKey = null; + $fileRootKey = null; + $manifestPlain = null; + $createdOutputRoot = false; + $createdOutputRootIdentity = null; + $createdDirectories = []; + $createdFiles = []; + + try { + $fileStat = fstat($input); + if (!is_array($fileStat) || !isset($fileStat['size']) || (int) $fileStat['size'] < StablePackageDescriptor::HEADER_BYTES + || (int) $fileStat['size'] > StablePackageDescriptor::MAX_PACKAGE_BYTES) { + throw new RuntimeException('Package size is outside the supported bounds.'); + } + $rawHeader = StablePackageDescriptor::readExact($input, StablePackageDescriptor::HEADER_BYTES); + $header = StablePackageDescriptor::parseHeader($rawHeader); + $rootKey = StablePackageCrypto::unwrapRootKey($header, $password, $pattern, $recoveryKey); + $manifestKey = StablePackageCrypto::deriveSubkey($rootKey, 'MANIFEST', $header['package_id']); + $fileRootKey = StablePackageCrypto::deriveSubkey($rootKey, 'FILE-ROOT', $header['package_id']); + $stage = $this->createPrivateStage(); + + $observedFiles = []; + $observedOrder = []; + $observedFileCount = 0; + $declaredTotalBytes = 0; + $actualTotalBytes = 0; + $recordCount = 0; + $manifestSeen = false; + + while (!$manifestSeen) { + $recordHeader = fread($input, StablePackageDescriptor::RECORD_HEADER_BYTES); + if ($recordHeader === false || $recordHeader === '') { + throw new RuntimeException('Package has no final manifest record.'); + } + if (strlen($recordHeader) !== StablePackageDescriptor::RECORD_HEADER_BYTES) { + throw new RuntimeException('Package record header is truncated.'); + } + $recordCount++; + $maximumRecords = (2 * StablePackageDescriptor::MAX_NODES) + + intdiv(StablePackageDescriptor::MAX_TOTAL_PLAIN_BYTES, StablePackageDescriptor::CHUNK_BYTES) + 1; + if ($recordCount > $maximumRecords) { + throw new RuntimeException('Package contains too many records.'); + } + $type = ord($recordHeader[0]); + $bodyLength = unpack('Nvalue', substr($recordHeader, 1, 4))['value'] ?? 0; + $this->validateRecordLength((int) $type, (int) $bodyLength, $active !== null); + + if ($type === StablePackageDescriptor::RECORD_FILE_START) { + if ($active !== null || $observedFileCount >= StablePackageDescriptor::MAX_NODES) { + throw new RuntimeException('Package file stream ordering is invalid.'); + } + $body = StablePackageDescriptor::readExact($input, (int) $bodyLength); + $fileId = substr($body, 0, StablePackageDescriptor::FILE_ID_BYTES); + $fileIdHex = bin2hex($fileId); + $size = StablePackageDescriptor::unpackUint64(substr($body, 16, 8)); + $streamHeader = substr($body, 24, StablePackageDescriptor::STREAM_HEADER_BYTES); + if (StablePackageDescriptor::isZeroBytes($fileId) || isset($observedFiles[$fileIdHex]) + || in_array($fileIdHex, $observedOrder, true) + || $size > StablePackageDescriptor::MAX_FILE_BYTES) { + throw new RuntimeException('Package file header is invalid.'); + } + $declaredTotalBytes += $size; + if ($declaredTotalBytes > StablePackageDescriptor::MAX_TOTAL_PLAIN_BYTES) { + throw new RuntimeException('Package exceeds the total data limit.'); + } + $fileKey = StablePackageCrypto::deriveSubkey($fileRootKey, 'FILE', $header['package_id'], $fileIdHex); + $streamState = sodium_crypto_secretstream_xchacha20poly1305_init_pull($streamHeader, $fileKey); + $stagePath = $stage . DIRECTORY_SEPARATOR . $fileIdHex . '.plain'; + $out = @fopen($stagePath, 'xb'); + if ($out === false) { + sodium_memzero($fileKey); + throw new RuntimeException('Unable to stage decrypted file.'); + } + @chmod($stagePath, 0600); + $observedOrder[] = $fileIdHex; + $active = [ + 'id' => $fileId, + 'id_hex' => $fileIdHex, + 'size' => $size, + 'expected_chunks' => StablePackageDescriptor::expectedChunkCount($size), + 'chunks' => 0, + 'written' => 0, + 'key' => $fileKey, + 'state' => $streamState, + 'out' => $out, + 'stage_path' => $stagePath, + ]; + continue; + } + + if ($type === StablePackageDescriptor::RECORD_FILE_CHUNK) { + if ($active === null) { + throw new RuntimeException('Package chunk appears outside a file stream.'); + } + $body = StablePackageDescriptor::readExact($input, (int) $bodyLength); + $recordFileId = substr($body, 0, StablePackageDescriptor::FILE_ID_BYTES); + $sequence = unpack('Nvalue', substr($body, 16, 4))['value'] ?? -1; + $cipher = substr($body, 20); + if (!hash_equals($active['id'], $recordFileId) || (int) $sequence !== $active['chunks'] + || $active['chunks'] >= $active['expected_chunks']) { + throw new RuntimeException('Package chunk sequence is invalid.'); + } + $plainResult = sodium_crypto_secretstream_xchacha20poly1305_pull( + $active['state'], + $cipher, + StablePackageCrypto::chunkAad($header['core_hash'], $header['package_id'], $active['id'], + (int) $sequence, $active['size']) + ); + if ($plainResult === false) { + throw new RuntimeException('Package chunk authentication failed.'); + } + [$plain, $tag] = $plainResult; + $plainLength = strlen($plain); + $newTotal = $active['written'] + $plainLength; + if ($newTotal > $active['size']) { + sodium_memzero($plain); + throw new RuntimeException('Decrypted file exceeds its declared size.'); + } + if ($tag === SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_MESSAGE) { + if ($plainLength !== StablePackageDescriptor::CHUNK_BYTES || $newTotal >= $active['size'] + || ($active['chunks'] + 1) >= $active['expected_chunks']) { + sodium_memzero($plain); + throw new RuntimeException('Non-final file chunk has an invalid length or position.'); + } + } elseif ($tag === SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_FINAL) { + if ($newTotal !== $active['size'] + || ($active['size'] === 0 && ($plainLength !== 0 || $active['chunks'] !== 0)) + || ($active['size'] > 0 && $plainLength === 0) + || ($active['chunks'] + 1) !== $active['expected_chunks']) { + sodium_memzero($plain); + throw new RuntimeException('Final file chunk has an invalid length or position.'); + } + } else { + sodium_memzero($plain); + throw new RuntimeException('Unsupported secretstream tag.'); + } + + if ($plainLength > 0) { + StablePackageDescriptor::writeAll($active['out'], $plain); + } + sodium_memzero($plain); + $active['written'] = $newTotal; + $active['chunks']++; + + if ($tag === SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_FINAL) { + if (!fflush($active['out'])) { + throw new RuntimeException('Unable to flush staged file.'); + } + if (function_exists('fsync') && !fsync($active['out'])) { + throw new RuntimeException('Unable to synchronize staged file.'); + } + if (!fclose($active['out'])) { + $active['out'] = null; + throw new RuntimeException('Unable to close staged file.'); + } + $observedFiles[$active['id_hex']] = [ + 'size' => $active['size'], + 'chunks' => $active['chunks'], + 'path' => $active['stage_path'], + ]; + $observedFileCount++; + $actualTotalBytes += $active['written']; + if ($actualTotalBytes > StablePackageDescriptor::MAX_TOTAL_PLAIN_BYTES) { + throw new RuntimeException('Package exceeds the total data limit.'); + } + sodium_memzero($active['key']); + if (is_string($active['state'])) sodium_memzero($active['state']); + $active = null; + } + continue; + } + + if ($type === StablePackageDescriptor::RECORD_MANIFEST) { + if ($active !== null || $bodyLength < StablePackageDescriptor::NONCE_BYTES + StablePackageDescriptor::AEAD_TAG_BYTES) { + throw new RuntimeException('Manifest record is out of order or truncated.'); + } + $body = StablePackageDescriptor::readExact($input, (int) $bodyLength); + $nonce = substr($body, 0, StablePackageDescriptor::NONCE_BYTES); + $cipher = substr($body, StablePackageDescriptor::NONCE_BYTES); + $manifestPlain = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt( + $cipher, + StablePackageCrypto::manifestAad($header['core_hash']), + $nonce, + $manifestKey + ); + if (!is_string($manifestPlain)) { + throw new RuntimeException('Manifest authentication failed.'); + } + $trailing = fread($input, 1); + if ($trailing === false || $trailing !== '') { + throw new RuntimeException('Package contains trailing data after the manifest.'); + } + $manifestSeen = true; + continue; + } + + throw new RuntimeException('Package contains an unknown record type.'); + } + + if (!is_string($manifestPlain)) { + throw new RuntimeException('Package manifest is missing.'); + } + $decodedManifest = StablePackageDescriptor::decodeManifest($manifestPlain); + if ($decodedManifest['file_count'] !== $observedFileCount + || $decodedManifest['total_plain_bytes'] !== $actualTotalBytes + || count($observedFiles) !== $decodedManifest['file_count']) { + throw new RuntimeException('Package manifest and file streams disagree.'); + } + $expectedStreamOrder = []; + foreach ($decodedManifest['entries'] as $entry) { + if ($entry['type'] !== 2) { + continue; + } + $expectedStreamOrder[] = $entry['id_hex']; + $observed = $observedFiles[$entry['id_hex']] ?? null; + if (!is_array($observed) || $observed['size'] !== $entry['size'] || $observed['chunks'] !== $entry['chunks']) { + throw new RuntimeException('Package manifest references an absent or inconsistent file stream.'); + } + } + if ($observedOrder !== $expectedStreamOrder) { + throw new RuntimeException('File streams are not in canonical manifest order.'); + } + // File identifiers are unique in the manifest and the inventories have equal counts; + // every manifest file was found above, so no unreferenced stream can remain. + $this->extractValidatedFiles($decodedManifest['entries'], $observedFiles, $stage, $destination, + $createdOutputRoot, $createdOutputRootIdentity, $createdDirectories, $createdFiles); + $this->removePrivateStage($stage); + $stage = null; + + return [ + 'package_id' => $header['package_id_hex'], + 'files' => $decodedManifest['file_count'], + 'directories' => $decodedManifest['directory_count'], + 'nodes' => count($decodedManifest['entries']), + 'total_plain_bytes' => $decodedManifest['total_plain_bytes'], + ]; + } catch (Throwable $e) { + if (is_array($active)) { + if (is_resource($active['out'] ?? null)) fclose($active['out']); + if (is_string($active['key'] ?? null)) sodium_memzero($active['key']); + if (is_string($active['state'] ?? null)) sodium_memzero($active['state']); + } + if ($createdOutputRoot) { + try { + $this->rollbackOutput($createdFiles, $createdDirectories, $destination, $createdOutputRootIdentity); + } catch (Throwable $cleanupError) { + throw new RuntimeException('Restore failed and created output could not be fully cleaned up.', 0, $cleanupError); + } + } + throw new RuntimeException('Unable to open package.', 0, $e); + } finally { + if (is_resource($input)) fclose($input); + if (is_array($active)) { + if (is_resource($active['out'] ?? null)) fclose($active['out']); + if (is_string($active['key'] ?? null)) sodium_memzero($active['key']); + if (is_string($active['state'] ?? null)) sodium_memzero($active['state']); + } + if (is_string($rootKey)) sodium_memzero($rootKey); + if (is_string($manifestKey)) sodium_memzero($manifestKey); + if (is_string($fileRootKey)) sodium_memzero($fileRootKey); + if (is_string($manifestPlain)) sodium_memzero($manifestPlain); + $password = ''; + $pattern = ''; + if (is_string($stage) && is_dir($stage)) { + $this->removePrivateStage($stage); + } + } + } + + private function validateRecordLength(int $type, int $length, bool $hasActiveFile): void + { + if ($length < 1 || $length > 0xFFFFFFFF) { + throw new RuntimeException('Package record length is invalid.'); + } + if ($type === StablePackageDescriptor::RECORD_FILE_START) { + if ($hasActiveFile || $length !== 48) { + throw new RuntimeException('Package file-start record is invalid.'); + } + return; + } + if ($type === StablePackageDescriptor::RECORD_FILE_CHUNK) { + if (!$hasActiveFile || $length < StablePackageDescriptor::FILE_ID_BYTES + 4 + 17 + || $length > StablePackageDescriptor::MAX_FILE_CHUNK_RECORD_BYTES) { + throw new RuntimeException('Package file-chunk record is invalid.'); + } + return; + } + if ($type === StablePackageDescriptor::RECORD_MANIFEST) { + if ($hasActiveFile || $length < StablePackageDescriptor::NONCE_BYTES + StablePackageDescriptor::AEAD_TAG_BYTES + || $length > StablePackageDescriptor::MAX_MANIFEST_RECORD_BYTES) { + throw new RuntimeException('Package manifest record is invalid.'); + } + return; + } + throw new RuntimeException('Package contains an unsupported record type.'); + } + + private function extractValidatedFiles(array $entries, array $observedFiles, string $stage, string $destination, + bool &$createdRoot, ?array &$createdRootIdentity, array &$createdDirectories, array &$createdFiles): void + { + $filesById = []; + foreach ($observedFiles as $idHex => $observed) { + $path = $observed['path'] ?? null; + if (!is_string($idHex) || !preg_match('/^[a-f0-9]{32}$/D', $idHex) + || !is_string($path) || is_link($path) || !is_file($path) + || dirname($path) !== $stage || basename($path) !== $idHex . '.plain') { + throw new RuntimeException('Private staging inventory is invalid.'); + } + $filesById[$idHex] = $path; + } + if (count($filesById) !== count(array_filter($entries, static fn(array $entry): bool => $entry['type'] === 2))) { + throw new RuntimeException('Private staging inventory does not match the manifest.'); + } + + if (file_exists($destination) || is_link($destination)) { + throw new RuntimeException('Restore destination already exists.'); + } + if (!@mkdir($destination, 0700)) { + throw new RuntimeException('Unable to create restore destination.'); + } + $createdRoot = true; + $createdRootIdentity = $this->captureIdentity($destination); + @chmod($destination, 0700); + + $directories = array_values(array_filter($entries, static fn(array $entry): bool => $entry['type'] === 1)); + usort($directories, static fn(array $a, array $b): int => ($a['depth'] <=> $b['depth']) ?: strcmp($a['path'], $b['path'])); + foreach ($directories as $entry) { + $target = $destination . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $entry['path']); + if (file_exists($target) || is_link($target) || !@mkdir($target, 0700)) { + throw new RuntimeException('Unable to create restored directory.'); + } + $createdDirectories[] = ['path' => $target, 'identity' => $this->captureIdentity($target)]; + @chmod($target, 0700); + } + + $files = array_values(array_filter($entries, static fn(array $entry): bool => $entry['type'] === 2)); + usort($files, static fn(array $a, array $b): int => strcmp($a['path'], $b['path'])); + foreach ($files as $entry) { + $target = $destination . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $entry['path']); + $parent = dirname($target); + if (!is_dir($parent) || is_link($parent) || file_exists($target) || is_link($target)) { + throw new RuntimeException('Restored file destination is unsafe.'); + } + $source = $filesById[$entry['id_hex']] ?? null; + if (!is_string($source) || is_link($source) || !is_file($source)) { + throw new RuntimeException('Authenticated file staging data is missing.'); + } + $in = @fopen($source, 'rb'); + $out = @fopen($target, 'xb'); + if ($in === false || $out === false) { + if (is_resource($in)) fclose($in); + if (is_resource($out)) fclose($out); + // Never unlink $target here: fopen('xb') did not establish ownership if it failed. + throw new RuntimeException('Unable to create restored file without overwriting.'); + } + $createdFiles[] = ['path' => $target, 'identity' => $this->captureIdentity($target)]; + @chmod($target, 0600); + try { + $copied = stream_copy_to_stream($in, $out); + if (!is_int($copied) || $copied !== $entry['size']) { + throw new RuntimeException('Restored file copy was incomplete.'); + } + if (!fflush($out) || (function_exists('fsync') && !fsync($out))) { + throw new RuntimeException('Unable to synchronize restored file.'); + } + } finally { + fclose($in); + fclose($out); + } + $actualSize = filesize($target); + if ($actualSize === false || (int) $actualSize !== $entry['size']) { + throw new RuntimeException('Restored file size does not match the authenticated manifest.'); + } + } + } + + private function canonicalPackageFile(string $path): string + { + if (is_link($path)) { + throw new RuntimeException('Package file must not be a symbolic link.'); + } + $real = realpath($path); + if ($real === false || !is_file($real)) { + throw new RuntimeException('Package file does not exist.'); + } + return $real; + } + + private function canonicalDestinationDirectory(string $path): string + { + $parent = realpath(dirname($path)); + $name = basename($path); + if ($parent === false || !is_dir($parent) || $name === '' || $name === '.' || $name === '..') { + throw new RuntimeException('Restore destination parent is invalid.'); + } + return rtrim($parent, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $name; + } + + private function createPrivateStage(): string + { + $base = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR); + for ($attempt = 0; $attempt < 5; $attempt++) { + $path = $base . DIRECTORY_SEPARATOR . 'spkg-stage-' . bin2hex(random_bytes(16)); + if (@mkdir($path, 0700)) { + @chmod($path, 0700); + if (is_link($path) || !is_dir($path)) { + @rmdir($path); + continue; + } + return $path; + } + } + throw new RuntimeException('Unable to create private staging directory.'); + } + + private function removePrivateStage(string $root): void + { + if (is_link($root)) { + if (!@unlink($root)) throw new RuntimeException('Unable to remove private staging link.'); + return; + } + if (!is_dir($root)) { + return; + } + $entries = @scandir($root); + if ($entries === false) { + throw new RuntimeException('Unable to inspect private staging directory for cleanup.'); + } + foreach (array_diff($entries, ['.', '..']) as $name) { + $path = $root . DIRECTORY_SEPARATOR . $name; + if (is_link($path) || is_file($path)) { + if (!@unlink($path)) throw new RuntimeException('Unable to remove private staged file.'); + } elseif (is_dir($path)) { + $this->removePrivateStage($path); + } elseif (file_exists($path)) { + if (!@unlink($path)) throw new RuntimeException('Unable to remove private staged entry.'); + } + } + if (!@rmdir($root)) { + throw new RuntimeException('Unable to remove private staging directory.'); + } + } + + private function rollbackOutput(array $files, array $directories, string $root, ?array $rootIdentity): void + { + $failures = []; + foreach (array_reverse($files) as $owned) { + $path = $owned['path'] ?? ''; + if (!is_string($path) || (!file_exists($path) && !is_link($path))) continue; + if (!$this->matchesIdentity($path, $owned['identity'] ?? null) || is_link($path) || !is_file($path)) { + $failures[] = $path; + continue; + } + if (!@unlink($path)) $failures[] = $path; + } + foreach (array_reverse($directories) as $owned) { + $path = $owned['path'] ?? ''; + if (!is_string($path) || (!file_exists($path) && !is_link($path))) continue; + if (!$this->matchesIdentity($path, $owned['identity'] ?? null) || is_link($path) || !is_dir($path)) { + $failures[] = $path; + continue; + } + if (!@rmdir($path)) $failures[] = $path; + } + if (!file_exists($root) && !is_link($root)) { + // The root was removed by another actor; do not recreate or touch it. + } elseif (!$this->matchesIdentity($root, $rootIdentity) || is_link($root) || !is_dir($root)) { + $failures[] = $root; + } elseif (!@rmdir($root)) { + // Unknown entries are preserved; never recursively delete data not created here. + $failures[] = $root; + } + if ($failures !== []) { + throw new RuntimeException('Output rollback could not safely remove every path created by this restore.'); + } + } + + private function captureIdentity(string $path): ?array + { + $stat = @lstat($path); + if (!is_array($stat)) return null; + return ['dev' => (int) ($stat['dev'] ?? -1), 'ino' => (int) ($stat['ino'] ?? 0), 'type' => ((int) ($stat['mode'] ?? 0)) & 0170000]; + } + + private function matchesIdentity(string $path, ?array $expected): bool + { + if (!is_array($expected) || ($expected['ino'] ?? 0) === 0) return false; + $current = $this->captureIdentity($path); + return is_array($current) && $current['ino'] !== 0 + && $current['dev'] === $expected['dev'] + && $current['ino'] === $expected['ino'] + && $current['type'] === $expected['type']; + } +} \ No newline at end of file From 4df80ccf4198ec5767eca25e9d87a03de62f30f5 Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 22:21:05 +0330 Subject: [PATCH 06/66] test(format): add stable binary package adversarial regression suite --- tests/StablePackageFormatTest.php | 216 ++++++++++++++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 tests/StablePackageFormatTest.php diff --git a/tests/StablePackageFormatTest.php b/tests/StablePackageFormatTest.php new file mode 100644 index 00000000..28a3901c --- /dev/null +++ b/tests/StablePackageFormatTest.php @@ -0,0 +1,216 @@ + $total) throw new RuntimeException('Fixture record header is truncated.'); + $type = ord($bytes[$offset]); + $length = unpack('Nvalue', substr($bytes, $offset + 1, 4))['value'] ?? 0; + $bodyOffset = $offset + Format::RECORD_HEADER_BYTES; + $end = $bodyOffset + $length; + if ($length < 1 || $end > $total) throw new RuntimeException('Fixture record length is invalid.'); + if ($type === $recordType) { + $position = $lastByte ? $end - 1 : $bodyOffset + ($type === Format::RECORD_FILE_CHUNK ? 20 : 0); + if ($position >= $end) throw new RuntimeException('Fixture record payload is empty.'); + $bytes[$position] = chr(ord($bytes[$position]) ^ 0x01); + if (file_put_contents($path, $bytes, LOCK_EX) !== strlen($bytes)) throw new RuntimeException('Unable to write tampered fixture.'); + return; + } + $offset = $end; + } + throw new RuntimeException('Requested record type was not found.'); +} + +function stableRemoveTree(string $path): void +{ + if (is_link($path) || is_file($path)) { + @unlink($path); + return; + } + if (!is_dir($path)) return; + $items = scandir($path); + if ($items === false) throw new RuntimeException('Unable to inspect test cleanup directory.'); + foreach (array_diff($items, ['.', '..']) as $item) stableRemoveTree($path . DIRECTORY_SEPARATOR . $item); + if (!@rmdir($path)) throw new RuntimeException('Unable to remove test cleanup directory.'); +} + +Format::assertRuntime(); +$root = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'stable-package-format-' . bin2hex(random_bytes(8)); +$source = $root . DIRECTORY_SEPARATOR . 'source'; +$package = $root . DIRECTORY_SEPARATOR . 'archive.spkg'; +$restore = $root . DIRECTORY_SEPARATOR . 'restore'; +$password = 'Strong!Password2026#'; +$pattern = 'Az!71_xYp#42Qw9'; +$wrongPassword = 'Wrong!Password2026#'; +$wrongPattern = 'Qz!71_xYp#42Qw9'; + +mkdir($source . DIRECTORY_SEPARATOR . 'nested', 0700, true); +file_put_contents($source . DIRECTORY_SEPARATOR . 'hello.txt', "stable-format\n"); +file_put_contents($source . DIRECTORY_SEPARATOR . 'empty.bin', ''); +file_put_contents($source . DIRECTORY_SEPARATOR . 'nested' . DIRECTORY_SEPARATOR . 'binary.dat', random_bytes(Format::CHUNK_BYTES + 137)); + +try { + $build = (new StablePackageWriter())->build($source, $package, $password, $pattern, str_repeat('a', 48), true); + stableAssert($build['package_id'] === str_repeat('a', 48), 'Package id was not preserved.'); + stableAssert($build['files'] === 3 && $build['directories'] === 1 && $build['nodes'] === 4, 'Built statistics are invalid.'); + stableAssert(is_string($build['recovery_key']) && strlen($build['recovery_key']) === 43, 'Recovery key was not emitted as canonical base64url.'); + + $raw = file_get_contents($package); + stableAssert(is_string($raw) && strlen($raw) > Format::HEADER_BYTES, 'Package output is empty.'); + stableAssert(substr($raw, 0, 8) === Format::MAGIC, 'Fixed opaque, version-independent marker is incorrect.'); + stableAssert(strlen(substr($raw, 0, Format::HEADER_BYTES)) === 253, 'Fixed header length changed.'); + stableAssert(substr($raw, 8, 1) === chr(Format::FLAG_RECOVERY_SLOT), 'Recovery flag is incorrect.'); + stableAssert(substr($raw, 9, 4) === "\x01\x01\x01\x01", 'Algorithm identifiers are not at the specified offsets.'); + + $header = Format::parseHeader(substr($raw, 0, Format::HEADER_BYTES)); + stableAssert($header['package_id_hex'] === str_repeat('a', 48), 'Header package identifier does not match the requested identifier.'); + stableAssert($header['ops'] === Format::DEFAULT_KDF_OPS, 'KDF operations parameter is not the writer profile.'); + stableAssert($header['memory_bytes'] === Format::DEFAULT_KDF_MEM_BYTES, 'KDF memory parameter is not the writer profile.'); + stableAssert(strlen($header['core']) === Format::CORE_BYTES, 'Core header length is invalid.'); + + $reader = new StablePackageReader(); + $restored = $reader->restore($package, $restore, $password, $pattern); + stableAssert($restored['files'] === 3 && $restored['directories'] === 1, 'Round-trip statistics are incorrect.'); + foreach (['hello.txt', 'empty.bin', 'nested/binary.dat'] as $relative) { + $expected = hash_file('sha256', $source . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relative)); + $actual = hash_file('sha256', $restore . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relative)); + stableAssert(is_string($expected) && is_string($actual) && hash_equals($expected, $actual), 'Round-trip mismatch: ' . $relative); + } + + $recoveryDestination = $root . DIRECTORY_SEPARATOR . 'recovery-restore'; + $recovered = $reader->restore($package, $recoveryDestination, '', '', $build['recovery_key']); + stableAssert($recovered['files'] === 3, 'Recovery-key restore failed.'); + stableAssert(hash_file('sha256', $source . '/nested/binary.dat') === hash_file('sha256', $recoveryDestination . '/nested/binary.dat'), 'Recovery restore content mismatch.'); + + $existingDestination = $root . DIRECTORY_SEPARATOR . 'already-exists'; + mkdir($existingDestination, 0700); + file_put_contents($existingDestination . DIRECTORY_SEPARATOR . 'sentinel.txt', 'keep-this-file'); + $sentinelHash = hash_file('sha256', $existingDestination . DIRECTORY_SEPARATOR . 'sentinel.txt'); + stableExpectFailure(fn() => $reader->restore($package, $existingDestination, $password, $pattern), 'Existing destination was accepted.'); + stableAssert(hash_equals((string) $sentinelHash, (string) hash_file('sha256', $existingDestination . DIRECTORY_SEPARATOR . 'sentinel.txt')), 'Existing destination was altered.'); + + stableExpectFailure(fn() => $reader->restore($package, $root . DIRECTORY_SEPARATOR . 'wrong-password', $wrongPassword, $pattern), 'Wrong password was accepted.'); + stableAssert(!file_exists($root . DIRECTORY_SEPARATOR . 'wrong-password'), 'Wrong password left a destination behind.'); + stableExpectFailure(fn() => $reader->restore($package, $root . DIRECTORY_SEPARATOR . 'wrong-pattern', $password, $wrongPattern), 'Wrong pattern was accepted.'); + stableAssert(!file_exists($root . DIRECTORY_SEPARATOR . 'wrong-pattern'), 'Wrong pattern left a destination behind.'); + + $tamperedHeader = $root . DIRECTORY_SEPARATOR . 'tampered-header.spkg'; + stableCopy($package, $tamperedHeader); + $tampered = file_get_contents($tamperedHeader); + $tampered[Format::HEADER_BYTES - 1] = chr(ord($tampered[Format::HEADER_BYTES - 1]) ^ 0x01); + file_put_contents($tamperedHeader, $tampered, LOCK_EX); + stableExpectFailure(fn() => $reader->restore($tamperedHeader, $root . DIRECTORY_SEPARATOR . 'bad-header-restore', $password, $pattern), 'Tampered header MAC was accepted.'); + stableAssert(!file_exists($root . DIRECTORY_SEPARATOR . 'bad-header-restore'), 'Tampered header left restored data behind.'); + + // Mutating the unused recovery slot must still invalidate the authenticated header. + $tamperedRecoverySlot = $root . DIRECTORY_SEPARATOR . 'tampered-recovery-slot.spkg'; + stableCopy($package, $tamperedRecoverySlot); + $bytes = file_get_contents($tamperedRecoverySlot); + $bytes[149] = chr(ord($bytes[149]) ^ 0x01); + file_put_contents($tamperedRecoverySlot, $bytes, LOCK_EX); + stableExpectFailure(fn() => $reader->restore($tamperedRecoverySlot, $root . DIRECTORY_SEPARATOR . 'bad-recovery-slot-restore', $password, $pattern), 'Tampered recovery slot was accepted with primary credentials.'); + stableAssert(!file_exists($root . DIRECTORY_SEPARATOR . 'bad-recovery-slot-restore'), 'Tampered recovery slot left restored data behind.'); + + $tamperedChunk = $root . DIRECTORY_SEPARATOR . 'tampered-chunk.spkg'; + stableCopy($package, $tamperedChunk); + stableMutateRecord($tamperedChunk, Format::RECORD_FILE_CHUNK); + stableExpectFailure(fn() => $reader->restore($tamperedChunk, $root . DIRECTORY_SEPARATOR . 'bad-chunk-restore', $password, $pattern), 'Tampered file chunk was accepted.'); + stableAssert(!file_exists($root . DIRECTORY_SEPARATOR . 'bad-chunk-restore'), 'Tampered chunk left restored data behind.'); + + $tamperedManifest = $root . DIRECTORY_SEPARATOR . 'tampered-manifest.spkg'; + stableCopy($package, $tamperedManifest); + stableMutateRecord($tamperedManifest, Format::RECORD_MANIFEST, true); + stableExpectFailure(fn() => $reader->restore($tamperedManifest, $root . DIRECTORY_SEPARATOR . 'bad-manifest-restore', $password, $pattern), 'Tampered manifest was accepted.'); + stableAssert(!file_exists($root . DIRECTORY_SEPARATOR . 'bad-manifest-restore'), 'Tampered manifest left restored data behind.'); + + $truncated = $root . DIRECTORY_SEPARATOR . 'truncated.spkg'; + stableCopy($package, $truncated); + $size = filesize($truncated); + stableAssert(is_int($size) && $size > 10, 'Truncation fixture is invalid.'); + $bytes = file_get_contents($truncated); + file_put_contents($truncated, substr((string) $bytes, 0, -10), LOCK_EX); + stableExpectFailure(fn() => $reader->restore($truncated, $root . DIRECTORY_SEPARATOR . 'truncated-restore', $password, $pattern), 'Truncated package was accepted.'); + stableAssert(!file_exists($root . DIRECTORY_SEPARATOR . 'truncated-restore'), 'Truncated package left restored data behind.'); + + $trailing = $root . DIRECTORY_SEPARATOR . 'trailing.spkg'; + stableCopy($package, $trailing); + file_put_contents($trailing, "unexpected-tail", FILE_APPEND | LOCK_EX); + stableExpectFailure(fn() => $reader->restore($trailing, $root . DIRECTORY_SEPARATOR . 'trailing-restore', $password, $pattern), 'Trailing bytes after manifest were accepted.'); + stableAssert(!file_exists($root . DIRECTORY_SEPARATOR . 'trailing-restore'), 'Trailing data left restored output behind.'); + + $unsafeKdf = $root . DIRECTORY_SEPARATOR . 'unsafe-kdf.spkg'; + stableCopy($package, $unsafeKdf); + $bytes = file_get_contents($unsafeKdf); + // memory_bytes at offset 17 must be rejected before Argon2id runs. + $bytes = substr((string) $bytes, 0, 17) . pack('N', Format::MAX_KDF_MEM_BYTES + 1024) . substr((string) $bytes, 21); + file_put_contents($unsafeKdf, $bytes, LOCK_EX); + stableExpectFailure(fn() => $reader->restore($unsafeKdf, $root . DIRECTORY_SEPARATOR . 'unsafe-kdf-restore', $password, $pattern), 'Out-of-range KDF parameters were accepted.'); + stableAssert(!file_exists($root . DIRECTORY_SEPARATOR . 'unsafe-kdf-restore'), 'Unsafe KDF header left output behind.'); + + $manifestProbe = Format::decodeManifest(Format::encodeManifest([ + ['type' => 2, 'id' => random_bytes(16), 'parent_id' => Format::zeroId(), 'name' => 'only.bin', 'size' => 0, 'chunks' => 1], + ])); + stableAssert(array_keys($manifestProbe) === ['entries', 'file_count', 'directory_count', 'total_plain_bytes'], 'Stable manifest decoder exposed unexpected metadata fields.'); + stableAssert(array_keys($manifestProbe['entries'][0]) === ['type', 'id', 'id_hex', 'parent_id', 'parent_hex', 'name', 'size', 'chunks', 'path', 'depth'], 'Stable manifest entry contains unexpected version or schema fields.'); + foreach (['../escape', 'CON', 'trailing.', 'trailing ', "nul\0name", 'a/b', 'a\\b'] as $unsafeName) { + stableExpectFailure(fn() => Format::assertName($unsafeName), 'Unsafe package name was accepted: ' . $unsafeName); + } + + $dirA = random_bytes(16); $dirB = random_bytes(16); $fileId = random_bytes(16); + $cycleManifest = Format::encodeManifest([ + ['type' => 1, 'id' => $dirA, 'parent_id' => $dirB, 'name' => 'alpha'], + ['type' => 1, 'id' => $dirB, 'parent_id' => $dirA, 'name' => 'beta'], + ['type' => 2, 'id' => $fileId, 'parent_id' => $dirA, 'name' => 'data.bin', 'size' => 0, 'chunks' => 1], + ]); + stableExpectFailure(fn() => Format::decodeManifest($cycleManifest), 'Manifest directory cycle was accepted.'); + + $noRecoveryPackage = $root . DIRECTORY_SEPARATOR . 'no-recovery.spkg'; + $noRecovery = (new StablePackageWriter())->build($source, $noRecoveryPackage, $password, $pattern, str_repeat('b', 48), false); + stableAssert($noRecovery['recovery_key'] === null, 'Disabled recovery key was returned.'); + $noRecoveryBytes = file_get_contents($noRecoveryPackage); + stableAssert(strlen(substr((string) $noRecoveryBytes, 0, Format::HEADER_BYTES)) === Format::HEADER_BYTES, 'No-recovery header size changed.'); + stableAssert(substr((string) $noRecoveryBytes, 8, 1) === "\0", 'No-recovery flag is incorrect.'); + stableAssert(Format::isZeroBytes(substr((string) $noRecoveryBytes, 149, Format::SLOT_BYTES)), 'Disabled recovery slot is not all-zero.'); + $noRecoveryDestination = $root . DIRECTORY_SEPARATOR . 'no-recovery-restore'; + $noRecoveryRestored = $reader->restore($noRecoveryPackage, $noRecoveryDestination, $password, $pattern); + stableAssert($noRecoveryRestored['files'] === 3, 'No-recovery package did not restore.'); + + echo "Stable binary package round-trip and adversarial tests passed.\n"; +} finally { + $password = ''; + $pattern = ''; + $wrongPassword = ''; + $wrongPattern = ''; + stableRemoveTree($root); +} From 3e448fb26e46ef12fa080c43d0a33f0be0c3a7f2 Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 22:22:15 +0330 Subject: [PATCH 07/66] docs(format): freeze stable single-file binary grammar --- docs/STABLE-PACKAGE-FORMAT-BINARY.md | 214 +++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 docs/STABLE-PACKAGE-FORMAT-BINARY.md diff --git a/docs/STABLE-PACKAGE-FORMAT-BINARY.md b/docs/STABLE-PACKAGE-FORMAT-BINARY.md new file mode 100644 index 00000000..25381cbd --- /dev/null +++ b/docs/STABLE-PACKAGE-FORMAT-BINARY.md @@ -0,0 +1,214 @@ +# Stable Single-File Package Format — Binary Specification + +**Status:** normative implementation candidate for review; not yet the release default. This document defines the bytes written by 'StablePackageWriter' and accepted by 'StablePackageReader'. Existing V14 server and browser V13 readers are separate legacy compatibility boundaries; they must not be repurposed for this format. + +This document uses MUST, MUST NOT, SHOULD, and MAY as normative requirements. Every multibyte integer is unsigned big-endian unless explicitly stated otherwise. Lengths are measured in bytes. + +## 1. Permanent-format invariants + +The package is one self-contained binary file, not ZIP and not a sidecar directory. The grammar is defined by this document and the fixed constants in 'StablePackageDescriptor'. + +No application-release version, format-generation label, schema-version number, Git tag, build identifier, or field whose purpose is distinguishing application/package generations is serialized. This applies to the clear header, record envelopes, and decrypted manifest grammar. A fixed opaque magic marker identifies file type but is neither a version nor readable format name. + +Algorithm identifiers name cryptographic algorithms only; bounded parameters describe the work needed to use them. They are not package-generation numbers. Unknown identifiers, reserved bits and unsafe KDF parameters MUST be rejected before KDF use. + +Header offsets, byte order, record values, key-derivation domains, path rules, and grammar in this document are frozen. Application version changes MUST NOT change package bytes or alter the grammar. Readers may reject algorithms they do not support; they MUST NOT reinterpret an identifier. + +## 2. Cryptographic profile and limits + +| Purpose | Fixed value | +|---|---| +| Password KDF | Argon2id version 1.3 | +| Header/manifest AEAD | XChaCha20-Poly1305-IETF | +| File stream | libsodium secretstream XChaCha20-Poly1305 | +| Hash / HMAC / HKDF digest | SHA-256 | +| Root key | 32 random bytes | +| Header salt | 32 random bytes | +| AEAD nonce | 24 bytes | +| AEAD tag | 16 bytes | +| Secretstream message overhead | 17 bytes | +| Plaintext chunk size | exactly 4 MiB except final chunk | +| Writer KDF profile | ops=3; memory=268435456 bytes (256 MiB) | +| Accepted KDF operations | 2 through 4 inclusive | +| Accepted KDF memory | 64 MiB through 256 MiB inclusive, aligned to 1024 bytes | +| Maximum single file | 16 GiB | +| Maximum total plaintext | 100 GiB | +| Maximum manifest plaintext | 64 MiB | +| Maximum entries | 100,000 | +| Maximum filename component | 255 UTF-8 bytes | +| Maximum relative path | 4096 UTF-8 bytes | +| Maximum nesting | 256 components | + +Password and pattern are independently processed using Argon2id. KDF fields are attacker-controlled until the header is authenticated and MUST be range-checked before invoking Argon2id. The current implementation requires 64-bit PHP, libsodium with Argon2id/secretstream support, and HKDF. + +All cryptographic randomness MUST come from the operating system CSPRNG through the cryptographic runtime. Failure of any required API MUST fail closed. + +## 3. Fixed 253-byte header + +There is exactly one 253-byte header. No padding, optional header extension, variable-length header, or footer follows it. + +Opaque magic bytes (hex): **D3 82 A1 4F C7 5E 90 6B**. + +| Absolute offset | Length | Field | Canonical rule | +|---:|---:|---|---| +| 0 | 8 | Magic | exact bytes shown above | +| 8 | 1 | Flags | bit 0 means recovery slot present; all other bits zero | +| 9 | 1 | KDF ID | 0x01 = Argon2id 1.3 | +| 10 | 1 | AEAD ID | 0x01 = XChaCha20-Poly1305-IETF | +| 11 | 1 | Stream ID | 0x01 = secretstream XChaCha20-Poly1305 | +| 12 | 1 | Hash ID | 0x01 = SHA-256 | +| 13 | 4 | KDF ops | u32 big-endian, bounded before use | +| 17 | 4 | KDF memory bytes | u32 big-endian, bounded before use | +| 21 | 24 | Package ID | random raw bytes, exposed outside package as 48 lowercase hex chars if needed | +| 45 | 32 | Salt | random; reject all-zero | +| 77 | 24 | Primary nonce | AEAD nonce | +| 101 | 48 | Primary wrapped root key | 32-byte ciphertext plus 16-byte tag | +| 149 | 24 | Recovery nonce | all-zero when recovery is disabled | +| 173 | 48 | Recovery wrapped root key | all-zero when recovery is disabled | +| 221 | 32 | Header MAC | HMAC-SHA-256 of bytes 0–220 inclusive | + +The 'core' is exact bytes 0–76 (77 bytes). The 'header_without_mac' is exact bytes 0–220 (221 bytes). The header MAC is bytes 221–252 (32 bytes). + +Readers MUST reject an incorrect magic, unsupported/reserved bits, unsupported algorithm identifiers, out-of-range KDF parameters, a zero package ID, zero salt, zero primary nonce, and a missing/incorrectly sized wrapped key. With recovery disabled, both recovery fields MUST be all zero. With recovery enabled, both fields MUST be valid and nonzero. Every field, including the slot not used for the current decrypt, is authenticated by the header MAC. + +### 3.1 Credential derivation + +Let 'salt' be the header's 32-byte salt and 'package_id' be the 24 raw bytes. + +1. 'password_salt = SHA256(ASCII("SPKG/PWHASH/PASSWORD") || 0x00 || salt)[0:16]'. +2. 'pattern_salt = SHA256(ASCII("SPKG/PWHASH/PATTERN") || 0x00 || salt)[0:16]'. +3. 'password_key = Argon2id13(password, password_salt, ops, memory_bytes, output=32)'. +4. 'pattern_key = Argon2id13(pattern, pattern_salt, ops, memory_bytes, output=32)'. +5. 'credential_key = HKDF-SHA-256(IKM=password_key || pattern_key, length=32, info=ASCII("SPKG/CREDENTIAL-WRAP") || 0x00 || package_id, salt=salt)'. + +The caller-supplied password and pattern are validated UTF-8 byte strings. They are not trimmed, case-folded, normalized, or transcoded implicitly. Password and pattern keys MUST be zeroized as soon as possible after the combined key is derived. + +### 3.2 Root-key wrapping and header authentication + +The writer generates a random 32-byte 'root_key'. + +- 'primary_cipher = XChaCha20-Poly1305.Encrypt(root_key, primary_nonce, credential_key, AAD=ASCII("SPKG/KEYWRAP/PRIMARY") || 0x00 || core)'. +- If recovery is enabled, 'recovery_cipher = XChaCha20-Poly1305.Encrypt(root_key, recovery_nonce, recovery_key_raw, AAD=ASCII("SPKG/KEYWRAP/RECOVERY") || 0x00 || core)', where 'recovery_key_raw' is a random 32-byte secret. +- 'header_mac_key = HKDF-SHA-256(IKM=root_key, length=32, info=ASCII("SPKG/HEADER-MAC") || 0x00, salt=package_id)'. +- 'header_mac = HMAC-SHA-256(header_mac_key, header_without_mac)'. + +Each wrapped key is exactly 48 bytes. A reader unwraps the root key through one slot, then compares the header MAC using a constant-time comparison. No record may be processed until both steps succeed. This authenticates the unused slot as well. Recovery keys are encoded as canonical unpadded base64url outside the package; decode must reject noncanonical encodings and any result not exactly 32 bytes. A recovery key MUST fail if the recovery slot flag is clear. + +## 4. Record envelope and parser state machine + +Every record starts with five bytes: + +| Relative offset | Length | Field | +|---:|---:|---| +| 0 | 1 | type | +| 1 | 4 | body length, u32 big-endian | +| 5 | body length | body | + +The length excludes the envelope. Readers MUST validate the type-specific length before reading a body, reject truncated/oversized records, and enforce the state machine rather than accepting arbitrary record permutations. + +Recognized types: +- 0x01 — file start +- 0x02 — encrypted file chunk +- 0x7F — final encrypted manifest + +The canonical package sequence is header, then each file stream in the order of file entries in the decrypted manifest (file-start followed by all of that file's chunks), then exactly one manifest record, then EOF. Unknown records, a manifest while a stream is active, a chunk outside a stream, missing manifest, extra manifest, and trailing bytes after manifest MUST be rejected. EOF immediately after the authenticated final manifest is the completion boundary. + +## 5. File-start record (type 0x01) + +The body is exactly 48 bytes: + +| Body offset | Length | Field | +|---:|---:|---| +| 0 | 16 | random file ID | +| 16 | 8 | declared plaintext size, u64 big-endian | +| 24 | 24 | secretstream header | + +The file ID is unique and nonzero. A size MUST be at most 16 GiB. No second file-start record may appear until the prior stream has supplied its authenticated final chunk. File streams MUST appear in canonical manifest file order, verified after manifest authentication. + +## 6. Chunk record (type 0x02) + +The body is: + +'file_id[16] || sequence[4] || ciphertext_and_secretstream_tag' + +Sequence is u32 big-endian, starts at zero for each file and increases by exactly one. The cipher body contains the secretstream ciphertext and its 17-byte message overhead. A record body is at least 37 bytes and no more than 4,194,341 bytes. + +Each per-file encryption key is derived independently: + +'file_key = HKDF-SHA-256(IKM=file_root_key, length=32, info=ASCII("SPKG/FILE") || 0x00 || lowercase_hex(file_id), salt=package_id)'. + +'file_root_key = HKDF-SHA-256(IKM=root_key, length=32, info=ASCII("SPKG/FILE-ROOT") || 0x00, salt=package_id)'. + +Chunk AAD is exact concatenation of: + +'ASCII("SPKG/CHUNK") || 0x00 || core_hash[32] || package_id[24] || file_id[16] || sequence_u32be || file_size_u64be'. + +'core_hash = SHA256(core)'. + +For a nonempty file, each non-final plaintext chunk MUST be exactly 4 MiB and have the MESSAGE tag. The final chunk MUST have the FINAL tag, the plaintext bytes from all chunks MUST equal the declared file size, and no more chunks may appear for that file. An empty file has exactly one zero-length FINAL-tag chunk. Readers MUST reject missing final tags, unexpected tags, out-of-order sequences, cross-file chunks, chunk lengths inconsistent with the declared size, and trailing bytes after the final record. + +## 7. Final manifest record (type 0x7F) + +The body is: + +'manifest_nonce[24] || manifest_ciphertext_and_tag'. + +Manifest plaintext is at most 67,108,864 bytes. The encrypted body is plaintext length plus 40 bytes (24-byte nonce plus 16-byte AEAD tag); the maximum body is 67,108,904 bytes. + +'manifest_key = HKDF-SHA-256(IKM=root_key, length=32, info=ASCII("SPKG/MANIFEST") || 0x00, salt=package_id)'. + +Manifest associated data is 'ASCII("SPKG/MANIFEST") || 0x00 || core_hash'. The nonce is fresh random 24-byte data. Manifest is custom binary, not JSON and not string-valued metadata. + +### 7.1 Manifest grammar + +The plaintext begins with 'entry_count:u32be', followed by exactly that many entries, no padding and no trailing bytes. + +Every entry begins with: +- 'entry_type:u8': 0x01 directory or 0x02 regular file; +- 'entry_id[16]': unique, random and nonzero; +- 'parent_id[16]': parent directory ID; 16 zero bytes denotes package root; +- 'name_length:u16be': 1–255 bytes; +- 'name[name_length]': UTF-8 filename component, not NUL-terminated. + +A regular-file entry appends 'plaintext_size:u64be' (at most 16 GiB) and 'chunk_count:u32be'. Chunk count MUST equal 'max(1, ceil(plaintext_size / 4 MiB))'. Directory entries have no suffix. No version, schema, archive name, release tag, generation, or extension fields exist in the grammar. All names and tree structure remain encrypted because the full manifest is AEAD-encrypted. + +Entry count is 1–100,000; at least one entry is a regular file. Total declared plaintext is at most 100 GiB. Readers MUST reject unknown entry types, duplicate IDs, truncated fields, trailing data, invalid UTF-8, invalid names, invalid sizes/counts, missing parents, non-directory parents, cycles, excessive depth, paths above 4096 bytes, and colliding siblings. + +### 7.2 Canonical manifest order and path graph + +Entries are strictly sorted by resolved relative path using raw UTF-8 byte ordering equivalent to strcmp. Root names have no prefix slash. A child path is parent path, slash, and name. Readers MUST recompute every path from parent IDs and reject noncanonical order. + +A nonzero parent ID must resolve to exactly one directory. All IDs are unique across files and directories. The complete graph is validated before the requested output directory is created. Maximum depth is 256 components and maximum resolved path size is 4096 bytes. + +Cross-platform path validation rejects empty names, '.' and '..', NUL/control characters, slash, backslash, colon, Windows forbidden characters '<', '>', '"', '|', '?', '*', trailing spaces/dots, and reserved device names CON, PRN, AUX, NUL, CONIN$, CONOUT$, COM1–COM9, LPT1–LPT9 (including extensions). If runtime extensions needed for Unicode normalization/case folding are missing, non-ASCII names MUST be rejected rather than checked incompletely. Otherwise names must be NFC and sibling names checked with Unicode-aware case folding. + +## 8. Resource and filesystem rules + +Hard limits are checked before allocation, long loops, file creation, or KDF invocation. Use exact-read/exact-write loops and check stream/filesystem results. A runtime unable to safely represent u64 values MUST reject the format; reference implementation requires 64-bit PHP. + +Creation enumerates and validates the source tree before publication, writes to a random private temporary file in the destination directory, flushes and synchronizes it where supported, and publishes without replacing an existing path. Destination must not be a symlink or existing file. If atomic no-replace publication cannot be achieved, creation MUST fail rather than fall back to an overwrite-prone rename. + +Source symlinks, special filesystem entries, unsafe names, case-colliding siblings, and files changing during encryption are rejected. Temporary key material is zeroized where PHP/libsodium supports it. A package is not published until all file streams and final encrypted manifest are fully written and the stream has been closed successfully. + +## 9. Restore and failure behavior + +A reader authenticates the header and verifies its MAC before reading records. Decrypted file content is written only to a private, random staging directory, with restrictive permissions and filenames based on random IDs. The user-requested destination tree MUST NOT be created before the final manifest has been authenticated, decoded, graph-validated, cross-checked against all observed streams, and strict EOF verified. + +After validation, restore may create a destination that did not previously exist. It creates directories and files without overwrite, uses restrictive permissions, does not intentionally follow symlinks, and records filesystem identities for paths created so rollback deletes only owned paths. It MUST NOT recursively delete unknown data. If cleanup cannot safely complete, this must be surfaced as a failed restore; never report success on partial output. + +Wrong password/pattern/recovery key, malformed recovery key, changed header or either key slot, wrong header MAC, altered chunks, wrong tags, missing/truncated chunks, changed manifest, unsupported algorithms, invalid paths, and trailing bytes fail closed. External API responses may use a generic error. Diagnostics MUST NOT leak credentials, keys, recovery secrets, or decrypted file contents. + +## 10. Acceptance matrix and release gates + +The implementation MUST have automated tests for: +- exact magic/header size and every field offset, reserved bits, unknown algorithm IDs, KDF bounds and recovery enabled/disabled slots; +- zero-byte, one-byte, exactly 4 MiB, and multi-chunk files; binary and nested files; file hashes identical after restore; +- primary credentials and recovery round trips, wrong password/pattern, malformed key, and recovery disabled; +- header/MAC/slot tampering, file-start tampering, chunk bit flips/reordering/missing chunks, invalid tags, malformed lengths, unknown records, truncation, missing manifest, duplicate manifest, and trailing bytes; +- malformed manifest entries, duplicate IDs, invalid names/UTF-8, directory graph cycles, missing parents, file-as-parent, case collisions, over-limit path/depth/chunk counts; +- existing destinations remaining byte-for-byte untouched; failure cleanup leaving no partial output or private staging directory; +- canonical record order and one-to-one correspondence between observed streams and manifest entries; +- CI on supported PHP versions and Windows testing of encryption, restore, and atomic no-overwrite behavior; +- unchanged server V14 and browser V13 compatibility tests. + +A green round trip alone is not enough. This is an implementation candidate, not release authorization, independent audit, or a promise of zero defects. The format must remain isolated behind an explicit interface until full CI and platform checks pass. Do not tag v1.0.0 until review of the byte-level spec, complete writer/reader diff, parser boundary and failure-injection tests, Windows/shared-hosting behavior, and release artifact. From 520db8b81058208c20df63e27527a0b0e9794bfa Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 22:22:48 +0330 Subject: [PATCH 08/66] docs(format): link stable contract to frozen binary specification --- docs/STABLE-PACKAGE-FORMAT.md | 93 +++++++++++------------------------ 1 file changed, 29 insertions(+), 64 deletions(-) diff --git a/docs/STABLE-PACKAGE-FORMAT.md b/docs/STABLE-PACKAGE-FORMAT.md index ecbb873a..4251bd27 100644 --- a/docs/STABLE-PACKAGE-FORMAT.md +++ b/docs/STABLE-PACKAGE-FORMAT.md @@ -1,83 +1,48 @@ # Stable Package Format Contract -Status: design contract for the first stable release; implementation is not yet approved. +Status: stable binary format implementation candidate is isolated on the feat/stable-binary-package-format branch. It is not the production/default writer, has not yet passed the complete platform matrix, and is not an independent security audit. ## Goal -The first stable release must establish one durable on-disk package format. Application releases may evolve, but ordinary application version bumps must not change the serialized package format or make existing packages unreadable. - -This document defines requirements and release gates. It does not claim that the new format has already been implemented or audited. +The v1.0.0 release must establish one durable on-disk format. Changing application release versions must never change the package byte grammar or make compatible old packages unreadable. ## Non-negotiable serialized-format rules -The stable package MUST NOT serialize any of the following: - -- Application release version (for example, a value derived from the root `VERSION` file). -- Format-generation/version labels such as `V14`, `SECURE-PKG-V14`, or equivalent generation names. -- A package schema-version number used only to distinguish successive application/package generations. -- Browser/application build versions, release tags, or version-dependent extensions. +The stable package MUST NOT serialize: +- an application release version; +- a format-generation label such as V14 or SECURE-PKG-V14; +- a schema-version field used to distinguish package/application generations; +- browser/application build versions, release tags, or version-dependent extensions. -These rules apply to every serialized layer: package preamble/header, encrypted or plaintext manifest, per-file records, footer/completion records, and any nested metadata written into the package. Tests must inspect the complete serialized representation, not only the top-level header. +The rules apply to every byte written to the package, including the header, record envelopes, file-start and chunk records, and encrypted manifest. See the normative byte-level specification in STABLE-PACKAGE-FORMAT-BINARY.md for fixed field offsets, grammar, key domains, limits and acceptance rules. -A fixed, version-independent magic byte sequence may be used for file-type recognition if needed. It must remain constant and must not encode an application or format-generation version. The exact byte layout and magic value must be decided and reviewed before implementation. - -Cryptographic algorithm identifiers and parameters that are required to interpret and verify the data are not application version numbers. They may be represented only when necessary, must be strictly allowlisted, and must not be used as a hidden substitute for a format-generation identifier. +A fixed opaque magic byte sequence may identify the file type if it remains constant and contains no version. Algorithm identifiers may describe the cryptographic algorithms required to decode the bytes, but they are allowlisted and must not serve as hidden generation identifiers. ## Security and correctness requirements -1. **Authenticated framing:** all security-relevant framing and metadata must be cryptographically authenticated. No unauthenticated metadata may influence filesystem paths, output names, algorithm selection, or resource allocation. -2. **Strict parsing:** reject unknown, duplicate, malformed, truncated, oversized, or out-of-order fields/records. Apply explicit limits before allocating or processing attacker-controlled sizes. -3. **Cryptographic separation:** use independent, domain-separated keys for metadata, filenames, and file content; bind each chunk to its intended file and sequence; verify final stream completion and integrity before treating a restore as successful. -4. **Credential handling:** preserve the existing reviewed password/pattern derivation and key-wrapping properties only after validating them against the final format design. Never silently weaken KDF parameters or accept caller-controlled unsafe resource settings. -5. **Safe output:** restore must not overwrite existing destinations by default, must defend against traversal, symlinks and path collisions, and must clean incomplete output on failure without deleting user data. -6. **Atomic package creation:** do not publish a package until every required record, final integrity check, and completion marker has been written and validated. -7. **No false success:** wrong credentials, tampering, truncation, missing final records, and unsupported cryptographic parameters must fail closed with generic external errors and useful internal diagnostics that do not leak secrets. -8. **Determinism is not required:** salts, nonces, package identifiers, and encrypted layouts must remain unpredictable where cryptographically required; two encryptions of the same input should not be expected to produce identical package bytes. - -## Compatibility and migration - -- The existing server `SECURE-PKG-V14` and browser `SECURE-BROWSER-V13` formats are legacy compatibility boundaries. Do not reinterpret or silently rewrite their bytes as the new stable format. -- Keep legacy readers isolated and explicitly tested. New packages must be emitted only in the stable format after its release gate passes. -- Decide and document whether old packages can be migrated by decrypting and re-encrypting, or remain readable only through legacy readers. Never promise transparent compatibility until tested. -- Do not remove a legacy reader or change existing package extensions as a side effect of unrelated security fixes. -- Application release versioning remains independent of the package serialization. Changing `VERSION`, UI text, or Git tags must not alter the bytes emitted by the stable package writer. - -## Required test matrix before release - -### Format contract -- Inspect every serialized layer and assert that no application version, format-generation label, or schema-version field is present. -- Verify the fixed file-type marker (if adopted) is identical across application releases and contains no version label. -- Test strict rejection of malformed lengths, duplicate/unknown fields, unsupported algorithms, reordered records, truncated input, and trailing garbage. -- Add fixtures created by the final stable writer and ensure a later compatible build reads them unchanged. - -### Cryptographic integrity -- Successful round-trip for empty files, nested paths, binary files, large/chunked files, and supported boundary sizes. -- Wrong password, wrong pattern, wrong recovery key, altered header, altered manifest, altered filename metadata, altered blob/chunk, missing chunk, reordered chunk, and missing final marker must all fail. -- Verify no plaintext is accepted as a valid package and no partially restored tree is reported as success. -- Check that package identifiers and key material are generated safely and that temporary secrets are cleared on all reachable paths where the runtime supports it. +1. Authenticate the fixed header and all security-relevant framing before trusting it. +2. Reject unknown types, bad/reserved flags, malformed/truncated/oversized records, incorrect order and trailing bytes. +3. Use independent domain-separated keys for key wrapping, header authentication, manifest and each file stream. +4. Bind every chunk to the package core, intended file, sequence and declared file size. +5. Bound KDF cost, sizes, entry counts, depth and memory allocation before expensive work. +6. Validate the complete encrypted manifest, parent graph, canonical path order and all stream/manifest cross-references before creating the requested restore destination. +7. Never overwrite a pre-existing output. Fail closed if an atomic no-replace filesystem operation is unavailable. +8. Clean incomplete output only when ownership of the created paths can be established; surface cleanup failures. +9. Zeroize key material where the runtime supports it and never expose credentials or plaintext in diagnostics. +10. Preserve V14 server and browser V13 compatibility independently. Do not silently reinterpret or rewrite existing packages. -### Filesystem and failure injection -- Existing restore destination remains byte-for-byte unchanged. -- Traversal paths, absolute paths, symlinks, duplicate/case-colliding paths, and reserved platform names are rejected safely. -- Inject failures during staging, writing, finalization, catalog update, cleanup, and rollback; preserve source data and avoid leaving a package that appears complete when it is not. -- Test cleanup failure explicitly and ensure it is surfaced instead of silently ignored. +## Implementation boundary -### Compatibility and platforms -- Run server stable-format tests on supported PHP versions and Windows. -- Run browser compatibility tests separately; do not assume browser V13 and server V14 are the same format. -- Test shared-hosting constraints, filesystem semantics, ZIP extension behavior, and atomic no-overwrite behavior on actual supported environments. -- Keep unexecuted manual checks marked NOT RUN. +The candidate implementation consists of these separate classes: +- app/Project/StablePackageDescriptor.php +- app/Project/StablePackageCrypto.php +- app/Project/StablePackageWriter.php +- app/Project/StablePackageReader.php -## Implementation sequence +They are intentionally separate from the V14 builder/reader. This branch adds regression tests and CI coverage, but it must not change the production/default writer or route application endpoints to this format before review and platform validation. Legacy compatibility tests must continue to pass. -1. Freeze this contract and agree on the byte-level format design before editing the writer. -2. Add a byte-level format specification and test fixtures. -3. Implement a new stable writer and reader behind explicit internal interfaces; avoid broad rewrites of the existing V14 implementation. -4. Add adversarial tests before switching any default writer. -5. Add migration/legacy compatibility tests and verify that old package fixtures remain readable through their designated readers. -6. Run the full security suite, PHP matrix, browser tests, Windows smoke tests, and failure-injection tests. -7. Review the complete diff and release artifact; publish only after all required checks pass. +## Required acceptance gates -## Release-blocking rule +Automated tests must include round-trip coverage for empty, binary, nested and multi-chunk files; credential and recovery flows; tamper/truncation/trailing-data rejection; strict header/KDF checks; manifest graph and path attacks; no-overwrite behavior; and cleanup on failure. Run the PHP matrix and a Windows test that actually writes and restores a stable package. Any manual/shared-hosting test not run must be marked NOT RUN. -A passing CI run alone does not authorize the stable-format release. The final candidate must pass the matrix above, and any unrun manual or platform-specific test must be explicitly disclosed. Do not claim an independent security audit unless one has actually occurred. +A green round-trip test alone is insufficient. Before v1.0.0, review the byte-level specification, the full writer/reader diff, adversarial parser tests, platform filesystem behavior and final release artifact. Do not claim zero defects or an independent cryptographic audit. From 859587f9ad7fe2abf5c02923e41b79bf88b1a17e Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 22:22:58 +0330 Subject: [PATCH 09/66] ci(format): test stable package encryption and restore on Windows --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 268fb2db..e7dfb706 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,3 +62,5 @@ jobs: } - name: Validate SPK13 icon integration safeguards run: php tests/WindowsSpk13IconIntegrationTest.php + - name: Stable binary package format regression + run: php tests/StablePackageFormatTest.php From e95a105a832c1462925ac2bb68abf91899962ec9 Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 22:23:02 +0330 Subject: [PATCH 10/66] ci(security): include stable binary format adversarial tests --- .github/workflows/security.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index fe31551c..680b49c7 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -85,6 +85,7 @@ jobs: php tests/V14RestoreSafetyTest.php php tests/V14PackageArchiveTest.php php tests/V14PackageArchiveStorageTest.php + php tests/StablePackageFormatTest.php - name: Self check run: php bin/self-check.php From 1639c77ffe739e2e5556a0ba9c6280cf4a0d755a Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 22:23:56 +0330 Subject: [PATCH 11/66] fix(format): clear cached stat before restored file verification --- app/Project/StablePackageReader.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/Project/StablePackageReader.php b/app/Project/StablePackageReader.php index f71fd4ad..90e14c16 100644 --- a/app/Project/StablePackageReader.php +++ b/app/Project/StablePackageReader.php @@ -400,6 +400,9 @@ private function extractValidatedFiles(array $entries, array $observedFiles, str fclose($in); fclose($out); } + // file_exists() is checked before creation; clear PHP's negative stat cache + // before verifying the newly written output, especially for empty files. + clearstatcache(true, $target); $actualSize = filesize($target); if ($actualSize === false || (int) $actualSize !== $entry['size']) { throw new RuntimeException('Restored file size does not match the authenticated manifest.'); From 6e65ff915ac116682db2a41bd3310b841cc3fbc7 Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 22:25:27 +0330 Subject: [PATCH 12/66] fix(format): clear partial data after exact-read failure --- app/Project/StablePackageDescriptor.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/Project/StablePackageDescriptor.php b/app/Project/StablePackageDescriptor.php index 9ae073c3..30f54fa5 100644 --- a/app/Project/StablePackageDescriptor.php +++ b/app/Project/StablePackageDescriptor.php @@ -152,6 +152,11 @@ public static function readExact($handle, int $length): string $data .= $chunk; } if (strlen($data) !== $length) { + // A short read may contain partial plaintext when this helper is used by + // the writer. Clear it before surfacing truncation. + if ($data !== '' && function_exists('sodium_memzero')) { + sodium_memzero($data); + } throw new RuntimeException('Package is truncated.'); } return $data; From fa6b954895766592df261f8f3f431afec9d76d7a Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 22:25:30 +0330 Subject: [PATCH 13/66] fix(format): zeroize source chunks on all error paths --- app/Project/StablePackageWriter.php | 34 +++++++++++++++-------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/app/Project/StablePackageWriter.php b/app/Project/StablePackageWriter.php index 5c2ea95e..ad7993e5 100644 --- a/app/Project/StablePackageWriter.php +++ b/app/Project/StablePackageWriter.php @@ -270,23 +270,25 @@ private function encryptFileEntry($out, array $entry, string $fileRootKey, strin while ($remaining > 0) { $wanted = min(StablePackageDescriptor::CHUNK_BYTES, $remaining); $plain = $this->readExactFromFile($input, $wanted); - $remaining -= strlen($plain); - if (strlen($plain) !== $wanted) { - sodium_memzero($plain); - throw new RuntimeException('Source file changed during package creation.'); + try { + $remaining -= strlen($plain); + if (strlen($plain) !== $wanted) { + throw new RuntimeException('Source file changed during package creation.'); + } + $tag = $remaining === 0 + ? SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_FINAL + : SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_MESSAGE; + $cipher = sodium_crypto_secretstream_xchacha20poly1305_push( + $state, + $plain, + StablePackageCrypto::chunkAad($coreHash, $packageId, $entry['id'], $sequence, $entry['size']), + $tag + ); + $this->writeChunkRecord($out, $entry['id'], $sequence, $cipher); + $sequence++; + } finally { + if ($plain !== '') sodium_memzero($plain); } - $tag = $remaining === 0 - ? SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_FINAL - : SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_MESSAGE; - $cipher = sodium_crypto_secretstream_xchacha20poly1305_push( - $state, - $plain, - StablePackageCrypto::chunkAad($coreHash, $packageId, $entry['id'], $sequence, $entry['size']), - $tag - ); - $this->writeChunkRecord($out, $entry['id'], $sequence, $cipher); - sodium_memzero($plain); - $sequence++; } } if ($sequence !== $entry['chunks']) { From 8107b5a29856858b2c3a9698a1670cc25b36640e Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 22:25:43 +0330 Subject: [PATCH 14/66] fix(format): zeroize decrypted chunks on all error paths --- app/Project/StablePackageReader.php | 53 ++++++++++++++--------------- 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/app/Project/StablePackageReader.php b/app/Project/StablePackageReader.php index 90e14c16..108a933d 100644 --- a/app/Project/StablePackageReader.php +++ b/app/Project/StablePackageReader.php @@ -143,37 +143,36 @@ public function restore( throw new RuntimeException('Package chunk authentication failed.'); } [$plain, $tag] = $plainResult; - $plainLength = strlen($plain); - $newTotal = $active['written'] + $plainLength; - if ($newTotal > $active['size']) { - sodium_memzero($plain); - throw new RuntimeException('Decrypted file exceeds its declared size.'); - } - if ($tag === SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_MESSAGE) { - if ($plainLength !== StablePackageDescriptor::CHUNK_BYTES || $newTotal >= $active['size'] - || ($active['chunks'] + 1) >= $active['expected_chunks']) { - sodium_memzero($plain); - throw new RuntimeException('Non-final file chunk has an invalid length or position.'); + try { + $plainLength = strlen($plain); + $newTotal = $active['written'] + $plainLength; + if ($newTotal > $active['size']) { + throw new RuntimeException('Decrypted file exceeds its declared size.'); } - } elseif ($tag === SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_FINAL) { - if ($newTotal !== $active['size'] - || ($active['size'] === 0 && ($plainLength !== 0 || $active['chunks'] !== 0)) - || ($active['size'] > 0 && $plainLength === 0) - || ($active['chunks'] + 1) !== $active['expected_chunks']) { - sodium_memzero($plain); - throw new RuntimeException('Final file chunk has an invalid length or position.'); + if ($tag === SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_MESSAGE) { + if ($plainLength !== StablePackageDescriptor::CHUNK_BYTES || $newTotal >= $active['size'] + || ($active['chunks'] + 1) >= $active['expected_chunks']) { + throw new RuntimeException('Non-final file chunk has an invalid length or position.'); + } + } elseif ($tag === SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_FINAL) { + if ($newTotal !== $active['size'] + || ($active['size'] === 0 && ($plainLength !== 0 || $active['chunks'] !== 0)) + || ($active['size'] > 0 && $plainLength === 0) + || ($active['chunks'] + 1) !== $active['expected_chunks']) { + throw new RuntimeException('Final file chunk has an invalid length or position.'); + } + } else { + throw new RuntimeException('Unsupported secretstream tag.'); } - } else { - sodium_memzero($plain); - throw new RuntimeException('Unsupported secretstream tag.'); - } - if ($plainLength > 0) { - StablePackageDescriptor::writeAll($active['out'], $plain); + if ($plainLength > 0) { + StablePackageDescriptor::writeAll($active['out'], $plain); + } + $active['written'] = $newTotal; + $active['chunks']++; + } finally { + if ($plain !== '') sodium_memzero($plain); } - sodium_memzero($plain); - $active['written'] = $newTotal; - $active['chunks']++; if ($tag === SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_FINAL) { if (!fflush($active['out'])) { From 17b107db17512a4c4a5d00e1489f6a536560619b Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 22:26:39 +0330 Subject: [PATCH 15/66] fix(format): enforce canonical random fields without banning valid ciphertext --- app/Project/StablePackageDescriptor.php | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/app/Project/StablePackageDescriptor.php b/app/Project/StablePackageDescriptor.php index 30f54fa5..9be9dfb9 100644 --- a/app/Project/StablePackageDescriptor.php +++ b/app/Project/StablePackageDescriptor.php @@ -87,6 +87,20 @@ public static function isZeroBytes(string $value): bool return $value !== '' && hash_equals(str_repeat("\0", strlen($value)), $value); } + public static function randomNonZeroBytes(int $length): string + { + if ($length < 1 || $length > 1024) { + throw new RuntimeException('Invalid random-byte length.'); + } + for ($attempt = 0; $attempt < 8; $attempt++) { + $value = random_bytes($length); + if (!self::isZeroBytes($value)) { + return $value; + } + } + throw new RuntimeException('Unable to generate nonzero cryptographic randomness.'); + } + public static function assertKdfParameters(int $ops, int $memoryBytes): void { // Bounds are checked before Argon2id is invoked. Header bytes are attacker-controlled @@ -194,13 +208,13 @@ public static function parseHeader(string $raw): array } self::assertKdfParameters((int) $ops, (int) $memoryBytes); if (self::isZeroBytes($packageId) || self::isZeroBytes($salt) - || self::isZeroBytes($primaryNonce) || self::isZeroBytes($primaryCipher)) { + || self::isZeroBytes($primaryNonce)) { throw new RuntimeException('Unable to open package.'); } $hasRecovery = ($flags & self::FLAG_RECOVERY_SLOT) !== 0; if ($hasRecovery) { - if (self::isZeroBytes($recoveryNonce) || self::isZeroBytes($recoveryCipher)) { + if (self::isZeroBytes($recoveryNonce)) { throw new RuntimeException('Unable to open package.'); } } elseif (!self::isZeroBytes($recoveryNonce) || !self::isZeroBytes($recoveryCipher)) { From ad90b4d89143183e3495768f56af1018e07f9f25 Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 22:26:43 +0330 Subject: [PATCH 16/66] fix(format): reject zero identifiers and bound random identifier retries --- app/Project/StablePackageWriter.php | 39 +++++++++++++++++++---------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/app/Project/StablePackageWriter.php b/app/Project/StablePackageWriter.php index ad7993e5..8c7d383a 100644 --- a/app/Project/StablePackageWriter.php +++ b/app/Project/StablePackageWriter.php @@ -71,10 +71,15 @@ public function build( throw new RuntimeException('Internal manifest validation failed.'); } - $packageId ??= PackageId::generate(); - PackageId::assert($packageId); - $packageIdRaw = hex2bin($packageId); - if (!is_string($packageIdRaw) || strlen($packageIdRaw) !== StablePackageDescriptor::PACKAGE_ID_BYTES) { + if ($packageId === null) { + $packageIdRaw = StablePackageDescriptor::randomNonZeroBytes(StablePackageDescriptor::PACKAGE_ID_BYTES); + $packageId = bin2hex($packageIdRaw); + } else { + PackageId::assert($packageId); + $packageIdRaw = hex2bin($packageId); + } + if (!is_string($packageIdRaw) || strlen($packageIdRaw) !== StablePackageDescriptor::PACKAGE_ID_BYTES + || StablePackageDescriptor::isZeroBytes($packageIdRaw)) { throw new RuntimeException('Invalid package identifier.'); } @@ -94,9 +99,9 @@ public function build( $published = false; try { - $salt = random_bytes(StablePackageDescriptor::SALT_BYTES); - $rootKey = random_bytes(StablePackageDescriptor::KEY_BYTES); - $recoveryKeyRaw = $createRecovery ? random_bytes(StablePackageDescriptor::KEY_BYTES) : null; + $salt = StablePackageDescriptor::randomNonZeroBytes(StablePackageDescriptor::SALT_BYTES); + $rootKey = StablePackageDescriptor::randomNonZeroBytes(StablePackageDescriptor::KEY_BYTES); + $recoveryKeyRaw = $createRecovery ? StablePackageDescriptor::randomNonZeroBytes(StablePackageDescriptor::KEY_BYTES) : null; $ops = StablePackageDescriptor::DEFAULT_KDF_OPS; $memory = StablePackageDescriptor::DEFAULT_KDF_MEM_BYTES; StablePackageDescriptor::assertKdfParameters($ops, $memory); @@ -116,7 +121,7 @@ public function build( $coreHash = hash('sha256', $core, true); $credentialKey = StablePackageCrypto::deriveCredentialKey($password, $pattern, $salt, $packageIdRaw, $ops, $memory); - $primaryNonce = random_bytes(StablePackageDescriptor::NONCE_BYTES); + $primaryNonce = StablePackageDescriptor::randomNonZeroBytes(StablePackageDescriptor::NONCE_BYTES); $primaryCipher = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt( $rootKey, "SPKG/KEYWRAP/PRIMARY\0" . $core, @@ -128,7 +133,7 @@ public function build( } if ($recoveryKeyRaw !== null) { - $recoveryNonce = random_bytes(StablePackageDescriptor::NONCE_BYTES); + $recoveryNonce = StablePackageDescriptor::randomNonZeroBytes(StablePackageDescriptor::NONCE_BYTES); $recoveryCipher = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt( $rootKey, "SPKG/KEYWRAP/RECOVERY\0" . $core, @@ -171,7 +176,7 @@ public function build( sodium_memzero($fileRootKey); } - $manifestNonce = random_bytes(StablePackageDescriptor::NONCE_BYTES); + $manifestNonce = StablePackageDescriptor::randomNonZeroBytes(StablePackageDescriptor::NONCE_BYTES); $manifestCipher = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt( $manifestPlain, "SPKG/MANIFEST\0" . $coreHash, @@ -355,10 +360,18 @@ private function scanTree(string $root): array if (++$nodeCount > StablePackageDescriptor::MAX_NODES) { throw new RuntimeException('Source tree contains too many entries.'); } - do { - $id = random_bytes(StablePackageDescriptor::FILE_ID_BYTES); + $id = ''; + $idHex = ''; + for ($attempt = 0; $attempt < 32; $attempt++) { + $id = StablePackageDescriptor::randomNonZeroBytes(StablePackageDescriptor::FILE_ID_BYTES); $idHex = bin2hex($id); - } while (StablePackageDescriptor::isZeroBytes($id) || isset($seenIds[$idHex])); + if (!isset($seenIds[$idHex])) { + break; + } + } + if ($id === '' || isset($seenIds[$idHex])) { + throw new RuntimeException('Unable to generate a unique file identifier.'); + } $seenIds[$idHex] = true; $pathName = $relative === '' ? $name : $relative . '/' . $name; if (strlen($pathName) > StablePackageDescriptor::MAX_PATH_BYTES) { From 0f44cecb06c3de4b8a32a4a495c9d6fd80f0dbbe Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 22:27:02 +0330 Subject: [PATCH 17/66] docs(format): clarify ciphertext bit patterns in header validation From f2f9f55a777a38a739491fa8d83d0383a70cdc1c Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 22:27:29 +0330 Subject: [PATCH 18/66] test(format): expand binary header and parser rejection coverage --- tests/StablePackageFormatTest.php | 56 +++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/tests/StablePackageFormatTest.php b/tests/StablePackageFormatTest.php index 28a3901c..7b649483 100644 --- a/tests/StablePackageFormatTest.php +++ b/tests/StablePackageFormatTest.php @@ -98,8 +98,39 @@ function stableRemoveTree(string $path): void stableAssert($header['ops'] === Format::DEFAULT_KDF_OPS, 'KDF operations parameter is not the writer profile.'); stableAssert($header['memory_bytes'] === Format::DEFAULT_KDF_MEM_BYTES, 'KDF memory parameter is not the writer profile.'); stableAssert(strlen($header['core']) === Format::CORE_BYTES, 'Core header length is invalid.'); + stableAssert(!str_contains($raw, 'V14') && !str_contains($raw, 'SECURE-PKG-V14') + && !str_contains($raw, 'version') && !str_contains($raw, 'schema'), + 'The serialized binary package unexpectedly contains a generation/version label or schema field.'); $reader = new StablePackageReader(); + + // Header parsers must reject unsafe values before any Argon2id invocation. + $headerMutations = [ + [0, ord($raw[0]) ^ 0x01, 'bad magic'], + [8, 0x80, 'reserved flag'], + [9, 0x02, 'unknown KDF id'], + [10, 0x02, 'unknown AEAD id'], + [11, 0x02, 'unknown stream id'], + [12, 0x02, 'unknown hash id'], + [16, 0x00, 'invalid KDF ops'], + [20, 0x01, 'unaligned KDF memory'], + ]; + foreach ($headerMutations as $index => [$offset, $value, $label]) { + $casePath = $root . DIRECTORY_SEPARATOR . 'bad-header-field-' . $index . '.spkg'; + stableCopy($package, $casePath); + $bytes = file_get_contents($casePath); + $bytes[$offset] = chr($value); + file_put_contents($casePath, $bytes, LOCK_EX); + $badDestination = $root . DIRECTORY_SEPARATOR . 'bad-header-field-restore-' . $index; + stableExpectFailure(fn() => $reader->restore($casePath, $badDestination, $password, $pattern), + 'Unsupported or unsafe header field was accepted: ' . $label); + stableAssert(!file_exists($badDestination), 'Invalid header left output behind: ' . $label); + } + + $zeroIdPackage = $root . DIRECTORY_SEPARATOR . 'zero-id.spkg'; + stableExpectFailure(fn() => (new StablePackageWriter())->build($source, $zeroIdPackage, + $password, $pattern, str_repeat('0', 48), false), 'All-zero package identifier was accepted by the writer.'); + stableAssert(!file_exists($zeroIdPackage), 'All-zero package identifier produced an output file.'); $restored = $reader->restore($package, $restore, $password, $pattern); stableAssert($restored['files'] === 3 && $restored['directories'] === 1, 'Round-trip statistics are incorrect.'); foreach (['hello.txt', 'empty.bin', 'nested/binary.dat'] as $relative) { @@ -113,6 +144,13 @@ function stableRemoveTree(string $path): void stableAssert($recovered['files'] === 3, 'Recovery-key restore failed.'); stableAssert(hash_file('sha256', $source . '/nested/binary.dat') === hash_file('sha256', $recoveryDestination . '/nested/binary.dat'), 'Recovery restore content mismatch.'); + stableExpectFailure(fn() => $reader->restore($package, $root . DIRECTORY_SEPARATOR . 'wrong-recovery', + '', '', str_repeat('A', 43)), 'Wrong recovery key was accepted.'); + stableAssert(!file_exists($root . DIRECTORY_SEPARATOR . 'wrong-recovery'), 'Wrong recovery key left output behind.'); + stableExpectFailure(fn() => $reader->restore($package, $root . DIRECTORY_SEPARATOR . 'malformed-recovery', + '', '', 'not a recovery key'), 'Malformed recovery key was accepted.'); + stableAssert(!file_exists($root . DIRECTORY_SEPARATOR . 'malformed-recovery'), 'Malformed recovery key left output behind.'); + $existingDestination = $root . DIRECTORY_SEPARATOR . 'already-exists'; mkdir($existingDestination, 0700); file_put_contents($existingDestination . DIRECTORY_SEPARATOR . 'sentinel.txt', 'keep-this-file'); @@ -142,6 +180,24 @@ function stableRemoveTree(string $path): void stableExpectFailure(fn() => $reader->restore($tamperedRecoverySlot, $root . DIRECTORY_SEPARATOR . 'bad-recovery-slot-restore', $password, $pattern), 'Tampered recovery slot was accepted with primary credentials.'); stableAssert(!file_exists($root . DIRECTORY_SEPARATOR . 'bad-recovery-slot-restore'), 'Tampered recovery slot left restored data behind.'); + $unknownRecord = $root . DIRECTORY_SEPARATOR . 'unknown-record.spkg'; + stableCopy($package, $unknownRecord); + $bytes = file_get_contents($unknownRecord); + $bytes[Format::HEADER_BYTES] = chr(0x55); + file_put_contents($unknownRecord, $bytes, LOCK_EX); + stableExpectFailure(fn() => $reader->restore($unknownRecord, $root . DIRECTORY_SEPARATOR . 'unknown-record-restore', $password, $pattern), 'Unknown record type was accepted.'); + stableAssert(!file_exists($root . DIRECTORY_SEPARATOR . 'unknown-record-restore'), 'Unknown record left output behind.'); + + $invalidLength = $root . DIRECTORY_SEPARATOR . 'invalid-length.spkg'; + stableCopy($package, $invalidLength); + $bytes = file_get_contents($invalidLength); + $bytes = substr((string) $bytes, 0, Format::HEADER_BYTES + 1) + . pack('N', 0xFFFFFFFF) + . substr((string) $bytes, Format::HEADER_BYTES + Format::RECORD_HEADER_BYTES); + file_put_contents($invalidLength, $bytes, LOCK_EX); + stableExpectFailure(fn() => $reader->restore($invalidLength, $root . DIRECTORY_SEPARATOR . 'invalid-length-restore', $password, $pattern), 'Oversized record length was accepted.'); + stableAssert(!file_exists($root . DIRECTORY_SEPARATOR . 'invalid-length-restore'), 'Invalid record length left output behind.'); + $tamperedChunk = $root . DIRECTORY_SEPARATOR . 'tampered-chunk.spkg'; stableCopy($package, $tamperedChunk); stableMutateRecord($tamperedChunk, Format::RECORD_FILE_CHUNK); From 5526117a67318d35a096b919a5e574008ac3cef0 Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 22:28:38 +0330 Subject: [PATCH 19/66] test(format): add immutable stable-format binary fixture --- tests/fixtures/stable-format-v1.spkg.b64 | 1 + 1 file changed, 1 insertion(+) create mode 100644 tests/fixtures/stable-format-v1.spkg.b64 diff --git a/tests/fixtures/stable-format-v1.spkg.b64 b/tests/fixtures/stable-format-v1.spkg.b64 new file mode 100644 index 00000000..f6baad20 --- /dev/null +++ b/tests/fixtures/stable-format-v1.spkg.b64 @@ -0,0 +1 @@ +04KhT8dekGsAAQEBAQAAAAMQAAAAIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiM+UJJGtWcGsPq6Pb99fyS5H+lf9mMCrAYnj2TOvrv/Oi8ojPpPjF4FGd2FdGfeBs+zx060PA4G3ObT2bo5W8V6GFrpbqtluY/GFXESopWUpphpOpxNXwz3mRvuMyjr8L2PXA+eMyUHMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABrTfGRynvz0FJOwsxXplieaFOMvpfBRvIhFrsl7RlRAgEAAAAwCIqfMA0q75OPk5FtkuO6LAAAAAAAAAAnfMSP5oXxgUmg8Yr4PXTlgJoWohyYlfWBAgAAAEwIip8wDSrvk4+TkW2S47osAAAAAL1H5edtZ76SA5krC/yJHjIgrnsFsl6WZDe303azz3ipEwfaYU5q6YNyE0dnJAJFhesY3oHp3Pw/fwAAAGbqHeyYEIfk+2KGT11xMWJNqjY0qVYK6lBihvwpOXWNqm9Tb4TKTyAb0EB7ZJ2tFECV3aZhOHccmQxWZ6R+6gbeWMA7DPE1zpqqYw56PaGIles4pF75IDsMiEkOyCl1sy+RPqvibWU= \ No newline at end of file From 3277af1ee362068963b55e7276efed469f58802b Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 22:28:54 +0330 Subject: [PATCH 20/66] test(format): verify immutable package fixture compatibility --- tests/StablePackageFormatTest.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/StablePackageFormatTest.php b/tests/StablePackageFormatTest.php index 7b649483..a903a973 100644 --- a/tests/StablePackageFormatTest.php +++ b/tests/StablePackageFormatTest.php @@ -104,6 +104,24 @@ function stableRemoveTree(string $path): void $reader = new StablePackageReader(); + // Immutable on-disk bytes from an earlier writer build must remain readable; + // a same-process writer/reader round trip cannot by itself prove compatibility. + $fixtureEncoded = trim((string) file_get_contents(__DIR__ . '/fixtures/stable-format-v1.spkg.b64')); + $fixtureBytes = base64_decode($fixtureEncoded, true); + stableAssert(is_string($fixtureBytes) && strlen($fixtureBytes) > Format::HEADER_BYTES, + 'Immutable stable-format fixture is missing or malformed.'); + $fixturePath = $root . DIRECTORY_SEPARATOR . 'stable-format-v1.spkg'; + stableAssert(file_put_contents($fixturePath, $fixtureBytes, LOCK_EX) === strlen($fixtureBytes), + 'Unable to materialize immutable stable-format fixture.'); + $fixtureDestination = $root . DIRECTORY_SEPARATOR . 'stable-format-v1-restore'; + $fixtureResult = $reader->restore($fixturePath, $fixtureDestination, + 'Golden!FixturePassword2026#', 'Golden#Pattern!92Secure'); + stableAssert($fixtureResult['package_id'] === str_repeat('2', 48) && $fixtureResult['files'] === 1, + 'Immutable stable-format fixture metadata changed.'); + stableAssert(file_get_contents($fixtureDestination . DIRECTORY_SEPARATOR . 'fixture.txt') + === "Stable binary format golden fixture v1\n", + 'Immutable stable-format fixture plaintext changed.'); + // Header parsers must reject unsafe values before any Argon2id invocation. $headerMutations = [ [0, ord($raw[0]) ^ 0x01, 'bad magic'], From 8aa6dab175727c0ffb822e976caa20eec597e6fd Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 22:29:35 +0330 Subject: [PATCH 21/66] test(format): avoid probabilistic ciphertext substring assertion --- tests/StablePackageFormatTest.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/StablePackageFormatTest.php b/tests/StablePackageFormatTest.php index a903a973..0a90d057 100644 --- a/tests/StablePackageFormatTest.php +++ b/tests/StablePackageFormatTest.php @@ -98,9 +98,11 @@ function stableRemoveTree(string $path): void stableAssert($header['ops'] === Format::DEFAULT_KDF_OPS, 'KDF operations parameter is not the writer profile.'); stableAssert($header['memory_bytes'] === Format::DEFAULT_KDF_MEM_BYTES, 'KDF memory parameter is not the writer profile.'); stableAssert(strlen($header['core']) === Format::CORE_BYTES, 'Core header length is invalid.'); - stableAssert(!str_contains($raw, 'V14') && !str_contains($raw, 'SECURE-PKG-V14') - && !str_contains($raw, 'version') && !str_contains($raw, 'schema'), - 'The serialized binary package unexpectedly contains a generation/version label or schema field.'); + stableAssert(substr($raw, 0, 8) === Format::MAGIC + && substr($raw, 0, 8) !== 'SECURE-PKG-V14', + 'The stable package marker is not the fixed opaque, version-independent marker.'); + // Manifest field/entry keys are asserted exactly below; scanning encrypted ciphertext + // for English words would make this test probabilistic because ciphertext is random. $reader = new StablePackageReader(); From 399560afdbcd732f03161b3220ed258031f53ba4 Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 23:50:26 +0330 Subject: [PATCH 22/66] test(format): cover malformed manifest graphs and stream ordering --- tests/StablePackageFormatTest.php | 47 +++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/StablePackageFormatTest.php b/tests/StablePackageFormatTest.php index 0a90d057..ab5aa0ad 100644 --- a/tests/StablePackageFormatTest.php +++ b/tests/StablePackageFormatTest.php @@ -218,6 +218,22 @@ function stableRemoveTree(string $path): void stableExpectFailure(fn() => $reader->restore($invalidLength, $root . DIRECTORY_SEPARATOR . 'invalid-length-restore', $password, $pattern), 'Oversized record length was accepted.'); stableAssert(!file_exists($root . DIRECTORY_SEPARATOR . 'invalid-length-restore'), 'Invalid record length left output behind.'); + $tamperedFileStart = $root . DIRECTORY_SEPARATOR . 'tampered-file-start.spkg'; + stableCopy($package, $tamperedFileStart); + stableMutateRecord($tamperedFileStart, Format::RECORD_FILE_START); + stableExpectFailure(fn() => $reader->restore($tamperedFileStart, $root . DIRECTORY_SEPARATOR . 'bad-file-start-restore', $password, $pattern), 'Tampered file-start record was accepted.'); + stableAssert(!file_exists($root . DIRECTORY_SEPARATOR . 'bad-file-start-restore'), 'Tampered file-start record left output behind.'); + + $wrongChunkSequence = $root . DIRECTORY_SEPARATOR . 'wrong-chunk-sequence.spkg'; + stableCopy($package, $wrongChunkSequence); + $bytes = file_get_contents($wrongChunkSequence); + // The first file-start record follows the header; its first chunk starts after the 48-byte body. + $sequenceOffset = Format::HEADER_BYTES + Format::RECORD_HEADER_BYTES + 48 + Format::RECORD_HEADER_BYTES + 16; + $bytes[$sequenceOffset + 3] = chr(ord($bytes[$sequenceOffset + 3]) ^ 0x01); + file_put_contents($wrongChunkSequence, $bytes, LOCK_EX); + stableExpectFailure(fn() => $reader->restore($wrongChunkSequence, $root . DIRECTORY_SEPARATOR . 'bad-sequence-restore', $password, $pattern), 'Out-of-order chunk sequence was accepted.'); + stableAssert(!file_exists($root . DIRECTORY_SEPARATOR . 'bad-sequence-restore'), 'Out-of-order chunk sequence left output behind.'); + $tamperedChunk = $root . DIRECTORY_SEPARATOR . 'tampered-chunk.spkg'; stableCopy($package, $tamperedChunk); stableMutateRecord($tamperedChunk, Format::RECORD_FILE_CHUNK); @@ -271,6 +287,37 @@ function stableRemoveTree(string $path): void ]); stableExpectFailure(fn() => Format::decodeManifest($cycleManifest), 'Manifest directory cycle was accepted.'); + $duplicateId = random_bytes(16); + $duplicateIdManifest = Format::encodeManifest([ + ['type' => 2, 'id' => $duplicateId, 'parent_id' => Format::zeroId(), 'name' => 'a.bin', 'size' => 0, 'chunks' => 1], + ['type' => 2, 'id' => $duplicateId, 'parent_id' => Format::zeroId(), 'name' => 'b.bin', 'size' => 0, 'chunks' => 1], + ]); + stableExpectFailure(fn() => Format::decodeManifest($duplicateIdManifest), 'Duplicate manifest entry IDs were accepted.'); + + $missingParentManifest = Format::encodeManifest([ + ['type' => 2, 'id' => random_bytes(16), 'parent_id' => random_bytes(16), 'name' => 'missing-parent.bin', 'size' => 0, 'chunks' => 1], + ]); + stableExpectFailure(fn() => Format::decodeManifest($missingParentManifest), 'Missing manifest parent was accepted.'); + + $fileParentId = random_bytes(16); + $fileAsParentManifest = Format::encodeManifest([ + ['type' => 2, 'id' => $fileParentId, 'parent_id' => Format::zeroId(), 'name' => 'file', 'size' => 0, 'chunks' => 1], + ['type' => 2, 'id' => random_bytes(16), 'parent_id' => $fileParentId, 'name' => 'child.bin', 'size' => 0, 'chunks' => 1], + ]); + stableExpectFailure(fn() => Format::decodeManifest($fileAsParentManifest), 'Regular file used as parent was accepted.'); + + $unorderedManifest = Format::encodeManifest([ + ['type' => 2, 'id' => random_bytes(16), 'parent_id' => Format::zeroId(), 'name' => 'z.bin', 'size' => 0, 'chunks' => 1], + ['type' => 2, 'id' => random_bytes(16), 'parent_id' => Format::zeroId(), 'name' => 'a.bin', 'size' => 0, 'chunks' => 1], + ]); + stableExpectFailure(fn() => Format::decodeManifest($unorderedManifest), 'Noncanonical manifest path order was accepted.'); + + $caseCollisionManifest = Format::encodeManifest([ + ['type' => 2, 'id' => random_bytes(16), 'parent_id' => Format::zeroId(), 'name' => 'A.bin', 'size' => 0, 'chunks' => 1], + ['type' => 2, 'id' => random_bytes(16), 'parent_id' => Format::zeroId(), 'name' => 'a.bin', 'size' => 0, 'chunks' => 1], + ]); + stableExpectFailure(fn() => Format::decodeManifest($caseCollisionManifest), 'Case-colliding sibling names were accepted.'); + $noRecoveryPackage = $root . DIRECTORY_SEPARATOR . 'no-recovery.spkg'; $noRecovery = (new StablePackageWriter())->build($source, $noRecoveryPackage, $password, $pattern, str_repeat('b', 48), false); stableAssert($noRecovery['recovery_key'] === null, 'Disabled recovery key was returned.'); From 78164fc192f6b786f4d838c93d587e57f3dbf417 Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 23:50:47 +0330 Subject: [PATCH 23/66] fix(format): detect source-file replacement before encryption --- app/Project/StablePackageWriter.php | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/app/Project/StablePackageWriter.php b/app/Project/StablePackageWriter.php index 8c7d383a..17dc322d 100644 --- a/app/Project/StablePackageWriter.php +++ b/app/Project/StablePackageWriter.php @@ -246,8 +246,21 @@ private function encryptFileEntry($out, array $entry, string $fileRootKey, strin if ($input === false) { throw new RuntimeException('Unable to open source file.'); } + // Compare the opened handle with the no-follow path identity captured during + // enumeration. This detects common symlink/replacement races before any plaintext + // from a substituted file is encrypted into the package. + $pathStat = @lstat($path); $before = fstat($input); - if (!is_array($before) || (int) ($before['size'] ?? -1) !== $entry['size']) { + $expectedDev = $entry['source_dev'] ?? null; + $expectedIno = $entry['source_ino'] ?? null; + $identityMatches = is_array($pathStat) && is_array($before) + && !is_link($path) + && ($pathStat['dev'] ?? null) === ($before['dev'] ?? null) + && ($pathStat['ino'] ?? null) === ($before['ino'] ?? null) + && ($before['dev'] ?? null) === $expectedDev + && ($before['ino'] ?? null) === $expectedIno + && (((int) ($before['mode'] ?? 0) & 0170000) === 0100000); + if (!$identityMatches || !is_array($before) || (int) ($before['size'] ?? -1) !== $entry['size']) { fclose($input); throw new RuntimeException('Source file changed during package creation.'); } @@ -389,11 +402,14 @@ private function scanTree(string $root): array 'chunks' => null, ]; if ($isFile) { - $stat = @stat($path); + $stat = @lstat($path); if (!is_array($stat) || !isset($stat['size']) || (int) $stat['size'] < 0 - || (int) $stat['size'] > StablePackageDescriptor::MAX_FILE_BYTES) { + || (int) $stat['size'] > StablePackageDescriptor::MAX_FILE_BYTES + || (((int) ($stat['mode'] ?? 0) & 0170000) !== 0100000)) { throw new RuntimeException('Source file exceeds the supported size limit.'); } + $entry['source_dev'] = $stat['dev'] ?? null; + $entry['source_ino'] = $stat['ino'] ?? null; $entry['size'] = (int) $stat['size']; $totalBytes += $entry['size']; if ($totalBytes > StablePackageDescriptor::MAX_TOTAL_PLAIN_BYTES) { From c8552ebb527b124a1b17f4e99622dd29036e1658 Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 23:51:12 +0330 Subject: [PATCH 24/66] test(format): prove package creation never overwrites existing files --- tests/StablePackageFormatTest.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/StablePackageFormatTest.php b/tests/StablePackageFormatTest.php index ab5aa0ad..9ac8da1a 100644 --- a/tests/StablePackageFormatTest.php +++ b/tests/StablePackageFormatTest.php @@ -318,6 +318,15 @@ function stableRemoveTree(string $path): void ]); stableExpectFailure(fn() => Format::decodeManifest($caseCollisionManifest), 'Case-colliding sibling names were accepted.'); + $existingPackagePath = $root . DIRECTORY_SEPARATOR . 'existing-package.spkg'; + file_put_contents($existingPackagePath, 'preserve-existing-package'); + $existingPackageHash = hash_file('sha256', $existingPackagePath); + stableExpectFailure(fn() => (new StablePackageWriter())->build($source, $existingPackagePath, + $password, $pattern, str_repeat('c', 48), false), 'Writer overwrote an existing package destination.'); + stableAssert(hash_equals((string) $existingPackageHash, (string) hash_file('sha256', $existingPackagePath)) + && file_get_contents($existingPackagePath) === 'preserve-existing-package', + 'Existing package destination was modified by a failed create.'); + $noRecoveryPackage = $root . DIRECTORY_SEPARATOR . 'no-recovery.spkg'; $noRecovery = (new StablePackageWriter())->build($source, $noRecoveryPackage, $password, $pattern, str_repeat('b', 48), false); stableAssert($noRecovery['recovery_key'] === null, 'Disabled recovery key was returned.'); From a6827b863affcf11ac024be7deb386864addb3c5 Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 23:51:45 +0330 Subject: [PATCH 25/66] fix(format): verify every required cryptographic primitive at runtime --- app/Project/StablePackageDescriptor.php | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/app/Project/StablePackageDescriptor.php b/app/Project/StablePackageDescriptor.php index 9be9dfb9..aaa18776 100644 --- a/app/Project/StablePackageDescriptor.php +++ b/app/Project/StablePackageDescriptor.php @@ -69,11 +69,25 @@ public static function assertRuntime(): void if (PHP_INT_SIZE < 8) { throw new RuntimeException('The stable package format requires a 64-bit PHP runtime.'); } + $requiredFunctions = [ + 'sodium_crypto_pwhash', + 'sodium_memzero', + 'sodium_crypto_aead_xchacha20poly1305_ietf_encrypt', + 'sodium_crypto_aead_xchacha20poly1305_ietf_decrypt', + 'sodium_crypto_secretstream_xchacha20poly1305_init_push', + 'sodium_crypto_secretstream_xchacha20poly1305_init_pull', + 'sodium_crypto_secretstream_xchacha20poly1305_push', + 'sodium_crypto_secretstream_xchacha20poly1305_pull', + 'hash_hkdf', + 'hash_hmac', + ]; if (!extension_loaded('sodium') || !defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13')) { throw new RuntimeException('The sodium extension with Argon2id support is required.'); } - if (!function_exists('hash_hkdf')) { - throw new RuntimeException('HKDF support is required.'); + foreach ($requiredFunctions as $function) { + if (!function_exists($function)) { + throw new RuntimeException('The cryptographic runtime is missing a required function.'); + } } } From 93ada54150d5b45095f2ebdcc2eb9dba5da745f0 Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 23:51:58 +0330 Subject: [PATCH 26/66] docs(format): specify recovery slot validation precisely --- docs/STABLE-PACKAGE-FORMAT-BINARY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/STABLE-PACKAGE-FORMAT-BINARY.md b/docs/STABLE-PACKAGE-FORMAT-BINARY.md index 25381cbd..7418df99 100644 --- a/docs/STABLE-PACKAGE-FORMAT-BINARY.md +++ b/docs/STABLE-PACKAGE-FORMAT-BINARY.md @@ -69,7 +69,7 @@ Opaque magic bytes (hex): **D3 82 A1 4F C7 5E 90 6B**. The 'core' is exact bytes 0–76 (77 bytes). The 'header_without_mac' is exact bytes 0–220 (221 bytes). The header MAC is bytes 221–252 (32 bytes). -Readers MUST reject an incorrect magic, unsupported/reserved bits, unsupported algorithm identifiers, out-of-range KDF parameters, a zero package ID, zero salt, zero primary nonce, and a missing/incorrectly sized wrapped key. With recovery disabled, both recovery fields MUST be all zero. With recovery enabled, both fields MUST be valid and nonzero. Every field, including the slot not used for the current decrypt, is authenticated by the header MAC. +Readers MUST reject an incorrect magic, unsupported/reserved bits, unsupported algorithm identifiers, out-of-range KDF parameters, a zero package ID, zero salt, zero primary nonce, and a missing/incorrectly sized wrapped key. With recovery disabled, both recovery fields MUST be all zero. With recovery enabled, the recovery nonce MUST be nonzero and the wrapped-key field MUST be exactly 48 bytes; ciphertext bytes are not constrained to a particular bit pattern. Every field, including the slot not used for the current decrypt, is authenticated by the header MAC. ### 3.1 Credential derivation From a76634c3896b1bf6225e36892bde100dee53624b Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 23:52:20 +0330 Subject: [PATCH 27/66] fix(format): reject additional Windows reserved device names --- app/Project/StablePackageDescriptor.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Project/StablePackageDescriptor.php b/app/Project/StablePackageDescriptor.php index aaa18776..1eefbdbc 100644 --- a/app/Project/StablePackageDescriptor.php +++ b/app/Project/StablePackageDescriptor.php @@ -430,7 +430,7 @@ public static function assertName(string $name): void || str_contains($name, '/') || str_contains($name, '\\') || preg_match('/[\x00-\x1F\x7F:<>"|?*]/u', $name) === 1 || str_ends_with($name, ' ') || str_ends_with($name, '.') - || preg_match('/^(CON|PRN|AUX|NUL|CONIN\$|CONOUT\$|COM[1-9]|LPT[1-9])(?:\..*)?$/i', $name) === 1) { + || preg_match('/^(CON|PRN|AUX|NUL|CLOCK\$|CONIN\$|CONOUT\$|COM[1-9¹²³]|LPT[1-9¹²³])(?:[ .].*)?$/iu', $name) === 1) { throw new RuntimeException('Unsafe or unsupported filesystem name.'); } self::foldName($name); // Ensure collision checking is safe on this runtime. From 09bcad47d355b609efc15c94d7859f2ac873d7bf Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 23:52:25 +0330 Subject: [PATCH 28/66] test(format): cover Windows device-name aliases --- tests/StablePackageFormatTest.php | 73 ++++++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/tests/StablePackageFormatTest.php b/tests/StablePackageFormatTest.php index 9ac8da1a..816fc99a 100644 --- a/tests/StablePackageFormatTest.php +++ b/tests/StablePackageFormatTest.php @@ -275,7 +275,78 @@ function stableRemoveTree(string $path): void ])); stableAssert(array_keys($manifestProbe) === ['entries', 'file_count', 'directory_count', 'total_plain_bytes'], 'Stable manifest decoder exposed unexpected metadata fields.'); stableAssert(array_keys($manifestProbe['entries'][0]) === ['type', 'id', 'id_hex', 'parent_id', 'parent_hex', 'name', 'size', 'chunks', 'path', 'depth'], 'Stable manifest entry contains unexpected version or schema fields.'); - foreach (['../escape', 'CON', 'trailing.', 'trailing ', "nul\0name", 'a/b', 'a\\b'] as $unsafeName) { + foreach (['../escape', 'CON', 'CON .txt', 'CLOCK + stableExpectFailure(fn() => Format::assertName($unsafeName), 'Unsafe package name was accepted: ' . $unsafeName); + } + + $dirA = random_bytes(16); $dirB = random_bytes(16); $fileId = random_bytes(16); + $cycleManifest = Format::encodeManifest([ + ['type' => 1, 'id' => $dirA, 'parent_id' => $dirB, 'name' => 'alpha'], + ['type' => 1, 'id' => $dirB, 'parent_id' => $dirA, 'name' => 'beta'], + ['type' => 2, 'id' => $fileId, 'parent_id' => $dirA, 'name' => 'data.bin', 'size' => 0, 'chunks' => 1], + ]); + stableExpectFailure(fn() => Format::decodeManifest($cycleManifest), 'Manifest directory cycle was accepted.'); + + $duplicateId = random_bytes(16); + $duplicateIdManifest = Format::encodeManifest([ + ['type' => 2, 'id' => $duplicateId, 'parent_id' => Format::zeroId(), 'name' => 'a.bin', 'size' => 0, 'chunks' => 1], + ['type' => 2, 'id' => $duplicateId, 'parent_id' => Format::zeroId(), 'name' => 'b.bin', 'size' => 0, 'chunks' => 1], + ]); + stableExpectFailure(fn() => Format::decodeManifest($duplicateIdManifest), 'Duplicate manifest entry IDs were accepted.'); + + $missingParentManifest = Format::encodeManifest([ + ['type' => 2, 'id' => random_bytes(16), 'parent_id' => random_bytes(16), 'name' => 'missing-parent.bin', 'size' => 0, 'chunks' => 1], + ]); + stableExpectFailure(fn() => Format::decodeManifest($missingParentManifest), 'Missing manifest parent was accepted.'); + + $fileParentId = random_bytes(16); + $fileAsParentManifest = Format::encodeManifest([ + ['type' => 2, 'id' => $fileParentId, 'parent_id' => Format::zeroId(), 'name' => 'file', 'size' => 0, 'chunks' => 1], + ['type' => 2, 'id' => random_bytes(16), 'parent_id' => $fileParentId, 'name' => 'child.bin', 'size' => 0, 'chunks' => 1], + ]); + stableExpectFailure(fn() => Format::decodeManifest($fileAsParentManifest), 'Regular file used as parent was accepted.'); + + $unorderedManifest = Format::encodeManifest([ + ['type' => 2, 'id' => random_bytes(16), 'parent_id' => Format::zeroId(), 'name' => 'z.bin', 'size' => 0, 'chunks' => 1], + ['type' => 2, 'id' => random_bytes(16), 'parent_id' => Format::zeroId(), 'name' => 'a.bin', 'size' => 0, 'chunks' => 1], + ]); + stableExpectFailure(fn() => Format::decodeManifest($unorderedManifest), 'Noncanonical manifest path order was accepted.'); + + $caseCollisionManifest = Format::encodeManifest([ + ['type' => 2, 'id' => random_bytes(16), 'parent_id' => Format::zeroId(), 'name' => 'A.bin', 'size' => 0, 'chunks' => 1], + ['type' => 2, 'id' => random_bytes(16), 'parent_id' => Format::zeroId(), 'name' => 'a.bin', 'size' => 0, 'chunks' => 1], + ]); + stableExpectFailure(fn() => Format::decodeManifest($caseCollisionManifest), 'Case-colliding sibling names were accepted.'); + + $existingPackagePath = $root . DIRECTORY_SEPARATOR . 'existing-package.spkg'; + file_put_contents($existingPackagePath, 'preserve-existing-package'); + $existingPackageHash = hash_file('sha256', $existingPackagePath); + stableExpectFailure(fn() => (new StablePackageWriter())->build($source, $existingPackagePath, + $password, $pattern, str_repeat('c', 48), false), 'Writer overwrote an existing package destination.'); + stableAssert(hash_equals((string) $existingPackageHash, (string) hash_file('sha256', $existingPackagePath)) + && file_get_contents($existingPackagePath) === 'preserve-existing-package', + 'Existing package destination was modified by a failed create.'); + + $noRecoveryPackage = $root . DIRECTORY_SEPARATOR . 'no-recovery.spkg'; + $noRecovery = (new StablePackageWriter())->build($source, $noRecoveryPackage, $password, $pattern, str_repeat('b', 48), false); + stableAssert($noRecovery['recovery_key'] === null, 'Disabled recovery key was returned.'); + $noRecoveryBytes = file_get_contents($noRecoveryPackage); + stableAssert(strlen(substr((string) $noRecoveryBytes, 0, Format::HEADER_BYTES)) === Format::HEADER_BYTES, 'No-recovery header size changed.'); + stableAssert(substr((string) $noRecoveryBytes, 8, 1) === "\0", 'No-recovery flag is incorrect.'); + stableAssert(Format::isZeroBytes(substr((string) $noRecoveryBytes, 149, Format::SLOT_BYTES)), 'Disabled recovery slot is not all-zero.'); + $noRecoveryDestination = $root . DIRECTORY_SEPARATOR . 'no-recovery-restore'; + $noRecoveryRestored = $reader->restore($noRecoveryPackage, $noRecoveryDestination, $password, $pattern); + stableAssert($noRecoveryRestored['files'] === 3, 'No-recovery package did not restore.'); + + echo "Stable binary package round-trip and adversarial tests passed.\n"; +} finally { + $password = ''; + $pattern = ''; + $wrongPassword = ''; + $wrongPattern = ''; + stableRemoveTree($root); +} +, 'COM¹.log', 'LPT²', 'trailing.', 'trailing ', "nul\0name", 'a/b', 'a\\b'] as $unsafeName) { stableExpectFailure(fn() => Format::assertName($unsafeName), 'Unsafe package name was accepted: ' . $unsafeName); } From a67904e3ad1903c66c69c0c4dad3fc78cb18de3c Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 23:52:28 +0330 Subject: [PATCH 29/66] docs(format): document conservative Windows device-name rejection --- docs/STABLE-PACKAGE-FORMAT-BINARY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/STABLE-PACKAGE-FORMAT-BINARY.md b/docs/STABLE-PACKAGE-FORMAT-BINARY.md index 7418df99..d9489a8d 100644 --- a/docs/STABLE-PACKAGE-FORMAT-BINARY.md +++ b/docs/STABLE-PACKAGE-FORMAT-BINARY.md @@ -180,7 +180,7 @@ Entries are strictly sorted by resolved relative path using raw UTF-8 byte order A nonzero parent ID must resolve to exactly one directory. All IDs are unique across files and directories. The complete graph is validated before the requested output directory is created. Maximum depth is 256 components and maximum resolved path size is 4096 bytes. -Cross-platform path validation rejects empty names, '.' and '..', NUL/control characters, slash, backslash, colon, Windows forbidden characters '<', '>', '"', '|', '?', '*', trailing spaces/dots, and reserved device names CON, PRN, AUX, NUL, CONIN$, CONOUT$, COM1–COM9, LPT1–LPT9 (including extensions). If runtime extensions needed for Unicode normalization/case folding are missing, non-ASCII names MUST be rejected rather than checked incompletely. Otherwise names must be NFC and sibling names checked with Unicode-aware case folding. +Cross-platform path validation rejects empty names, '.' and '..', NUL/control characters, slash, backslash, colon, Windows forbidden characters '<', '>', '"', '|', '?', '*', trailing spaces/dots, and reserved device names CON, PRN, AUX, NUL, CLOCK$, CONIN$, CONOUT$, COM1–COM9, LPT1–LPT9 and the superscript-digit COM/LPT variants (including extensions and names with spaces or dots immediately after the reserved base). If runtime extensions needed for Unicode normalization/case folding are missing, non-ASCII names MUST be rejected rather than checked incompletely. Otherwise names must be NFC and sibling names checked with Unicode-aware case folding. ## 8. Resource and filesystem rules From 7a58f9199f72c14229ad6f317619ac1769c87652 Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 23:53:07 +0330 Subject: [PATCH 30/66] fix(format): make duplicate stream detection linear-time --- app/Project/StablePackageReader.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/Project/StablePackageReader.php b/app/Project/StablePackageReader.php index 108a933d..919e9eae 100644 --- a/app/Project/StablePackageReader.php +++ b/app/Project/StablePackageReader.php @@ -54,6 +54,7 @@ public function restore( $observedFiles = []; $observedOrder = []; + $observedIds = []; $observedFileCount = 0; $declaredTotalBytes = 0; $actualTotalBytes = 0; @@ -87,8 +88,7 @@ public function restore( $fileIdHex = bin2hex($fileId); $size = StablePackageDescriptor::unpackUint64(substr($body, 16, 8)); $streamHeader = substr($body, 24, StablePackageDescriptor::STREAM_HEADER_BYTES); - if (StablePackageDescriptor::isZeroBytes($fileId) || isset($observedFiles[$fileIdHex]) - || in_array($fileIdHex, $observedOrder, true) + if (StablePackageDescriptor::isZeroBytes($fileId) || isset($observedIds[$fileIdHex]) || $size > StablePackageDescriptor::MAX_FILE_BYTES) { throw new RuntimeException('Package file header is invalid.'); } @@ -105,6 +105,7 @@ public function restore( throw new RuntimeException('Unable to stage decrypted file.'); } @chmod($stagePath, 0600); + $observedIds[$fileIdHex] = true; $observedOrder[] = $fileIdHex; $active = [ 'id' => $fileId, From 2b2990b81724176f7593a8818a5f55c7c3e17eed Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 23:54:04 +0330 Subject: [PATCH 31/66] fix(test): repair malformed Windows filename cases and remove duplicate tail --- tests/StablePackageFormatTest.php | 73 +------------------------------ 1 file changed, 1 insertion(+), 72 deletions(-) diff --git a/tests/StablePackageFormatTest.php b/tests/StablePackageFormatTest.php index 816fc99a..cfe932e8 100644 --- a/tests/StablePackageFormatTest.php +++ b/tests/StablePackageFormatTest.php @@ -275,78 +275,7 @@ function stableRemoveTree(string $path): void ])); stableAssert(array_keys($manifestProbe) === ['entries', 'file_count', 'directory_count', 'total_plain_bytes'], 'Stable manifest decoder exposed unexpected metadata fields.'); stableAssert(array_keys($manifestProbe['entries'][0]) === ['type', 'id', 'id_hex', 'parent_id', 'parent_hex', 'name', 'size', 'chunks', 'path', 'depth'], 'Stable manifest entry contains unexpected version or schema fields.'); - foreach (['../escape', 'CON', 'CON .txt', 'CLOCK - stableExpectFailure(fn() => Format::assertName($unsafeName), 'Unsafe package name was accepted: ' . $unsafeName); - } - - $dirA = random_bytes(16); $dirB = random_bytes(16); $fileId = random_bytes(16); - $cycleManifest = Format::encodeManifest([ - ['type' => 1, 'id' => $dirA, 'parent_id' => $dirB, 'name' => 'alpha'], - ['type' => 1, 'id' => $dirB, 'parent_id' => $dirA, 'name' => 'beta'], - ['type' => 2, 'id' => $fileId, 'parent_id' => $dirA, 'name' => 'data.bin', 'size' => 0, 'chunks' => 1], - ]); - stableExpectFailure(fn() => Format::decodeManifest($cycleManifest), 'Manifest directory cycle was accepted.'); - - $duplicateId = random_bytes(16); - $duplicateIdManifest = Format::encodeManifest([ - ['type' => 2, 'id' => $duplicateId, 'parent_id' => Format::zeroId(), 'name' => 'a.bin', 'size' => 0, 'chunks' => 1], - ['type' => 2, 'id' => $duplicateId, 'parent_id' => Format::zeroId(), 'name' => 'b.bin', 'size' => 0, 'chunks' => 1], - ]); - stableExpectFailure(fn() => Format::decodeManifest($duplicateIdManifest), 'Duplicate manifest entry IDs were accepted.'); - - $missingParentManifest = Format::encodeManifest([ - ['type' => 2, 'id' => random_bytes(16), 'parent_id' => random_bytes(16), 'name' => 'missing-parent.bin', 'size' => 0, 'chunks' => 1], - ]); - stableExpectFailure(fn() => Format::decodeManifest($missingParentManifest), 'Missing manifest parent was accepted.'); - - $fileParentId = random_bytes(16); - $fileAsParentManifest = Format::encodeManifest([ - ['type' => 2, 'id' => $fileParentId, 'parent_id' => Format::zeroId(), 'name' => 'file', 'size' => 0, 'chunks' => 1], - ['type' => 2, 'id' => random_bytes(16), 'parent_id' => $fileParentId, 'name' => 'child.bin', 'size' => 0, 'chunks' => 1], - ]); - stableExpectFailure(fn() => Format::decodeManifest($fileAsParentManifest), 'Regular file used as parent was accepted.'); - - $unorderedManifest = Format::encodeManifest([ - ['type' => 2, 'id' => random_bytes(16), 'parent_id' => Format::zeroId(), 'name' => 'z.bin', 'size' => 0, 'chunks' => 1], - ['type' => 2, 'id' => random_bytes(16), 'parent_id' => Format::zeroId(), 'name' => 'a.bin', 'size' => 0, 'chunks' => 1], - ]); - stableExpectFailure(fn() => Format::decodeManifest($unorderedManifest), 'Noncanonical manifest path order was accepted.'); - - $caseCollisionManifest = Format::encodeManifest([ - ['type' => 2, 'id' => random_bytes(16), 'parent_id' => Format::zeroId(), 'name' => 'A.bin', 'size' => 0, 'chunks' => 1], - ['type' => 2, 'id' => random_bytes(16), 'parent_id' => Format::zeroId(), 'name' => 'a.bin', 'size' => 0, 'chunks' => 1], - ]); - stableExpectFailure(fn() => Format::decodeManifest($caseCollisionManifest), 'Case-colliding sibling names were accepted.'); - - $existingPackagePath = $root . DIRECTORY_SEPARATOR . 'existing-package.spkg'; - file_put_contents($existingPackagePath, 'preserve-existing-package'); - $existingPackageHash = hash_file('sha256', $existingPackagePath); - stableExpectFailure(fn() => (new StablePackageWriter())->build($source, $existingPackagePath, - $password, $pattern, str_repeat('c', 48), false), 'Writer overwrote an existing package destination.'); - stableAssert(hash_equals((string) $existingPackageHash, (string) hash_file('sha256', $existingPackagePath)) - && file_get_contents($existingPackagePath) === 'preserve-existing-package', - 'Existing package destination was modified by a failed create.'); - - $noRecoveryPackage = $root . DIRECTORY_SEPARATOR . 'no-recovery.spkg'; - $noRecovery = (new StablePackageWriter())->build($source, $noRecoveryPackage, $password, $pattern, str_repeat('b', 48), false); - stableAssert($noRecovery['recovery_key'] === null, 'Disabled recovery key was returned.'); - $noRecoveryBytes = file_get_contents($noRecoveryPackage); - stableAssert(strlen(substr((string) $noRecoveryBytes, 0, Format::HEADER_BYTES)) === Format::HEADER_BYTES, 'No-recovery header size changed.'); - stableAssert(substr((string) $noRecoveryBytes, 8, 1) === "\0", 'No-recovery flag is incorrect.'); - stableAssert(Format::isZeroBytes(substr((string) $noRecoveryBytes, 149, Format::SLOT_BYTES)), 'Disabled recovery slot is not all-zero.'); - $noRecoveryDestination = $root . DIRECTORY_SEPARATOR . 'no-recovery-restore'; - $noRecoveryRestored = $reader->restore($noRecoveryPackage, $noRecoveryDestination, $password, $pattern); - stableAssert($noRecoveryRestored['files'] === 3, 'No-recovery package did not restore.'); - - echo "Stable binary package round-trip and adversarial tests passed.\n"; -} finally { - $password = ''; - $pattern = ''; - $wrongPassword = ''; - $wrongPattern = ''; - stableRemoveTree($root); -} -, 'COM¹.log', 'LPT²', 'trailing.', 'trailing ', "nul\0name", 'a/b', 'a\\b'] as $unsafeName) { + foreach (['../escape', 'CON', 'CON .txt', 'CLOCK' . chr(36), 'COM¹.log', 'LPT²', 'trailing.', 'trailing ', "nul\0name", 'a/b', 'a\\b'] as $unsafeName) { stableExpectFailure(fn() => Format::assertName($unsafeName), 'Unsafe package name was accepted: ' . $unsafeName); } From d6766a5fe92479218e86ee7e72ad4fee3c304984 Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 23:54:22 +0330 Subject: [PATCH 32/66] test(format): cover one-byte and exact-chunk file boundaries --- tests/StablePackageFormatTest.php | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/StablePackageFormatTest.php b/tests/StablePackageFormatTest.php index cfe932e8..fcbb96fe 100644 --- a/tests/StablePackageFormatTest.php +++ b/tests/StablePackageFormatTest.php @@ -78,12 +78,14 @@ function stableRemoveTree(string $path): void mkdir($source . DIRECTORY_SEPARATOR . 'nested', 0700, true); file_put_contents($source . DIRECTORY_SEPARATOR . 'hello.txt', "stable-format\n"); file_put_contents($source . DIRECTORY_SEPARATOR . 'empty.bin', ''); +file_put_contents($source . DIRECTORY_SEPARATOR . 'one-byte.bin', "x"); +file_put_contents($source . DIRECTORY_SEPARATOR . 'exact-chunk.bin', random_bytes(Format::CHUNK_BYTES)); file_put_contents($source . DIRECTORY_SEPARATOR . 'nested' . DIRECTORY_SEPARATOR . 'binary.dat', random_bytes(Format::CHUNK_BYTES + 137)); try { $build = (new StablePackageWriter())->build($source, $package, $password, $pattern, str_repeat('a', 48), true); stableAssert($build['package_id'] === str_repeat('a', 48), 'Package id was not preserved.'); - stableAssert($build['files'] === 3 && $build['directories'] === 1 && $build['nodes'] === 4, 'Built statistics are invalid.'); + stableAssert($build['files'] === 5 && $build['directories'] === 1 && $build['nodes'] === 6, 'Built statistics are invalid.'); stableAssert(is_string($build['recovery_key']) && strlen($build['recovery_key']) === 43, 'Recovery key was not emitted as canonical base64url.'); $raw = file_get_contents($package); @@ -152,8 +154,8 @@ function stableRemoveTree(string $path): void $password, $pattern, str_repeat('0', 48), false), 'All-zero package identifier was accepted by the writer.'); stableAssert(!file_exists($zeroIdPackage), 'All-zero package identifier produced an output file.'); $restored = $reader->restore($package, $restore, $password, $pattern); - stableAssert($restored['files'] === 3 && $restored['directories'] === 1, 'Round-trip statistics are incorrect.'); - foreach (['hello.txt', 'empty.bin', 'nested/binary.dat'] as $relative) { + stableAssert($restored['files'] === 5 && $restored['directories'] === 1, 'Round-trip statistics are incorrect.'); + foreach (['hello.txt', 'empty.bin', 'one-byte.bin', 'exact-chunk.bin', 'nested/binary.dat'] as $relative) { $expected = hash_file('sha256', $source . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relative)); $actual = hash_file('sha256', $restore . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relative)); stableAssert(is_string($expected) && is_string($actual) && hash_equals($expected, $actual), 'Round-trip mismatch: ' . $relative); @@ -161,7 +163,7 @@ function stableRemoveTree(string $path): void $recoveryDestination = $root . DIRECTORY_SEPARATOR . 'recovery-restore'; $recovered = $reader->restore($package, $recoveryDestination, '', '', $build['recovery_key']); - stableAssert($recovered['files'] === 3, 'Recovery-key restore failed.'); + stableAssert($recovered['files'] === 5, 'Recovery-key restore failed.'); stableAssert(hash_file('sha256', $source . '/nested/binary.dat') === hash_file('sha256', $recoveryDestination . '/nested/binary.dat'), 'Recovery restore content mismatch.'); stableExpectFailure(fn() => $reader->restore($package, $root . DIRECTORY_SEPARATOR . 'wrong-recovery', @@ -336,7 +338,7 @@ function stableRemoveTree(string $path): void stableAssert(Format::isZeroBytes(substr((string) $noRecoveryBytes, 149, Format::SLOT_BYTES)), 'Disabled recovery slot is not all-zero.'); $noRecoveryDestination = $root . DIRECTORY_SEPARATOR . 'no-recovery-restore'; $noRecoveryRestored = $reader->restore($noRecoveryPackage, $noRecoveryDestination, $password, $pattern); - stableAssert($noRecoveryRestored['files'] === 3, 'No-recovery package did not restore.'); + stableAssert($noRecoveryRestored['files'] === 5, 'No-recovery package did not restore.'); echo "Stable binary package round-trip and adversarial tests passed.\n"; } finally { From 754541b74d42fc5e90ee467b810f3a2ecf2f9164 Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 23:55:23 +0330 Subject: [PATCH 33/66] test(format): reject source symlinks when supported by host --- tests/StablePackageFormatTest.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/StablePackageFormatTest.php b/tests/StablePackageFormatTest.php index fcbb96fe..0f3814c8 100644 --- a/tests/StablePackageFormatTest.php +++ b/tests/StablePackageFormatTest.php @@ -340,6 +340,16 @@ function stableRemoveTree(string $path): void $noRecoveryRestored = $reader->restore($noRecoveryPackage, $noRecoveryDestination, $password, $pattern); stableAssert($noRecoveryRestored['files'] === 5, 'No-recovery package did not restore.'); + // Exercise symlink rejection where the host permits creating a test symlink. + $sourceLink = $source . DIRECTORY_SEPARATOR . 'nested' . DIRECTORY_SEPARATOR . 'source-link'; + if (@symlink($source . DIRECTORY_SEPARATOR . 'hello.txt', $sourceLink)) { + $symlinkPackage = $root . DIRECTORY_SEPARATOR . 'symlink-source.spkg'; + stableExpectFailure(fn() => (new StablePackageWriter())->build($source, $symlinkPackage, + $password, $pattern, str_repeat('d', 48), false), 'Source symlink was accepted.'); + stableAssert(!file_exists($symlinkPackage), 'Source symlink produced a package.'); + if (!@unlink($sourceLink)) throw new RuntimeException('Unable to remove test symlink.'); + } + echo "Stable binary package round-trip and adversarial tests passed.\n"; } finally { $password = ''; From 1f15b9dd44e9c9ff06de5e6ed500dd7758a489ec Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 23:55:49 +0330 Subject: [PATCH 34/66] test(format): assert tampered restores clean private staging data --- tests/StablePackageFormatTest.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/StablePackageFormatTest.php b/tests/StablePackageFormatTest.php index 0f3814c8..6f4e5033 100644 --- a/tests/StablePackageFormatTest.php +++ b/tests/StablePackageFormatTest.php @@ -239,8 +239,13 @@ function stableRemoveTree(string $path): void $tamperedChunk = $root . DIRECTORY_SEPARATOR . 'tampered-chunk.spkg'; stableCopy($package, $tamperedChunk); stableMutateRecord($tamperedChunk, Format::RECORD_FILE_CHUNK); + $stagePattern = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'spkg-stage-*'; + $stagesBeforeTamper = glob($stagePattern) ?: []; stableExpectFailure(fn() => $reader->restore($tamperedChunk, $root . DIRECTORY_SEPARATOR . 'bad-chunk-restore', $password, $pattern), 'Tampered file chunk was accepted.'); stableAssert(!file_exists($root . DIRECTORY_SEPARATOR . 'bad-chunk-restore'), 'Tampered chunk left restored data behind.'); + $stagesAfterTamper = glob($stagePattern) ?: []; + $newStages = array_values(array_diff($stagesAfterTamper, $stagesBeforeTamper)); + stableAssert($newStages === [], 'Tampered package left a new private plaintext staging directory behind.'); $tamperedManifest = $root . DIRECTORY_SEPARATOR . 'tampered-manifest.spkg'; stableCopy($package, $tamperedManifest); From 34da069c96b0c6ed58de13ce033b14f41b654204 Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 23:56:09 +0330 Subject: [PATCH 35/66] docs(format): document visible package metadata and size leakage --- docs/STABLE-PACKAGE-FORMAT-BINARY.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/STABLE-PACKAGE-FORMAT-BINARY.md b/docs/STABLE-PACKAGE-FORMAT-BINARY.md index d9489a8d..79b4bac6 100644 --- a/docs/STABLE-PACKAGE-FORMAT-BINARY.md +++ b/docs/STABLE-PACKAGE-FORMAT-BINARY.md @@ -45,6 +45,11 @@ All cryptographic randomness MUST come from the operating system CSPRNG through ## 3. Fixed 253-byte header +### 3.0 Explicit metadata-leakage boundary + +This format encrypts the manifest (names, parent relationships and directory entries), but it does **not** hide traffic shape. The clear header exposes the fixed marker, algorithm identifiers, KDF cost, random package ID, and whether a recovery slot exists. Record boundaries and chunk lengths expose the number of file streams, approximate and often exact file sizes, total plaintext volume, and the package's total size. The fixed 4 MiB chunking does not prevent a final-chunk length from revealing the remaining bytes. This format does not claim padding-based size confidentiality. If hiding sizes or file counts is a requirement, that needs a separately designed and reviewed padding/framing scheme; it must not be improvised by changing this frozen grammar. + + There is exactly one 253-byte header. No padding, optional header extension, variable-length header, or footer follows it. Opaque magic bytes (hex): **D3 82 A1 4F C7 5E 90 6B**. From c16cc05c4319e7520166e9fd49360985bb89f37d Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 23:56:26 +0330 Subject: [PATCH 36/66] docs(format): place metadata-leakage limits with crypto profile --- docs/STABLE-PACKAGE-FORMAT-BINARY.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/STABLE-PACKAGE-FORMAT-BINARY.md b/docs/STABLE-PACKAGE-FORMAT-BINARY.md index 79b4bac6..83ecbbf1 100644 --- a/docs/STABLE-PACKAGE-FORMAT-BINARY.md +++ b/docs/STABLE-PACKAGE-FORMAT-BINARY.md @@ -43,12 +43,12 @@ Password and pattern are independently processed using Argon2id. KDF fields are All cryptographic randomness MUST come from the operating system CSPRNG through the cryptographic runtime. Failure of any required API MUST fail closed. -## 3. Fixed 253-byte header -### 3.0 Explicit metadata-leakage boundary +### Explicit metadata-leakage boundary This format encrypts the manifest (names, parent relationships and directory entries), but it does **not** hide traffic shape. The clear header exposes the fixed marker, algorithm identifiers, KDF cost, random package ID, and whether a recovery slot exists. Record boundaries and chunk lengths expose the number of file streams, approximate and often exact file sizes, total plaintext volume, and the package's total size. The fixed 4 MiB chunking does not prevent a final-chunk length from revealing the remaining bytes. This format does not claim padding-based size confidentiality. If hiding sizes or file counts is a requirement, that needs a separately designed and reviewed padding/framing scheme; it must not be improvised by changing this frozen grammar. +## 3. Fixed 253-byte header There is exactly one 253-byte header. No padding, optional header extension, variable-length header, or footer follows it. From 99f383a89f8546b275bc7cc044bae25c3fc044a7 Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 23:57:09 +0330 Subject: [PATCH 37/66] test(format): reject reordered multi-chunk file records --- tests/StablePackageFormatTest.php | 47 +++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/StablePackageFormatTest.php b/tests/StablePackageFormatTest.php index 6f4e5033..daf53c81 100644 --- a/tests/StablePackageFormatTest.php +++ b/tests/StablePackageFormatTest.php @@ -52,6 +52,47 @@ function stableMutateRecord(string $path, int $recordType, bool $lastByte = fals throw new RuntimeException('Requested record type was not found.'); } +function stableSwapMultiChunkRecords(string $path): void +{ + $bytes = file_get_contents($path); + if (!is_string($bytes) || strlen($bytes) < Format::HEADER_BYTES) { + throw new RuntimeException('Unable to read chunk-order fixture.'); + } + $offset = Format::HEADER_BYTES; + $total = strlen($bytes); + while ($offset + Format::RECORD_HEADER_BYTES <= $total) { + $type = ord($bytes[$offset]); + $length = unpack('Nvalue', substr($bytes, $offset + 1, 4))['value'] ?? 0; + $bodyOffset = $offset + Format::RECORD_HEADER_BYTES; + $end = $bodyOffset + $length; + if ($length < 1 || $end > $total) throw new RuntimeException('Chunk-order fixture record is malformed.'); + if ($type === Format::RECORD_FILE_START && $length === 48) { + $size = Format::unpackUint64(substr($bytes, $bodyOffset + 16, 8)); + if ($size > Format::CHUNK_BYTES) { + $firstStart = $end; + if ($firstStart + Format::RECORD_HEADER_BYTES > $total) throw new RuntimeException('First chunk is missing.'); + $firstLength = unpack('Nvalue', substr($bytes, $firstStart + 1, 4))['value'] ?? 0; + $secondStart = $firstStart + Format::RECORD_HEADER_BYTES + $firstLength; + if ($firstLength < 1 || $secondStart + Format::RECORD_HEADER_BYTES > $total) { + throw new RuntimeException('Second chunk is missing.'); + } + $secondLength = unpack('Nvalue', substr($bytes, $secondStart + 1, 4))['value'] ?? 0; + $secondEnd = $secondStart + Format::RECORD_HEADER_BYTES + $secondLength; + if ($secondLength < 1 || $secondEnd > $total) throw new RuntimeException('Second chunk is malformed.'); + $first = substr($bytes, $firstStart, Format::RECORD_HEADER_BYTES + $firstLength); + $second = substr($bytes, $secondStart, Format::RECORD_HEADER_BYTES + $secondLength); + $bytes = substr($bytes, 0, $firstStart) . $second . $first . substr($bytes, $secondEnd); + if (file_put_contents($path, $bytes, LOCK_EX) !== strlen($bytes)) { + throw new RuntimeException('Unable to write reordered chunk fixture.'); + } + return; + } + } + $offset = $end; + } + throw new RuntimeException('No multi-chunk file stream was found.'); +} + function stableRemoveTree(string $path): void { if (is_link($path) || is_file($path)) { @@ -236,6 +277,12 @@ function stableRemoveTree(string $path): void stableExpectFailure(fn() => $reader->restore($wrongChunkSequence, $root . DIRECTORY_SEPARATOR . 'bad-sequence-restore', $password, $pattern), 'Out-of-order chunk sequence was accepted.'); stableAssert(!file_exists($root . DIRECTORY_SEPARATOR . 'bad-sequence-restore'), 'Out-of-order chunk sequence left output behind.'); + $reorderedChunks = $root . DIRECTORY_SEPARATOR . 'reordered-chunks.spkg'; + stableCopy($package, $reorderedChunks); + stableSwapMultiChunkRecords($reorderedChunks); + stableExpectFailure(fn() => $reader->restore($reorderedChunks, $root . DIRECTORY_SEPARATOR . 'reordered-chunks-restore', $password, $pattern), 'Reordered file chunks were accepted.'); + stableAssert(!file_exists($root . DIRECTORY_SEPARATOR . 'reordered-chunks-restore'), 'Reordered chunks left restored output behind.'); + $tamperedChunk = $root . DIRECTORY_SEPARATOR . 'tampered-chunk.spkg'; stableCopy($package, $tamperedChunk); stableMutateRecord($tamperedChunk, Format::RECORD_FILE_CHUNK); From 4ad292f6503d03e4b927862d26d0fe1fcec8279c Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 23:57:20 +0330 Subject: [PATCH 38/66] test(format): reject packages without final manifest --- tests/StablePackageFormatTest.php | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/StablePackageFormatTest.php b/tests/StablePackageFormatTest.php index daf53c81..59f207b8 100644 --- a/tests/StablePackageFormatTest.php +++ b/tests/StablePackageFormatTest.php @@ -93,6 +93,31 @@ function stableSwapMultiChunkRecords(string $path): void throw new RuntimeException('No multi-chunk file stream was found.'); } +function stableRemoveFinalManifest(string $path): void +{ + $bytes = file_get_contents($path); + if (!is_string($bytes) || strlen($bytes) < Format::HEADER_BYTES) { + throw new RuntimeException('Unable to read missing-manifest fixture.'); + } + $offset = Format::HEADER_BYTES; + $total = strlen($bytes); + while ($offset + Format::RECORD_HEADER_BYTES <= $total) { + $type = ord($bytes[$offset]); + $length = unpack('Nvalue', substr($bytes, $offset + 1, 4))['value'] ?? 0; + $end = $offset + Format::RECORD_HEADER_BYTES + $length; + if ($length < 1 || $end > $total) throw new RuntimeException('Missing-manifest fixture record is malformed.'); + if ($type === Format::RECORD_MANIFEST) { + if ($end !== $total) throw new RuntimeException('Manifest was not the final record in the fixture.'); + if (file_put_contents($path, substr($bytes, 0, $offset), LOCK_EX) !== $offset) { + throw new RuntimeException('Unable to remove final manifest fixture.'); + } + return; + } + $offset = $end; + } + throw new RuntimeException('Final manifest record was not found.'); +} + function stableRemoveTree(string $path): void { if (is_link($path) || is_file($path)) { @@ -300,6 +325,12 @@ function stableRemoveTree(string $path): void stableExpectFailure(fn() => $reader->restore($tamperedManifest, $root . DIRECTORY_SEPARATOR . 'bad-manifest-restore', $password, $pattern), 'Tampered manifest was accepted.'); stableAssert(!file_exists($root . DIRECTORY_SEPARATOR . 'bad-manifest-restore'), 'Tampered manifest left restored data behind.'); + $missingManifest = $root . DIRECTORY_SEPARATOR . 'missing-manifest.spkg'; + stableCopy($package, $missingManifest); + stableRemoveFinalManifest($missingManifest); + stableExpectFailure(fn() => $reader->restore($missingManifest, $root . DIRECTORY_SEPARATOR . 'missing-manifest-restore', $password, $pattern), 'Package without a final manifest was accepted.'); + stableAssert(!file_exists($root . DIRECTORY_SEPARATOR . 'missing-manifest-restore'), 'Missing manifest left restored output behind.'); + $truncated = $root . DIRECTORY_SEPARATOR . 'truncated.spkg'; stableCopy($package, $truncated); $size = filesize($truncated); From 59b66dd73ec07f8dfdced28f01a9a8a69b2119f7 Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 23:58:26 +0330 Subject: [PATCH 39/66] fix(format): bound manifest and entry counts for shared-hosting memory --- app/Project/StablePackageDescriptor.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Project/StablePackageDescriptor.php b/app/Project/StablePackageDescriptor.php index 1eefbdbc..a2e8011c 100644 --- a/app/Project/StablePackageDescriptor.php +++ b/app/Project/StablePackageDescriptor.php @@ -51,11 +51,11 @@ final class StablePackageDescriptor public const MAX_KDF_MEM_BYTES = 268_435_456; // 256 MiB public const MAX_FILE_CHUNK_RECORD_BYTES = self::CHUNK_BYTES + self::FILE_ID_BYTES + 4 + 17; - public const MAX_MANIFEST_BYTES = 67_108_864; // 64 MiB plaintext + public const MAX_MANIFEST_BYTES = 8_388_608; // 8 MiB plaintext; bounded for ordinary PHP memory limits public const MAX_MANIFEST_RECORD_BYTES = self::MAX_MANIFEST_BYTES + self::NONCE_BYTES + self::AEAD_TAG_BYTES; // Conservative upper bound for plaintext bytes plus worst-case per-record framing and manifest. public const MAX_PACKAGE_BYTES = 107_452_366_757; // 100 GiB data + bounded framing/manifest overhead - public const MAX_NODES = 100_000; + public const MAX_NODES = 10_000; // conservative to bound PHP array/graph-validation memory public const MAX_FILE_BYTES = 17_179_869_184; // 16 GiB public const MAX_TOTAL_PLAIN_BYTES = 107_374_182_400; // 100 GiB public const MAX_FILENAME_BYTES = 255; From 83367577f6593fa201f7aeef79c9033b92f642c1 Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 23:58:30 +0330 Subject: [PATCH 40/66] docs(format): freeze memory-aware manifest and entry limits --- docs/STABLE-PACKAGE-FORMAT-BINARY.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/STABLE-PACKAGE-FORMAT-BINARY.md b/docs/STABLE-PACKAGE-FORMAT-BINARY.md index 83ecbbf1..756bd45a 100644 --- a/docs/STABLE-PACKAGE-FORMAT-BINARY.md +++ b/docs/STABLE-PACKAGE-FORMAT-BINARY.md @@ -33,8 +33,8 @@ Header offsets, byte order, record values, key-derivation domains, path rules, a | Accepted KDF memory | 64 MiB through 256 MiB inclusive, aligned to 1024 bytes | | Maximum single file | 16 GiB | | Maximum total plaintext | 100 GiB | -| Maximum manifest plaintext | 64 MiB | -| Maximum entries | 100,000 | +| Maximum manifest plaintext | 8 MiB | +| Maximum entries | 10,000 | | Maximum filename component | 255 UTF-8 bytes | | Maximum relative path | 4096 UTF-8 bytes | | Maximum nesting | 256 components | @@ -158,7 +158,7 @@ The body is: 'manifest_nonce[24] || manifest_ciphertext_and_tag'. -Manifest plaintext is at most 67,108,864 bytes. The encrypted body is plaintext length plus 40 bytes (24-byte nonce plus 16-byte AEAD tag); the maximum body is 67,108,904 bytes. +Manifest plaintext is at most 8,388,608 bytes. The encrypted body is plaintext length plus 40 bytes (24-byte nonce plus 16-byte AEAD tag); the maximum body is 8,388,648 bytes. 'manifest_key = HKDF-SHA-256(IKM=root_key, length=32, info=ASCII("SPKG/MANIFEST") || 0x00, salt=package_id)'. @@ -177,7 +177,7 @@ Every entry begins with: A regular-file entry appends 'plaintext_size:u64be' (at most 16 GiB) and 'chunk_count:u32be'. Chunk count MUST equal 'max(1, ceil(plaintext_size / 4 MiB))'. Directory entries have no suffix. No version, schema, archive name, release tag, generation, or extension fields exist in the grammar. All names and tree structure remain encrypted because the full manifest is AEAD-encrypted. -Entry count is 1–100,000; at least one entry is a regular file. Total declared plaintext is at most 100 GiB. Readers MUST reject unknown entry types, duplicate IDs, truncated fields, trailing data, invalid UTF-8, invalid names, invalid sizes/counts, missing parents, non-directory parents, cycles, excessive depth, paths above 4096 bytes, and colliding siblings. +Entry count is 1–10,000; at least one entry is a regular file. Total declared plaintext is at most 100 GiB. Readers MUST reject unknown entry types, duplicate IDs, truncated fields, trailing data, invalid UTF-8, invalid names, invalid sizes/counts, missing parents, non-directory parents, cycles, excessive depth, paths above 4096 bytes, and colliding siblings. ### 7.2 Canonical manifest order and path graph From 1482e2272641f1307da4c0453c36edbf3808f13d Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 23:58:33 +0330 Subject: [PATCH 41/66] test(format): enforce fixed manifest entry-count ceiling --- tests/StablePackageFormatTest.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/StablePackageFormatTest.php b/tests/StablePackageFormatTest.php index 59f207b8..9d7ad96c 100644 --- a/tests/StablePackageFormatTest.php +++ b/tests/StablePackageFormatTest.php @@ -355,6 +355,9 @@ function stableRemoveTree(string $path): void stableExpectFailure(fn() => $reader->restore($unsafeKdf, $root . DIRECTORY_SEPARATOR . 'unsafe-kdf-restore', $password, $pattern), 'Out-of-range KDF parameters were accepted.'); stableAssert(!file_exists($root . DIRECTORY_SEPARATOR . 'unsafe-kdf-restore'), 'Unsafe KDF header left output behind.'); + stableExpectFailure(fn() => Format::decodeManifest(pack('N', Format::MAX_NODES + 1)), + 'Manifest entry count above the permanent limit was accepted.'); + $manifestProbe = Format::decodeManifest(Format::encodeManifest([ ['type' => 2, 'id' => random_bytes(16), 'parent_id' => Format::zeroId(), 'name' => 'only.bin', 'size' => 0, 'chunks' => 1], ])); From 68617a51e014d3db1192970df58e084b18b9939e Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 23:58:53 +0330 Subject: [PATCH 42/66] fix(format): cap encrypted manifest at four MiB --- app/Project/StablePackageDescriptor.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Project/StablePackageDescriptor.php b/app/Project/StablePackageDescriptor.php index a2e8011c..7e7bbacd 100644 --- a/app/Project/StablePackageDescriptor.php +++ b/app/Project/StablePackageDescriptor.php @@ -51,7 +51,7 @@ final class StablePackageDescriptor public const MAX_KDF_MEM_BYTES = 268_435_456; // 256 MiB public const MAX_FILE_CHUNK_RECORD_BYTES = self::CHUNK_BYTES + self::FILE_ID_BYTES + 4 + 17; - public const MAX_MANIFEST_BYTES = 8_388_608; // 8 MiB plaintext; bounded for ordinary PHP memory limits + public const MAX_MANIFEST_BYTES = 4_194_304; // 4 MiB; above the grammar's maximum for 10,000 entries public const MAX_MANIFEST_RECORD_BYTES = self::MAX_MANIFEST_BYTES + self::NONCE_BYTES + self::AEAD_TAG_BYTES; // Conservative upper bound for plaintext bytes plus worst-case per-record framing and manifest. public const MAX_PACKAGE_BYTES = 107_452_366_757; // 100 GiB data + bounded framing/manifest overhead From 35297642a602f1e1c833d1a4d2ddf9f7a877df9f Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 23:58:57 +0330 Subject: [PATCH 43/66] docs(format): freeze four-MiB manifest ceiling --- docs/STABLE-PACKAGE-FORMAT-BINARY.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/STABLE-PACKAGE-FORMAT-BINARY.md b/docs/STABLE-PACKAGE-FORMAT-BINARY.md index 756bd45a..0a77ca46 100644 --- a/docs/STABLE-PACKAGE-FORMAT-BINARY.md +++ b/docs/STABLE-PACKAGE-FORMAT-BINARY.md @@ -33,7 +33,7 @@ Header offsets, byte order, record values, key-derivation domains, path rules, a | Accepted KDF memory | 64 MiB through 256 MiB inclusive, aligned to 1024 bytes | | Maximum single file | 16 GiB | | Maximum total plaintext | 100 GiB | -| Maximum manifest plaintext | 8 MiB | +| Maximum manifest plaintext | 4 MiB | | Maximum entries | 10,000 | | Maximum filename component | 255 UTF-8 bytes | | Maximum relative path | 4096 UTF-8 bytes | @@ -158,7 +158,7 @@ The body is: 'manifest_nonce[24] || manifest_ciphertext_and_tag'. -Manifest plaintext is at most 8,388,608 bytes. The encrypted body is plaintext length plus 40 bytes (24-byte nonce plus 16-byte AEAD tag); the maximum body is 8,388,648 bytes. +Manifest plaintext is at most 4,194,304 bytes. The encrypted body is plaintext length plus 40 bytes (24-byte nonce plus 16-byte AEAD tag); the maximum body is 4,194,344 bytes. 'manifest_key = HKDF-SHA-256(IKM=root_key, length=32, info=ASCII("SPKG/MANIFEST") || 0x00, salt=package_id)'. From 9e2ac0b2461e150285fb9fa001610da7f0dfbdb4 Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 23:59:20 +0330 Subject: [PATCH 44/66] test(format): cover file, aggregate-size and depth limits --- tests/StablePackageFormatTest.php | 37 +++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/StablePackageFormatTest.php b/tests/StablePackageFormatTest.php index 9d7ad96c..bff3a398 100644 --- a/tests/StablePackageFormatTest.php +++ b/tests/StablePackageFormatTest.php @@ -375,6 +375,43 @@ function stableRemoveTree(string $path): void ]); stableExpectFailure(fn() => Format::decodeManifest($cycleManifest), 'Manifest directory cycle was accepted.'); + stableExpectFailure(fn() => Format::encodeManifest([ + ['type' => 2, 'id' => random_bytes(16), 'parent_id' => Format::zeroId(), + 'name' => 'oversized.bin', 'size' => Format::MAX_FILE_BYTES + 1, 'chunks' => 1], + ]), 'File size above the fixed per-file limit was accepted.'); + + stableExpectFailure(fn() => Format::encodeManifest([ + ['type' => 2, 'id' => random_bytes(16), 'parent_id' => Format::zeroId(), + 'name' => 'wrong-chunks.bin', 'size' => Format::CHUNK_BYTES + 1, 'chunks' => 1], + ]), 'Inconsistent manifest chunk count was accepted.'); + + $oversizedTotalEntries = []; + for ($i = 0; $i < 7; $i++) { + $oversizedTotalEntries[] = [ + 'type' => 2, + 'id' => random_bytes(16), + 'parent_id' => Format::zeroId(), + 'name' => sprintf('large-%02d.bin', $i), + 'size' => Format::MAX_FILE_BYTES, + 'chunks' => Format::expectedChunkCount(Format::MAX_FILE_BYTES), + ]; + } + $oversizedTotalManifest = Format::encodeManifest($oversizedTotalEntries); + stableExpectFailure(fn() => Format::decodeManifest($oversizedTotalManifest), + 'Manifest exceeding the total plaintext limit was accepted.'); + + $deepEntries = []; + $parentId = Format::zeroId(); + for ($depthIndex = 0; $depthIndex < Format::MAX_DEPTH; $depthIndex++) { + $directoryId = random_bytes(16); + $deepEntries[] = ['type' => 1, 'id' => $directoryId, 'parent_id' => $parentId, 'name' => 'a']; + $parentId = $directoryId; + } + $deepEntries[] = ['type' => 2, 'id' => random_bytes(16), 'parent_id' => $parentId, + 'name' => 'x', 'size' => 0, 'chunks' => 1]; + stableExpectFailure(fn() => Format::decodeManifest(Format::encodeManifest($deepEntries)), + 'Manifest exceeding the maximum path depth was accepted.'); + $duplicateId = random_bytes(16); $duplicateIdManifest = Format::encodeManifest([ ['type' => 2, 'id' => $duplicateId, 'parent_id' => Format::zeroId(), 'name' => 'a.bin', 'size' => 0, 'chunks' => 1], From 0ff9b9de75f2330e8aea5629360ca6d7b720c26d Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Sun, 25 Oct 2026 23:59:54 +0330 Subject: [PATCH 45/66] fix(format): enforce path depth after memoized graph resolution --- app/Project/StablePackageDescriptor.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/Project/StablePackageDescriptor.php b/app/Project/StablePackageDescriptor.php index 7e7bbacd..1e809c17 100644 --- a/app/Project/StablePackageDescriptor.php +++ b/app/Project/StablePackageDescriptor.php @@ -406,8 +406,12 @@ public static function decodeManifest(string $plain): array throw new RuntimeException('Manifest entries are not in canonical path order.'); } $previousPath = $path; + $entryDepth = substr_count($path, '/') + 1; + if ($entryDepth > self::MAX_DEPTH) { + throw new RuntimeException('Manifest directory depth exceeds the supported limit.'); + } $entry['path'] = $path; - $entry['depth'] = substr_count($path, '/') + 1; + $entry['depth'] = $entryDepth; } unset($entry); From c8f3da5a53f379225272292b44f95f9a44f03712 Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Mon, 26 Oct 2026 00:00:07 +0330 Subject: [PATCH 46/66] test(format): enforce resolved path length ceiling --- tests/StablePackageFormatTest.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/StablePackageFormatTest.php b/tests/StablePackageFormatTest.php index bff3a398..f5486430 100644 --- a/tests/StablePackageFormatTest.php +++ b/tests/StablePackageFormatTest.php @@ -412,6 +412,19 @@ function stableRemoveTree(string $path): void stableExpectFailure(fn() => Format::decodeManifest(Format::encodeManifest($deepEntries)), 'Manifest exceeding the maximum path depth was accepted.'); + $longPathEntries = []; + $parentId = Format::zeroId(); + $longComponent = str_repeat('x', Format::MAX_FILENAME_BYTES); + for ($pathIndex = 0; $pathIndex < 17; $pathIndex++) { + $directoryId = random_bytes(16); + $longPathEntries[] = ['type' => 1, 'id' => $directoryId, 'parent_id' => $parentId, 'name' => $longComponent]; + $parentId = $directoryId; + } + $longPathEntries[] = ['type' => 2, 'id' => random_bytes(16), 'parent_id' => $parentId, + 'name' => 'f', 'size' => 0, 'chunks' => 1]; + stableExpectFailure(fn() => Format::decodeManifest(Format::encodeManifest($longPathEntries)), + 'Manifest exceeding the maximum resolved path length was accepted.'); + $duplicateId = random_bytes(16); $duplicateIdManifest = Format::encodeManifest([ ['type' => 2, 'id' => $duplicateId, 'parent_id' => Format::zeroId(), 'name' => 'a.bin', 'size' => 0, 'chunks' => 1], From afaa7eb14e3be1746c3e87fea7ea979649433853 Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Mon, 26 Oct 2026 00:00:55 +0330 Subject: [PATCH 47/66] test(format): preserve empty directories in stable packages --- tests/StablePackageFormatTest.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/StablePackageFormatTest.php b/tests/StablePackageFormatTest.php index f5486430..12a0765a 100644 --- a/tests/StablePackageFormatTest.php +++ b/tests/StablePackageFormatTest.php @@ -142,6 +142,7 @@ function stableRemoveTree(string $path): void $wrongPattern = 'Qz!71_xYp#42Qw9'; mkdir($source . DIRECTORY_SEPARATOR . 'nested', 0700, true); +mkdir($source . DIRECTORY_SEPARATOR . 'empty-dir', 0700); file_put_contents($source . DIRECTORY_SEPARATOR . 'hello.txt', "stable-format\n"); file_put_contents($source . DIRECTORY_SEPARATOR . 'empty.bin', ''); file_put_contents($source . DIRECTORY_SEPARATOR . 'one-byte.bin', "x"); @@ -151,7 +152,7 @@ function stableRemoveTree(string $path): void try { $build = (new StablePackageWriter())->build($source, $package, $password, $pattern, str_repeat('a', 48), true); stableAssert($build['package_id'] === str_repeat('a', 48), 'Package id was not preserved.'); - stableAssert($build['files'] === 5 && $build['directories'] === 1 && $build['nodes'] === 6, 'Built statistics are invalid.'); + stableAssert($build['files'] === 5 && $build['directories'] === 2 && $build['nodes'] === 7, 'Built statistics are invalid.'); stableAssert(is_string($build['recovery_key']) && strlen($build['recovery_key']) === 43, 'Recovery key was not emitted as canonical base64url.'); $raw = file_get_contents($package); @@ -220,12 +221,14 @@ function stableRemoveTree(string $path): void $password, $pattern, str_repeat('0', 48), false), 'All-zero package identifier was accepted by the writer.'); stableAssert(!file_exists($zeroIdPackage), 'All-zero package identifier produced an output file.'); $restored = $reader->restore($package, $restore, $password, $pattern); - stableAssert($restored['files'] === 5 && $restored['directories'] === 1, 'Round-trip statistics are incorrect.'); + stableAssert($restored['files'] === 5 && $restored['directories'] === 2, 'Round-trip statistics are incorrect.'); foreach (['hello.txt', 'empty.bin', 'one-byte.bin', 'exact-chunk.bin', 'nested/binary.dat'] as $relative) { $expected = hash_file('sha256', $source . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relative)); $actual = hash_file('sha256', $restore . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relative)); stableAssert(is_string($expected) && is_string($actual) && hash_equals($expected, $actual), 'Round-trip mismatch: ' . $relative); } + stableAssert(is_dir($restore . DIRECTORY_SEPARATOR . 'empty-dir'), + 'Empty directory was not preserved by the manifest and restore.'); $recoveryDestination = $root . DIRECTORY_SEPARATOR . 'recovery-restore'; $recovered = $reader->restore($package, $recoveryDestination, '', '', $build['recovery_key']); From 1660b3f6c560a79d30755b402797a92bdd96b9a4 Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Mon, 26 Oct 2026 00:01:24 +0330 Subject: [PATCH 48/66] docs(format): clarify package identifier generation contract --- docs/STABLE-PACKAGE-FORMAT-BINARY.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/STABLE-PACKAGE-FORMAT-BINARY.md b/docs/STABLE-PACKAGE-FORMAT-BINARY.md index 0a77ca46..8657a63e 100644 --- a/docs/STABLE-PACKAGE-FORMAT-BINARY.md +++ b/docs/STABLE-PACKAGE-FORMAT-BINARY.md @@ -46,7 +46,7 @@ All cryptographic randomness MUST come from the operating system CSPRNG through ### Explicit metadata-leakage boundary -This format encrypts the manifest (names, parent relationships and directory entries), but it does **not** hide traffic shape. The clear header exposes the fixed marker, algorithm identifiers, KDF cost, random package ID, and whether a recovery slot exists. Record boundaries and chunk lengths expose the number of file streams, approximate and often exact file sizes, total plaintext volume, and the package's total size. The fixed 4 MiB chunking does not prevent a final-chunk length from revealing the remaining bytes. This format does not claim padding-based size confidentiality. If hiding sizes or file counts is a requirement, that needs a separately designed and reviewed padding/framing scheme; it must not be improvised by changing this frozen grammar. +This format encrypts the manifest (names, parent relationships and directory entries), but it does **not** hide traffic shape. The clear header exposes the fixed marker, algorithm identifiers, KDF cost, package identifier, and whether a recovery slot exists. Record boundaries and chunk lengths expose the number of file streams, approximate and often exact file sizes, total plaintext volume, and the package's total size. The fixed 4 MiB chunking does not prevent a final-chunk length from revealing the remaining bytes. This format does not claim padding-based size confidentiality. If hiding sizes or file counts is a requirement, that needs a separately designed and reviewed padding/framing scheme; it must not be improvised by changing this frozen grammar. ## 3. Fixed 253-byte header @@ -64,7 +64,7 @@ Opaque magic bytes (hex): **D3 82 A1 4F C7 5E 90 6B**. | 12 | 1 | Hash ID | 0x01 = SHA-256 | | 13 | 4 | KDF ops | u32 big-endian, bounded before use | | 17 | 4 | KDF memory bytes | u32 big-endian, bounded before use | -| 21 | 24 | Package ID | random raw bytes, exposed outside package as 48 lowercase hex chars if needed | +| 21 | 24 | Package ID | opaque 24-byte identifier; random by default, or supplied by the integrating application; nonzero and not a secret | | 45 | 32 | Salt | random; reject all-zero | | 77 | 24 | Primary nonce | AEAD nonce | | 101 | 48 | Primary wrapped root key | 32-byte ciphertext plus 16-byte tag | From e5ca1c2ad3b48b983541f62d84b6523112627e3e Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Mon, 26 Oct 2026 00:01:41 +0330 Subject: [PATCH 49/66] docs(format): make AEAD key, nonce, plaintext and AAD roles explicit --- docs/STABLE-PACKAGE-FORMAT-BINARY.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/STABLE-PACKAGE-FORMAT-BINARY.md b/docs/STABLE-PACKAGE-FORMAT-BINARY.md index 8657a63e..13d55fc4 100644 --- a/docs/STABLE-PACKAGE-FORMAT-BINARY.md +++ b/docs/STABLE-PACKAGE-FORMAT-BINARY.md @@ -92,8 +92,8 @@ The caller-supplied password and pattern are validated UTF-8 byte strings. They The writer generates a random 32-byte 'root_key'. -- 'primary_cipher = XChaCha20-Poly1305.Encrypt(root_key, primary_nonce, credential_key, AAD=ASCII("SPKG/KEYWRAP/PRIMARY") || 0x00 || core)'. -- If recovery is enabled, 'recovery_cipher = XChaCha20-Poly1305.Encrypt(root_key, recovery_nonce, recovery_key_raw, AAD=ASCII("SPKG/KEYWRAP/RECOVERY") || 0x00 || core)', where 'recovery_key_raw' is a random 32-byte secret. +- 'primary_cipher = AEAD_Encrypt(key=credential_key, nonce=primary_nonce, plaintext=root_key, aad=ASCII("SPKG/KEYWRAP/PRIMARY") || 0x00 || core)', using XChaCha20-Poly1305-IETF. +- If recovery is enabled, 'recovery_cipher = AEAD_Encrypt(key=recovery_key_raw, nonce=recovery_nonce, plaintext=root_key, aad=ASCII("SPKG/KEYWRAP/RECOVERY") || 0x00 || core)', using XChaCha20-Poly1305-IETF, where 'recovery_key_raw' is a random 32-byte secret. - 'header_mac_key = HKDF-SHA-256(IKM=root_key, length=32, info=ASCII("SPKG/HEADER-MAC") || 0x00, salt=package_id)'. - 'header_mac = HMAC-SHA-256(header_mac_key, header_without_mac)'. @@ -162,6 +162,8 @@ Manifest plaintext is at most 4,194,304 bytes. The encrypted body is plaintext l 'manifest_key = HKDF-SHA-256(IKM=root_key, length=32, info=ASCII("SPKG/MANIFEST") || 0x00, salt=package_id)'. +'manifest_ciphertext_and_tag = AEAD_Encrypt(key=manifest_key, nonce=manifest_nonce, plaintext=manifest_plain, aad=ASCII("SPKG/MANIFEST") || 0x00 || core_hash)' using XChaCha20-Poly1305-IETF. + Manifest associated data is 'ASCII("SPKG/MANIFEST") || 0x00 || core_hash'. The nonce is fresh random 24-byte data. Manifest is custom binary, not JSON and not string-valued metadata. ### 7.1 Manifest grammar From 411960e3bced559b402253a958ed1e95a90e5dc1 Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Mon, 26 Oct 2026 00:02:00 +0330 Subject: [PATCH 50/66] fix(format): reject noncanonical zero stream and manifest nonces --- app/Project/StablePackageReader.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/Project/StablePackageReader.php b/app/Project/StablePackageReader.php index 919e9eae..d5579f86 100644 --- a/app/Project/StablePackageReader.php +++ b/app/Project/StablePackageReader.php @@ -88,7 +88,9 @@ public function restore( $fileIdHex = bin2hex($fileId); $size = StablePackageDescriptor::unpackUint64(substr($body, 16, 8)); $streamHeader = substr($body, 24, StablePackageDescriptor::STREAM_HEADER_BYTES); - if (StablePackageDescriptor::isZeroBytes($fileId) || isset($observedIds[$fileIdHex]) + if (StablePackageDescriptor::isZeroBytes($fileId) + || StablePackageDescriptor::isZeroBytes($streamHeader) + || isset($observedIds[$fileIdHex]) || $size > StablePackageDescriptor::MAX_FILE_BYTES) { throw new RuntimeException('Package file header is invalid.'); } @@ -209,6 +211,9 @@ public function restore( } $body = StablePackageDescriptor::readExact($input, (int) $bodyLength); $nonce = substr($body, 0, StablePackageDescriptor::NONCE_BYTES); + if (StablePackageDescriptor::isZeroBytes($nonce)) { + throw new RuntimeException('Manifest nonce is invalid.'); + } $cipher = substr($body, StablePackageDescriptor::NONCE_BYTES); $manifestPlain = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt( $cipher, From 53ab327debf93ea3d63fd6df955ea0cb636380fd Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Mon, 26 Oct 2026 00:02:08 +0330 Subject: [PATCH 51/66] docs(format): specify canonical nonzero stream and manifest nonces --- docs/STABLE-PACKAGE-FORMAT-BINARY.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/STABLE-PACKAGE-FORMAT-BINARY.md b/docs/STABLE-PACKAGE-FORMAT-BINARY.md index 13d55fc4..5f0e24d3 100644 --- a/docs/STABLE-PACKAGE-FORMAT-BINARY.md +++ b/docs/STABLE-PACKAGE-FORMAT-BINARY.md @@ -128,7 +128,7 @@ The body is exactly 48 bytes: | 16 | 8 | declared plaintext size, u64 big-endian | | 24 | 24 | secretstream header | -The file ID is unique and nonzero. A size MUST be at most 16 GiB. No second file-start record may appear until the prior stream has supplied its authenticated final chunk. File streams MUST appear in canonical manifest file order, verified after manifest authentication. +The file ID is unique and nonzero; the secretstream header MUST not be all-zero. A size MUST be at most 16 GiB. No second file-start record may appear until the prior stream has supplied its authenticated final chunk. File streams MUST appear in canonical manifest file order, verified after manifest authentication. ## 6. Chunk record (type 0x02) @@ -164,7 +164,7 @@ Manifest plaintext is at most 4,194,304 bytes. The encrypted body is plaintext l 'manifest_ciphertext_and_tag = AEAD_Encrypt(key=manifest_key, nonce=manifest_nonce, plaintext=manifest_plain, aad=ASCII("SPKG/MANIFEST") || 0x00 || core_hash)' using XChaCha20-Poly1305-IETF. -Manifest associated data is 'ASCII("SPKG/MANIFEST") || 0x00 || core_hash'. The nonce is fresh random 24-byte data. Manifest is custom binary, not JSON and not string-valued metadata. +Manifest associated data is 'ASCII("SPKG/MANIFEST") || 0x00 || core_hash'. The nonce is fresh random 24-byte data and MUST not be all-zero. Manifest is custom binary, not JSON and not string-valued metadata. ### 7.1 Manifest grammar From 2c73b90909e093f8da40a59c263dfd6519c80600 Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Mon, 26 Oct 2026 00:02:19 +0330 Subject: [PATCH 52/66] test(format): reject all-zero secretstream headers --- tests/StablePackageFormatTest.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/StablePackageFormatTest.php b/tests/StablePackageFormatTest.php index 12a0765a..902bbe4e 100644 --- a/tests/StablePackageFormatTest.php +++ b/tests/StablePackageFormatTest.php @@ -289,6 +289,17 @@ function stableRemoveTree(string $path): void stableExpectFailure(fn() => $reader->restore($invalidLength, $root . DIRECTORY_SEPARATOR . 'invalid-length-restore', $password, $pattern), 'Oversized record length was accepted.'); stableAssert(!file_exists($root . DIRECTORY_SEPARATOR . 'invalid-length-restore'), 'Invalid record length left output behind.'); + $zeroStreamHeader = $root . DIRECTORY_SEPARATOR . 'zero-stream-header.spkg'; + stableCopy($package, $zeroStreamHeader); + $bytes = file_get_contents($zeroStreamHeader); + $streamHeaderOffset = Format::HEADER_BYTES + Format::RECORD_HEADER_BYTES + 16 + 8; + $bytes = substr((string) $bytes, 0, $streamHeaderOffset) + . str_repeat("\\0", Format::STREAM_HEADER_BYTES) + . substr((string) $bytes, $streamHeaderOffset + Format::STREAM_HEADER_BYTES); + file_put_contents($zeroStreamHeader, $bytes, LOCK_EX); + stableExpectFailure(fn() => $reader->restore($zeroStreamHeader, $root . DIRECTORY_SEPARATOR . 'zero-stream-header-restore', $password, $pattern), 'All-zero secretstream header was accepted.'); + stableAssert(!file_exists($root . DIRECTORY_SEPARATOR . 'zero-stream-header-restore'), 'All-zero secretstream header left output behind.'); + $tamperedFileStart = $root . DIRECTORY_SEPARATOR . 'tampered-file-start.spkg'; stableCopy($package, $tamperedFileStart); stableMutateRecord($tamperedFileStart, Format::RECORD_FILE_START); From 070dd28c3a146921cff7acbeba26d888cd4e0fd1 Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Mon, 26 Oct 2026 00:02:34 +0330 Subject: [PATCH 53/66] fix(test): write actual zero bytes into stream-header fixture --- tests/StablePackageFormatTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/StablePackageFormatTest.php b/tests/StablePackageFormatTest.php index 902bbe4e..931588f3 100644 --- a/tests/StablePackageFormatTest.php +++ b/tests/StablePackageFormatTest.php @@ -294,7 +294,7 @@ function stableRemoveTree(string $path): void $bytes = file_get_contents($zeroStreamHeader); $streamHeaderOffset = Format::HEADER_BYTES + Format::RECORD_HEADER_BYTES + 16 + 8; $bytes = substr((string) $bytes, 0, $streamHeaderOffset) - . str_repeat("\\0", Format::STREAM_HEADER_BYTES) + . str_repeat("\0", Format::STREAM_HEADER_BYTES) . substr((string) $bytes, $streamHeaderOffset + Format::STREAM_HEADER_BYTES); file_put_contents($zeroStreamHeader, $bytes, LOCK_EX); stableExpectFailure(fn() => $reader->restore($zeroStreamHeader, $root . DIRECTORY_SEPARATOR . 'zero-stream-header-restore', $password, $pattern), 'All-zero secretstream header was accepted.'); From 7aa97c52a455bffb556c0745c377b2b8f12bf37b Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Mon, 26 Oct 2026 00:03:16 +0330 Subject: [PATCH 54/66] fix(format): verify owned directory identities during restore --- app/Project/StablePackageReader.php | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/app/Project/StablePackageReader.php b/app/Project/StablePackageReader.php index d5579f86..6e5ca99e 100644 --- a/app/Project/StablePackageReader.php +++ b/app/Project/StablePackageReader.php @@ -358,16 +358,28 @@ private function extractValidatedFiles(array $entries, array $observedFiles, str } $createdRoot = true; $createdRootIdentity = $this->captureIdentity($destination); + if (!is_array($createdRootIdentity)) { + throw new RuntimeException('Unable to verify restore destination identity.'); + } + $ownedDirectories = [$destination => $createdRootIdentity]; @chmod($destination, 0700); $directories = array_values(array_filter($entries, static fn(array $entry): bool => $entry['type'] === 1)); usort($directories, static fn(array $a, array $b): int => ($a['depth'] <=> $b['depth']) ?: strcmp($a['path'], $b['path'])); foreach ($directories as $entry) { $target = $destination . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $entry['path']); - if (file_exists($target) || is_link($target) || !@mkdir($target, 0700)) { - throw new RuntimeException('Unable to create restored directory.'); + $parent = dirname($target); + $parentIdentity = $ownedDirectories[$parent] ?? null; + if (!is_dir($parent) || is_link($parent) || !$this->matchesIdentity($parent, $parentIdentity) + || file_exists($target) || is_link($target) || !@mkdir($target, 0700)) { + throw new RuntimeException('Unable to create restored directory safely.'); + } + $identity = $this->captureIdentity($target); + $createdDirectories[] = ['path' => $target, 'identity' => $identity]; + if (!is_array($identity)) { + throw new RuntimeException('Unable to verify restored directory identity.'); } - $createdDirectories[] = ['path' => $target, 'identity' => $this->captureIdentity($target)]; + $ownedDirectories[$target] = $identity; @chmod($target, 0700); } @@ -376,7 +388,9 @@ private function extractValidatedFiles(array $entries, array $observedFiles, str foreach ($files as $entry) { $target = $destination . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $entry['path']); $parent = dirname($target); - if (!is_dir($parent) || is_link($parent) || file_exists($target) || is_link($target)) { + $parentIdentity = $ownedDirectories[$parent] ?? null; + if (!is_dir($parent) || is_link($parent) || !$this->matchesIdentity($parent, $parentIdentity) + || file_exists($target) || is_link($target)) { throw new RuntimeException('Restored file destination is unsafe.'); } $source = $filesById[$entry['id_hex']] ?? null; From a8692b66f1dd04fe7286687b9f16fb52c224b5ca Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Mon, 26 Oct 2026 00:03:43 +0330 Subject: [PATCH 55/66] fix(format): verify restored file identity before and after copy --- app/Project/StablePackageReader.php | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/app/Project/StablePackageReader.php b/app/Project/StablePackageReader.php index 6e5ca99e..4f4186cc 100644 --- a/app/Project/StablePackageReader.php +++ b/app/Project/StablePackageReader.php @@ -405,7 +405,18 @@ private function extractValidatedFiles(array $entries, array $observedFiles, str // Never unlink $target here: fopen('xb') did not establish ownership if it failed. throw new RuntimeException('Unable to create restored file without overwriting.'); } - $createdFiles[] = ['path' => $target, 'identity' => $this->captureIdentity($target)]; + $targetIdentity = $this->captureIdentity($target); + $openedStat = fstat($out); + if (!is_array($targetIdentity) || !is_array($openedStat) + || ($targetIdentity['dev'] ?? null) !== (int) ($openedStat['dev'] ?? -1) + || ($targetIdentity['ino'] ?? null) !== (int) ($openedStat['ino'] ?? 0) + || ($targetIdentity['type'] ?? null) !== 0100000 + || (((int) ($openedStat['mode'] ?? 0) & 0170000) !== 0100000)) { + fclose($in); + fclose($out); + throw new RuntimeException('Restored file identity could not be verified.'); + } + $createdFiles[] = ['path' => $target, 'identity' => $targetIdentity]; @chmod($target, 0600); try { $copied = stream_copy_to_stream($in, $out); @@ -422,6 +433,9 @@ private function extractValidatedFiles(array $entries, array $observedFiles, str // file_exists() is checked before creation; clear PHP's negative stat cache // before verifying the newly written output, especially for empty files. clearstatcache(true, $target); + if (is_link($target) || !$this->matchesIdentity($target, $targetIdentity) || !is_file($target)) { + throw new RuntimeException('Restored file identity changed during publication.'); + } $actualSize = filesize($target); if ($actualSize === false || (int) $actualSize !== $entry['size']) { throw new RuntimeException('Restored file size does not match the authenticated manifest.'); From c34c0a5b3f39dac989a56b1ba2878f07af88de9a Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Mon, 26 Oct 2026 00:04:07 +0330 Subject: [PATCH 56/66] fix(format): reject zero-valued recovery secrets --- app/Project/StablePackageCrypto.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/Project/StablePackageCrypto.php b/app/Project/StablePackageCrypto.php index 26cc45c0..473dddf9 100644 --- a/app/Project/StablePackageCrypto.php +++ b/app/Project/StablePackageCrypto.php @@ -66,7 +66,7 @@ public static function manifestAad(string $coreHash): string public static function encodeRecoveryKey(string $raw): string { - if (strlen($raw) !== StablePackageDescriptor::KEY_BYTES) { + if (strlen($raw) !== StablePackageDescriptor::KEY_BYTES || StablePackageDescriptor::isZeroBytes($raw)) { throw new RuntimeException('Invalid recovery key.'); } return rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); @@ -81,6 +81,7 @@ public static function decodeRecoveryKey(string $encoded): string $padded .= str_repeat('=', (4 - strlen($padded) % 4) % 4); $raw = base64_decode($padded, true); if (!is_string($raw) || strlen($raw) !== StablePackageDescriptor::KEY_BYTES + || StablePackageDescriptor::isZeroBytes($raw) || !hash_equals(self::encodeRecoveryKey($raw), $encoded)) { throw new RuntimeException('Unable to open package.'); } From 6fcfc93033b05d62c2dd95081e3e06d3fd5295cf Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Mon, 26 Oct 2026 00:04:13 +0330 Subject: [PATCH 57/66] docs(format): specify canonical nonzero recovery secrets --- docs/STABLE-PACKAGE-FORMAT-BINARY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/STABLE-PACKAGE-FORMAT-BINARY.md b/docs/STABLE-PACKAGE-FORMAT-BINARY.md index 5f0e24d3..efad2764 100644 --- a/docs/STABLE-PACKAGE-FORMAT-BINARY.md +++ b/docs/STABLE-PACKAGE-FORMAT-BINARY.md @@ -97,7 +97,7 @@ The writer generates a random 32-byte 'root_key'. - 'header_mac_key = HKDF-SHA-256(IKM=root_key, length=32, info=ASCII("SPKG/HEADER-MAC") || 0x00, salt=package_id)'. - 'header_mac = HMAC-SHA-256(header_mac_key, header_without_mac)'. -Each wrapped key is exactly 48 bytes. A reader unwraps the root key through one slot, then compares the header MAC using a constant-time comparison. No record may be processed until both steps succeed. This authenticates the unused slot as well. Recovery keys are encoded as canonical unpadded base64url outside the package; decode must reject noncanonical encodings and any result not exactly 32 bytes. A recovery key MUST fail if the recovery slot flag is clear. +Each wrapped key is exactly 48 bytes. A reader unwraps the root key through one slot, then compares the header MAC using a constant-time comparison. No record may be processed until both steps succeed. This authenticates the unused slot as well. Recovery keys are encoded as canonical unpadded base64url outside the package; decode MUST reject noncanonical encodings, an all-zero value, and any result not exactly 32 bytes. A recovery key MUST fail if the recovery slot flag is clear. ## 4. Record envelope and parser state machine From 448719255881b6909da4d8078035ee2356040499 Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Mon, 26 Oct 2026 00:04:34 +0330 Subject: [PATCH 58/66] fix(format): reject zero-valued unwrapped root keys --- app/Project/StablePackageCrypto.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/Project/StablePackageCrypto.php b/app/Project/StablePackageCrypto.php index 473dddf9..249e78d5 100644 --- a/app/Project/StablePackageCrypto.php +++ b/app/Project/StablePackageCrypto.php @@ -118,7 +118,8 @@ public static function unwrapRootKey(array $header, string $password, string $pa $credentialKey ); } - if (!is_string($rootKey) || strlen($rootKey) !== StablePackageDescriptor::KEY_BYTES) { + if (!is_string($rootKey) || strlen($rootKey) !== StablePackageDescriptor::KEY_BYTES + || StablePackageDescriptor::isZeroBytes($rootKey)) { throw new RuntimeException('Unable to open package.'); } $macKey = self::deriveSubkey($rootKey, 'HEADER-MAC', $header['package_id']); From 4336ef0435d4fe43613b8c23ef496ae5b80ee3ee Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Mon, 26 Oct 2026 00:07:04 +0330 Subject: [PATCH 59/66] docs(format): specify nonzero root-key canonicality --- docs/STABLE-PACKAGE-FORMAT-BINARY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/STABLE-PACKAGE-FORMAT-BINARY.md b/docs/STABLE-PACKAGE-FORMAT-BINARY.md index efad2764..72035ac3 100644 --- a/docs/STABLE-PACKAGE-FORMAT-BINARY.md +++ b/docs/STABLE-PACKAGE-FORMAT-BINARY.md @@ -90,7 +90,7 @@ The caller-supplied password and pattern are validated UTF-8 byte strings. They ### 3.2 Root-key wrapping and header authentication -The writer generates a random 32-byte 'root_key'. +The writer generates a random, nonzero 32-byte 'root_key'. Readers MUST reject an all-zero unwrapped root key. - 'primary_cipher = AEAD_Encrypt(key=credential_key, nonce=primary_nonce, plaintext=root_key, aad=ASCII("SPKG/KEYWRAP/PRIMARY") || 0x00 || core)', using XChaCha20-Poly1305-IETF. - If recovery is enabled, 'recovery_cipher = AEAD_Encrypt(key=recovery_key_raw, nonce=recovery_nonce, plaintext=root_key, aad=ASCII("SPKG/KEYWRAP/RECOVERY") || 0x00 || core)', using XChaCha20-Poly1305-IETF, where 'recovery_key_raw' is a random 32-byte secret. From 5260a99f2cf6bf1bceabb105836dad8eca40d6e8 Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Mon, 26 Oct 2026 01:46:13 +0330 Subject: [PATCH 60/66] fix(format): detect source-directory replacement during tree scan --- app/Project/StablePackageWriter.php | 79 ++++++++++++++++++++++------- 1 file changed, 60 insertions(+), 19 deletions(-) diff --git a/app/Project/StablePackageWriter.php b/app/Project/StablePackageWriter.php index 17dc322d..c843e28d 100644 --- a/app/Project/StablePackageWriter.php +++ b/app/Project/StablePackageWriter.php @@ -335,11 +335,42 @@ private function scanTree(string $root): array $seenIds = []; $nodeCount = 0; $totalBytes = 0; - $walk = function (string $directory, string $parentId, string $relative, int $depth) use (&$walk, &$entries, &$seenIds, &$nodeCount, &$totalBytes, $root): void { + $rootStat = @lstat($root); + if (!is_array($rootStat) || is_link($root) + || (((int) ($rootStat['mode'] ?? 0) & 0170000) !== 0040000)) { + throw new RuntimeException('Source root is not a stable directory.'); + } + $rootDev = (int) ($rootStat['dev'] ?? -1); + $rootIno = (int) ($rootStat['ino'] ?? 0); + $rootNormalized = str_replace('\\\\', '/', $root); + $rootPrefix = rtrim($rootNormalized, '/') . '/'; + $walk = function (string $directory, string $parentId, string $relative, int $depth, + int $expectedDev, int $expectedIno) use (&$walk, &$entries, &$seenIds, &$nodeCount, &$totalBytes, $root, $rootPrefix): void { + // PHP lacks a portable openat(O_NOFOLLOW) tree-walk API. Verify directory + // identity immediately before and after enumeration and before descending. + $beforeDir = @lstat($directory); + $realBefore = realpath($directory); + $realBeforeNormalized = $realBefore === false ? '' : str_replace('\\\\', '/', $realBefore); + if (!is_array($beforeDir) || is_link($directory) + || (((int) ($beforeDir['mode'] ?? 0) & 0170000) !== 0040000) + || (int) ($beforeDir['dev'] ?? -2) !== $expectedDev + || (int) ($beforeDir['ino'] ?? 0) !== $expectedIno + || $realBefore === false + || ($directory !== $root && !str_starts_with($realBeforeNormalized, $rootPrefix)) + || ($directory === $root && $realBeforeNormalized !== str_replace('\\\\', '/', $root))) { + throw new RuntimeException('Source directory changed or escaped during package creation.'); + } $names = @scandir($directory); if ($names === false) { throw new RuntimeException('Unable to inspect the source directory.'); } + $afterDir = @lstat($directory); + if (!is_array($afterDir) || is_link($directory) + || (int) ($afterDir['dev'] ?? -2) !== $expectedDev + || (int) ($afterDir['ino'] ?? 0) !== $expectedIno + || (((int) ($afterDir['mode'] ?? 0) & 0170000) !== 0040000)) { + throw new RuntimeException('Source directory changed during enumeration.'); + } $names = array_values(array_diff($names, ['.', '..'])); sort($names, SORT_STRING); $siblings = []; @@ -352,20 +383,21 @@ private function scanTree(string $root): array $siblings[$fold] = true; $path = $directory . DIRECTORY_SEPARATOR . $name; - if (is_link($path)) { - throw new RuntimeException('Symbolic links are not supported in package sources.'); + $entryStat = @lstat($path); + if (!is_array($entryStat) || is_link($path)) { + throw new RuntimeException('Symbolic links or unstable source entries are not supported.'); + } + $entryType = ((int) ($entryStat['mode'] ?? 0)) & 0170000; + $isDirectory = $entryType === 0040000; + $isFile = $entryType === 0100000; + if (!$isDirectory && !$isFile) { + throw new RuntimeException('Special filesystem entries are not supported.'); } $real = realpath($path); - $rootPrefix = rtrim(str_replace('\\', '/', $root), '/') . '/'; - $realNormalized = $real === false ? '' : str_replace('\\', '/', $real); + $realNormalized = $real === false ? '' : str_replace('\\\\', '/', $real); if ($real === false || !str_starts_with($realNormalized, $rootPrefix)) { throw new RuntimeException('Source entry escaped the source directory.'); } - $isDirectory = is_dir($path); - $isFile = is_file($path); - if (!$isDirectory && !$isFile) { - throw new RuntimeException('Special filesystem entries are not supported.'); - } $nodeDepth = $depth; if ($nodeDepth > StablePackageDescriptor::MAX_DEPTH) { throw new RuntimeException('Source directory nesting exceeds the package limit.'); @@ -398,19 +430,17 @@ private function scanTree(string $root): array 'path' => $pathName, 'source_path' => $path, 'source_root' => $root, + 'source_dev' => (int) ($entryStat['dev'] ?? -1), + 'source_ino' => (int) ($entryStat['ino'] ?? 0), 'size' => 0, 'chunks' => null, ]; if ($isFile) { - $stat = @lstat($path); - if (!is_array($stat) || !isset($stat['size']) || (int) $stat['size'] < 0 - || (int) $stat['size'] > StablePackageDescriptor::MAX_FILE_BYTES - || (((int) ($stat['mode'] ?? 0) & 0170000) !== 0100000)) { + if (!isset($entryStat['size']) || (int) $entryStat['size'] < 0 + || (int) $entryStat['size'] > StablePackageDescriptor::MAX_FILE_BYTES) { throw new RuntimeException('Source file exceeds the supported size limit.'); } - $entry['source_dev'] = $stat['dev'] ?? null; - $entry['source_ino'] = $stat['ino'] ?? null; - $entry['size'] = (int) $stat['size']; + $entry['size'] = (int) $entryStat['size']; $totalBytes += $entry['size']; if ($totalBytes > StablePackageDescriptor::MAX_TOTAL_PLAIN_BYTES) { throw new RuntimeException('Source tree exceeds the total package size limit.'); @@ -418,11 +448,22 @@ private function scanTree(string $root): array } $entries[] = $entry; if ($isDirectory) { - $walk($path, $id, $pathName, $depth + 1); + $childStat = @lstat($path); + $childReal = realpath($path); + $childRealNormalized = $childReal === false ? '' : str_replace('\\\\', '/', $childReal); + if (!is_array($childStat) || is_link($path) + || (int) ($childStat['dev'] ?? -2) !== (int) ($entryStat['dev'] ?? -1) + || (int) ($childStat['ino'] ?? 0) !== (int) ($entryStat['ino'] ?? 0) + || (((int) ($childStat['mode'] ?? 0) & 0170000) !== 0040000) + || $childReal === false || !str_starts_with($childRealNormalized, $rootPrefix)) { + throw new RuntimeException('Source directory changed before descent.'); + } + $walk($path, $id, $pathName, $depth + 1, + (int) ($entryStat['dev'] ?? -1), (int) ($entryStat['ino'] ?? 0)); } } }; - $walk($root, StablePackageDescriptor::zeroId(), '', 1); + $walk($root, StablePackageDescriptor::zeroId(), '', 1, $rootDev, $rootIno); return $entries; } From 01c6602670984fc9957adbeb621192d7da0b0543 Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Mon, 26 Oct 2026 01:46:25 +0330 Subject: [PATCH 61/66] fix(format): normalize Windows source paths correctly --- app/Project/StablePackageWriter.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/Project/StablePackageWriter.php b/app/Project/StablePackageWriter.php index c843e28d..8a776045 100644 --- a/app/Project/StablePackageWriter.php +++ b/app/Project/StablePackageWriter.php @@ -342,7 +342,7 @@ private function scanTree(string $root): array } $rootDev = (int) ($rootStat['dev'] ?? -1); $rootIno = (int) ($rootStat['ino'] ?? 0); - $rootNormalized = str_replace('\\\\', '/', $root); + $rootNormalized = str_replace('\\', '/', $root); $rootPrefix = rtrim($rootNormalized, '/') . '/'; $walk = function (string $directory, string $parentId, string $relative, int $depth, int $expectedDev, int $expectedIno) use (&$walk, &$entries, &$seenIds, &$nodeCount, &$totalBytes, $root, $rootPrefix): void { @@ -350,14 +350,14 @@ private function scanTree(string $root): array // identity immediately before and after enumeration and before descending. $beforeDir = @lstat($directory); $realBefore = realpath($directory); - $realBeforeNormalized = $realBefore === false ? '' : str_replace('\\\\', '/', $realBefore); + $realBeforeNormalized = $realBefore === false ? '' : str_replace('\\', '/', $realBefore); if (!is_array($beforeDir) || is_link($directory) || (((int) ($beforeDir['mode'] ?? 0) & 0170000) !== 0040000) || (int) ($beforeDir['dev'] ?? -2) !== $expectedDev || (int) ($beforeDir['ino'] ?? 0) !== $expectedIno || $realBefore === false || ($directory !== $root && !str_starts_with($realBeforeNormalized, $rootPrefix)) - || ($directory === $root && $realBeforeNormalized !== str_replace('\\\\', '/', $root))) { + || ($directory === $root && $realBeforeNormalized !== str_replace('\\', '/', $root))) { throw new RuntimeException('Source directory changed or escaped during package creation.'); } $names = @scandir($directory); @@ -394,7 +394,7 @@ private function scanTree(string $root): array throw new RuntimeException('Special filesystem entries are not supported.'); } $real = realpath($path); - $realNormalized = $real === false ? '' : str_replace('\\\\', '/', $real); + $realNormalized = $real === false ? '' : str_replace('\\', '/', $real); if ($real === false || !str_starts_with($realNormalized, $rootPrefix)) { throw new RuntimeException('Source entry escaped the source directory.'); } @@ -450,7 +450,7 @@ private function scanTree(string $root): array if ($isDirectory) { $childStat = @lstat($path); $childReal = realpath($path); - $childRealNormalized = $childReal === false ? '' : str_replace('\\\\', '/', $childReal); + $childRealNormalized = $childReal === false ? '' : str_replace('\\', '/', $childReal); if (!is_array($childStat) || is_link($path) || (int) ($childStat['dev'] ?? -2) !== (int) ($entryStat['dev'] ?? -1) || (int) ($childStat['ino'] ?? 0) !== (int) ($entryStat['ino'] ?? 0) From b7d57788bbb53f8b785df643e46982a56e0d3519 Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Mon, 26 Oct 2026 01:46:40 +0330 Subject: [PATCH 62/66] test(format): cover source-root and nested directory symlinks --- tests/StablePackageFormatTest.php | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/tests/StablePackageFormatTest.php b/tests/StablePackageFormatTest.php index 931588f3..08fd0c38 100644 --- a/tests/StablePackageFormatTest.php +++ b/tests/StablePackageFormatTest.php @@ -490,14 +490,35 @@ function stableRemoveTree(string $path): void $noRecoveryRestored = $reader->restore($noRecoveryPackage, $noRecoveryDestination, $password, $pattern); stableAssert($noRecoveryRestored['files'] === 5, 'No-recovery package did not restore.'); - // Exercise symlink rejection where the host permits creating a test symlink. + // Exercise root, directory and file symlink rejection where the host permits symlinks. + $rootLink = $root . DIRECTORY_SEPARATOR . 'source-root-link'; + if (@symlink($source, $rootLink)) { + $rootLinkPackage = $root . DIRECTORY_SEPARATOR . 'root-link.spkg'; + stableExpectFailure(fn() => (new StablePackageWriter())->build($rootLink, $rootLinkPackage, + $password, $pattern, str_repeat('d', 48), false), 'Source-root symlink was accepted.'); + stableAssert(!file_exists($rootLinkPackage), 'Source-root symlink produced a package.'); + if (!@unlink($rootLink)) throw new RuntimeException('Unable to remove source-root test symlink.'); + } + $sourceLink = $source . DIRECTORY_SEPARATOR . 'nested' . DIRECTORY_SEPARATOR . 'source-link'; if (@symlink($source . DIRECTORY_SEPARATOR . 'hello.txt', $sourceLink)) { $symlinkPackage = $root . DIRECTORY_SEPARATOR . 'symlink-source.spkg'; stableExpectFailure(fn() => (new StablePackageWriter())->build($source, $symlinkPackage, - $password, $pattern, str_repeat('d', 48), false), 'Source symlink was accepted.'); - stableAssert(!file_exists($symlinkPackage), 'Source symlink produced a package.'); - if (!@unlink($sourceLink)) throw new RuntimeException('Unable to remove test symlink.'); + $password, $pattern, str_repeat('e', 48), false), 'Source file symlink was accepted.'); + stableAssert(!file_exists($symlinkPackage), 'Source file symlink produced a package.'); + if (!@unlink($sourceLink)) throw new RuntimeException('Unable to remove file symlink.'); + } + + $externalDirectory = $root . DIRECTORY_SEPARATOR . 'external-directory'; + mkdir($externalDirectory, 0700); + file_put_contents($externalDirectory . DIRECTORY_SEPARATOR . 'outside.txt', 'outside source tree'); + $directoryLink = $source . DIRECTORY_SEPARATOR . 'nested' . DIRECTORY_SEPARATOR . 'directory-link'; + if (@symlink($externalDirectory, $directoryLink)) { + $directoryLinkPackage = $root . DIRECTORY_SEPARATOR . 'directory-link.spkg'; + stableExpectFailure(fn() => (new StablePackageWriter())->build($source, $directoryLinkPackage, + $password, $pattern, str_repeat('f', 48), false), 'Nested directory symlink was accepted.'); + stableAssert(!file_exists($directoryLinkPackage), 'Nested directory symlink produced a package.'); + if (!@unlink($directoryLink)) throw new RuntimeException('Unable to remove directory symlink.'); } echo "Stable binary package round-trip and adversarial tests passed.\n"; From 424d6f814868d214b98eafaa341191f5109c9b4d Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Mon, 26 Oct 2026 01:47:03 +0330 Subject: [PATCH 63/66] fix(format): detect source-path replacement after encryption --- app/Project/StablePackageWriter.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/Project/StablePackageWriter.php b/app/Project/StablePackageWriter.php index 8a776045..7a60e9ef 100644 --- a/app/Project/StablePackageWriter.php +++ b/app/Project/StablePackageWriter.php @@ -316,6 +316,21 @@ private function encryptFileEntry($out, array $entry, string $fileRootKey, strin if ($extra === false || $extra !== '') { throw new RuntimeException('Source file changed during package creation.'); } + // Also verify the path still names the exact inode opened above. Checking only + // fstat() would miss replacement of the directory entry while its old handle + // remained readable. + clearstatcache(true, $path); + $pathAfter = @lstat($path); + $realAfter = realpath($path); + $realAfterNormalized = $realAfter === false ? '' : str_replace('\\\\', '/', $realAfter); + $rootPrefixAfter = rtrim(str_replace('\\\\', '/', $root), '/') . '/'; + if (!is_array($pathAfter) || is_link($path) + || (int) ($pathAfter['dev'] ?? -2) !== (int) ($before['dev'] ?? -1) + || (int) ($pathAfter['ino'] ?? 0) !== (int) ($before['ino'] ?? 0) + || (((int) ($pathAfter['mode'] ?? 0) & 0170000) !== 0100000) + || $realAfter === false || !str_starts_with($realAfterNormalized, $rootPrefixAfter)) { + throw new RuntimeException('Source path changed during package creation.'); + } $after = fstat($input); if (!is_array($after) || (int) ($after['size'] ?? -1) !== $entry['size'] || (int) ($after['mtime'] ?? -1) !== (int) ($before['mtime'] ?? -1) From 97e0a28a2cc0be82149a632e45fbeb6ef7e7e802 Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Mon, 26 Oct 2026 01:47:17 +0330 Subject: [PATCH 64/66] fix(format): normalize post-read path checks on Windows --- app/Project/StablePackageWriter.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Project/StablePackageWriter.php b/app/Project/StablePackageWriter.php index 7a60e9ef..6e9dd172 100644 --- a/app/Project/StablePackageWriter.php +++ b/app/Project/StablePackageWriter.php @@ -322,8 +322,8 @@ private function encryptFileEntry($out, array $entry, string $fileRootKey, strin clearstatcache(true, $path); $pathAfter = @lstat($path); $realAfter = realpath($path); - $realAfterNormalized = $realAfter === false ? '' : str_replace('\\\\', '/', $realAfter); - $rootPrefixAfter = rtrim(str_replace('\\\\', '/', $root), '/') . '/'; + $realAfterNormalized = $realAfter === false ? '' : str_replace('\\', '/', $realAfter); + $rootPrefixAfter = rtrim(str_replace('\\', '/', $root), '/') . '/'; if (!is_array($pathAfter) || is_link($path) || (int) ($pathAfter['dev'] ?? -2) !== (int) ($before['dev'] ?? -1) || (int) ($pathAfter['ino'] ?? 0) !== (int) ($before['ino'] ?? 0) From 7e6c7fa4acd0ad886b07d58f7b5d5eec37557413 Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Mon, 26 Oct 2026 01:48:29 +0330 Subject: [PATCH 65/66] fix(test): clean up directory symlinks on Windows --- tests/StablePackageFormatTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/StablePackageFormatTest.php b/tests/StablePackageFormatTest.php index 08fd0c38..3a933e89 100644 --- a/tests/StablePackageFormatTest.php +++ b/tests/StablePackageFormatTest.php @@ -497,7 +497,7 @@ function stableRemoveTree(string $path): void stableExpectFailure(fn() => (new StablePackageWriter())->build($rootLink, $rootLinkPackage, $password, $pattern, str_repeat('d', 48), false), 'Source-root symlink was accepted.'); stableAssert(!file_exists($rootLinkPackage), 'Source-root symlink produced a package.'); - if (!@unlink($rootLink)) throw new RuntimeException('Unable to remove source-root test symlink.'); + if (!@unlink($rootLink) && !@rmdir($rootLink)) throw new RuntimeException('Unable to remove source-root test symlink.'); } $sourceLink = $source . DIRECTORY_SEPARATOR . 'nested' . DIRECTORY_SEPARATOR . 'source-link'; @@ -518,7 +518,7 @@ function stableRemoveTree(string $path): void stableExpectFailure(fn() => (new StablePackageWriter())->build($source, $directoryLinkPackage, $password, $pattern, str_repeat('f', 48), false), 'Nested directory symlink was accepted.'); stableAssert(!file_exists($directoryLinkPackage), 'Nested directory symlink produced a package.'); - if (!@unlink($directoryLink)) throw new RuntimeException('Unable to remove directory symlink.'); + if (!@unlink($directoryLink) && !@rmdir($directoryLink)) throw new RuntimeException('Unable to remove directory symlink.'); } echo "Stable binary package round-trip and adversarial tests passed.\n"; From 6019ed90a4191783188fe015463030d4f1460190 Mon Sep 17 00:00:00 2001 From: Elman Coder Date: Mon, 26 Oct 2026 01:49:16 +0330 Subject: [PATCH 66/66] fix(format): preserve filesystem-root source paths --- app/Project/StablePackageWriter.php | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/Project/StablePackageWriter.php b/app/Project/StablePackageWriter.php index 6e9dd172..be560827 100644 --- a/app/Project/StablePackageWriter.php +++ b/app/Project/StablePackageWriter.php @@ -491,7 +491,16 @@ private function canonicalSourceDirectory(string $sourceDir): string if ($real === false || !is_dir($real)) { throw new RuntimeException('Source directory does not exist.'); } - return rtrim($real, DIRECTORY_SEPARATOR); + $trimmed = rtrim($real, DIRECTORY_SEPARATOR); + if ($trimmed === '') { + return DIRECTORY_SEPARATOR; + } + // On Windows, trimming the root separator from "C:\\" yields "C:", + // which has drive-relative rather than drive-root semantics. + if (DIRECTORY_SEPARATOR === '\\\\' && preg_match('/^[A-Za-z]:$/D', $trimmed) === 1) { + return $trimmed . DIRECTORY_SEPARATOR; + } + return $trimmed; } private function canonicalDestinationFile(string $path): string