Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
66 commits
Select commit Hold shift + click to select a range
56a107c
feat(format): add domain-separated stable package cryptography
ElmanCod110 Oct 25, 2026
1b95391
feat(format): complete stable package cryptography helpers
ElmanCod110 Oct 25, 2026
a949591
feat(format): define stable binary package descriptor and manifest codec
ElmanCod110 Oct 25, 2026
d82d92d
feat(format): implement stable binary package writer
ElmanCod110 Oct 25, 2026
57799ec
feat(format): implement strict stable binary package reader
ElmanCod110 Oct 25, 2026
4df80cc
test(format): add stable binary package adversarial regression suite
ElmanCod110 Oct 25, 2026
3e448fb
docs(format): freeze stable single-file binary grammar
ElmanCod110 Oct 25, 2026
520db8b
docs(format): link stable contract to frozen binary specification
ElmanCod110 Oct 25, 2026
859587f
ci(format): test stable package encryption and restore on Windows
ElmanCod110 Oct 25, 2026
e95a105
ci(security): include stable binary format adversarial tests
ElmanCod110 Oct 25, 2026
1639c77
fix(format): clear cached stat before restored file verification
ElmanCod110 Oct 25, 2026
6e65ff9
fix(format): clear partial data after exact-read failure
ElmanCod110 Oct 25, 2026
fa6b954
fix(format): zeroize source chunks on all error paths
ElmanCod110 Oct 25, 2026
8107b5a
fix(format): zeroize decrypted chunks on all error paths
ElmanCod110 Oct 25, 2026
17b107d
fix(format): enforce canonical random fields without banning valid ci…
ElmanCod110 Oct 25, 2026
ad90b4d
fix(format): reject zero identifiers and bound random identifier retries
ElmanCod110 Oct 25, 2026
0f44cec
docs(format): clarify ciphertext bit patterns in header validation
ElmanCod110 Oct 25, 2026
f2f9f55
test(format): expand binary header and parser rejection coverage
ElmanCod110 Oct 25, 2026
5526117
test(format): add immutable stable-format binary fixture
ElmanCod110 Oct 25, 2026
3277af1
test(format): verify immutable package fixture compatibility
ElmanCod110 Oct 25, 2026
8aa6dab
test(format): avoid probabilistic ciphertext substring assertion
ElmanCod110 Oct 25, 2026
399560a
test(format): cover malformed manifest graphs and stream ordering
ElmanCod110 Oct 25, 2026
78164fc
fix(format): detect source-file replacement before encryption
ElmanCod110 Oct 25, 2026
c8552eb
test(format): prove package creation never overwrites existing files
ElmanCod110 Oct 25, 2026
a6827b8
fix(format): verify every required cryptographic primitive at runtime
ElmanCod110 Oct 25, 2026
93ada54
docs(format): specify recovery slot validation precisely
ElmanCod110 Oct 25, 2026
a76634c
fix(format): reject additional Windows reserved device names
ElmanCod110 Oct 25, 2026
09bcad4
test(format): cover Windows device-name aliases
ElmanCod110 Oct 25, 2026
a67904e
docs(format): document conservative Windows device-name rejection
ElmanCod110 Oct 25, 2026
7a58f91
fix(format): make duplicate stream detection linear-time
ElmanCod110 Oct 25, 2026
2b2990b
fix(test): repair malformed Windows filename cases and remove duplica…
ElmanCod110 Oct 25, 2026
d6766a5
test(format): cover one-byte and exact-chunk file boundaries
ElmanCod110 Oct 25, 2026
754541b
test(format): reject source symlinks when supported by host
ElmanCod110 Oct 25, 2026
1f15b9d
test(format): assert tampered restores clean private staging data
ElmanCod110 Oct 25, 2026
34da069
docs(format): document visible package metadata and size leakage
ElmanCod110 Oct 25, 2026
c16cc05
docs(format): place metadata-leakage limits with crypto profile
ElmanCod110 Oct 25, 2026
99f383a
test(format): reject reordered multi-chunk file records
ElmanCod110 Oct 25, 2026
4ad292f
test(format): reject packages without final manifest
ElmanCod110 Oct 25, 2026
59b66dd
fix(format): bound manifest and entry counts for shared-hosting memory
ElmanCod110 Oct 25, 2026
8336757
docs(format): freeze memory-aware manifest and entry limits
ElmanCod110 Oct 25, 2026
1482e22
test(format): enforce fixed manifest entry-count ceiling
ElmanCod110 Oct 25, 2026
68617a5
fix(format): cap encrypted manifest at four MiB
ElmanCod110 Oct 25, 2026
3529764
docs(format): freeze four-MiB manifest ceiling
ElmanCod110 Oct 25, 2026
9e2ac0b
test(format): cover file, aggregate-size and depth limits
ElmanCod110 Oct 25, 2026
0ff9b9d
fix(format): enforce path depth after memoized graph resolution
ElmanCod110 Oct 25, 2026
c8f3da5
test(format): enforce resolved path length ceiling
ElmanCod110 Oct 25, 2026
afaa7eb
test(format): preserve empty directories in stable packages
ElmanCod110 Oct 25, 2026
1660b3f
docs(format): clarify package identifier generation contract
ElmanCod110 Oct 25, 2026
e5ca1c2
docs(format): make AEAD key, nonce, plaintext and AAD roles explicit
ElmanCod110 Oct 25, 2026
411960e
fix(format): reject noncanonical zero stream and manifest nonces
ElmanCod110 Oct 25, 2026
53ab327
docs(format): specify canonical nonzero stream and manifest nonces
ElmanCod110 Oct 25, 2026
2c73b90
test(format): reject all-zero secretstream headers
ElmanCod110 Oct 25, 2026
070dd28
fix(test): write actual zero bytes into stream-header fixture
ElmanCod110 Oct 25, 2026
7aa97c5
fix(format): verify owned directory identities during restore
ElmanCod110 Oct 25, 2026
a8692b6
fix(format): verify restored file identity before and after copy
ElmanCod110 Oct 25, 2026
c34c0a5
fix(format): reject zero-valued recovery secrets
ElmanCod110 Oct 25, 2026
6fcfc93
docs(format): specify canonical nonzero recovery secrets
ElmanCod110 Oct 25, 2026
4487192
fix(format): reject zero-valued unwrapped root keys
ElmanCod110 Oct 25, 2026
4336ef0
docs(format): specify nonzero root-key canonicality
ElmanCod110 Oct 25, 2026
5260a99
fix(format): detect source-directory replacement during tree scan
ElmanCod110 Oct 25, 2026
01c6602
fix(format): normalize Windows source paths correctly
ElmanCod110 Oct 25, 2026
b7d5778
test(format): cover source-root and nested directory symlinks
ElmanCod110 Oct 25, 2026
424d6f8
fix(format): detect source-path replacement after encryption
ElmanCod110 Oct 25, 2026
97e0a28
fix(format): normalize post-read path checks on Windows
ElmanCod110 Oct 25, 2026
7e6c7fa
fix(test): clean up directory symlinks on Windows
ElmanCod110 Oct 25, 2026
6019ed9
fix(format): preserve filesystem-root source paths
ElmanCod110 Oct 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
145 changes: 145 additions & 0 deletions app/Project/StablePackageCrypto.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);

namespace SecurePackage\Project;

use RuntimeException;

/** Shared, domain-separated cryptographic operations for the stable format. */
final class StablePackageCrypto
{
public static function deriveCredentialKey(string $password, string $pattern, string $salt, string $packageId, int $ops, int $memoryBytes): string
{
StablePackageDescriptor::assertRuntime();
StablePackageDescriptor::assertKdfParameters($ops, $memoryBytes);
if (strlen($salt) !== StablePackageDescriptor::SALT_BYTES
|| strlen($packageId) !== StablePackageDescriptor::PACKAGE_ID_BYTES) {
throw new RuntimeException('Invalid credential derivation context.');
}
$passwordSalt = substr(hash('sha256', "SPKG/PWHASH/PASSWORD\0" . $salt, true), 0, SODIUM_CRYPTO_PWHASH_SALTBYTES);
$patternSalt = substr(hash('sha256', "SPKG/PWHASH/PATTERN\0" . $salt, true), 0, SODIUM_CRYPTO_PWHASH_SALTBYTES);
$passwordKey = sodium_crypto_pwhash(StablePackageDescriptor::KEY_BYTES, $password, $passwordSalt, $ops, $memoryBytes, SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13);
$patternKey = null;
try {
$patternKey = sodium_crypto_pwhash(StablePackageDescriptor::KEY_BYTES, $pattern, $patternSalt, $ops, $memoryBytes, SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13);
$combined = $passwordKey . $patternKey;
try {
return hash_hkdf('sha256', $combined, StablePackageDescriptor::KEY_BYTES,
"SPKG/CREDENTIAL-WRAP\0" . $packageId, $salt);
} finally {
sodium_memzero($combined);
}
} finally {
sodium_memzero($passwordKey);
if (is_string($patternKey)) sodium_memzero($patternKey);
}
}

public static function deriveSubkey(string $root, string $purpose, string $packageId, string $context = ''): string
{
if (strlen($root) !== StablePackageDescriptor::KEY_BYTES
|| strlen($packageId) !== StablePackageDescriptor::PACKAGE_ID_BYTES
|| $purpose === '' || strlen($context) > 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 = '';
}
}
}
Loading
Loading