Add SealShare file sharing application with encryption, branding, auth, Docker, and Octane support
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
4790be8142
commit
e37b322dde
@@ -0,0 +1,256 @@
|
||||
<?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);
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key);
|
||||
|
||||
expect(file_exists($encryptedPath))->toBeTrue();
|
||||
expect(file_get_contents($encryptedPath))->not->toBe($content);
|
||||
|
||||
$decrypted = $this->service->decryptFile($encryptedPath, $key);
|
||||
|
||||
expect($decrypted)->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');
|
||||
|
||||
$correctKey = $this->service->generateRandomKey();
|
||||
$wrongKey = $this->service->generateRandomKey();
|
||||
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $correctKey);
|
||||
|
||||
$this->service->decryptFile($encryptedPath, $wrongKey);
|
||||
})->throws(RuntimeException::class, 'Decryption failed');
|
||||
|
||||
test('derive key produces consistent results', function () {
|
||||
$password = 'my-secure-password';
|
||||
$salt = $this->service->generateSalt();
|
||||
|
||||
$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 = $this->service->generateSalt();
|
||||
|
||||
$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, $this->service->generateSalt());
|
||||
$key2 = $this->service->deriveKey($password, $this->service->generateSalt());
|
||||
|
||||
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('generate salt returns 64 char hex string', function () {
|
||||
$salt = $this->service->generateSalt();
|
||||
|
||||
expect(strlen($salt))->toBe(64);
|
||||
expect(ctype_xdigit($salt))->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);
|
||||
|
||||
$password = 'user-password';
|
||||
$salt = $this->service->generateSalt();
|
||||
$key = bin2hex($this->service->deriveKey($password, $salt));
|
||||
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key);
|
||||
$decrypted = $this->service->decryptFile($encryptedPath, $key);
|
||||
|
||||
expect($decrypted)->toBe($content);
|
||||
});
|
||||
|
||||
test('decrypt file stream returns streamed response', function () {
|
||||
$sourcePath = $this->tempDir.'/source.txt';
|
||||
$encryptedPath = $this->tempDir.'/encrypted.enc';
|
||||
$content = 'Streamed content';
|
||||
|
||||
file_put_contents($sourcePath, $content);
|
||||
|
||||
$key = $this->service->generateRandomKey();
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key);
|
||||
|
||||
$response = $this->service->decryptFileStream($encryptedPath, $key, 'test.txt', 'text/plain');
|
||||
|
||||
expect($response)->toBeInstanceOf(Symfony\Component\HttpFoundation\StreamedResponse::class);
|
||||
expect($response->headers->get('Content-Type'))->toBe('text/plain');
|
||||
expect($response->headers->get('Content-Disposition'))->toContain('test.txt');
|
||||
});
|
||||
|
||||
test('chunked file has SEALCHK1 magic header', function () {
|
||||
$sourcePath = $this->tempDir.'/source.txt';
|
||||
$encryptedPath = $this->tempDir.'/encrypted.enc';
|
||||
|
||||
file_put_contents($sourcePath, 'test content');
|
||||
|
||||
$key = $this->service->generateRandomKey();
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key);
|
||||
|
||||
$header = file_get_contents($encryptedPath, false, null, 0, 8);
|
||||
|
||||
expect($header)->toBe('SEALCHK1');
|
||||
});
|
||||
|
||||
test('multi-chunk round-trip works', function () {
|
||||
$sourcePath = $this->tempDir.'/large.bin';
|
||||
$encryptedPath = $this->tempDir.'/large.enc';
|
||||
|
||||
// Create a file larger than one 4 MB chunk (5 MB)
|
||||
$chunkSize = 4 * 1024 * 1024;
|
||||
$content = random_bytes($chunkSize + (1024 * 1024));
|
||||
|
||||
file_put_contents($sourcePath, $content);
|
||||
|
||||
$key = $this->service->generateRandomKey();
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key);
|
||||
$decrypted = $this->service->decryptFile($encryptedPath, $key);
|
||||
|
||||
expect($decrypted)->toBe($content);
|
||||
});
|
||||
|
||||
test('exact chunk boundary round-trip works', function () {
|
||||
$sourcePath = $this->tempDir.'/exact.bin';
|
||||
$encryptedPath = $this->tempDir.'/exact.enc';
|
||||
|
||||
// Create a file exactly equal to one chunk (4 MB)
|
||||
$content = random_bytes(4 * 1024 * 1024);
|
||||
|
||||
file_put_contents($sourcePath, $content);
|
||||
|
||||
$key = $this->service->generateRandomKey();
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key);
|
||||
$decrypted = $this->service->decryptFile($encryptedPath, $key);
|
||||
|
||||
expect($decrypted)->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();
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key);
|
||||
$decrypted = $this->service->decryptFile($encryptedPath, $key);
|
||||
|
||||
expect($decrypted)->toBe('');
|
||||
});
|
||||
|
||||
test('legacy format backward compatibility', function () {
|
||||
$sourcePath = $this->tempDir.'/source.txt';
|
||||
$encryptedPath = $this->tempDir.'/legacy.enc';
|
||||
$content = 'Legacy encrypted content';
|
||||
|
||||
file_put_contents($sourcePath, $content);
|
||||
|
||||
$key = $this->service->generateRandomKey();
|
||||
$binaryKey = hex2bin($key);
|
||||
|
||||
// Manually create a legacy format file: [nonce][tag][ciphertext]
|
||||
$nonce = random_bytes(12);
|
||||
$tag = '';
|
||||
$ciphertext = openssl_encrypt($content, 'aes-256-gcm', $binaryKey, OPENSSL_RAW_DATA, $nonce, $tag, '', 16);
|
||||
file_put_contents($encryptedPath, $nonce.$tag.$ciphertext);
|
||||
|
||||
$decrypted = $this->service->decryptFile($encryptedPath, $key);
|
||||
|
||||
expect($decrypted)->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, 'Chunked secret data');
|
||||
|
||||
$correctKey = $this->service->generateRandomKey();
|
||||
$wrongKey = $this->service->generateRandomKey();
|
||||
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $correctKey);
|
||||
|
||||
$this->service->decryptFile($encryptedPath, $wrongKey);
|
||||
})->throws(RuntimeException::class, 'Decryption failed');
|
||||
|
||||
test('decryptFileToCallback returns working stream resource', function () {
|
||||
$sourcePath = $this->tempDir.'/source.txt';
|
||||
$encryptedPath = $this->tempDir.'/encrypted.enc';
|
||||
$content = 'Callback decrypted content';
|
||||
|
||||
file_put_contents($sourcePath, $content);
|
||||
|
||||
$key = $this->service->generateRandomKey();
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key);
|
||||
|
||||
$callback = $this->service->decryptFileToCallback($encryptedPath, $key);
|
||||
$resource = $callback();
|
||||
|
||||
expect(is_resource($resource))->toBeTrue();
|
||||
|
||||
$decrypted = stream_get_contents($resource);
|
||||
fclose($resource);
|
||||
|
||||
expect($decrypted)->toBe($content);
|
||||
});
|
||||
|
||||
test('decrypt file stream with file size sets content-length header', function () {
|
||||
$sourcePath = $this->tempDir.'/source.txt';
|
||||
$encryptedPath = $this->tempDir.'/encrypted.enc';
|
||||
$content = 'Content with known size';
|
||||
|
||||
file_put_contents($sourcePath, $content);
|
||||
|
||||
$key = $this->service->generateRandomKey();
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key);
|
||||
|
||||
$response = $this->service->decryptFileStream($encryptedPath, $key, 'test.txt', 'text/plain', strlen($content));
|
||||
|
||||
expect($response->headers->get('Content-Length'))->toBe((string) strlen($content));
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\Share;
|
||||
use App\Models\ShareFile;
|
||||
use App\Services\ShareService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
pest()->extend(Tests\TestCase::class)
|
||||
->use(Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Storage::fake('shares');
|
||||
$this->service = app(ShareService::class);
|
||||
});
|
||||
|
||||
test('create share without password stores encryption key', function () {
|
||||
$file = UploadedFile::fake()->create('document.pdf', 1024);
|
||||
|
||||
$share = $this->service->createShare([
|
||||
['file' => $file, 'relativePath' => null],
|
||||
]);
|
||||
|
||||
expect($share)->toBeInstanceOf(Share::class);
|
||||
expect($share->token)->toHaveLength(16);
|
||||
expect($share->password)->toBeNull();
|
||||
expect($share->encryption_key)->not->toBeNull();
|
||||
expect($share->encryption_salt)->not->toBeNull();
|
||||
expect($share->files)->toHaveCount(1);
|
||||
expect($share->files->first()->original_name)->toBe('document.pdf');
|
||||
});
|
||||
|
||||
test('create share with password does not store encryption key', function () {
|
||||
$file = UploadedFile::fake()->create('secret.txt', 512);
|
||||
|
||||
$share = $this->service->createShare([
|
||||
['file' => $file, 'relativePath' => null],
|
||||
], [
|
||||
'password' => 'my-password',
|
||||
]);
|
||||
|
||||
expect($share->password)->not->toBeNull();
|
||||
expect($share->encryption_key)->toBeNull();
|
||||
expect(\Illuminate\Support\Facades\Hash::check('my-password', $share->password))->toBeTrue();
|
||||
});
|
||||
|
||||
test('create share with options sets expiration and max downloads', function () {
|
||||
$file = UploadedFile::fake()->create('file.txt', 256);
|
||||
|
||||
$share = $this->service->createShare([
|
||||
['file' => $file, 'relativePath' => null],
|
||||
], [
|
||||
'expires_at' => now()->addDay(),
|
||||
'max_downloads' => 5,
|
||||
]);
|
||||
|
||||
expect($share->expires_at)->not->toBeNull();
|
||||
expect($share->max_downloads)->toBe(5);
|
||||
});
|
||||
|
||||
test('create share with multiple files', function () {
|
||||
$file1 = UploadedFile::fake()->create('file1.txt', 100);
|
||||
$file2 = UploadedFile::fake()->create('file2.txt', 200);
|
||||
|
||||
$share = $this->service->createShare([
|
||||
['file' => $file1, 'relativePath' => 'folder/file1.txt'],
|
||||
['file' => $file2, 'relativePath' => 'folder/file2.txt'],
|
||||
]);
|
||||
|
||||
expect($share->files)->toHaveCount(2);
|
||||
expect($share->files->first()->relative_path)->toBe('folder/file1.txt');
|
||||
});
|
||||
|
||||
test('delete share removes files and database records', function () {
|
||||
$file = UploadedFile::fake()->create('file.txt', 100);
|
||||
|
||||
$share = $this->service->createShare([
|
||||
['file' => $file, 'relativePath' => null],
|
||||
]);
|
||||
|
||||
$shareId = $share->id;
|
||||
$token = $share->token;
|
||||
|
||||
$this->service->deleteShare($share);
|
||||
|
||||
expect(Share::query()->find($shareId))->toBeNull();
|
||||
expect(ShareFile::query()->where('share_id', $shareId)->count())->toBe(0);
|
||||
expect(Storage::disk('shares')->directories())->not->toContain($token);
|
||||
});
|
||||
|
||||
test('verify password returns true for correct password', function () {
|
||||
$file = UploadedFile::fake()->create('file.txt', 100);
|
||||
|
||||
$share = $this->service->createShare([
|
||||
['file' => $file, 'relativePath' => null],
|
||||
], [
|
||||
'password' => 'correct-password',
|
||||
]);
|
||||
|
||||
expect($this->service->verifyPassword($share, 'correct-password'))->toBeTrue();
|
||||
expect($this->service->verifyPassword($share, 'wrong-password'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('verify password returns true for non-password share', function () {
|
||||
$file = UploadedFile::fake()->create('file.txt', 100);
|
||||
|
||||
$share = $this->service->createShare([
|
||||
['file' => $file, 'relativePath' => null],
|
||||
]);
|
||||
|
||||
expect($this->service->verifyPassword($share, 'any'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('record download increments counter', function () {
|
||||
$share = Share::factory()->create(['download_count' => 0]);
|
||||
|
||||
$this->service->recordDownload($share);
|
||||
|
||||
expect($share->fresh()->download_count)->toBe(1);
|
||||
});
|
||||
|
||||
test('record download auto-deletes when limit reached', function () {
|
||||
$share = Share::factory()->withMaxDownloads(1)->create(['download_count' => 0]);
|
||||
|
||||
$this->service->recordDownload($share);
|
||||
|
||||
expect(Share::query()->find($share->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('get total used space sums share sizes', function () {
|
||||
Share::factory()->create(['total_size' => 1000]);
|
||||
Share::factory()->create(['total_size' => 2000]);
|
||||
|
||||
expect($this->service->getTotalUsedSpace())->toBe(3000);
|
||||
});
|
||||
|
||||
test('is storage full checks against quota', function () {
|
||||
Setting::set('max_storage_quota', 1000);
|
||||
|
||||
Share::factory()->create(['total_size' => 999]);
|
||||
expect($this->service->isStorageFull())->toBeFalse();
|
||||
|
||||
Share::factory()->create(['total_size' => 1]);
|
||||
expect($this->service->isStorageFull())->toBeTrue();
|
||||
});
|
||||
|
||||
test('get decryption key returns stored key for non-password share', function () {
|
||||
$file = UploadedFile::fake()->create('file.txt', 100);
|
||||
|
||||
$share = $this->service->createShare([
|
||||
['file' => $file, 'relativePath' => null],
|
||||
]);
|
||||
|
||||
$key = $this->service->getDecryptionKey($share);
|
||||
|
||||
expect($key)->not->toBeNull();
|
||||
expect(strlen($key))->toBe(64);
|
||||
});
|
||||
|
||||
test('get decryption key derives key for password share', function () {
|
||||
$file = UploadedFile::fake()->create('file.txt', 100);
|
||||
|
||||
$share = $this->service->createShare([
|
||||
['file' => $file, 'relativePath' => null],
|
||||
], [
|
||||
'password' => 'test-password',
|
||||
]);
|
||||
|
||||
$key = $this->service->getDecryptionKey($share, 'test-password');
|
||||
|
||||
expect($key)->not->toBeNull();
|
||||
expect(strlen($key))->toBe(64);
|
||||
});
|
||||
|
||||
test('get decryption key throws for password share without password', function () {
|
||||
$file = UploadedFile::fake()->create('file.txt', 100);
|
||||
|
||||
$share = $this->service->createShare([
|
||||
['file' => $file, 'relativePath' => null],
|
||||
], [
|
||||
'password' => 'test-password',
|
||||
]);
|
||||
|
||||
$this->service->getDecryptionKey($share);
|
||||
})->throws(RuntimeException::class, 'Password required');
|
||||
Reference in New Issue
Block a user