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(); $this->service->encryptFile($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'); $this->service->encryptFile($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 = $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); $key = bin2hex($this->service->deriveKey('user-password', $this->service->generateSalt())); $this->service->encryptFile($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'); $this->service->encryptFile($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(); $this->service->encryptFile($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(); $this->service->encryptFile($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(); $this->service->encryptFile($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(); $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 () { $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)); $this->service->encryptFile($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');