OpenSSLFileEncryption
File-level symmetric encryption powered by OpenSSL.
Security properties of this class
What it guarantees for files written by encrypt() (format V3):
- Confidentiality — AES-256-GCM. Without the passphrase, the plaintext is computationally infeasible to recover.
- Integrity / authenticity — every block carries a 16-byte GCM tag, and every
tag covers associated data naming the file, the block's position and whether it
closes the stream (encryptionBlockAad). Tampering
with a block, swapping two of them, dropping one, cutting the file short or
splicing two files all make
decrypt()throw instead of returning something. - Brute-force resistance on the passphrase — the encryption key is
derived via Argon2id (if
ext-sodiumis loaded) or PBKDF2-SHA256 with 600 000 iterations. A per-file random salt prevents rainbow tables. - Per-file uniqueness — a fresh random salt (16 B) + nonce prefix (8 B) is
generated for each call to
encrypt(). Encrypting the same plaintext twice with the same passphrase produces two unrelated ciphertexts. - Constant memory — both directions read and write one block at a time, so a 2 GB archive costs the same memory as a 2 kB note.
What it does NOT guarantee:
- Forward secrecy: there is no per-session key. Anyone with the passphrase can decrypt every past file encrypted with that passphrase.
- Key revocation: changing the passphrase does not invalidate old files. You must re-encrypt them with the new passphrase.
- Protection from a compromised endpoint: if an attacker can read PHP
memory while the passphrase is in use, they can recover it. The
__destruct()cleanup is best-effort. - Protection from the user choosing a weak passphrase: KDF only slows brute-force; it does not prevent it. Use long, random passphrases for sensitive data.
Why V3 streams, and V2 cannot
V2 sealed the whole file under a single GCM tag. PHP exposes no incremental AEAD
API, so verifying that one tag means holding the entire ciphertext in memory — and
openssl_decrypt() then allocates the plaintext beside it. Measured on
2026-10-24: a 300 MB file peaked at 1.18 GB, and a 1 GB file was killed by the
kernel's OOM killer, which leaves no exception and no stack trace to report. The
operation did not fail; it vanished.
V3 cuts the plaintext into blocks of EncryptionFormat::DEFAULT_BLOCK_SIZE_EXPONENT and seals each on its own, so neither direction ever holds more than a block.
Backward compatibility (legacy formats)
- V2 files (
OPHE\x02…) remain readable, through the in-memory path, behind a guard that compares the file against the memory budget and says it cannot be read here rather than being killed half way. - V1 files, written by
oihana/php-files≤ 1.0, carry a raw IV followed by AES-CBC ciphertext, with the passphrase used directly as the key and no MAC. They have no tampering detection, but they still decrypt.
encrypt() always produces V3. To migrate an older file, call decrypt() then
encrypt().
Tags
Table of Contents
Constants
- PARTIAL_SUFFIX : string = '.part'
- Suffix of the file written while an operation is still running.
Properties
- $ivLength : int
- The IV length of the legacy cipher (used for V1 backward-compat).
- $blockSizeExponent : int
- Block size of the V3 files this instance writes, as a power of two.
- $cipher : string
- The cipher method used to decrypt legacy V1 files.
- $maxInputBytes : int|null
- Optional cap on the size of the input files for {@see encrypt()} / {@see decrypt()}.
- $passphrase : string
- The passphrase used for encryption and decryption.
Methods
- __construct() : mixed
- Constructor.
- __destruct() : mixed
- Destructor.
- decrypt() : string
- Decrypts a previously encrypted file.
- encrypt() : string
- Encrypts a file in V3 format: AES-256-GCM, one sealed block at a time.
- hasEncryptedFileSize() : bool
- Checks if a file is large enough to *possibly* be a legacy V1 encrypted file.
- isEncryptedFile() : bool
- Heuristically checks whether a file appears to be encrypted by this class.
- isKdfAvailable() : bool
- Whether the KDF a file names can be run here.
- memoryBudget() : int|null
- How many bytes this process may reasonably allocate, or `null` when it cannot be told.
- assertKdfAvailable() : void
- Refuses a file whose KDF this host cannot run, and says which extension is missing.
- assertLegacyFitsInMemory() : void
- Refuses a V1/V2 file that the memory budget cannot hold, and says why.
- assertWithinMaxInputBytes() : void
- Rejects an input file whose size exceeds the configured `$maxInputBytes` cap.
- decryptLegacy() : string
- Decrypts a legacy V1 payload (no magic, no MAC, raw passphrase as key).
- decryptV2() : string
- Decrypts a V2 payload: reads the header, derives the key, validates the tag.
- decryptV3() : void
- Decrypts a V3 file, one frame at a time, into `$outputFile`.
- detectFormatVersion() : int
- Reports which of the three formats a file's first bytes announce.
- lastErrorMessage() : string
- The last PHP error message, or a placeholder when there is none.
- readExactly() : string
- Reads exactly `$length` bytes, however many reads that takes.
- readHeader() : string
- Reads at most `$length` bytes from the start of a file.
- writeAll() : void
- Writes a whole string, however many writes that takes.
Constants
PARTIAL_SUFFIX
Suffix of the file written while an operation is still running.
private
string
PARTIAL_SUFFIX
= '.part'
Neither direction writes to its destination until the last block has been
sealed, or the last tag verified. A V2 decrypt() could not leave a partial
file — it held the whole plaintext before writing a byte — but a streaming one
would, and a half-written plaintext looks exactly like a complete one. So the
payload lands beside the destination and is moved into place by a rename(),
which is atomic within a filesystem.
Properties
$ivLength
The IV length of the legacy cipher (used for V1 backward-compat).
public
int
$ivLength
Hooks
public
int
get
$blockSizeExponent
Block size of the V3 files this instance writes, as a power of two.
private
int
$blockSizeExponent
$cipher
The cipher method used to decrypt legacy V1 files.
private
string
$cipher
$maxInputBytes
Optional cap on the size of the input files for {@see encrypt()} / {@see decrypt()}.
private
int|null
$maxInputBytes
null means no limit.
$passphrase
The passphrase used for encryption and decryption.
private
string
$passphrase
Methods
__construct()
Constructor.
public
__construct(string $passphrase[, string $cipher = EncryptionFormat::LEGACY_CIPHER ][, int|null $maxInputBytes = null ][, int|null $blockSizeExponent = null ]) : mixed
Parameters
- $passphrase : string
-
Secret used to derive the encryption key. Must be non-empty.
- $cipher : string = EncryptionFormat::LEGACY_CIPHER
-
Cipher used to decrypt legacy V1 files. New V3 files always use EncryptionFormat::DEFAULT_CIPHER. Default
aes-256-cbcmatches the historical behaviour of this class. - $maxInputBytes : int|null = null
-
Optional cap on the size of the files read by encrypt() and decrypt(). When set, a file whose size exceeds this value is rejected before being read (RuntimeException). Default
null(no limit — historical behaviour). - $blockSizeExponent : int|null = null
-
Block size of the V3 files this instance writes, as a power of two. Default EncryptionFormat::DEFAULT_BLOCK_SIZE_EXPONENT (8 MiB). Validated here rather than at
encrypt()time, so a bad value fails before a file has been opened.
Tags
__destruct()
Destructor.
public
__destruct() : mixed
Best-effort wiping of the passphrase from memory. Uses sodium_memzero()
when available (true in-place wipe), falling back to a string overwrite
(which only releases the current reference — older copies created by
PHP's copy-on-write may persist in RAM).
For strongly-secured deployments, prefer wiping the passphrase at the
application level (e.g. immediately after encrypt() / decrypt())
rather than relying on this destructor.
Tags
decrypt()
Decrypts a previously encrypted file.
public
decrypt(string $inputFile[, string|null $outputFile = null ]) : string
Auto-detects the format from the first bytes (OpenSSLFileEncryption::detectFormatVersion()):
'OPHE\x03'— V3. Read one frame at a time, each tag verified against the header, the block index and the final-block flag before its plaintext is written. Constant memory.'OPHE\x02'— V2. Read whole, behind the memory guard (OpenSSLFileEncryption::assertLegacyFitsInMemory()).- anything else — legacy V1: the first
ivLengthbytes are the IV, the rest is AES-CBC ciphertext, the passphrase is the key. Also behind the guard.
Every path produces the same exception type on a cryptographic failure:
RuntimeException with the same generic message (no oracle on "wrong
passphrase" vs "tampered ciphertext").
Parameters
- $inputFile : string
-
Path to the encrypted file.
- $outputFile : string|null = null
-
Optional output path. If null, strips
.encfrom$inputFile.
Tags
Return values
string —Path to the decrypted output file.
encrypt()
Encrypts a file in V3 format: AES-256-GCM, one sealed block at a time.
public
encrypt(string $inputFile[, string|null $outputFile = null ]) : string
Steps:
- Generate a 16-byte salt and an 8-byte nonce prefix with
random_bytes(). - Derive a 32-byte key from the passphrase + salt via the best available KDF
(Argon2id if
ext-sodiumis loaded, PBKDF2 otherwise). - Write the header:
MAGIC | VERSION | KDF | BLOCKEXP | salt | nonce prefix. - Read the plaintext one block at a time and append
ciphertext | tagfor each, the tag covering the header, the block index and the final-block flag. - Move the finished file into place.
An empty input still produces one frame — an empty block and its tag. Without it a file could be reduced to its header and would then decrypt to nothing at all, silently, which is the one truncation a per-block tag cannot catch.
Parameters
- $inputFile : string
-
Path to the plaintext input file.
- $outputFile : string|null = null
-
Optional output path. If null, appends
.encto$inputFile.
Tags
Return values
string —Path to the encrypted output file.
hasEncryptedFileSize()
Checks if a file is large enough to *possibly* be a legacy V1 encrypted file.
public
hasEncryptedFileSize(string $filePath) : bool
Verifies that the file has at least enough bytes to contain a legacy IV. This is a cheap size-only check; it does NOT validate the magic or confirm anything about the content. Use isEncryptedFile() for a heuristic content check.
Parameters
- $filePath : string
-
Path to the file to check.
Return values
bool —True if the file exists and is at least ivLength bytes long.
isEncryptedFile()
Heuristically checks whether a file appears to be encrypted by this class.
public
isEncryptedFile(string $filePath) : bool
Reads the first bytes only — never the whole file, which is what the version before this one did, at the cost of the file's size in memory to answer a yes-or-no question.
For files bearing the OPHE magic, the answer is the magic plus a minimum
length for the version named. A version this library does not read still
answers true: the file is one of ours, it is simply newer.
For legacy V1 files (no magic), falls back to the historical heuristic:
- file at least
ivLengthbytes long; - IV not entirely zero;
- IV not dominated by printable characters (which would indicate plaintext).
This method is best-effort. Some plaintext binary files (e.g. arbitrary compressed data) may pass the legacy heuristic. Always treat its result as a hint, never as a guarantee that decryption will succeed.
Parameters
- $filePath : string
-
Path to the file to check.
Return values
bool —True if the file likely contains encrypted content.
isKdfAvailable()
Whether the KDF a file names can be run here.
protected
isKdfAvailable(int $kdfAlgorithm) : bool
A method rather than a direct call so a test can state the answer instead of inheriting the suite's own extensions.
Parameters
- $kdfAlgorithm : int
Return values
boolmemoryBudget()
How many bytes this process may reasonably allocate, or `null` when it cannot be told.
protected
memoryBudget() : int|null
A method rather than a direct call so a test can state a budget instead of inheriting the runner's.
Return values
int|nullassertKdfAvailable()
Refuses a file whose KDF this host cannot run, and says which extension is missing.
private
assertKdfAvailable(int $kdfAlgorithm) : void
The failure this replaces was the most misleading one in the class. A file
written where ext-sodium is loaded names Argon2id in its header; read where it
is not, deriveKey() threw, the message was wrapped under "incorrect
passphrase or corrupted data", and the real cause stayed in a previous
exception that no command line prints. Measured on 2026-10-24 with
disable_functions=sodium_crypto_pwhash: an intact archive, the right
passphrase, and a report sending the operator after a lost secret or a corrupt
file. The extension takes a minute to install; the hunt it caused did not.
Naming it gives nothing away. The missing extension is a property of the reader's own machine, which an attacker reading their own machine already knows, and it says nothing about the key or the plaintext — unlike why a tag failed, which is why that one stays generic.
Parameters
- $kdfAlgorithm : int
Tags
assertLegacyFitsInMemory()
Refuses a V1/V2 file that the memory budget cannot hold, and says why.
private
assertLegacyFitsInMemory(string $inputFile, int $version, int $size) : void
The two legacy formats seal their whole payload under one tag, and PHP has no incremental AEAD API to verify it in pieces — so reading one means holding the ciphertext, a copy of it, and the plaintext, about EncryptionFormat::LEGACY_MEMORY_FACTOR times the file.
Past the budget there are two outcomes, and the bad one is the quiet one. With a
memory_limit set, PHP throws a fatal error naming the limit. With
memory_limit = -1, the kernel's OOM killer takes the process down: no
exception, no stack trace, no log line from PHP — a backup that neither
succeeded nor failed. This refusal replaces that silence with a sentence.
No-op when the budget is unknown (availableMemoryBytes
returns null, as it does on macOS without a memory_limit): refusing on a
number nobody has would break reads that work today.
Parameters
- $inputFile : string
- $version : int
- $size : int
Tags
assertWithinMaxInputBytes()
Rejects an input file whose size exceeds the configured `$maxInputBytes` cap.
private
assertWithinMaxInputBytes(string $inputFile) : void
No-op when $maxInputBytes is null (default).
Parameters
- $inputFile : string
Tags
decryptLegacy()
Decrypts a legacy V1 payload (no magic, no MAC, raw passphrase as key).
private
decryptLegacy(string $data) : string
Kept for backward-compatibility only. Files in this format have no tampering detection; the integrity of the plaintext depends on the integrity of the storage medium.
Parameters
- $data : string
Return values
stringdecryptV2()
Decrypts a V2 payload: reads the header, derives the key, validates the tag.
private
decryptV2(string $data) : string
Parameters
- $data : string
Return values
stringdecryptV3()
Decrypts a V3 file, one frame at a time, into `$outputFile`.
private
decryptV3(string $inputFile, string $outputFile, int $size) : void
Frame boundaries come from arithmetic, not from a length field: every frame but
the last is exactly blockSize + 16 bytes, so the reader knows where each one
ends from the declared block size and the file size. Which is also how it knows
which frame is the last — and that knowledge is part of what each tag covers, so
a file cut short presents a frame sealed as non-final where a final one belongs.
Parameters
- $inputFile : string
- $outputFile : string
- $size : int
-
The file size, read once by the caller.
Tags
detectFormatVersion()
Reports which of the three formats a file's first bytes announce.
private
detectFormatVersion(string $header) : int
A file that does not start with the magic is V1 by elimination — V1 files carry no header at all, so there is nothing to recognise them by.
Parameters
- $header : string
Tags
Return values
int —One of EncryptionFormat::VERSION_V1, VERSION_V2, VERSION_V3.
lastErrorMessage()
The last PHP error message, or a placeholder when there is none.
private
static lastErrorMessage() : string
Return values
stringreadExactly()
Reads exactly `$length` bytes, however many reads that takes.
private
readExactly(resource $handle, int $length) : string
A short fread() would otherwise produce a frame smaller than the declared
block size, and a reader that computes frame boundaries from that size would
then parse the rest of the file at the wrong offsets.
Parameters
- $handle : resource
- $length : int
Tags
Return values
stringreadHeader()
Reads at most `$length` bytes from the start of a file.
private
readHeader(string $filePath, int $length) : string
The whole reason isEncryptedFile() and decrypt() no longer begin
with file_get_contents(): deciding what a file is takes 31 bytes, and the
version that read all of them turned a 2 GB archive into 2 GB of memory to
answer a yes-or-no question.
Parameters
- $filePath : string
- $length : int
Return values
string —What was read, possibly shorter than asked, or '' when the file
cannot be opened.
writeAll()
Writes a whole string, however many writes that takes.
private
writeAll(resource $handle, string $data, string $context) : void
Parameters
- $handle : resource
- $data : string
- $context : string
-
Message prefix, so a failure reads as the operation the caller is performing rather than as a generic write error.