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 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 diff --git a/app/Project/StablePackageCrypto.php b/app/Project/StablePackageCrypto.php new file mode 100644 index 00000000..249e78d5 --- /dev/null +++ b/app/Project/StablePackageCrypto.php @@ -0,0 +1,145 @@ + 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 || StablePackageDescriptor::isZeroBytes($raw)) { + throw new RuntimeException('Invalid recovery key.'); + } + return rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); + } + + 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 + || StablePackageDescriptor::isZeroBytes($raw) + || !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 + || StablePackageDescriptor::isZeroBytes($rootKey)) { + 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 diff --git a/app/Project/StablePackageDescriptor.php b/app/Project/StablePackageDescriptor.php new file mode 100644 index 00000000..1e809c17 --- /dev/null +++ b/app/Project/StablePackageDescriptor.php @@ -0,0 +1,494 @@ + 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 + // until the wrapped root key and header MAC have been authenticated. + if ($ops < self::MIN_KDF_OPS || $ops > 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) { + // 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; + } + + /** 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)) { + throw new RuntimeException('Unable to open package.'); + } + + $hasRecovery = ($flags & self::FLAG_RECOVERY_SLOT) !== 0; + if ($hasRecovery) { + if (self::isZeroBytes($recoveryNonce)) { + 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; + $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'] = $entryDepth; + } + 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|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. + } + + 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 diff --git a/app/Project/StablePackageReader.php b/app/Project/StablePackageReader.php new file mode 100644 index 00000000..4f4186cc --- /dev/null +++ b/app/Project/StablePackageReader.php @@ -0,0 +1,563 @@ +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 = []; + $observedIds = []; + $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) + || StablePackageDescriptor::isZeroBytes($streamHeader) + || isset($observedIds[$fileIdHex]) + || $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); + $observedIds[$fileIdHex] = true; + $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; + try { + $plainLength = strlen($plain); + $newTotal = $active['written'] + $plainLength; + if ($newTotal > $active['size']) { + 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']) { + 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.'); + } + + if ($plainLength > 0) { + StablePackageDescriptor::writeAll($active['out'], $plain); + } + $active['written'] = $newTotal; + $active['chunks']++; + } finally { + if ($plain !== '') sodium_memzero($plain); + } + + 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); + 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, + 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); + 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']); + $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.'); + } + $ownedDirectories[$target] = $identity; + @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); + $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; + 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.'); + } + $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); + 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); + } + // 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.'); + } + } + } + + 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 diff --git a/app/Project/StablePackageWriter.php b/app/Project/StablePackageWriter.php new file mode 100644 index 00000000..be560827 --- /dev/null +++ b/app/Project/StablePackageWriter.php @@ -0,0 +1,558 @@ +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.'); + } + + 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.'); + } + + $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 = 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); + $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 = StablePackageDescriptor::randomNonZeroBytes(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 = StablePackageDescriptor::randomNonZeroBytes(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 = StablePackageDescriptor::randomNonZeroBytes(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.'); + } + // 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); + $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.'); + } + + $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); + 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); + } + } + } + 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.'); + } + // 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) + || (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; + $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 = []; + 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; + $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); + $realNormalized = $real === false ? '' : str_replace('\\', '/', $real); + if ($real === false || !str_starts_with($realNormalized, $rootPrefix)) { + throw new RuntimeException('Source entry escaped the source directory.'); + } + $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.'); + } + $id = ''; + $idHex = ''; + for ($attempt = 0; $attempt < 32; $attempt++) { + $id = StablePackageDescriptor::randomNonZeroBytes(StablePackageDescriptor::FILE_ID_BYTES); + $idHex = bin2hex($id); + 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) { + 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, + 'source_dev' => (int) ($entryStat['dev'] ?? -1), + 'source_ino' => (int) ($entryStat['ino'] ?? 0), + 'size' => 0, + 'chunks' => null, + ]; + if ($isFile) { + 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['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.'); + } + } + $entries[] = $entry; + if ($isDirectory) { + $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, $rootDev, $rootIno); + 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.'); + } + $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 + { + $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.'); + } + } +} diff --git a/docs/STABLE-PACKAGE-FORMAT-BINARY.md b/docs/STABLE-PACKAGE-FORMAT-BINARY.md new file mode 100644 index 00000000..72035ac3 --- /dev/null +++ b/docs/STABLE-PACKAGE-FORMAT-BINARY.md @@ -0,0 +1,221 @@ +# 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 | 4 MiB | +| Maximum entries | 10,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. + + +### 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, 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 + +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 | 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 | +| 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, 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 + +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, 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. +- '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, 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 + +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; 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) + +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 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)'. + +'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 and MUST not be all-zero. 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–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 + +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, 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 + +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. 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. diff --git a/tests/StablePackageFormatTest.php b/tests/StablePackageFormatTest.php new file mode 100644 index 00000000..3a933e89 --- /dev/null +++ b/tests/StablePackageFormatTest.php @@ -0,0 +1,531 @@ + $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 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 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)) { + @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); +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"); +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'] === 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); + 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.'); + 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(); + + // 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'], + [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'] === 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']); + 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', + '', '', 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'); + $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.'); + + $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.'); + + $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); + 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.'); + + $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); + $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); + 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.'); + + $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); + 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.'); + + 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], + ])); + 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' . 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); + } + + $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.'); + + 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.'); + + $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], + ['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'] === 5, 'No-recovery package did not restore.'); + + // 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) && !@rmdir($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('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) && !@rmdir($directoryLink)) throw new RuntimeException('Unable to remove directory symlink.'); + } + + echo "Stable binary package round-trip and adversarial tests passed.\n"; +} finally { + $password = ''; + $pattern = ''; + $wrongPassword = ''; + $wrongPattern = ''; + stableRemoveTree($root); +} 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