Skip to content
Merged
Show file tree
Hide file tree
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 Aug 11, 2026
2eb3df8
Migrate the core test suite to PHPUnit 11
robbinjanssen Aug 11, 2026
3fa2368
Run the Laravel integration tests on orchestra/testbench
robbinjanssen Aug 11, 2026
549725d
Modernise the code base to PHP 8.2 with strict types
robbinjanssen Aug 11, 2026
aad421c
Add PHPStan static analysis at level 6
robbinjanssen Aug 11, 2026
9fa9370
Rewrite CI: PHP 8.2-8.4 matrix against Laravel 12 and 13
robbinjanssen Aug 11, 2026
ddc9cec
Update documentation for v2
robbinjanssen Aug 11, 2026
c18f060
Add AGENTS.md with guidance for AI agents
robbinjanssen Aug 11, 2026
757c9ca
Add file support to the core library
robbinjanssen Aug 11, 2026
a824072
Cover file messages and binary content in the core tests
robbinjanssen Aug 11, 2026
45005da
Store encrypted file messages on a dedicated Laravel disk
robbinjanssen Aug 11, 2026
5c4ca90
Test the Laravel file message flows
robbinjanssen Aug 11, 2026
2962aa0
Document file messages
robbinjanssen Aug 11, 2026
7dea460
Document that the source file is left untouched after encrypting
robbinjanssen Aug 11, 2026
7d2ca68
Wipe keys before dispatching decrypt-failure events
robbinjanssen Aug 11, 2026
8660737
Merge pull request #39 from exonet/feature/v2.2-security-key-wipe
ltenhagen Aug 27, 2026
6fde4cf
Clarify the two size factors in the docs
robbinjanssen Aug 27, 2026
5bab282
Document why UTF-8 validation uses PCRE
robbinjanssen Aug 27, 2026
8de8b70
Document the composed 32 byte meta key
robbinjanssen Aug 27, 2026
5d539b7
Merge pull request #38 from exonet/feature/file-messages
robbinjanssen Aug 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 42 additions & 15 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,33 +11,60 @@ on:

jobs:
run-tests:
name: Run tests
name: Tests (PHP ${{ matrix.php }}, testbench ${{ matrix.testbench }})
runs-on: ubuntu-latest

strategy:
fail-fast: false
matrix:
versions: [
{ php: "7.3", phpunit: "9" },
{ php: "7.4", phpunit: "9" },
{ php: "8.0", phpunit: "9" },
{ php: "8.1", phpunit: "10" },
]
php: ["8.2", "8.3", "8.4"]
# orchestra/testbench 10 = Laravel 12, 11 = Laravel 13.
testbench: ["^10.0", "^11.0"]
exclude:
# orchestra/testbench 11 (Laravel 13) requires PHP >= 8.3.
- php: "8.2"
testbench: "^11.0"

steps:
- name: Checkout repository
uses: actions/checkout@v5

- name: Composer install
uses: php-actions/composer@v6
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php_version: ${{ matrix.versions.php }}
php-version: ${{ matrix.php }}
extensions: sodium
coverage: none

- name: Select testbench version
run: composer require --dev --no-update "orchestra/testbench:${{ matrix.testbench }}"

- name: Install dependencies
run: composer update --prefer-dist --no-interaction --no-progress

- name: Run unit tests
uses: php-actions/phpunit@v3.0.3
run: vendor/bin/phpunit --testdox

static-analysis:
name: Static analysis
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v5

- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
configuration: phpunit.xml
version: ${{ matrix.versions.phpunit }}
php_version: ${{ matrix.versions.php }}
php-version: "8.3"
extensions: sodium
coverage: none

- name: Install dependencies
run: composer update --prefer-dist --no-interaction --no-progress

- name: Run PHPStan
run: vendor/bin/phpstan analyse --no-progress

check-code-style:
name: Check code style
Expand All @@ -53,7 +80,7 @@ jobs:

- name: Run php-cs-fixer
uses: docker://oskarstark/php-cs-fixer-ga

- name: Apply php-cs-fixer changes
uses: stefanzweifel/git-auto-commit-action@v6
with:
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@
/vendor/
composer.lock
.php-cs-fixer.cache
.phpunit.result.cache
.phpunit.result.cache
.phpunit.cache
2 changes: 2 additions & 0 deletions .php-cs-fixer.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
$config = new PhpCsFixer\Config();

return $config
->setRiskyAllowed(true)
->setRules([
'declare_strict_types' => true,
'@PSR2' => true,
'@Symfony' => true,
'@PhpCsFixer' => true,
Expand Down
116 changes: 116 additions & 0 deletions AGENTS.md
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.
37 changes: 34 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ The verification code can be sent (securely) to the receiver of the secure messa
message and read it.

## Requirements
This package requires at least PHP 7.3 with the [sodium](https://www.php.net/manual/en/sodium.installation.php) extension enabled.
This package requires PHP 8.2 or newer with the [sodium](https://www.php.net/manual/en/sodium.installation.php) extension enabled.
The optional Laravel integration supports Laravel 12 and 13. For PHP 7.3 up to 8.1, use version 1.x of this package.

## Install

Expand All @@ -33,17 +34,47 @@ $ composer require exonet/securemessage
```php
// Create the factory.
$secureMessageFactory = new Exonet\SecureMessage\Factory();
// Set the (application wide) meta key.
$secureMessageFactory->setMetaKey('A_10_random_characters_long_key.');
// Set the (application wide) meta key. This key must be exactly 10 characters long.
Comment thread
robbinjanssen marked this conversation as resolved.
$secureMessageFactory->setMetaKey('djuyteb765');

// Create a new SecureMessage. Note: it is not encrypted yet!
$secureMessage = $secureMessageFactory->make('Hello, world!');
// Encrypt the Secure Message.
$encryptedMessage = $secureMessage->encrypt();
```

Files (documents, images) can also be stored as a secure message. The file contents are encrypted in memory and the
file name, mime type and size travel along in the encrypted meta data:

```php
$secureMessage = $secureMessageFactory->makeFile('/path/to/report.pdf');
$encryptedMessage = $secureMessage->encrypt();
```

> Mime type detection uses the `fileinfo` extension when it is available.

Please see the `/docs` folder for complete documentation and additional examples.

## Upgrading from v1

Messages encrypted with v1 can still be decrypted with v2: the encrypted format and the key structure are unchanged.
Notable changes:

- PHP 8.2 or newer is required and the Laravel integration requires Laravel 12 or 13.
- The `sodium` extension is now a hard composer requirement (`ext-sodium`). On servers without the extension,
`composer install` fails immediately instead of the package failing at the first encrypt.
- The whole code base is strictly typed (`declare(strict_types=1)`). Make sure you pass the documented types.
In particular, check your published `config/secure_messages.php`: `hit_points` and `expires_in` must be real
integers. A numeric string (for example from an `env()` call) was silently coerced by v1, but throws a
`TypeError` in v2.
- The Laravel events (`DecryptionFailed`, `HitPointLimitReached`, `SecureMessageExpired`) now expose the secure
message through a `public readonly` property `$secureMessage` (previously this property was private and inaccessible
to listeners).
- A malformed encrypted message now throws a `DecryptException` when decrypting or validating a key, instead of
failing with a PHP error. Code catching `TypeError` for this case should catch `DecryptException` instead.
- The migration class `CreateSecureMessagesTable` is now an anonymous class. The migration filename is unchanged,
so existing installations are unaffected, but code referencing the class by name no longer works.

## Change log

Please see [releases][link-releases] for more information on what has changed recently.
Expand Down
19 changes: 14 additions & 5 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@
"description": "Encrypt and decrypt messages in a secure way.",
"type": "library",
"require-dev": {
"mockery/mockery": "^1.4",
"phpunit/phpunit": "^9"
"larastan/larastan": "^3.0",
"mockery/mockery": "^1.6",
"orchestra/testbench": "^10.0|^11.0",
"phpstan/phpstan": "^2.2",
"phpstan/phpstan-mockery": "^2.0",
"phpunit/phpunit": "^11.5"
},
"license": "MIT",
"authors": [
Expand All @@ -14,7 +18,11 @@
}
],
"require": {
"php": "^7.3|^8.0"
"php": "^8.2",
"ext-sodium": "*"
},
"suggest": {
"ext-fileinfo": "Required to detect the mime type of file messages (falls back to application/octet-stream)."
},
"autoload": {
"psr-4": {
Expand All @@ -23,11 +31,12 @@
},
"autoload-dev": {
"psr-4": {
"Exonet\\SecureMessage\\": "tests"
"Exonet\\SecureMessage\\Tests\\": "tests/"
}
},
"scripts": {
"test": "phpunit --testdox tests/"
"test": "phpunit --testdox",
"analyse": "phpstan analyse --no-progress"
},
"config": {
"sort-packages": true
Expand Down
2 changes: 2 additions & 0 deletions docs/examples/basic_example.php
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';
Expand Down
47 changes: 47 additions & 0 deletions docs/examples/file_example.php
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);
Loading
Loading