-
Notifications
You must be signed in to change notification settings - Fork 0
v2.0: PHP 8.2, Laravel 12/13, PHPUnit 11 and static analysis #37
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
bcd5143
Require PHP 8.2 and ext-sodium, modernise dev dependencies
robbinjanssen 2eb3df8
Migrate the core test suite to PHPUnit 11
robbinjanssen 3fa2368
Run the Laravel integration tests on orchestra/testbench
robbinjanssen 549725d
Modernise the code base to PHP 8.2 with strict types
robbinjanssen aad421c
Add PHPStan static analysis at level 6
robbinjanssen 9fa9370
Rewrite CI: PHP 8.2-8.4 matrix against Laravel 12 and 13
robbinjanssen ddc9cec
Update documentation for v2
robbinjanssen c18f060
Add AGENTS.md with guidance for AI agents
robbinjanssen 757c9ca
Add file support to the core library
robbinjanssen a824072
Cover file messages and binary content in the core tests
robbinjanssen 45005da
Store encrypted file messages on a dedicated Laravel disk
robbinjanssen 5c4ca90
Test the Laravel file message flows
robbinjanssen 2962aa0
Document file messages
robbinjanssen 7dea460
Document that the source file is left untouched after encrypting
robbinjanssen 7d2ca68
Wipe keys before dispatching decrypt-failure events
robbinjanssen 8660737
Merge pull request #39 from exonet/feature/v2.2-security-key-wipe
ltenhagen 6fde4cf
Clarify the two size factors in the docs
robbinjanssen 5bab282
Document why UTF-8 validation uses PCRE
robbinjanssen 8de8b70
Document the composed 32 byte meta key
robbinjanssen 5d539b7
Merge pull request #38 from exonet/feature/file-messages
robbinjanssen File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,4 +2,5 @@ | |
| /vendor/ | ||
| composer.lock | ||
| .php-cs-fixer.cache | ||
| .phpunit.result.cache | ||
| .phpunit.result.cache | ||
| .phpunit.cache | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| # AGENTS.md | ||
|
|
||
| Guidance for AI agents working in this repository. | ||
|
|
||
| ## What this is | ||
|
|
||
| `exonet/securemessage` is a framework-agnostic PHP library (with an optional | ||
| Laravel integration) for encrypting messages using libsodium secretbox. The | ||
| 32-byte encryption key is deliberately split into three parts stored in | ||
| different places, so a single compromised store never yields a complete key: | ||
|
|
||
| - **database key** — 11 random bytes, stored in a database. | ||
| - **storage key** — 11 random bytes, stored on a disk/filesystem. | ||
| - **verification code** — 10 characters, never stored; sent to the recipient. | ||
|
|
||
| The message meta data (expiry timestamp, remaining "hit points" = allowed | ||
| failed decrypt attempts) is encrypted separately with a **meta key**: an | ||
| application-wide 10-character key concatenated with the database and storage | ||
| keys (10 + 11 + 11 = 32 bytes). | ||
|
|
||
| ## Layout | ||
|
|
||
| - `src/Crypto.php` — sodium encrypt/decrypt, hit-point reduction, key validation. | ||
| - `src/Factory.php` — creates messages, generates the three key parts. | ||
| - `src/SecureMessage.php` — value object holding content, keys and meta; has | ||
| `wipe*FromMemory()` methods built on `sodium_memzero()`. | ||
| - `src/Exceptions/` — all extend `SecureMessageException`; `ExpiredException` | ||
| and `HitPointLimitReachedException` extend `DecryptException`. | ||
| - `src/Laravel/` — service provider, facade, Eloquent model + migration, | ||
| config, events and the `secure_message:housekeeping` command. Persists the | ||
| storage key via a Laravel filesystem disk and the rest in the database, each | ||
| wrapped in Laravel's own `Encrypter` as a second layer. | ||
| - `tests/` — PHPUnit tests for the core library; `tests/Laravel/` — tests for | ||
| the Laravel integration, running on orchestra/testbench (in-memory sqlite). | ||
| - `docs/` — usage documentation and a runnable example. | ||
|
|
||
| ## Commands | ||
|
|
||
| - `composer test` — runs the whole suite (testsuites `Core` and `Laravel`, | ||
| PHPUnit 11). Requires PHP with the `sodium` extension (available locally). | ||
| - `composer analyse` — PHPStan level 6 (with larastan and phpstan-mockery), | ||
| configured in `phpstan.neon.dist`. Keep it clean. | ||
| - Code style is enforced by php-cs-fixer using `.php-cs-fixer.php` | ||
| (`@PSR2` + `@Symfony` + `@PhpCsFixer` plus overrides, including | ||
| `declare_strict_types`). CI runs it on every PR **and auto-commits the | ||
| fixes to the PR branch**, so don't be surprised by extra commits; running | ||
| the fixer locally before pushing avoids them. | ||
|
|
||
| ## Constraints and gotchas | ||
|
|
||
| - **PHP compatibility: `^8.2`** (v2). CI tests 8.2, 8.3 and 8.4, against both | ||
| Laravel 12 (testbench `^10.0`) and Laravel 13 (testbench `^11.0`). Typed | ||
| properties, promotion, readonly and match are in use; typed class constants | ||
| are NOT (8.3+ feature). | ||
| - **Everything is `declare(strict_types=1)`.** When adding code paths, mind | ||
| implicit coercions that no longer happen (e.g. `SecureMessage::setMeta()` | ||
| deliberately casts `hit_points`/`expires_at` to int for this reason). | ||
| - **No production dependencies** other than `php` and `ext-sodium`. The | ||
| Laravel classes reference `illuminate/*` and `nesbot/carbon`, which resolve | ||
| via orchestra/testbench in dev and via the host app in production. Don't | ||
| add them to `require`. | ||
| - **`sodium_memzero()` nulls its by-reference argument.** Every property that | ||
| gets wiped in `SecureMessage::wipe*FromMemory()` must stay nullable | ||
| (`?string = null`) and must never be `readonly`, or wiping throws a | ||
| `TypeError` in the security-critical path. | ||
| - **Don't add native types to inherited Laravel properties** (`$table`, | ||
| `$incrementing`, `$keyType`, `$signature`, `$description`) — the parents | ||
| declare them untyped, so typing them is a fatal error. | ||
| - **The migration filename must never change.** Laravel records migrations by | ||
| filename; renaming re-runs it and crashes existing installs. | ||
| - **Key lengths are load-bearing.** `Factory::setMetaKey()` requires exactly | ||
| 10 characters; `Crypto` requires the *combined* keys to be exactly 32 bytes | ||
| (11-byte database key + 11-byte storage key + 10-char verification code). | ||
|
|
||
| ## File messages (since v2.1) | ||
|
|
||
| - A file is a regular `SecureMessage`: the content holds the file bytes, the | ||
| encrypted meta carries `file_name`, `mime_type` and `file_size`. There is no | ||
| separate file class; `isFile()` means "meta has a file_name". | ||
| - File names (and mime types) must be valid UTF-8 — the meta is JSON encoded | ||
| and `json_encode()` returning false would blow up inside the crypto path. | ||
| The setters validate this; keep it that way. | ||
| - In the Laravel integration, `content === null` on the database record ⇔ | ||
| file message: the encrypted blob lives on the files disk under | ||
| `files/{id}`. The `files/` prefix is load-bearing — without it a blob would | ||
| overwrite the storage-key file when both disks point at the same location. | ||
| - The files disk is resolved **lazily** (`Laravel\Factory::filesDisk()`), so | ||
| installations that never use file messages don't need to configure it. | ||
| Never resolve it in the constructor or in code paths that plain text | ||
| messages hit (this includes `destroy()`, which checks the record first). | ||
| - The encrypted content must always be loaded onto the `SecureMessage` | ||
| *before* `decrypt()` is called, also on failure paths — null content causes | ||
| `TypeError`s inside `Crypto` and inside the `DecryptException` constructor. | ||
| - The `$meta` array type is `array<string, int|string|null>`; PHPStan level 6 | ||
| accepts this, levels 7+ would need the narrowing the file-meta getters | ||
| already do. Don't loosen those getters. | ||
| - **Security invariants — preserve them when touching `Crypto`/`SecureMessage`:** | ||
| nonces are randomly generated per encryption and never reused; failed or | ||
| invalid decrypt attempts must keep reducing hit points (this is the | ||
| brute-force protection); plaintext, keys and decrypted meta are wiped with | ||
| `sodium_memzero()` after use. Don't weaken or reorder these paths. | ||
| - Changing the encrypted wire format (`Crypto::toString()`/`fromString()`: | ||
| base64 of a JSON array of base64 nonce + ciphertext) breaks decryption of | ||
| all previously stored messages — treat it as a breaking change. | ||
|
|
||
| ## Conventions | ||
|
|
||
| - Every method has a full PHPDoc block (`@param`/`@throws`/`@return` with | ||
| descriptions) — match this style; php-cs-fixer enforces the ordering. | ||
| - Setters return `$this` (fluent); properties are `private` with getters/setters. | ||
| - Follow SemVer. PRs need tests, documentation updates for behaviour changes, | ||
| and **exactly the labels CI expects** (`bugfix`, `new-feature`, | ||
| `breaking-change`, `enhancement`, `documentation`, `dependencies`, | ||
| `maintenance`, `ci`, …) — the `verify-pr-labels` workflow blocks unlabeled | ||
| PRs, and release-drafter builds the changelog and version bump from labels. | ||
| - Security issues go to development@exonet.nl, never the public issue tracker. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,7 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| use Exonet\SecureMessage\Factory; | ||
|
|
||
| require __DIR__.'/../../vendor/autoload.php'; | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| use Exonet\SecureMessage\Factory; | ||
|
|
||
| require __DIR__.'/../../vendor/autoload.php'; | ||
|
|
||
| // Create a small binary example file. | ||
| $examplePath = tempnam(sys_get_temp_dir(), 'securemessage_example'); | ||
| file_put_contents($examplePath, "\x89PNG\r\n\x1A\n".random_bytes(256)); | ||
|
|
||
| // Create the factory. | ||
| $secureMessageFactory = new Factory(); | ||
| // Set the (application wide) meta key. (Don't use this simple key in production!) | ||
| $secureMessageFactory->setMetaKey('0123456789'); | ||
|
|
||
| // Create a new SecureMessage from the file and encrypt it. The file name, mime type and size are | ||
| // stored in the encrypted meta data. | ||
| $secureMessage = $secureMessageFactory->makeFile($examplePath, fileName: 'example.bin'); | ||
| $encryptedMessage = $secureMessage->encrypt(); | ||
|
|
||
| echo '---[ ENCRYPTED FILE MESSAGE ]---'."\n"; | ||
| echo sprintf("ID: %s\n", $encryptedMessage->getId()); | ||
| echo sprintf("Verification code: %s\n", $encryptedMessage->getVerificationCode()); | ||
| echo sprintf("Encrypted size: %d bytes\n", strlen((string) $encryptedMessage->getEncryptedContent())); | ||
|
|
||
| echo "\n"; | ||
|
|
||
| /* | ||
| * To keep things simple for this example, the encrypted data and keys are reused directly. In a real | ||
| * world application you'll have to store the keys at their three separate locations, and read them | ||
| * back when the receiver enters the verification code. | ||
| */ | ||
| $decryptedMessage = $secureMessageFactory->decrypt($encryptedMessage); | ||
|
|
||
| echo '---[ DECRYPTED FILE MESSAGE ]---'."\n"; | ||
| echo sprintf("Is file: %s\n", $decryptedMessage->isFile() ? 'yes' : 'no'); | ||
| echo sprintf("File name: %s\n", $decryptedMessage->getFileName()); | ||
| echo sprintf("Mime type: %s\n", $decryptedMessage->getMimeType()); | ||
| echo sprintf("File size: %d bytes\n", $decryptedMessage->getFileSize()); | ||
| echo sprintf( | ||
| "Contents intact: %s\n", | ||
| $decryptedMessage->getContent() === file_get_contents($examplePath) ? 'yes' : 'no' | ||
| ); | ||
|
|
||
| unlink($examplePath); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.