Encrypt uploads in the browser and send them in chunks

A 6 GB upload kept a customer waiting long after its progress bar
reached 100%. The server wrote every upload three times: PHP's
temporary file, Livewire's copy of it ("Processing files...") and the
encrypted file ("Create Share Link"), each a full rewrite of a slow
disk. The unencrypted copy also stayed behind in livewire-tmp.

Now the uploader's browser encrypts each file in 16 MB chunks with
WebCrypto and PUTs them one at a time; the server checks each chunk in
memory and writes it once, already encrypted. Creating the share only
wraps its key and saves the options. A 200 MB upload through the
Docker image took 2.8 s, and its download matched byte for byte.

- SEALCHK2: a 19-byte header (chunk size, 7-byte nonce prefix), then
  ciphertext and tag per chunk. Each nonce holds the chunk index and a
  last-chunk flag (the STREAM construction), so cut or reordered files
  fail to decrypt. SEALCHK1 and the single-block format still read.
- Envelope encryption: one random key per share. With a password it is
  wrapped with Argon2id (sodium, libsodium's interactive limits) in
  shares.wrapped_key, which names its parameters. Password shares from
  before keep their PBKDF2-derived key.
- The upload page registers each selection with FileUploader into a
  pending share of its own, lists the files with their progress, retries
  a failed chunk after 1-16 s, then offers Retry; Remove and Cancel
  abort. UploadChunkController only accepts chunks from the session that
  started the share: a repeat is acknowledged, a skip gets 409 with the
  count stored. Chunks go out as Blobs, which Chromium sends about eight
  times faster than ArrayBuffers.
- Uploads need a secure context: over plain HTTP the page says HTTPS is
  needed and takes no files. The Docker image gains AUTO_HTTPS, which
  serves Let's Encrypt on 443 for SERVER_NAME and redirects 80; without
  it the container stays on HTTP 80 behind a proxy. docker/Caddyfile was
  never loaded and is gone; docker/healthcheck.sh covers both modes.
- "Download all" streams the ZIP with maennchen/zipstream-php (STORE,
  ZIP64) instead of decrypting whole files into memory and writing the
  archive unencrypted to /tmp.
- Pending shares count towards the quota, stay out of the admin
  dashboard and 404 everywhere else. shares:cleanup deletes uploads idle
  for 4 hours and Livewire temporary files older than that.
- PHP's upload limits no longer cap the admin's max file size and
  default to 64M; LIVEWIRE_MAX_UPLOAD_TIME is gone and
  UPLOAD_CHUNK_SIZE_MB is new.
- Tests cover the format, key wrapping, registration limits, the chunk
  endpoint's answers, completing a share, the streamed ZIP, cleanup,
  and in Chromium a real chunked upload and the HTTPS warning; the
  selected-files overflow test runs again. README, website, CHANGELOG
  and .ai/rules follow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andreas Reinhold / reini
2026-09-16 20:49:17 +02:00
co-authored by Claude Opus 5
parent 504971ad7f
commit 40e35bab0e
53 changed files with 2280 additions and 946 deletions
+23 -2
View File
@@ -3,6 +3,7 @@
use App\Models\Setting;
use App\Models\Share;
use App\Models\User;
use App\Services\FileEncryptionService;
use App\Services\ShareService;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Crypt;
@@ -28,8 +29,28 @@ test('files dragged over the drop zone turn its shape into a burst', function ()
->assertNoJavaScriptErrors();
});
// Pest's in-process server does not store a multipart upload, so the upload itself is covered by
// FileUploadTest; this picks up where it ends, on the page the upload leads to.
test('a chosen file is encrypted in the browser, sent in chunks and shared with its exact content', function () {
// Pest's in-process server takes request bodies up to 128 KB: 64 KB chunks send this file in three.
config(['uploads.chunk_size' => 64 * 1024]);
$content = random_bytes(150 * 1024);
$path = sys_get_temp_dir().'/sealshare-browser-upload-'.uniqid().'.bin';
file_put_contents($path, $content);
$page = ready(visit('/upload'));
$page->attach('[data-test="file-input"]', $path)
->waitForText('Uploaded')
->click('[data-test="create-share"]')
->waitForText('Share Created!')
->assertNoJavaScriptErrors();
$file = Share::query()->sole()->files->sole();
expect($file->uploaded_chunks)->toBe(3);
$stored = app(FileEncryptionService::class)->decryptedChunks(app(ShareService::class)->storedFilePath($file), $file->share->encryption_key);
expect(implode('', iterator_to_array($stored, false)))->toBe($content);
unlink($path);
});
test('a new share\'s link can be copied from the page the upload leads to', function () {
$share = app(ShareService::class)->createShare(
[['file' => UploadedFile::fake()->create('contract.pdf', 80), 'relativePath' => null]],
+31 -6
View File
@@ -45,12 +45,37 @@ test('tab reaches Browse Files with its focus ring, and Enter or Space opens the
});
test('the selected files list avoids horizontal overflow once files are chosen', function () {
// Pest's in-process browser server never parses the multipart body a real file selection sends
// (vendor/pestphp/pest-plugin-browser/src/Drivers/LaravelHttpServer.php:257, `[], // @TODO
// files...`) — Livewire's temporary-upload request has nowhere to land, so a selected file can
// never reach this list to be measured. tests/Feature/FileUploadTest.php covers the list's data
// through Livewire::test(); driving a real selection through the browser is impractical here.
})->skip('the in-process browser server drops multipart uploads (LaravelHttpServer.php:257): a real file selection cannot reach the list');
$path = sys_get_temp_dir().'/a-genuinely-quite-long-holiday-photos-archive-from-portugal-'.uniqid().'.zip';
file_put_contents($path, 'archive');
$page = ready(visit('/upload')->resize(393, 852));
$page->attach('[data-test="file-input"]', $path)
->waitForText('Uploaded');
$rows = $page->script("(() => {
const rows = [...document.querySelectorAll('[data-test=selected-file]')];
return { count: rows.length, allFit: rows.every((row) => row.getBoundingClientRect().right <= window.innerWidth + 0.5) };
})()");
expect($rows['count'])->toBe(1);
expect($rows['allFit'])->toBeTrue();
$page->assertScript('document.documentElement.scrollWidth <= window.innerWidth')
->assertNoJavaScriptErrors();
unlink($path);
});
test('without a secure context the upload page says HTTPS is needed and takes no files', function () {
$page = ready(visit('/upload'));
$page->script("window.eval(\"Alpine.\$data(document.querySelector('[data-test=drop-zone]')).secure = false\")");
$page->assertScript("getComputedStyle(document.querySelector('[data-test=insecure-context]')).display !== 'none'")
->assertSee('Uploads need a secure connection (HTTPS).')
->assertScript("document.querySelector('[data-test=file-input]').disabled === true")
->assertScript("document.querySelector('[data-test=drop-zone]').getAttribute('aria-disabled') === 'true'")
->assertNoJavaScriptErrors();
});
test('the drop zone hides its burst again once the drag leaves', function () {
$page = ready(visit('/upload'));
@@ -96,3 +96,20 @@ test('without shares the dashboard shows an empty state instead of the table', f
->assertSee('No shares yet')
->assertDontSee('<table', false);
});
test('shares whose files are still uploading are neither listed nor counted, but their bytes count as used space', function () {
$admin = User::query()->where('is_admin', true)->first();
$completed = Share::factory()->create(['token' => 'completedshare01', 'total_size' => 1000]);
ShareFile::factory()->for($completed)->create();
$pending = Share::factory()->pending()->create(['token' => 'pendingshare0001', 'total_size' => 500]);
ShareFile::factory()->for($pending)->uploading()->create();
Livewire::actingAs($admin)
->test(AdminDashboard::class)
->assertSee('completedshare01')
->assertDontSee('pendingshare0001')
->assertViewHas('totalShares', 1)
->assertViewHas('activeShares', 1)
->assertViewHas('totalFiles', 1)
->assertViewHas('usedSpace', 1500);
});
+17 -9
View File
@@ -33,11 +33,9 @@ test('admin can access settings page', function () {
test('admin can save settings', function () {
$admin = User::query()->where('is_admin', true)->first();
$phpMaxMb = AdminSettings::phpMaxUploadMb();
Livewire::actingAs($admin)
->test(AdminSettings::class)
->set('maxFileSize', min(200, $phpMaxMb))
->set('maxFileSize', 200)
->set('maxStorageQuota', 50)
->set('maxFilesPerShare', 100)
->set('maxSizePerShare', 5)
@@ -46,7 +44,7 @@ test('admin can save settings', function () {
->assertHasNoErrors()
->assertDispatched('toast', type: 'success', title: 'Settings saved successfully.');
expect(Setting::get('max_file_size'))->toBe((string) (min(200, $phpMaxMb) * 1024 * 1024));
expect(Setting::get('max_file_size'))->toBe((string) (200 * 1024 * 1024));
expect(Setting::get('max_storage_quota'))->toBe((string) (50 * 1024 * 1024 * 1024));
expect(Setting::get('max_files_per_share'))->toBe('100');
expect(Setting::get('max_size_per_share'))->toBe((string) (5 * 1024 * 1024 * 1024));
@@ -55,11 +53,9 @@ test('admin can save settings', function () {
test('admin can set system password', function () {
$admin = User::query()->where('is_admin', true)->first();
$phpMaxMb = AdminSettings::phpMaxUploadMb();
Livewire::actingAs($admin)
->test(AdminSettings::class)
->set('maxFileSize', $phpMaxMb)
->set('maxFileSize', 100)
->set('systemPassword', 'new-system-password')
->call('saveSettings')
->assertHasNoErrors();
@@ -87,8 +83,7 @@ test('admin can clear system password', function () {
test('settings page loads existing values', function () {
$admin = User::query()->where('is_admin', true)->first();
$phpMaxMb = AdminSettings::phpMaxUploadMb();
$testSize = min(40, $phpMaxMb);
$testSize = 40;
Setting::set('max_file_size', $testSize * 1024 * 1024);
Setting::set('max_files_per_share', 75);
@@ -261,3 +256,16 @@ test('switching the password generator off hides its options', function () {
->assertDontSeeHtml('wire:model.live="passwordGeneratorType"')
->assertDontSeeHtml('data-test="password-example"');
});
test('a max file size far above PHP\'s upload limit loads and saves unchanged', function () {
$admin = User::query()->where('is_admin', true)->first();
Setting::set('max_file_size', 15000 * 1024 * 1024);
Livewire::actingAs($admin)
->test(AdminSettings::class)
->assertSet('maxFileSize', 15000)
->call('saveSettings')
->assertHasNoErrors();
expect(Setting::get('max_file_size'))->toBe((string) (15000 * 1024 * 1024));
});
@@ -2,6 +2,7 @@
use App\Models\Share;
use Illuminate\Support\Facades\Storage;
use Livewire\Features\SupportFileUploads\FileUploadConfiguration;
test('cleanup removes expired shares', function () {
Storage::fake('shares');
@@ -58,3 +59,35 @@ test('cleanup removes both expired and download-limited shares', function () {
expect(Share::query()->find($limitReached->id))->toBeNull();
expect(Share::query()->find($active->id))->not->toBeNull();
});
test('cleanup removes uploads no chunk reached for 4 hours, and keeps recent ones and completed shares', function () {
Storage::fake('shares');
$this->freezeTime();
$abandoned = Share::factory()->pending()->create(['updated_at' => now()->subHours(4)->subMinute()]);
Storage::disk('shares')->put($abandoned->token.'/file.enc', 'encrypted');
$recent = Share::factory()->pending()->create(['updated_at' => now()->subHours(3)]);
$completed = Share::factory()->create(['updated_at' => now()->subDays(3)]);
$this->artisan('shares:cleanup')
->expectsOutputToContain('Cleaned up 1 abandoned upload(s)')
->assertExitCode(0);
$this->assertModelMissing($abandoned);
$this->assertModelExists($recent);
$this->assertModelExists($completed);
expect(Storage::disk('shares')->directories())->not->toContain($abandoned->token);
});
test('cleanup removes temporary upload files older than 4 hours and keeps newer ones', function () {
$storage = FileUploadConfiguration::storage();
$storage->put(FileUploadConfiguration::path('old.pdf'), 'unencrypted leftover');
$storage->put(FileUploadConfiguration::path('new.png'), 'a logo being chosen');
touch($storage->path(FileUploadConfiguration::path('old.pdf')), now()->subHours(5)->getTimestamp());
$this->artisan('shares:cleanup')
->expectsOutputToContain('Cleaned up 1 temporary upload file(s)')
->assertExitCode(0);
expect($storage->exists(FileUploadConfiguration::path('old.pdf')))->toBeFalse();
expect($storage->exists(FileUploadConfiguration::path('new.png')))->toBeTrue();
});
+143 -86
View File
@@ -4,13 +4,41 @@ use App\Livewire\FileUploader;
use App\Livewire\SystemPasswordPrompt;
use App\Models\Setting;
use App\Models\Share;
use App\Models\ShareFile;
use App\Services\ShareService;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
/**
* Upload files through the page as the browser does: register them in one batch, then store each
* one's encrypted content (the chunk endpoint itself is covered by UploadChunkTest).
*
* @param array<string, string> $files name => content
* @return array<int, array<string, mixed>|null> what the page handed the browser
*/
function uploadThroughPage(Testable $component, array $files): array
{
$targets = [];
$component->call('registerFiles', collect($files)->map(fn (string $content, string $name): array => ['name' => $name, 'size' => strlen($content), 'path' => null])->values()->all())
->assertReturned(function (array $returned) use (&$targets): bool {
$targets = $returned;
return true;
});
foreach (array_values($files) as $position => $content) {
if ($targets[$position] !== null) {
$file = ShareFile::query()->findOrFail($targets[$position]['id']);
app(ShareService::class)->storeChunk($file, 0, encryptedChunk($file, $content, 0, true));
}
}
return $targets;
}
test('upload page can be rendered', function () {
$response = $this->get(route('upload'));
@@ -36,28 +64,100 @@ test('upload page accessible after system password verified', function () {
test('file upload creates share', function () {
Storage::fake('shares');
$component = Livewire::test(FileUploader::class);
uploadThroughPage($component, ['document.pdf' => 'the document']);
$file = UploadedFile::fake()->create('document.pdf', 1024);
Livewire::test(FileUploader::class)
->set('files', [$file])
->call('createShare')
$component->call('createShare')
->assertRedirectContains('/share/');
expect(Share::query()->count())->toBe(1);
$share = Share::query()->sole();
expect($share->isCompleted())->toBeTrue();
expect($share->files->pluck('original_name')->all())->toBe(['document.pdf']);
expect(session('pending_shares'))->not->toContain($share->token);
});
$share = Share::query()->first();
expect($share->files)->toHaveCount(1);
expect($share->files->first()->original_name)->toBe('document.pdf');
test('registering files hands the browser each file\'s chunk URL, the share key and the file\'s nonce prefix', function () {
Storage::fake('shares');
config(['uploads.chunk_size' => 4]);
$component = Livewire::test(FileUploader::class);
$targets = uploadThroughPage($component, ['a.txt' => 'abc']);
$file = ShareFile::query()->sole();
expect($targets[0])->toMatchArray([
'id' => $file->id,
'url' => url('upload/files/'.$file->id.'/chunks'),
'key' => $file->share->encryption_key,
'chunkSize' => 4,
'chunkCount' => 1,
]);
expect($targets[0]['noncePrefix'])->toBe(bin2hex(substr(file_get_contents(app(ShareService::class)->storedFilePath($file)), 12, 7)));
expect(session('pending_shares'))->toBe([$file->share->token]);
$component->assertSet('pendingToken', $file->share->token)
->assertSeeHtml('data-test="selected-file"');
});
test('each upload page gets its own pending share', function () {
Storage::fake('shares');
$first = Livewire::test(FileUploader::class);
$second = Livewire::test(FileUploader::class);
uploadThroughPage($first, ['one.txt' => 'one']);
uploadThroughPage($second, ['two.txt' => 'two']);
expect($first->get('pendingToken'))->not->toBe($second->get('pendingToken'));
expect(Share::query()->pluck('total_size')->all())->toBe([3, 3]);
});
test('a file an admin limit refuses gets no target and shows why, while the rest of the batch is registered', function () {
Storage::fake('shares');
Setting::set('max_file_size', 1024 * 1024);
$component = Livewire::test(FileUploader::class);
$component->call('registerFiles', [
['name' => 'small.txt', 'size' => 3, 'path' => null],
['name' => 'large.txt', 'size' => 2 * 1024 * 1024, 'path' => null],
]);
$component->assertReturned(fn (array $targets): bool => $targets[0] !== null && $targets[1] === null);
expect($component->errors()->first('files'))->toBe('"large.txt" is too large (2 MB). Maximum file size is 1 MB.');
expect(ShareFile::query()->pluck('original_name')->all())->toBe(['small.txt']);
});
test('removing files takes them out of the pending share', function () {
Storage::fake('shares');
$component = Livewire::test(FileUploader::class);
$targets = uploadThroughPage($component, ['keep.txt' => 'keep', 'remove.txt' => 'remove']);
$component->call('removeFiles', [$targets[1]['id']]);
expect(ShareFile::query()->pluck('original_name')->all())->toBe(['keep.txt']);
expect(Share::query()->sole()->total_size)->toBe(4);
});
test('a share cannot be created while a file is still uploading', function () {
Storage::fake('shares');
$component = Livewire::test(FileUploader::class)
->call('registerFiles', [['name' => 'unfinished.txt', 'size' => 10, 'path' => null]]);
$component->call('createShare');
expect($component->errors()->first('files'))->toBe('Wait until every file has finished uploading, or remove the ones that failed.');
expect(Share::query()->sole()->isCompleted())->toBeFalse();
});
test('the page offers a warning for browsers without a secure context', function () {
Livewire::test(FileUploader::class)
->assertSeeHtml('data-test="insecure-context"')
->assertSee('Uploads need a secure connection (HTTPS).');
});
test('file upload with password creates password-protected share', function () {
Storage::fake('shares');
$component = Livewire::test(FileUploader::class);
uploadThroughPage($component, ['secret.txt' => 'secret']);
$file = UploadedFile::fake()->create('secret.txt', 512);
Livewire::test(FileUploader::class)
->set('files', [$file])
$component
->set('usePassword', true)
->set('password', 'my-password')
->call('createShare')
@@ -79,9 +179,9 @@ test('switching password protection on leaves the field empty and offers a Gener
test('a generated password protects the share and is flashed, encrypted, for the page the upload leads to', function () {
Storage::fake('shares');
$component = Livewire::test(FileUploader::class)
->set('files', [UploadedFile::fake()->create('secret.txt', 512)])
->set('usePassword', true)
$component = Livewire::test(FileUploader::class);
uploadThroughPage($component, ['secret.txt' => 'secret']);
$component->set('usePassword', true)
->call('generatePassword');
$password = $component->get('password');
$component->call('createShare');
@@ -96,9 +196,10 @@ test('a generated password protects the share and is flashed, encrypted, for the
test('a share without a password flashes no password', function () {
Storage::fake('shares');
Livewire::test(FileUploader::class)
->set('files', [UploadedFile::fake()->create('document.pdf', 100)])
->call('createShare')
$component = Livewire::test(FileUploader::class);
uploadThroughPage($component, ['document.pdf' => 'the document']);
$component->call('createShare')
->assertRedirectContains('/share/');
expect(session()->has('share_password'))->toBeFalse();
@@ -136,10 +237,10 @@ test('a generator switched off offers no Generate button and generates nothing',
test('file upload with expiration sets expires_at', function () {
Storage::fake('shares');
$file = UploadedFile::fake()->create('file.txt', 256);
$component = Livewire::test(FileUploader::class);
uploadThroughPage($component, ['file.txt' => 'content']);
Livewire::test(FileUploader::class)
->set('files', [$file])
$component
->set('expiration', '24h')
->call('createShare')
->assertRedirectContains('/share/');
@@ -151,10 +252,10 @@ test('file upload with expiration sets expires_at', function () {
test('file upload with max downloads sets limit', function () {
Storage::fake('shares');
$file = UploadedFile::fake()->create('file.txt', 256);
$component = Livewire::test(FileUploader::class);
uploadThroughPage($component, ['file.txt' => 'content']);
Livewire::test(FileUploader::class)
->set('files', [$file])
$component
->set('maxDownloads', 5)
->call('createShare')
->assertRedirectContains('/share/');
@@ -163,69 +264,26 @@ test('file upload with max downloads sets limit', function () {
expect($share->max_downloads)->toBe(5);
});
test('every upload batch dispatches files-processed to clear the uploading state', function () {
Storage::fake('shares');
Livewire::test(FileUploader::class)
->set('files', [UploadedFile::fake()->create('first.txt', 64)])
->assertDispatched('files-processed')
->set('files', [UploadedFile::fake()->create('second.txt', 64)])
->assertDispatched('files-processed');
});
test('files added in multiple batches end up in the same share', function () {
Storage::fake('shares');
Livewire::test(FileUploader::class)
->set('files', [UploadedFile::fake()->create('first.txt', 64)])
->set('files', [UploadedFile::fake()->create('second.txt', 64)])
->call('createShare')
->assertHasNoErrors()
->assertRedirectContains('/share/');
$share = Share::query()->first();
expect($share->files->pluck('original_name')->all())->toBe(['first.txt', 'second.txt']);
});
test('files larger than 4 GB can be shared when within the admin file size limit', function () {
test('a file of 6 GB is accepted when the admin limits allow it', function () {
Storage::fake('shares');
Setting::set('max_file_size', 15000 * 1024 * 1024);
Setting::set('max_size_per_share', 20 * 1024 * 1024 * 1024);
Livewire::test(FileUploader::class)
->set('files', [UploadedFile::fake()->create('backup.dump', 6 * 1024 * 1024)])
->assertHasNoErrors('files')
->call('createShare')
->assertHasNoErrors()
->assertRedirectContains('/share/');
expect(Share::query()->first()->total_size)->toBe(6 * 1024 * 1024 * 1024);
});
test('a rejected upload logs the real reason instead of blaming the file size limit', function () {
Log::spy();
Setting::set('max_file_size', 15000 * 1024 * 1024);
$errors = ['files.0' => ['The files.0 failed to upload.']];
Setting::set('max_storage_quota', 50 * 1024 * 1024 * 1024);
$component = Livewire::test(FileUploader::class)
->call('_uploadErrored', 'files', json_encode(['errors' => $errors]), true)
->assertDispatched('upload:errored');
->call('registerFiles', [['name' => 'backup.dump', 'size' => 6 * 1024 * 1024 * 1024, 'path' => null]]);
expect($component->errors()->first('files'))
->toBe('Upload failed: the server could not accept the file. Please try again or contact the administrator.');
Log::shouldHaveReceived('warning')
->withArgs(fn (string $message, array $context): bool => $context['errors'] === $errors)
->once();
$component->assertHasNoErrors('files');
expect(Share::query()->sole()->total_size)->toBe(6 * 1024 * 1024 * 1024);
expect(ShareFile::query()->sole()->file_size)->toBe(6 * 1024 * 1024 * 1024);
});
test('file upload requires at least one file', function () {
Livewire::test(FileUploader::class)
->set('files', [])
->call('createShare')
->assertHasErrors(['files']);
$component = Livewire::test(FileUploader::class)
->call('createShare');
expect($component->errors()->first('files'))->toBe('Please select at least one file to upload.');
expect(Share::query()->count())->toBe(0);
});
test('file upload blocks when storage is full', function () {
@@ -233,12 +291,11 @@ test('file upload blocks when storage is full', function () {
Setting::set('max_storage_quota', 100);
Share::factory()->create(['total_size' => 100]);
$file = UploadedFile::fake()->create('file.txt', 1);
$component = Livewire::test(FileUploader::class)
->call('registerFiles', [['name' => 'file.txt', 'size' => 1, 'path' => null]]);
Livewire::test(FileUploader::class)
->set('files', [$file])
->call('createShare')
->assertHasErrors(['files']);
expect($component->errors()->first('files'))->toBe('Storage is full. Please contact the administrator.');
expect(ShareFile::query()->count())->toBe(0);
});
test('system password prompt verifies correct password', function () {
+20 -79
View File
@@ -4,8 +4,8 @@ use App\Livewire\Admin\AdminSettings;
use App\Livewire\FileUploader;
use App\Livewire\ShareDownload;
use App\Models\Share;
use App\Models\ShareFile;
use App\Models\User;
use App\Services\FileEncryptionService;
use App\Services\ShareService;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\RateLimiter;
@@ -94,23 +94,17 @@ test('rate limiter clears after successful password verification', function () {
test('share password must be at least 8 characters', function () {
Storage::fake('shares');
$file = UploadedFile::fake()->create('file.txt', 100);
Livewire::test(FileUploader::class)
->set('files', [$file])
->set('usePassword', true)
->set('password', 'short')
->call('createShare')
->assertHasErrors(['password']);
->assertHasErrors(['password' => 'min']);
});
test('share password of 8 characters is accepted', function () {
Storage::fake('shares');
$file = UploadedFile::fake()->create('file.txt', 100);
Livewire::test(FileUploader::class)
->set('files', [$file])
->set('usePassword', true)
->set('password', 'longenough')
->call('createShare')
@@ -151,42 +145,17 @@ test('setup wizard createAdmin is blocked when admin already exists', function (
test('content disposition handles special characters in filename', function () {
Storage::fake('shares');
$service = app(ShareService::class);
$encryptionService = app(FileEncryptionService::class);
$file = UploadedFile::fake()->create('normal.txt', 100);
$share = $service->createShare([
['file' => $file, 'relativePath' => null],
$share = app(ShareService::class)->createShare([
['file' => UploadedFile::fake()->createWithContent('normal.txt', 'test content'), 'relativePath' => null],
]);
$share->load('files');
$shareFile = $share->files->first();
$shareFile->update(['original_name' => 'file"with"quotes.txt']);
$shareFile->original_name = 'file"with"quotes.txt';
$shareFile->save();
$response = $this->get(route('share.download.file', [$share, $shareFile]));
$encryptedDir = Storage::disk('shares')->path($share->token);
if (! is_dir($encryptedDir)) {
mkdir($encryptedDir, 0755, true);
}
$encryptedPath = $encryptedDir.'/'.basename($shareFile->stored_path);
$tempSource = tempnam(sys_get_temp_dir(), 'test');
file_put_contents($tempSource, 'test content');
$encryptionService->encryptFile($tempSource, $encryptedPath, $share->encryption_key);
unlink($tempSource);
$response = $encryptionService->decryptFileStream(
$encryptedPath,
$share->encryption_key,
'file"with"quotes.txt',
'text/plain',
12,
);
$contentDisposition = $response->headers->get('Content-Disposition');
expect($contentDisposition)->not->toContain('file"with"quotes.txt');
expect($contentDisposition)->toContain('attachment');
expect($response->headers->get('Content-Disposition'))
->toContain('attachment')
->not->toContain('file"with"quotes.txt');
});
// --- SVG upload rejected ---
@@ -194,11 +163,9 @@ test('content disposition handles special characters in filename', function () {
test('svg upload is rejected for site logo', function () {
$admin = User::query()->where('is_admin', true)->first();
$phpMaxMb = AdminSettings::phpMaxUploadMb();
Livewire::actingAs($admin)
->test(AdminSettings::class)
->set('maxFileSize', $phpMaxMb)
->set('maxFileSize', 100)
->set('siteLogo', UploadedFile::fake()->create('logo.svg', 100, 'image/svg+xml'))
->call('saveSettings')
->assertHasErrors(['siteLogo']);
@@ -206,52 +173,26 @@ test('svg upload is rejected for site logo', function () {
// --- Relative path validation (Zip Slip prevention) ---
test('relative paths with directory traversal are sanitized', function () {
test('relative paths from a dropped folder that could reach outside the share are dropped', function (string $relativePath) {
Storage::fake('shares');
$file = UploadedFile::fake()->create('file.txt', 100);
Livewire::test(FileUploader::class)
->set('files', [$file])
->set('relativePaths', ['../../etc/passwd'])
->call('createShare')
->assertRedirectContains('/share/');
->call('registerFiles', [['name' => 'file.txt', 'size' => 100, 'path' => $relativePath]]);
$share = Share::query()->first();
$shareFile = $share->files->first();
expect($shareFile->relative_path)->toBeNull();
});
test('relative paths with absolute paths are sanitized', function () {
Storage::fake('shares');
$file = UploadedFile::fake()->create('file.txt', 100);
Livewire::test(FileUploader::class)
->set('files', [$file])
->set('relativePaths', ['/etc/passwd'])
->call('createShare')
->assertRedirectContains('/share/');
$share = Share::query()->first();
$shareFile = $share->files->first();
expect($shareFile->relative_path)->toBeNull();
});
expect(ShareFile::query()->sole()->relative_path)->toBeNull();
})->with([
'directory traversal' => '../../etc/passwd',
'absolute path' => '/etc/passwd',
'windows traversal' => '..\\..\\windows\\system.ini',
]);
test('valid relative paths are preserved', function () {
Storage::fake('shares');
$file = UploadedFile::fake()->create('file.txt', 100);
Livewire::test(FileUploader::class)
->set('files', [$file])
->set('relativePaths', ['folder/subfolder/file.txt'])
->call('createShare')
->assertRedirectContains('/share/');
->call('registerFiles', [['name' => 'file.txt', 'size' => 100, 'path' => 'folder/subfolder/file.txt']]);
$share = Share::query()->first();
$shareFile = $share->files->first();
expect($shareFile->relative_path)->toBe('folder/subfolder/file.txt');
expect(ShareFile::query()->sole()->relative_path)->toBe('folder/subfolder/file.txt');
});
// --- Security headers ---
+56 -81
View File
@@ -2,6 +2,7 @@
use App\Livewire\ShareDownload;
use App\Models\Share;
use App\Models\ShareFile;
use App\Services\FileEncryptionService;
use App\Services\ShareService;
use Illuminate\Http\UploadedFile;
@@ -81,102 +82,79 @@ test('non-password share shows files directly', function () {
test('download counter increments on zip download', function () {
Storage::fake('shares');
$share = createShareWithFile();
$share->load('files');
$encryptionService = app(FileEncryptionService::class);
$key = $share->encryption_key;
$content = 'test content';
foreach ($share->files as $file) {
$dir = Storage::disk('shares')->path($share->token);
if (! is_dir($dir)) {
mkdir($dir, 0755, true);
}
$encryptedPath = $dir.'/'.basename($file->stored_path);
$tempSource = tempnam(sys_get_temp_dir(), 'test');
file_put_contents($tempSource, $content);
$encryptionService->encryptFile($tempSource, $encryptedPath, $key);
unlink($tempSource);
$file->update(['file_size' => strlen($content)]);
}
$response = $this->withSession(['share_password_'.$share->token => null])
->get(route('share.download.all', $share));
$response->assertDownload();
$response = $this->get(route('share.download.all', $share));
$response->streamedContent();
$response->assertDownload('share-'.$share->token.'.zip');
expect($share->fresh()->download_count)->toBe(1);
});
test('zip download produces a valid archive', function () {
test('zip download streams a valid archive with every file\'s original content', function () {
Storage::fake('shares');
config(['uploads.chunk_size' => 1000]);
$binary = random_bytes(2500);
$share = app(ShareService::class)->createShare([
['file' => UploadedFile::fake()->createWithContent('notes.txt', 'hello zip content'), 'relativePath' => null],
['file' => UploadedFile::fake()->createWithContent('photo.bin', $binary), 'relativePath' => 'holiday/photo.bin'],
]);
$zipPath = tempnam(sys_get_temp_dir(), 'zip');
$share = createShareWithFile();
$share->load('files');
$encryptionService = app(FileEncryptionService::class);
$key = $share->encryption_key;
$content = 'hello zip content';
foreach ($share->files as $file) {
$dir = Storage::disk('shares')->path($share->token);
if (! is_dir($dir)) {
mkdir($dir, 0755, true);
}
$encryptedPath = $dir.'/'.basename($file->stored_path);
$tempSource = tempnam(sys_get_temp_dir(), 'test');
file_put_contents($tempSource, $content);
$encryptionService->encryptFile($tempSource, $encryptedPath, $key);
unlink($tempSource);
$file->update(['file_size' => strlen($content)]);
}
$response = $this->get(route('share.download.all', $share));
$response->assertDownload();
$zipPath = $response->getFile()->getPathname();
file_put_contents($zipPath, $this->get(route('share.download.all', $share))->streamedContent());
$zip = new ZipArchive;
$result = $zip->open($zipPath);
expect($result)->toBe(true);
expect($zip->numFiles)->toBe(1);
expect($zip->statIndex(0)['size'])->toBe(strlen($content));
expect($zip->open($zipPath))->toBeTrue();
expect($zip->numFiles)->toBe(2);
expect($zip->getFromName('notes.txt'))->toBe('hello zip content');
expect($zip->getFromName('holiday/photo.bin'))->toBe($binary);
$zip->close();
unlink($zipPath);
});
test('last download streams successfully before auto-delete', function () {
Storage::fake('shares');
$share = createShareWithFile();
$share->update(['max_downloads' => 1]);
$share->load('files');
$encryptionService = app(FileEncryptionService::class);
$key = $share->encryption_key;
$content = 'last download content';
$content = $this->get(route('share.download.all', $share))->streamedContent();
foreach ($share->files as $file) {
$dir = Storage::disk('shares')->path($share->token);
if (! is_dir($dir)) {
mkdir($dir, 0755, true);
}
$encryptedPath = $dir.'/'.basename($file->stored_path);
$tempSource = tempnam(sys_get_temp_dir(), 'test');
file_put_contents($tempSource, $content);
$encryptionService->encryptFile($tempSource, $encryptedPath, $key);
unlink($tempSource);
$file->update(['file_size' => strlen($content)]);
}
expect($content)->toStartWith("PK\x03\x04");
$this->assertModelMissing($share);
});
$response = $this->get(route('share.download.all', $share));
$response->assertDownload();
test('a share whose files are still uploading is not found anywhere a recipient or uploader could open it', function (string $route) {
Storage::fake('shares');
$share = Share::factory()->pending()->create();
$file = ShareFile::factory()->for($share)->uploading()->create();
// Share was deleted after download
expect(Share::query()->find($share->id))->toBeNull();
$response = $this->get(route($route, ['share' => $share, 'shareFile' => $file]));
$response->assertNotFound();
})->with([
'download page' => 'share.download',
'download all' => 'share.download.all',
'download one file' => 'share.download.file',
'share created page' => 'share.created',
]);
test('a password share created before key wrapping still unlocks and downloads', function () {
Storage::fake('shares');
$salt = str_repeat('cd', 32);
$share = Share::factory()->withPassword('old-password')->create(['encryption_salt' => $salt]);
$file = ShareFile::factory()->for($share)->create(['stored_path' => 'shares/'.$share->token.'/old.enc', 'file_size' => 11]);
$source = tempnam(sys_get_temp_dir(), 'old');
file_put_contents($source, 'old content');
Storage::disk('shares')->makeDirectory($share->token);
app(FileEncryptionService::class)->encryptFile($source, Storage::disk('shares')->path($share->token.'/old.enc'), bin2hex(hash_pbkdf2('sha256', 'old-password', hex2bin($salt), 100000, 32, true)), 1024);
unlink($source);
Livewire::test(ShareDownload::class, ['share' => $share])
->set('password', 'old-password')
->call('verifyPassword')
->assertSet('authenticated', true);
expect($this->get(route('share.download.file', [$share, $file]))->streamedContent())->toBe('old content');
});
test('share auto-deletes after reaching download limit', function () {
@@ -199,13 +177,10 @@ test('share auto-deletes after reaching download limit', function () {
/**
* Helper to create a share with an actual encrypted file.
*/
function createShareWithFile(?string $password = null): Share
function createShareWithFile(?string $password = null, string $content = 'test content'): Share
{
$service = app(ShareService::class);
$file = UploadedFile::fake()->create('testfile.txt', 100);
return $service->createShare([
['file' => $file, 'relativePath' => null],
return app(ShareService::class)->createShare([
['file' => UploadedFile::fake()->createWithContent('testfile.txt', $content), 'relativePath' => null],
], [
'password' => $password,
]);
+110
View File
@@ -0,0 +1,110 @@
<?php
use App\Models\Setting;
use App\Models\ShareFile;
use App\Services\ShareService;
use Illuminate\Support\Facades\Storage;
/**
* PUT a chunk's bytes as the uploader's page does, with the given pending shares in the session.
*
* @param array<int, string> $pendingShares
*/
function putChunk(mixed $test, ShareFile $file, int $index, string $chunk, array $pendingShares): mixed
{
return $test->withSession(['pending_shares' => $pendingShares])->call(
'PUT',
route('upload.chunk', ['shareFile' => $file, 'index' => $index]),
server: ['CONTENT_TYPE' => 'application/octet-stream', 'HTTP_ACCEPT' => 'application/json'],
content: $chunk,
);
}
test('a chunk for a pending share this session started is stored', function () {
Storage::fake('shares');
config(['uploads.chunk_size' => 4]);
$file = app(ShareService::class)->registerFile(null, 'notes.txt', 6, null);
$response = putChunk($this, $file, 0, encryptedChunk($file, 'abcd', 0, false), [$file->share->token]);
$response->assertOk()->assertExactJson(['uploaded_chunks' => 1]);
expect($file->refresh()->uploaded_chunks)->toBe(1);
});
test('a chunk for a pending share another session started returns 404', function () {
Storage::fake('shares');
$file = app(ShareService::class)->registerFile(null, 'notes.txt', 4, null);
$response = putChunk($this, $file, 0, encryptedChunk($file, 'abcd', 0, true), ['someOtherToken12']);
$response->assertNotFound();
expect($file->refresh()->uploaded_chunks)->toBe(0);
});
test('a chunk for a share that is already complete returns 404', function () {
Storage::fake('shares');
$file = app(ShareService::class)->registerFile(null, 'notes.txt', 4, null);
$file->share->update(['completed_at' => now()]);
$response = putChunk($this, $file, 0, encryptedChunk($file, 'abcd', 0, true), [$file->share->token]);
$response->assertNotFound();
});
test('a chunk for a removed file returns 404', function () {
Storage::fake('shares');
$service = app(ShareService::class);
$file = $service->registerFile(null, 'notes.txt', 4, null);
$chunk = encryptedChunk($file, 'abcd', 0, true);
$token = $file->share->token;
$service->removeFile($file);
$response = putChunk($this, $file, 0, $chunk, [$token]);
$response->assertNotFound();
});
test('a chunk that skips ahead returns 409 with the number of chunks stored', function () {
Storage::fake('shares');
config(['uploads.chunk_size' => 4]);
$file = app(ShareService::class)->registerFile(null, 'notes.txt', 6, null);
$response = putChunk($this, $file, 1, encryptedChunk($file, 'ef', 1, true), [$file->share->token]);
$response->assertConflict()->assertExactJson(['uploaded_chunks' => 0]);
expect($file->refresh()->uploaded_chunks)->toBe(0);
});
test('a chunk sent again after it was stored is acknowledged without storing it twice', function () {
Storage::fake('shares');
config(['uploads.chunk_size' => 4]);
$file = app(ShareService::class)->registerFile(null, 'notes.txt', 6, null);
$chunk = encryptedChunk($file, 'abcd', 0, false);
putChunk($this, $file, 0, $chunk, [$file->share->token]);
$response = putChunk($this, $file->refresh(), 0, $chunk, [$file->share->token]);
$response->assertOk()->assertExactJson(['uploaded_chunks' => 1]);
expect($file->refresh()->uploaded_chunks)->toBe(1);
});
test('an invalid chunk returns 422 and is not stored', function () {
Storage::fake('shares');
$file = app(ShareService::class)->registerFile(null, 'notes.txt', 4, null);
$response = putChunk($this, $file, 0, str_repeat("\0", 20), [$file->share->token]);
$response->assertUnprocessable();
expect($file->refresh()->uploaded_chunks)->toBe(0);
});
test('a chunk is refused until the system password was entered', function () {
Storage::fake('shares');
Setting::set('system_password', bcrypt('system-secret'));
$file = app(ShareService::class)->registerFile(null, 'notes.txt', 4, null);
$response = putChunk($this, $file, 0, encryptedChunk($file, 'abcd', 0, true), [$file->share->token]);
$response->assertRedirect(route('system-password'));
expect($file->refresh()->uploaded_chunks)->toBe(0);
});
+14
View File
@@ -1,6 +1,9 @@
<?php
use App\Models\ShareFile;
use App\Models\User;
use App\Services\FileEncryptionService;
use App\Services\ShareService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
@@ -63,3 +66,14 @@ function ready(mixed $page): mixed
return $page->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
}
/**
* A chunk of a registered file, encrypted with its share's key and its header's nonce prefix.
*/
function encryptedChunk(ShareFile $file, string $plaintext, int $index, bool $isLast): string
{
$encryption = app(FileEncryptionService::class);
$header = $encryption->parseHeader(file_get_contents(app(ShareService::class)->storedFilePath($file), false, null, 0, FileEncryptionService::HEADER_LENGTH));
return $encryption->encryptChunk($plaintext, $file->share->encryption_key, $header['noncePrefix'], $index, $isLast);
}
+23 -20
View File
@@ -1,11 +1,10 @@
<?php
use App\Models\Share;
use App\Services\QrCodeService;
use Illuminate\Http\UploadedFile;
use App\Services\ShareService;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage;
use Livewire\Features\SupportFileUploads\FileUploadConfiguration;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Tests\Screenshots\DemoData;
use Tests\Screenshots\Publisher;
@@ -19,14 +18,9 @@ beforeEach(function () {
// Sessions have to outlive a request: a sign-in, an unlocked share.
config(['session.driver' => 'file']);
Storage::fake('shares');
Storage::fake('tmp-for-tests');
$this->travelTo(Carbon::parse('2026-10-01 09:30'));
// Livewire deletes temporary uploads a day older than now, by the files' real modification
// times: under the frozen clock that is every file this run stores.
config(['livewire.temporary_file_upload.cleanup' => false]);
DemoData::shares();
});
@@ -71,27 +65,36 @@ function shoot(mixed $page, string $device, string $theme, string $name): void
}
/**
* Put files into the uploader the way a finished upload does: stored as Livewire temporary uploads
* and handed to `_finishUpload` by their signed names. A browser test cannot select files through
* the file input, because the in-process server does not store a multipart upload.
* Put files into the uploader the way a finished upload does: registered through the page's own
* `registerFiles`, their encrypted content stored here as the browser would have sent it (zeros of
* the demo size), and the list refreshed to show them uploaded.
*
* @param array<string, int> $files relative path => size in kilobytes
*/
function selectFiles(mixed $page, array $files): void
{
$signed = [];
$selection = collect($files)->map(fn (int $kilobytes, string $path): array => [
'name' => basename($path),
'size' => $kilobytes * 1024,
'path' => str_contains($path, '/') ? $path : null,
])->values();
$wire = 'Livewire.find(document.querySelector("[data-test=drop-zone]").closest("[wire\\\\:id]").getAttribute("wire:id"))';
foreach ($files as $path => $kilobytes) {
// Livewire's own storage for an arriving upload: the file and its name, type and size beside it.
$stored = FileUploadConfiguration::storeTemporaryFile(UploadedFile::fake()->create(basename($path), $kilobytes), 'tmp-for-tests');
$page->script('(async () => { await '.$wire.'.registerFiles('.json_encode($selection).') })()');
$page->waitForText('Selected Files ('.count($files).')');
$signed[] = TemporaryUploadedFile::signPath(basename($stored));
$shareService = app(ShareService::class);
foreach (Share::query()->whereNull('completed_at')->latest('id')->firstOrFail()->files as $file) {
$header = $shareService->readHeader($file);
for ($index = 0; $index < $header['chunkCount']; $index++) {
$length = min($header['chunkSize'], $file->file_size - $index * $header['chunkSize']);
$shareService->storeChunk($file->refresh(), $index, encryptedChunk($file, str_repeat("\0", $length), $index, $index === $header['chunkCount'] - 1));
}
}
$relativePaths = array_map(fn (string $path): ?string => str_contains($path, '/') ? $path : null, array_keys($files));
$page->script('(() => { const wire = Livewire.find(document.querySelector("[data-test=drop-zone]").closest("[wire\\\\:id]").getAttribute("wire:id")); wire.$set("relativePaths", '.json_encode($relativePaths).', false); wire._finishUpload("files", '.json_encode($signed).', true) })()');
$page->script('(async () => { await '.$wire.'.$refresh() })()');
$page->assertSee('Selected Files ('.count($files).')');
}
+133 -83
View File
@@ -1,7 +1,6 @@
<?php
use App\Services\FileEncryptionService;
use Symfony\Component\HttpFoundation\StreamedResponse;
beforeEach(function () {
$this->service = new FileEncryptionService;
@@ -16,6 +15,27 @@ afterEach(function () {
}
});
/**
* 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';
@@ -25,14 +45,10 @@ test('encrypt and decrypt round-trip works', function () {
$key = $this->service->generateRandomKey();
$this->service->encryptFile($sourcePath, $encryptedPath, $key);
$this->service->encryptFile($sourcePath, $encryptedPath, $key, 1024);
expect(file_exists($encryptedPath))->toBeTrue();
expect(file_get_contents($encryptedPath))->not->toBe($content);
$decrypted = $this->service->decryptFile($encryptedPath, $key);
expect($decrypted)->toBe($content);
expect(file_get_contents($encryptedPath))->not->toContain($content);
expect(decryptToString($this->service, $encryptedPath, $key))->toBe($content);
});
test('decrypt with wrong key fails', function () {
@@ -41,12 +57,9 @@ test('decrypt with wrong key fails', function () {
file_put_contents($sourcePath, 'Secret data');
$correctKey = $this->service->generateRandomKey();
$wrongKey = $this->service->generateRandomKey();
$this->service->encryptFile($sourcePath, $encryptedPath, $this->service->generateRandomKey(), 1024);
$this->service->encryptFile($sourcePath, $encryptedPath, $correctKey);
$this->service->decryptFile($encryptedPath, $wrongKey);
decryptToString($this->service, $encryptedPath, $this->service->generateRandomKey());
})->throws(RuntimeException::class, 'Decryption failed');
test('derive key produces consistent results', function () {
@@ -98,78 +111,50 @@ test('password-derived key encrypt/decrypt round-trip works', function () {
file_put_contents($sourcePath, $content);
$password = 'user-password';
$salt = $this->service->generateSalt();
$key = bin2hex($this->service->deriveKey($password, $salt));
$key = bin2hex($this->service->deriveKey('user-password', $this->service->generateSalt()));
$this->service->encryptFile($sourcePath, $encryptedPath, $key);
$decrypted = $this->service->decryptFile($encryptedPath, $key);
$this->service->encryptFile($sourcePath, $encryptedPath, $key, 1024);
expect($decrypted)->toBe($content);
expect(decryptToString($this->service, $encryptedPath, $key))->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(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 () {
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');
$key = $this->service->generateRandomKey();
$this->service->encryptFile($sourcePath, $encryptedPath, $key);
$this->service->encryptFile($sourcePath, $encryptedPath, $this->service->generateRandomKey(), 1024);
$header = file_get_contents($encryptedPath, false, null, 0, 8);
expect($header)->toBe('SEALCHK1');
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';
// Create a file larger than one 4 MB chunk (5 MB)
$chunkSize = 4 * 1024 * 1024;
$content = random_bytes($chunkSize + (1024 * 1024));
$content = random_bytes(2500);
file_put_contents($sourcePath, $content);
$key = $this->service->generateRandomKey();
$this->service->encryptFile($sourcePath, $encryptedPath, $key);
$decrypted = $this->service->decryptFile($encryptedPath, $key);
$this->service->encryptFile($sourcePath, $encryptedPath, $key, 1000);
expect($decrypted)->toBe($content);
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';
// Create a file exactly equal to one chunk (4 MB)
$content = random_bytes(4 * 1024 * 1024);
$content = random_bytes(2000);
file_put_contents($sourcePath, $content);
$key = $this->service->generateRandomKey();
$this->service->encryptFile($sourcePath, $encryptedPath, $key);
$decrypted = $this->service->decryptFile($encryptedPath, $key);
$this->service->encryptFile($sourcePath, $encryptedPath, $key, 1000);
expect($decrypted)->toBe($content);
expect(filesize($encryptedPath))->toBe(19 + 2 * 16 + 2000);
expect(decryptToString($this->service, $encryptedPath, $key))->toBe($content);
});
test('empty file round-trip works', function () {
@@ -179,58 +164,123 @@ test('empty file round-trip works', function () {
file_put_contents($sourcePath, '');
$key = $this->service->generateRandomKey();
$this->service->encryptFile($sourcePath, $encryptedPath, $key);
$decrypted = $this->service->decryptFile($encryptedPath, $key);
$this->service->encryptFile($sourcePath, $encryptedPath, $key, 1000);
expect($decrypted)->toBe('');
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();
$this->service->encryptFile($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 () {
$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);
$ciphertext = openssl_encrypt($content, 'aes-256-gcm', hex2bin($key), OPENSSL_RAW_DATA, $nonce, $tag, '', 16);
file_put_contents($encryptedPath, $nonce.$tag.$ciphertext);
$decrypted = $this->service->decryptFile($encryptedPath, $key);
expect($decrypted)->toBe($content);
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, 'Chunked secret data');
file_put_contents($sourcePath, random_bytes(2500));
$correctKey = $this->service->generateRandomKey();
$wrongKey = $this->service->generateRandomKey();
$this->service->encryptFile($sourcePath, $encryptedPath, $this->service->generateRandomKey(), 1000);
$this->service->encryptFile($sourcePath, $encryptedPath, $correctKey);
$this->service->decryptFile($encryptedPath, $wrongKey);
decryptToString($this->service, $encryptedPath, $this->service->generateRandomKey());
})->throws(RuntimeException::class, 'Decryption failed');
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';
test('a wrapped key unwraps with its password to the same data key', function () {
$dataKey = $this->service->generateRandomKey();
file_put_contents($sourcePath, $content);
$wrapped = $this->service->wrapKey($dataKey, 'correct horse battery');
$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));
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');
+189 -5
View File
@@ -3,11 +3,14 @@
use App\Models\Setting;
use App\Models\Share;
use App\Models\ShareFile;
use App\Services\FileEncryptionService;
use App\Services\ShareService;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\ValidationException;
use Tests\TestCase;
pest()->extend(TestCase::class)
@@ -29,7 +32,7 @@ test('create share without password stores encryption key', function () {
expect($share->token)->toHaveLength(16);
expect($share->password)->toBeNull();
expect($share->encryption_key)->not->toBeNull();
expect($share->encryption_salt)->not->toBeNull();
expect($share->wrapped_key)->toBeNull();
expect($share->files)->toHaveCount(1);
expect($share->files->first()->original_name)->toBe('document.pdf');
});
@@ -161,8 +164,8 @@ test('get decryption key returns stored key for non-password share', function ()
expect(strlen($key))->toBe(64);
});
test('get decryption key derives key for password share', function () {
$file = UploadedFile::fake()->create('file.txt', 100);
test('get decryption key unwraps the data key of a password share, which decrypts its files', function () {
$file = UploadedFile::fake()->createWithContent('file.txt', 'the secret contents');
$share = $this->service->createShare([
['file' => $file, 'relativePath' => null],
@@ -172,8 +175,17 @@ test('get decryption key derives key for password share', function () {
$key = $this->service->getDecryptionKey($share, 'test-password');
expect($key)->not->toBeNull();
expect(strlen($key))->toBe(64);
$decrypted = implode('', iterator_to_array(app(FileEncryptionService::class)->decryptedChunks($this->service->storedFilePath($share->files->first()), $key), false));
expect($decrypted)->toBe('the secret contents');
});
test('get decryption key derives the key of a password share created before key wrapping', function () {
$salt = str_repeat('ab', 32);
$share = Share::factory()->withPassword('old-password')->create(['encryption_salt' => $salt]);
$key = $this->service->getDecryptionKey($share, 'old-password');
expect($key)->toBe(hash_pbkdf2('sha256', 'old-password', hex2bin($salt), 100000, 64));
});
test('get decryption key throws for password share without password', function () {
@@ -187,3 +199,175 @@ test('get decryption key throws for password share without password', function (
$this->service->getDecryptionKey($share);
})->throws(RuntimeException::class, 'Password required');
test('registering a file starts a pending share with the file\'s encrypted header on disk', function () {
config(['uploads.chunk_size' => 4]);
$file = $this->service->registerFile(null, 'report.pdf', 10, 'reports/report.pdf');
expect($file->share->isCompleted())->toBeFalse();
expect($file->share->total_size)->toBe(10);
expect($file->relative_path)->toBe('reports/report.pdf');
expect($file->completed_at)->toBeNull();
expect(file_get_contents($this->service->storedFilePath($file), false, null, 0, 12))->toBe('SEALCHK2'.pack('N', 4));
});
test('a second registered file joins the same pending share and adds its size', function () {
$first = $this->service->registerFile(null, 'one.txt', 10, null);
$second = $this->service->registerFile($first->share, 'two.txt', 20, null);
expect($second->share_id)->toBe($first->share_id);
expect($second->share->total_size)->toBe(30);
});
test('a file larger than the admin file size limit is rejected', function () {
Setting::set('max_file_size', 5 * 1024 * 1024);
expect(fn () => $this->service->registerFile(null, 'big.iso', 6 * 1024 * 1024, null))
->toThrow(ValidationException::class, '"big.iso" is too large (6 MB). Maximum file size is 5 MB.');
expect(Share::query()->count())->toBe(0);
});
test('a file beyond the admin limit of files per share is rejected', function () {
Setting::set('max_files_per_share', 1);
$first = $this->service->registerFile(null, 'one.txt', 10, null);
expect(fn () => $this->service->registerFile($first->share, 'two.txt', 10, null))
->toThrow(ValidationException::class, 'Too many files. Maximum 1 files allowed per share.');
expect(ShareFile::query()->count())->toBe(1);
});
test('a file that takes the share beyond the admin size per share is rejected', function () {
Setting::set('max_size_per_share', 25);
$first = $this->service->registerFile(null, 'one.txt', 20, null);
expect(fn () => $this->service->registerFile($first->share, 'two.txt', 10, null))
->toThrow(ValidationException::class, 'Total file size exceeds the maximum allowed per share.');
});
test('a file that does not fit the storage quota beside files still uploading is rejected', function () {
Setting::set('max_storage_quota', 100);
Share::factory()->pending()->create(['total_size' => 60]);
expect(fn () => $this->service->registerFile(null, 'file.txt', 50, null))
->toThrow(ValidationException::class, 'Storage is full. Please contact the administrator.');
});
test('chunks stored in order complete the file', function () {
config(['uploads.chunk_size' => 4]);
$file = $this->service->registerFile(null, 'notes.txt', 6, null);
$afterFirst = $this->service->storeChunk($file, 0, encryptedChunk($file, 'abcd', 0, false));
$afterLast = $this->service->storeChunk($file->refresh(), 1, encryptedChunk($file, 'ef', 1, true));
expect([$afterFirst, $afterLast])->toBe([1, 2]);
expect($file->refresh()->completed_at)->not->toBeNull();
$decrypted = implode('', iterator_to_array(app(FileEncryptionService::class)->decryptedChunks($this->service->storedFilePath($file), $file->share->encryption_key), false));
expect($decrypted)->toBe('abcdef');
});
test('a chunk stored a second time is counted once', function () {
config(['uploads.chunk_size' => 4]);
$file = $this->service->registerFile(null, 'notes.txt', 6, null);
$chunk = encryptedChunk($file, 'abcd', 0, false);
$this->service->storeChunk($file, 0, $chunk);
$uploadedChunks = $this->service->storeChunk($file, 0, $chunk);
expect($uploadedChunks)->toBe(1);
expect($file->refresh()->uploaded_chunks)->toBe(1);
});
test('a chunk with the wrong length is rejected', function () {
config(['uploads.chunk_size' => 4]);
$file = $this->service->registerFile(null, 'notes.txt', 6, null);
expect(fn () => $this->service->storeChunk($file, 0, encryptedChunk($file, 'abc', 0, false)))
->toThrow(InvalidArgumentException::class, 'wrong length');
expect($file->refresh()->uploaded_chunks)->toBe(0);
});
test('a chunk that fails authentication is rejected', function () {
config(['uploads.chunk_size' => 4]);
$file = $this->service->registerFile(null, 'notes.txt', 6, null);
$chunk = encryptedChunk($file, 'abcd', 0, false);
$chunk[0] = $chunk[0] ^ "\x01";
expect(fn () => $this->service->storeChunk($file, 0, $chunk))
->toThrow(InvalidArgumentException::class, 'failed authentication');
expect($file->refresh()->uploaded_chunks)->toBe(0);
});
test('a chunk beyond the end of the file is rejected', function () {
config(['uploads.chunk_size' => 4]);
$file = $this->service->registerFile(null, 'notes.txt', 4, null);
expect(fn () => $this->service->storeChunk($file, 1, encryptedChunk($file, 'abcd', 1, true)))
->toThrow(InvalidArgumentException::class, 'beyond the end');
});
test('the MIME type is detected from the first chunk\'s content', function () {
$png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==');
$file = $this->service->registerFile(null, 'photo.bin', strlen($png), null);
$this->service->storeChunk($file, 0, encryptedChunk($file, $png, 0, true));
expect($file->refresh()->mime_type)->toBe('image/png');
});
test('a chunk for a removed file is rejected', function () {
$file = $this->service->registerFile(null, 'notes.txt', 4, null);
$chunk = encryptedChunk($file, 'abcd', 0, true);
$this->service->removeFile($file);
$this->service->storeChunk($file, 0, $chunk);
})->throws(ModelNotFoundException::class);
test('removing a file deletes it and gives its size back', function () {
$keep = $this->service->registerFile(null, 'keep.txt', 10, null);
$remove = $this->service->registerFile($keep->share, 'remove.txt', 20, null);
$path = $this->service->storedFilePath($remove);
$this->service->removeFile($remove);
expect($keep->share->refresh()->total_size)->toBe(10);
expect(file_exists($path))->toBeFalse();
$this->assertModelMissing($remove);
});
test('completing a share with a file still uploading is rejected', function () {
$file = $this->service->registerFile(null, 'notes.txt', 4, null);
expect(fn () => $this->service->completeShare($file->share))
->toThrow(ValidationException::class, 'Wait until every file has finished uploading, or remove the ones that failed.');
expect($file->share->refresh()->isCompleted())->toBeFalse();
});
test('completing a share without files is rejected', function () {
$share = Share::factory()->pending()->create();
expect(fn () => $this->service->completeShare($share))
->toThrow(ValidationException::class, 'Please select at least one file to upload.');
});
test('completing a share with a password wraps its data key instead of storing it', function () {
$file = $this->service->registerFile(null, 'notes.txt', 4, null);
$this->service->storeChunk($file, 0, encryptedChunk($file, 'abcd', 0, true));
$dataKey = $file->share->encryption_key;
$share = $this->service->completeShare($file->share, ['password' => 'a-long-password']);
expect($share->refresh()->isCompleted())->toBeTrue();
expect($share->encryption_key)->toBeNull();
expect(app(FileEncryptionService::class)->unwrapKey($share->wrapped_key, 'a-long-password'))->toBe($dataKey);
});
test('create share stores an empty file', function () {
$share = $this->service->createShare([
['file' => UploadedFile::fake()->createWithContent('empty.txt', ''), 'relativePath' => null],
]);
expect($share->files->first()->completed_at)->not->toBeNull();
expect(filesize($this->service->storedFilePath($share->files->first())))->toBe(FileEncryptionService::HEADER_LENGTH + FileEncryptionService::TAG_LENGTH);
});