Skip to content

v2.0: PHP 8.2, Laravel 12/13, PHPUnit 11 and static analysis - #37

Merged
robbinjanssen merged 20 commits into
masterfrom
feature/v2-modernization
Aug 27, 2026
Merged

v2.0: PHP 8.2, Laravel 12/13, PHPUnit 11 and static analysis#37
robbinjanssen merged 20 commits into
masterfrom
feature/v2-modernization

Conversation

@robbinjanssen

Copy link
Copy Markdown
Contributor

Summary

v2.0 modernization of the package. The encrypted wire format and the key structure are unchanged: messages encrypted with v1 remain decryptable (covered by the existing hard-coded ciphertext fixtures in CryptoTest).

  • PHP ^8.2 (8.2/8.3/8.4) with ext-sodium as an explicit requirement; the whole code base uses declare(strict_types=1), typed properties, promotion, readonly and match.
  • The Laravel integration tests now actually run. They previously required a host Laravel app and had signatures that have been fatal since PHPUnit 8. They now run on orchestra/testbench (in-memory sqlite) and moved from src/Laravel/tests/ (which shipped in dist installs) to tests/Laravel/.
  • PHPUnit 9 → 11, with Mockery expectations now actually verified (MockeryPHPUnitIntegration).
  • PHPStan level 6 (larastan + phpstan-mockery), clean; runs in CI via composer analyse.
  • CI matrix: PHP 8.2–8.4 × Laravel 12 (testbench 10) / Laravel 13 (testbench 11), plus a static-analysis job. Laravel 11 is EOL with open security advisories, so it is not part of the matrix.
  • Bug fixes found along the way: the Laravel event payload was private without a getter (listeners could never read it — now public readonly); malformed ciphertext crashed with a TypeError (now a clean DecryptException); the meta key examples in README (32 chars) and docs/using.md (11 chars) did not match the required length of 10.

Breaking changes

See the "Upgrading from v1" section added to the README. Highlights: PHP ^8.2, Laravel 12/13, ext-sodium required at install time, strict types (check published configs for numeric strings in hit_points/expires_in), DecryptException instead of TypeError for malformed input, and the migration is now an anonymous class (same filename, existing installs unaffected).

Verification

  • composer test: 34 tests, 210 assertions, green (Core + Laravel suites).
  • composer analyse: PHPStan level 6, no errors.
  • php-cs-fixer dry run: clean.
  • docs/examples/basic_example.php runs and decrypts the v1 wire format.
  • Every CI matrix cell was resolution-checked locally against Packagist using config.platform.php.

🤖 Generated with Claude Code

robbinjanssen and others added 8 commits August 11, 2026 08:59
Adds orchestra/testbench (Laravel 12/13), PHPUnit 11, Mockery 1.6, PHPStan
with larastan and phpstan-mockery. Fixes the autoload-dev mapping, which
pointed the root namespace at tests/ and never actually PSR-4-resolved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rewrites phpunit.xml to the 11.5 schema (backupStaticAttributes no longer
exists, coverage include moved to <source>) with separate Core and Laravel
test suites. Renames the test namespace to Exonet\SecureMessage\Tests,
adds the Mockery-PHPUnit integration trait so expectations are actually
verified, and adds void return types and strict_types to all tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
These tests never ran: they required a host Laravel application bootstrap
file and their setUp/tearDown signatures have been fatal since PHPUnit 8.
They now run on testbench with an in-memory sqlite database. This also
fixes fixtures that omitted NOT NULL columns, a Carbon::setTestNow() leak,
a Storage factory mock that stood in for the Filesystem returned by
disk(), and moves the tests out of src/ so they no longer ship in dist
installs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
declare(strict_types=1) everywhere, typed properties, constructor
promotion, readonly and match. Properties wiped by sodium_memzero() stay
nullable because memzero nulls its by-reference argument. setMeta() casts
hit_points/expires_at to int to preserve v1 behaviour for numeric-string
input. Behavioural fixes:

- A malformed encrypted message now throws DecryptException instead of
  crashing with a TypeError (the wire format itself is unchanged).
- The Laravel event payload is now a public readonly property; it used to
  be private without a getter, so listeners could never read it.
- The migration is an anonymous class (same filename, so existing
  installations are unaffected).
- The secure_messages config comment claimed the meta key is 32
  characters; it is 10.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Uses larastan for the Eloquent magic and phpstan-mockery for the test
mocks. Runs clean via 'composer analyse'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the php-actions images with shivammathur/setup-php and the
composer-managed PHPUnit. Laravel 11 is EOL with open security advisories
(composer refuses to install it), so the matrix covers testbench 10
(Laravel 12) and 11 (Laravel 13, PHP 8.3+). Adds a PHPStan job and the
declare_strict_types rule to php-cs-fixer; the auto-commit style job is
unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes the meta key examples (the README used 32 characters, docs/using.md
used 11; the required length is exactly 10), documents the new
requirements and adds an upgrade guide covering the breaking changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@robbinjanssen robbinjanssen added the breaking-change A breaking change for existing users. label Aug 11, 2026
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 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 src/Laravel/config/secure_messages.php
Comment thread README.md
@ltenhagen ltenhagen self-assigned this Aug 27, 2026
robbinjanssen and others added 4 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>
The configured meta key is 10 characters; the key the crypto validates is
the composed database key (11) + storage key (11) + meta key (10). Spelled
out so the 10 in the config and the 32 in Crypto no longer read as a
contradiction.

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

Labels

breaking-change A breaking change for existing users. development

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants