- Config: auth, services, logging, queue and database only repeated the
framework's own files and are gone; the others keep only the keys that
differ (app version, cache serializable_classes, session cookie name,
Markdown mail theme, the shares disk, three Octane values, Livewire's
pagination theme and payload guards).
- Email verification is removed: User never implemented MustVerifyEmail,
so it was never enforced, and SealShare has a single admin and no
registration. CreateNewUser goes with it.
- FileEncryptionService::encryptFile() and generateSalt() were only used
by tests; tests build files with encryptTestFile() in tests/Pest.php.
- The expiration options are defined once, as Share::EXPIRATIONS. "30 Days"
now lasts 30 days instead of a calendar month, and Admin settings only
save a default expiration that is one of the options.
- One-caller helpers are inlined, the uploader reads chunk responses with
XHR's responseType, and starter-kit leftovers are removed.
- Docker: PHP reads the PHP_* limits from the environment itself
(${VAR:-default} in uploads.ini); both entrypoints stop writing the ini.
docker-compose.yml shares the app and scheduler variables through one
anchor. The dev image installs gd for the screenshot publisher and fake
test images.
- Development runs in Docker only: the composer dev script, concurrently,
laravel/pail, laravel/sail, autoprefixer and the shell-quote override
are gone.
- phpunit.xml forces the test environment with <server> entries, so tests
run in the dev container no longer use its real database.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
280 lines
10 KiB
PHP
280 lines
10 KiB
PHP
<?php
|
|
|
|
use App\Services\FileEncryptionService;
|
|
|
|
beforeEach(function () {
|
|
$this->service = new FileEncryptionService;
|
|
$this->tempDir = sys_get_temp_dir().'/sealshare-test-'.uniqid();
|
|
mkdir($this->tempDir, 0755, true);
|
|
});
|
|
|
|
afterEach(function () {
|
|
if (is_dir($this->tempDir)) {
|
|
array_map('unlink', glob($this->tempDir.'/*'));
|
|
rmdir($this->tempDir);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* The whole decrypted content of an encrypted file.
|
|
*/
|
|
function decryptToString(FileEncryptionService $service, string $path, string $key): string
|
|
{
|
|
return implode('', iterator_to_array($service->decryptedChunks($path, $key), false));
|
|
}
|
|
|
|
/**
|
|
* A chunk encrypted the way the uploader's browser does, built here without the service: the
|
|
* nonce is prefix, index and last-chunk flag; the tag follows the ciphertext.
|
|
*/
|
|
function browserChunk(string $plaintext, string $keyHex, string $noncePrefix, int $index, bool $isLast): string
|
|
{
|
|
$tag = '';
|
|
$nonce = $noncePrefix.pack('N', $index).($isLast ? "\x01" : "\x00");
|
|
$ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', hex2bin($keyHex), OPENSSL_RAW_DATA, $nonce, $tag, '', 16);
|
|
|
|
return $ciphertext.$tag;
|
|
}
|
|
|
|
test('encrypt and decrypt round-trip works', function () {
|
|
$sourcePath = $this->tempDir.'/source.txt';
|
|
$encryptedPath = $this->tempDir.'/encrypted.enc';
|
|
$content = 'Hello, World! This is a secret message.';
|
|
|
|
file_put_contents($sourcePath, $content);
|
|
|
|
$key = $this->service->generateRandomKey();
|
|
|
|
encryptTestFile($sourcePath, $encryptedPath, $key, 1024);
|
|
|
|
expect(file_get_contents($encryptedPath))->not->toContain($content);
|
|
expect(decryptToString($this->service, $encryptedPath, $key))->toBe($content);
|
|
});
|
|
|
|
test('decrypt with wrong key fails', function () {
|
|
$sourcePath = $this->tempDir.'/source.txt';
|
|
$encryptedPath = $this->tempDir.'/encrypted.enc';
|
|
|
|
file_put_contents($sourcePath, 'Secret data');
|
|
|
|
encryptTestFile($sourcePath, $encryptedPath, $this->service->generateRandomKey(), 1024);
|
|
|
|
decryptToString($this->service, $encryptedPath, $this->service->generateRandomKey());
|
|
})->throws(RuntimeException::class, 'Decryption failed');
|
|
|
|
test('derive key produces consistent results', function () {
|
|
$password = 'my-secure-password';
|
|
$salt = bin2hex(random_bytes(32));
|
|
|
|
$key1 = $this->service->deriveKey($password, $salt);
|
|
$key2 = $this->service->deriveKey($password, $salt);
|
|
|
|
expect($key1)->toBe($key2);
|
|
});
|
|
|
|
test('derive key with different passwords produces different keys', function () {
|
|
$salt = bin2hex(random_bytes(32));
|
|
|
|
$key1 = $this->service->deriveKey('password1', $salt);
|
|
$key2 = $this->service->deriveKey('password2', $salt);
|
|
|
|
expect($key1)->not->toBe($key2);
|
|
});
|
|
|
|
test('derive key with different salts produces different keys', function () {
|
|
$password = 'same-password';
|
|
|
|
$key1 = $this->service->deriveKey($password, bin2hex(random_bytes(32)));
|
|
$key2 = $this->service->deriveKey($password, bin2hex(random_bytes(32)));
|
|
|
|
expect($key1)->not->toBe($key2);
|
|
});
|
|
|
|
test('generate random key returns 64 char hex string', function () {
|
|
$key = $this->service->generateRandomKey();
|
|
|
|
expect(strlen($key))->toBe(64);
|
|
expect(ctype_xdigit($key))->toBeTrue();
|
|
});
|
|
|
|
test('password-derived key encrypt/decrypt round-trip works', function () {
|
|
$sourcePath = $this->tempDir.'/source.txt';
|
|
$encryptedPath = $this->tempDir.'/encrypted.enc';
|
|
$content = 'Password protected content';
|
|
|
|
file_put_contents($sourcePath, $content);
|
|
|
|
$key = bin2hex($this->service->deriveKey('user-password', bin2hex(random_bytes(32))));
|
|
|
|
encryptTestFile($sourcePath, $encryptedPath, $key, 1024);
|
|
|
|
expect(decryptToString($this->service, $encryptedPath, $key))->toBe($content);
|
|
});
|
|
|
|
test('an encrypted file starts with the SEALCHK2 header and its chunk size', function () {
|
|
$sourcePath = $this->tempDir.'/source.txt';
|
|
$encryptedPath = $this->tempDir.'/encrypted.enc';
|
|
|
|
file_put_contents($sourcePath, 'test content');
|
|
|
|
encryptTestFile($sourcePath, $encryptedPath, $this->service->generateRandomKey(), 1024);
|
|
|
|
expect(file_get_contents($encryptedPath, false, null, 0, 12))->toBe('SEALCHK2'.pack('N', 1024));
|
|
});
|
|
|
|
test('multi-chunk round-trip works', function () {
|
|
$sourcePath = $this->tempDir.'/large.bin';
|
|
$encryptedPath = $this->tempDir.'/large.enc';
|
|
$content = random_bytes(2500);
|
|
|
|
file_put_contents($sourcePath, $content);
|
|
|
|
$key = $this->service->generateRandomKey();
|
|
encryptTestFile($sourcePath, $encryptedPath, $key, 1000);
|
|
|
|
expect(filesize($encryptedPath))->toBe(19 + 3 * 16 + 2500);
|
|
expect(decryptToString($this->service, $encryptedPath, $key))->toBe($content);
|
|
});
|
|
|
|
test('exact chunk boundary round-trip works', function () {
|
|
$sourcePath = $this->tempDir.'/exact.bin';
|
|
$encryptedPath = $this->tempDir.'/exact.enc';
|
|
$content = random_bytes(2000);
|
|
|
|
file_put_contents($sourcePath, $content);
|
|
|
|
$key = $this->service->generateRandomKey();
|
|
encryptTestFile($sourcePath, $encryptedPath, $key, 1000);
|
|
|
|
expect(filesize($encryptedPath))->toBe(19 + 2 * 16 + 2000);
|
|
expect(decryptToString($this->service, $encryptedPath, $key))->toBe($content);
|
|
});
|
|
|
|
test('empty file round-trip works', function () {
|
|
$sourcePath = $this->tempDir.'/empty.bin';
|
|
$encryptedPath = $this->tempDir.'/empty.enc';
|
|
|
|
file_put_contents($sourcePath, '');
|
|
|
|
$key = $this->service->generateRandomKey();
|
|
encryptTestFile($sourcePath, $encryptedPath, $key, 1000);
|
|
|
|
expect(filesize($encryptedPath))->toBe(19 + 16);
|
|
expect(decryptToString($this->service, $encryptedPath, $key))->toBe('');
|
|
});
|
|
|
|
test('chunks encrypted the way the browser does decrypt to the original file', function () {
|
|
$key = $this->service->generateRandomKey();
|
|
$header = $this->service->createHeader(4);
|
|
$noncePrefix = substr($header, 12, 7);
|
|
$encryptedPath = $this->tempDir.'/browser.enc';
|
|
|
|
file_put_contents($encryptedPath, $header
|
|
.browserChunk('abcd', $key, $noncePrefix, 0, false)
|
|
.browserChunk('ef', $key, $noncePrefix, 1, true));
|
|
|
|
expect(decryptToString($this->service, $encryptedPath, $key))->toBe('abcdef');
|
|
});
|
|
|
|
test('a chunk with the wrong last-chunk flag is rejected', function () {
|
|
$key = $this->service->generateRandomKey();
|
|
$noncePrefix = random_bytes(7);
|
|
|
|
$this->service->decryptChunk(browserChunk('abcd', $key, $noncePrefix, 0, false), $key, $noncePrefix, 0, true);
|
|
})->throws(RuntimeException::class, 'Decryption failed');
|
|
|
|
test('a file cut short at a chunk boundary fails to decrypt', function () {
|
|
$sourcePath = $this->tempDir.'/source.bin';
|
|
$encryptedPath = $this->tempDir.'/truncated.enc';
|
|
|
|
file_put_contents($sourcePath, random_bytes(3000));
|
|
|
|
$key = $this->service->generateRandomKey();
|
|
encryptTestFile($sourcePath, $encryptedPath, $key, 1000);
|
|
|
|
$handle = fopen($encryptedPath, 'r+b');
|
|
ftruncate($handle, 19 + 2 * (1000 + 16));
|
|
fclose($handle);
|
|
|
|
decryptToString($this->service, $encryptedPath, $key);
|
|
})->throws(RuntimeException::class, 'Decryption failed');
|
|
|
|
test('a file with two chunks swapped fails to decrypt', function () {
|
|
$key = $this->service->generateRandomKey();
|
|
$header = $this->service->createHeader(4);
|
|
$noncePrefix = substr($header, 12, 7);
|
|
$encryptedPath = $this->tempDir.'/swapped.enc';
|
|
|
|
file_put_contents($encryptedPath, $header
|
|
.browserChunk('efgh', $key, $noncePrefix, 1, false)
|
|
.browserChunk('abcd', $key, $noncePrefix, 0, false)
|
|
.browserChunk('ij', $key, $noncePrefix, 2, true));
|
|
|
|
decryptToString($this->service, $encryptedPath, $key);
|
|
})->throws(RuntimeException::class, 'Decryption failed');
|
|
|
|
test('SEALCHK1 files from before still decrypt', function () {
|
|
$key = $this->service->generateRandomKey();
|
|
$encryptedPath = $this->tempDir.'/sealchk1.enc';
|
|
$baseNonce = random_bytes(12);
|
|
$file = 'SEALCHK1'.pack('N', 4).$baseNonce;
|
|
|
|
foreach (['abcd', 'ef'] as $index => $plaintext) {
|
|
$nonce = $baseNonce;
|
|
$indexBytes = pack('N', $index);
|
|
|
|
for ($i = 0; $i < 4; $i++) {
|
|
$nonce[8 + $i] = $nonce[8 + $i] ^ $indexBytes[$i];
|
|
}
|
|
|
|
$tag = '';
|
|
$ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', hex2bin($key), OPENSSL_RAW_DATA, $nonce, $tag, '', 16);
|
|
$file .= $tag.$ciphertext;
|
|
}
|
|
|
|
file_put_contents($encryptedPath, $file);
|
|
|
|
expect(decryptToString($this->service, $encryptedPath, $key))->toBe('abcdef');
|
|
});
|
|
|
|
test('legacy format backward compatibility', function () {
|
|
$encryptedPath = $this->tempDir.'/legacy.enc';
|
|
$content = 'Legacy encrypted content';
|
|
|
|
$key = $this->service->generateRandomKey();
|
|
|
|
// Manually create a legacy format file: [nonce][tag][ciphertext]
|
|
$nonce = random_bytes(12);
|
|
$tag = '';
|
|
$ciphertext = openssl_encrypt($content, 'aes-256-gcm', hex2bin($key), OPENSSL_RAW_DATA, $nonce, $tag, '', 16);
|
|
file_put_contents($encryptedPath, $nonce.$tag.$ciphertext);
|
|
|
|
expect(decryptToString($this->service, $encryptedPath, $key))->toBe($content);
|
|
});
|
|
|
|
test('wrong key on chunked file throws exception', function () {
|
|
$sourcePath = $this->tempDir.'/source.txt';
|
|
$encryptedPath = $this->tempDir.'/encrypted.enc';
|
|
|
|
file_put_contents($sourcePath, random_bytes(2500));
|
|
|
|
encryptTestFile($sourcePath, $encryptedPath, $this->service->generateRandomKey(), 1000);
|
|
|
|
decryptToString($this->service, $encryptedPath, $this->service->generateRandomKey());
|
|
})->throws(RuntimeException::class, 'Decryption failed');
|
|
|
|
test('a wrapped key unwraps with its password to the same data key', function () {
|
|
$dataKey = $this->service->generateRandomKey();
|
|
|
|
$wrapped = $this->service->wrapKey($dataKey, 'correct horse battery');
|
|
|
|
expect($wrapped)->toStartWith('argon2id$')->not->toContain($dataKey);
|
|
expect($this->service->unwrapKey($wrapped, 'correct horse battery'))->toBe($dataKey);
|
|
});
|
|
|
|
test('a wrapped key does not unwrap with a wrong password', function () {
|
|
$wrapped = $this->service->wrapKey($this->service->generateRandomKey(), 'correct horse battery');
|
|
|
|
$this->service->unwrapKey($wrapped, 'wrong horse battery');
|
|
})->throws(RuntimeException::class, 'Unwrapping failed');
|