Skip to content

v2.1: Files as secure messages - #38

Merged
robbinjanssen merged 10 commits into
feature/v2-modernizationfrom
feature/file-messages
Aug 27, 2026
Merged

v2.1: Files as secure messages#38
robbinjanssen merged 10 commits into
feature/v2-modernizationfrom
feature/file-messages

Conversation

@robbinjanssen

Copy link
Copy Markdown
Contributor

Summary

Adds files as secure messages (v2.1, fully additive on top of #37). A file is a regular SecureMessage: the file bytes are the binary-safe content, and the file name, mime type and size travel along in the already encrypted meta data. Same split-key security, hit points, expiry and events as text messages. The wire format and Crypto are untouched.

Core

  • Factory::makeFile($path, $hitPoints, $expiresAt, $fileName) — reads a file, detects the mime type (ext-fileinfo when available, suggested in composer.json), stores name/mime/size in the encrypted meta.
  • File names and mime types are validated as UTF-8 in the setters, because the meta is JSON encoded inside the crypto path.

Laravel

  • SecureMessage::encryptFile($pathOrSplFileInfo, ...) — uploads work out of the box (client file name is used, client mime type deliberately is not). Max size guarded by the new max_file_size config (default 10 MB; files are encrypted in memory and the stored blob is ~3x the original).
  • Encrypted blobs are stored on a new, lazily resolved files disk (files_disk_name config) under a files/ prefix; the database record has a null content column, which marks it as a file message. A new migration makes content nullable — upgrading is php artisan migrate.
  • Lazy resolution means installations that never use file messages need zero configuration changes; the files/ prefix prevents blob/key-file collisions when both disks point at the same location.
  • destroy() and housekeeping clean up the blob as well.

Verification

  • composer test: 51 tests, 325 assertions, green — all pre-existing tests pass unchanged, proving the feature is additive and the files disk is only resolved for file messages.
  • composer analyse: PHPStan level 6 clean; php-cs-fixer dry run clean.
  • docs/examples/file_example.php round-trips a binary file end-to-end.
  • The new migration runs on sqlite (testbench RefreshDatabase) and uses Laravel-native change() (no doctrine/dbal).

Notes for review

  • Documented caveat: the file name is part of the meta and is readable server side via getMeta() without the verification code — don't render it on pre-verification pages.
  • A download() response helper was deliberately left out to keep the surface small; docs/laravel.md contains a 5-line recipe. Streaming encryption for large files is a possible follow-up.

🤖 Generated with Claude Code

robbinjanssen and others added 5 commits August 11, 2026 09:30
A file is a regular SecureMessage: the file bytes are the (binary safe)
content and the file name, mime type and size travel along in the already
encrypted meta data. Factory::makeFile() reads a file from a path, with an
optional file name override for files on temporary paths such as uploads.

File names and mime types must be valid UTF-8, enforced in the setters:
the meta data is JSON encoded inside Crypto::encrypt(), and json_encode()
returning false would surface as a TypeError inside the crypto path. This
keeps Crypto itself unchanged. Mime detection uses ext-fileinfo when
available (suggested in composer.json) and falls back to
application/octet-stream.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds round-trips for binary content (all 256 byte values, >1MB random
bytes), file meta accessors including the UTF-8 guards, meta survival
through encrypt/decrypt and through the failed-decrypt hit-point flow, and
the makeFile happy and error paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
File blobs go to a new, lazily resolved files disk (config key
files_disk_name) under a 'files/' prefix; the database record is stored
with a null content column, which is what marks a record as a file
message. A new migration makes the content column nullable.

Two deliberate design points:
- The files disk is resolved lazily and memoized, never in the
  constructor: existing installations upgrading to 2.1 have no files disk
  configured, and eager resolution would break every one of them. This is
  also why destroy() checks the record before touching the files disk
  (Housekeeping destroys plain messages too).
- The 'files/' prefix prevents a blob from overwriting the storage key
  file when the files disk and the storage key disk point at the same
  location.

The encrypted content is always loaded onto the SecureMessage before
decrypting, also on failure paths: the hit-point reduction and the
DecryptException constructor both need it. encryptFile() accepts a path or
an SplFileInfo (so Laravel/Symfony uploads work out of the box, using the
client name but never the client mime type) and enforces the new
max_file_size config setting before reading the file into memory.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Covers encryptFile (blob on the files disk, null content column, max size
guard), the file decrypt flow including a missing blob and the hit-point
limit path, and destroy for file messages. All pre-existing tests pass
unchanged, which proves the files disk is only resolved for file messages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds usage documentation for makeFile/encryptFile, a runnable example, the
files disk setup with a security note on separating it from the storage
key disk, the 'php artisan migrate' upgrade step, and a caveat that the
file name is part of the meta data and thus readable server side without
the verification code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@robbinjanssen robbinjanssen added the new-feature New features or options. label Aug 11, 2026
robbinjanssen and others added 2 commits August 11, 2026 09:36
encryptFile()/makeFile() only read the file; removing the unencrypted
original is the responsibility of the application.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Laravel events expose their SecureMessage as a public readonly property,
so listeners (and anything they serialise the event to, such as a queued
listener writing to Redis) can read it. Most decrypt-failure paths hand over a
wiped instance, because the DecryptException constructor wipes the keys when it
is given the secure message. Three paths throw without it and left the
decrypted key material on the dispatched instance:

- a missing storage key file (database key + verification code present);
- malformed stored ciphertext (all key parts present);
- a missing file blob (database key + verification code present).

This violates the split-key promise that the key parts never co-locate. Wipe
the secure message in the catch block, before any event is dispatched,
regardless of whether the exception carried it. The already-wiped paths are
unaffected (wiping is idempotent). Regression tests assert the dispatched event
carries no key material on both null paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@robbinjanssen
robbinjanssen marked this pull request as ready for review August 27, 2026 07:45
Wipe keys before dispatching decrypt-failure events
Comment thread docs/using.md Outdated
Comment thread src/SecureMessage.php
@ltenhagen ltenhagen self-assigned this Aug 27, 2026
robbinjanssen and others added 2 commits August 27, 2026 12:47
The 1.8x in using.md is the core encrypted payload; the 3x in laravel.md
and the config is the stored blob, which the Laravel integration encrypts
a second time. Both are measured values; the note makes the difference
explicit so they no longer read as a contradiction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mb_check_encoding reads nicer, but mbstring is not a package dependency
and PCRE always is; both validate strict UTF-8 equally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@robbinjanssen
robbinjanssen merged commit 5d539b7 into feature/v2-modernization Aug 27, 2026
1 check passed
@robbinjanssen
robbinjanssen deleted the feature/file-messages branch August 27, 2026 13:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

development new-feature New features or options.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants