Oihana PHP

OpenSSLFileEncryption

File-level symmetric encryption powered by OpenSSL.

Security properties of this class

What it guarantees for files written by encrypt() (format V3):

  1. Confidentiality — AES-256-GCM. Without the passphrase, the plaintext is computationally infeasible to recover.
  2. 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.
  3. Brute-force resistance on the passphrase — the encryption key is derived via Argon2id (if ext-sodium is loaded) or PBKDF2-SHA256 with 600 000 iterations. A per-file random salt prevents rainbow tables.
  4. 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.
  5. 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
example
use oihana\files\openssl\OpenSSLFileEncryption;

$crypto = new OpenSSLFileEncryption('my-secret-passphrase');

// Always produces a V3 file ('OPHE\x03…'), whatever its size.
$encryptedPath = $crypto->encrypt('/path/to/archive.tar.gz');

// Reads V3, V2 and legacy V1 transparently.
$decryptedPath = $crypto->decrypt($encryptedPath);
author

Marc Alcaraz (ekameleon)

since
1.0.0

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

$maxInputBytes

Optional cap on the size of the input files for {@see encrypt()} / {@see decrypt()}.

private int|null $maxInputBytes

null means no limit.

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-cbc matches 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
throws
InvalidArgumentException

If the passphrase is empty, the cipher is unsupported by OpenSSL, or the block-size exponent is out of range.

__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
throws
SodiumException

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 ivLength bytes 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 .enc from $inputFile.

Tags
throws
FileException

If the input file is invalid.

RuntimeException

On read/write/decryption failure (including tampering, wrong passphrase).

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:

  1. Generate a 16-byte salt and an 8-byte nonce prefix with random_bytes().
  2. Derive a 32-byte key from the passphrase + salt via the best available KDF (Argon2id if ext-sodium is loaded, PBKDF2 otherwise).
  3. Write the header: MAGIC | VERSION | KDF | BLOCKEXP | salt | nonce prefix.
  4. Read the plaintext one block at a time and append ciphertext | tag for each, the tag covering the header, the block index and the final-block flag.
  5. 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 .enc to $inputFile.

Tags
throws
FileException

If the input file is invalid.

DirectoryException

If the output directory is not writable.

RuntimeException

On read/write/encryption failure.

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 ivLength bytes 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
bool

memoryBudget()

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|null

assertKdfAvailable()

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
throws
RuntimeException

If the file's KDF needs an extension that is not loaded.

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
throws
RuntimeException

If the file needs more memory than the budget allows.

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
throws
RuntimeException

If the file size strictly exceeds the cap.

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
string

decryptV2()

Decrypts a V2 payload: reads the header, derives the key, validates the tag.

private decryptV2(string $data) : string
Parameters
$data : string
Return values
string

decryptV3()

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
throws
RuntimeException

On a malformed header, a failed tag, or a write failure.

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
throws
RuntimeException

If the file bears the magic but names a version this library does not read. Guessing would be worse: a file written by a newer release must not be run through the V1 path, which has no integrity check and would emit whatever AES-CBC made of it.

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
string

readExactly()

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
throws
RuntimeException

If the stream ends early.

Return values
string

readHeader()

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.

Tags
throws
RuntimeException

If the stream stops accepting bytes.

On this page

Search results