$$$$`, in hex. */ public function wrapKey(string $dataKeyHex, string $password): string { $salt = random_bytes(SODIUM_CRYPTO_PWHASH_SALTBYTES); $opslimit = SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE; $memlimit = SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE; $wrappingKey = $this->deriveWrappingKey($password, $salt, $opslimit, $memlimit); $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); $box = sodium_crypto_secretbox(hex2bin($dataKeyHex), $nonce, $wrappingKey); sodium_memzero($wrappingKey); return implode('$', [self::WRAPPED_KEY_ALGORITHM, $opslimit, $memlimit, bin2hex($salt), bin2hex($nonce), bin2hex($box)]); } /** * Unwrap a share's data key with its password; returns the key as hex. */ public function unwrapKey(string $wrappedKey, string $password): string { $parts = explode('$', $wrappedKey); if (count($parts) !== 6 || $parts[0] !== self::WRAPPED_KEY_ALGORITHM) { throw new RuntimeException('Unsupported wrapped key'); } [, $opslimit, $memlimit, $salt, $nonce, $box] = $parts; $wrappingKey = $this->deriveWrappingKey($password, hex2bin($salt), (int) $opslimit, (int) $memlimit); $dataKey = sodium_crypto_secretbox_open(hex2bin($box), hex2bin($nonce), $wrappingKey); sodium_memzero($wrappingKey); if ($dataKey === false) { throw new RuntimeException('Unwrapping failed - wrong password or corrupted key'); } return bin2hex($dataKey); } /** * The header a new encrypted file starts with, with a fresh random nonce prefix. */ public function createHeader(int $chunkSize): string { return self::MAGIC.pack('N', $chunkSize).random_bytes(self::NONCE_PREFIX_LENGTH); } /** * Read a `SEALCHK2` header. * * @return array{chunkSize: int, noncePrefix: string} */ public function parseHeader(string $header): array { if (strlen($header) !== self::HEADER_LENGTH || ! str_starts_with($header, self::MAGIC)) { throw new RuntimeException('Invalid encrypted file header'); } return [ 'chunkSize' => unpack('N', substr($header, 8, 4))[1], 'noncePrefix' => substr($header, 12, self::NONCE_PREFIX_LENGTH), ]; } /** * How many chunks a file of this size is sent in; an empty file is one empty chunk. */ public function chunkCount(int $size, int $chunkSize): int { return max(1, intdiv($size + $chunkSize - 1, $chunkSize)); } /** * Where chunk `$index` starts in the encrypted file. */ public function chunkOffset(int $index, int $chunkSize): int { return self::HEADER_LENGTH + $index * ($chunkSize + self::TAG_LENGTH); } /** * Encrypt one chunk: its ciphertext followed by its tag, as WebCrypto returns it. */ public function encryptChunk(string $plaintext, string $key, string $noncePrefix, int $index, bool $isLast): string { $tag = ''; $ciphertext = openssl_encrypt( $plaintext, self::CIPHER, $this->normalizeToBinaryKey($key), OPENSSL_RAW_DATA, $this->chunkNonce($noncePrefix, $index, $isLast), $tag, '', self::TAG_LENGTH, ); if ($ciphertext === false) { throw new RuntimeException('Encryption failed at chunk '.$index); } return $ciphertext.$tag; } /** * Decrypt one chunk, which fails unless its index and last-chunk flag are the ones it was * encrypted with. */ public function decryptChunk(string $chunk, string $key, string $noncePrefix, int $index, bool $isLast): string { if (strlen($chunk) < self::TAG_LENGTH) { throw new RuntimeException('Invalid encrypted file: truncated chunk '.$index); } $plaintext = openssl_decrypt( substr($chunk, 0, -self::TAG_LENGTH), self::CIPHER, $this->normalizeToBinaryKey($key), OPENSSL_RAW_DATA, $this->chunkNonce($noncePrefix, $index, $isLast), substr($chunk, -self::TAG_LENGTH), ); if ($plaintext === false) { throw new RuntimeException('Decryption failed - wrong key or corrupted data'); } return $plaintext; } /** * Encrypt a file on the server in the `SEALCHK2` format. */ public function encryptFile(string $sourcePath, string $destPath, string $key, int $chunkSize): void { $source = fopen($sourcePath, 'rb'); if ($source === false) { throw new RuntimeException("Cannot read source file: {$sourcePath}"); } $dest = fopen($destPath, 'wb'); if ($dest === false) { fclose($source); throw new RuntimeException("Cannot write encrypted file: {$destPath}"); } try { $header = $this->createHeader($chunkSize); $noncePrefix = $this->parseHeader($header)['noncePrefix']; $chunkCount = $this->chunkCount((int) filesize($sourcePath), $chunkSize); fwrite($dest, $header); for ($index = 0; $index < $chunkCount; $index++) { $plaintext = (string) fread($source, $chunkSize); fwrite($dest, $this->encryptChunk($plaintext, $key, $noncePrefix, $index, $index === $chunkCount - 1)); } } catch (RuntimeException $e) { fclose($source); fclose($dest); @unlink($destPath); throw $e; } fclose($source); fclose($dest); } /** * Stream decrypted file content directly to output (echo). */ public function streamDecryptedFile(string $encryptedPath, string $key): void { foreach ($this->decryptedChunks($encryptedPath, $key) as $chunk) { echo $chunk; flush(); } } /** * The decrypted content of a file in any of the three formats, chunk by chunk. * * @return Generator */ public function decryptedChunks(string $encryptedPath, string $key): Generator { $magic = (string) file_get_contents($encryptedPath, false, null, 0, 8); if ($magic === self::MAGIC) { yield from $this->decryptChunks($encryptedPath, $key); } elseif ($magic === self::LEGACY_CHUNKED_MAGIC) { yield from $this->decryptLegacyChunks($encryptedPath, $key); } else { yield $this->decryptLegacy($encryptedPath, $key); } } /** * Normalize a hex key to binary. */ private function normalizeToBinaryKey(string $key): string { return strlen($key) === 64 ? hex2bin($key) : $key; } private function deriveWrappingKey(string $password, string $salt, int $opslimit, int $memlimit): string { return sodium_crypto_pwhash( SODIUM_CRYPTO_SECRETBOX_KEYBYTES, $password, $salt, $opslimit, $memlimit, SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13, ); } /** * A `SEALCHK2` chunk's nonce: the file's prefix, the chunk index and the last-chunk flag. */ private function chunkNonce(string $noncePrefix, int $index, bool $isLast): string { return $noncePrefix.pack('N', $index).($isLast ? "\x01" : "\x00"); } /** * Decrypt a `SEALCHK2` file; the chunk count comes from the file's length, so a file cut short * at a chunk boundary fails on its new last chunk. * * @return Generator */ private function decryptChunks(string $encryptedPath, string $key): Generator { $handle = fopen($encryptedPath, 'rb'); if ($handle === false) { throw new RuntimeException("Cannot read encrypted file: {$encryptedPath}"); } try { ['chunkSize' => $chunkSize, 'noncePrefix' => $noncePrefix] = $this->parseHeader((string) fread($handle, self::HEADER_LENGTH)); $storedChunkSize = $chunkSize + self::TAG_LENGTH; $payloadLength = (int) filesize($encryptedPath) - self::HEADER_LENGTH; $chunkCount = intdiv($payloadLength + $storedChunkSize - 1, $storedChunkSize); if ($chunkCount === 0) { throw new RuntimeException('Invalid encrypted file: no chunks'); } for ($index = 0; $index < $chunkCount; $index++) { $chunk = (string) fread($handle, $storedChunkSize); yield $this->decryptChunk($chunk, $key, $noncePrefix, $index, $index === $chunkCount - 1); } } finally { fclose($handle); } } /** * Decrypt a `SEALCHK1` file. * * @return Generator */ private function decryptLegacyChunks(string $encryptedPath, string $key): Generator { $handle = fopen($encryptedPath, 'rb'); if ($handle === false) { throw new RuntimeException("Cannot read encrypted file: {$encryptedPath}"); } try { fread($handle, 8); $chunkSize = unpack('N', (string) fread($handle, 4))[1]; $baseNonce = (string) fread($handle, self::NONCE_LENGTH); if (strlen($baseNonce) !== self::NONCE_LENGTH) { throw new RuntimeException('Invalid chunked file: truncated header'); } $binaryKey = $this->normalizeToBinaryKey($key); $chunkIndex = 0; while (! feof($handle)) { $tag = fread($handle, self::TAG_LENGTH); if ($tag === false || strlen($tag) === 0) { break; } if (strlen($tag) !== self::TAG_LENGTH) { throw new RuntimeException('Invalid chunked file: truncated tag at chunk '.$chunkIndex); } $ciphertext = fread($handle, $chunkSize); if ($ciphertext === false || $ciphertext === '') { throw new RuntimeException('Invalid chunked file: missing ciphertext at chunk '.$chunkIndex); } $plaintext = openssl_decrypt( $ciphertext, self::CIPHER, $binaryKey, OPENSSL_RAW_DATA, $this->legacyChunkNonce($baseNonce, $chunkIndex), $tag, ); if ($plaintext === false) { throw new RuntimeException('Decryption failed - wrong key or corrupted data'); } yield $plaintext; $chunkIndex++; } } finally { fclose($handle); } } /** * A `SEALCHK1` chunk's nonce: the chunk index XORed into the last 4 bytes of the base nonce. */ private function legacyChunkNonce(string $baseNonce, int $chunkIndex): string { $nonce = $baseNonce; $indexBytes = pack('N', $chunkIndex); for ($i = 0; $i < 4; $i++) { $nonce[self::NONCE_LENGTH - 4 + $i] = $nonce[self::NONCE_LENGTH - 4 + $i] ^ $indexBytes[$i]; } return $nonce; } /** * Decrypt a legacy single-block encrypted file. * Format: [12-byte nonce][16-byte auth tag][ciphertext] */ private function decryptLegacy(string $encryptedPath, string $key): string { $data = file_get_contents($encryptedPath); if ($data === false) { throw new RuntimeException("Cannot read encrypted file: {$encryptedPath}"); } $plaintext = openssl_decrypt( substr($data, self::NONCE_LENGTH + self::TAG_LENGTH), self::CIPHER, $this->normalizeToBinaryKey($key), OPENSSL_RAW_DATA, substr($data, 0, self::NONCE_LENGTH), substr($data, self::NONCE_LENGTH, self::TAG_LENGTH), ); if ($plaintext === false) { throw new RuntimeException('Decryption failed - wrong key or corrupted data'); } return $plaintext; } }