Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
23 changes: 23 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,29 @@ keys (10 + 11 + 11 = 32 bytes).
- **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
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@ $secureMessage = $secureMessageFactory->make('Hello, world!');
$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
Expand Down
3 changes: 3 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
"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": {
"Exonet\\SecureMessage\\": "src"
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);
49 changes: 49 additions & 0 deletions docs/laravel.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,52 @@ following command to clean up the database and file storage:
```bash
php artisan secure_message:housekeeping
```

## Files as secure messages

### Setup
- In your `config/filesystems.php`, add a storage disk with the name `secure_messages_files`. Use a disk that is
separate from the `secure_messages` (storage key) disk — and ideally separate from the database host — so that no
single compromised store holds multiple parts of the encryption key material. With the default local driver:
`'secure_messages_files' => ['driver' => 'local', 'root' => storage_path('/secure_messages_files')],`
- Upgrading from a version before 2.1? Run `php artisan migrate` — the package ships a migration that makes the
`content` column nullable. Installations that only use text messages don't need to configure the files disk: it is
resolved lazily, only when file messages are used.

### Encrypting a file

```php
// From a path:
$encryptedMessage = \SecureMessage::encryptFile('/path/to/report.pdf');

// Or directly from an upload; the original client file name is stored automatically:
$encryptedMessage = \SecureMessage::encryptFile($request->file('attachment'));
```

The encrypted file contents are stored (double encrypted, like everything else) on the files disk; the database
record only holds the keys and meta data. The maximum file size is limited by the `max_file_size` config setting
(default 10 MB) because files are encrypted in memory and the stored blob is roughly three times the original file
size.

> **Note:** the source file itself is left untouched — `encryptFile()` only *reads* it. For uploads this is fine
> (PHP removes the temporary upload file at the end of the request), but if your application first writes a file to
> disk and then stores it as a secure message, deleting the unencrypted original afterwards is the responsibility of
> your application.

### Decrypting and downloading a file

`decryptMessage` works for file messages exactly as it does for text messages, including hit points, expiry and the
events. To offer the file as a download:

```php
$message = \SecureMessage::decryptMessage('SECUREMESSAGEID', 'verificationCode');

return response($message->getContent(), 200, [
'Content-Type' => $message->getMimeType() ?? 'application/octet-stream',
'Content-Disposition' => 'attachment; filename="'.addslashes($message->getFileName()).'"',
]);
```

> **Note:** the file meta data (including the file name!) is part of the meta and can be read server side via
> `SecureMessage::getMeta()` *without* the verification code. Don't show the file name to visitors before they have
> entered a valid verification code, unless that is intended.
38 changes: 38 additions & 0 deletions docs/using.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,41 @@ $secureMessage->setVerificationCode('a1bc2ef4xy');

$decryptedMessage = $secureMessageFactory->decrypt($secureMessage);
```

## Files as Secure Messages

A file can be stored as a secure message: the file contents become the (binary safe) message content and the file
name, mime type and file size travel along in the encrypted meta data.

```php
$secureMessageFactory = new \Exonet\SecureMessage\Factory();
$secureMessageFactory->setMetaKey('djuyteb765');

// Create a SecureMessage from a file. Note: it is not encrypted yet!
$secureMessage = $secureMessageFactory->makeFile('/path/to/report.pdf');
$encryptedMessage = $secureMessage->encrypt();
```

Decrypting works exactly the same as for text messages. After decrypting, the file meta data is available:

```php
$decrypted = $secureMessageFactory->decrypt($secureMessage);

$decrypted->isFile(); // true
$decrypted->getFileName(); // 'report.pdf'
$decrypted->getMimeType(); // 'application/pdf'
$decrypted->getFileSize(); // The size in bytes.
$decrypted->getContent(); // The raw file contents.
```

Some things to keep in mind:

- Files are encrypted **in memory**, so this is meant for small files (documents, images). The encoded, encrypted
message is roughly 1.8 times the original file size.
- The file name must be valid UTF-8 (the meta data is JSON encoded). For files with a non-UTF-8 name, pass an
explicit name: `$factory->makeFile($path, fileName: 'sanitized-name.bin')`. The same applies to files on
temporary paths (such as uploads), where the base name of the path is meaningless.
- Mime type detection requires the `fileinfo` extension; without it, `application/octet-stream` is stored.
- **The source file itself is left untouched.** `makeFile()` only *reads* the file: the original, unencrypted file
stays at its path. If the goal is that the contents only exist as a secure message, deleting (or shredding) the
source file after encrypting is the responsibility of your application.
7 changes: 7 additions & 0 deletions src/Exceptions/InvalidFileException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php

declare(strict_types=1);

namespace Exonet\SecureMessage\Exceptions;

class InvalidFileException extends SecureMessageException {}
69 changes: 69 additions & 0 deletions src/Factory.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Exonet\SecureMessage;

use Exonet\SecureMessage\Exceptions\InvalidFileException;
use Exonet\SecureMessage\Exceptions\InvalidKeyLengthException;

/**
Expand Down Expand Up @@ -71,6 +72,42 @@ public function make(string $content, int $hitPoints = 3, ?int $expiresAt = null
return $factory;
}

/**
* Make a new secure message factory for a file. The file contents become the message content and
* the file name, mime type and file size are stored in the (encrypted) meta data.
*
* @param string $path The path of the file to encrypt.
* @param int $hitPoints The number of hit points.
* @param int|null $expiresAt The expire timestamp.
* @param string|null $fileName The file name to store in the meta data. Defaults to the base name
* of the path; pass an explicit name for files on temporary paths
* (for example uploads).
*
* @throws InvalidFileException If the file does not exist, can not be read or has a file name that
* is not valid UTF-8.
*
* @return Factory The new (yet unencrypted) secure message factory.
*/
public function makeFile(string $path, int $hitPoints = 3, ?int $expiresAt = null, ?string $fileName = null): self
{
if (!is_file($path) || !is_readable($path)) {
throw new InvalidFileException(sprintf('The file [%s] does not exist or is not readable.', $path));
}

$content = file_get_contents($path);
if ($content === false) {
throw new InvalidFileException(sprintf('The file [%s] could not be read.', $path));
}

$factory = $this->make($content, $hitPoints, $expiresAt);
$factory->secureMessage
->setFileName($fileName ?? $this->getBaseName($path))
->setMimeType($this->detectMimeType($path))
->setFileSize(strlen($content));

return $factory;
}

/**
* Perform the actual encryption on the given secure message or the secure message of the current factory. A
* SecureMessage instance with the encrypted content and keys are returned. Please note that when a SecureMessage
Expand Down Expand Up @@ -211,4 +248,36 @@ protected function generateKeys(): array
'verification_code' => $verificationCode,
];
}

/**
* Get the base name of a path. basename() is locale sensitive and can truncate multibyte
* characters, so the path separators are stripped manually.
*
* @param string $path The path to get the base name of.
*
* @return string The base name.
*/
private function getBaseName(string $path): string
{
return preg_replace('#^.*[/\\\]#', '', $path) ?? $path;
}

/**
* Detect the mime type of the given file. Uses ext-fileinfo when available and falls back to
* application/octet-stream.
*
* @param string $path The path of the file.
*
* @return string The detected mime type.
*/
private function detectMimeType(string $path): string
{
if (!class_exists(\finfo::class)) {
return 'application/octet-stream';
}

$mimeType = (new \finfo(FILEINFO_MIME_TYPE))->file($path);

return $mimeType === false ? 'application/octet-stream' : $mimeType;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
/**
* Run the migrations. The content column is null for file messages: their encrypted contents
* are stored on the configured files disk instead of in the database.
*/
public function up(): void
{
Schema::table('secure_messages', function (Blueprint $table) {
$table->text('content')->nullable()->change();
});
}

/**
* Reverse the migrations. Note: reversing fails when file messages (rows with a null content
* column) exist in the table.
*/
public function down(): void
{
Schema::table('secure_messages', function (Blueprint $table) {
$table->text('content')->nullable(false)->change();
});
}
};
3 changes: 2 additions & 1 deletion src/Laravel/Database/SecureMessage.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
/**
* @property string $id The secure message ID.
* @property string $meta The encrypted meta data.
* @property string $content The encrypted content.
* @property string|null $content The encrypted content. Null for file messages: their encrypted
* contents are stored on the configured files disk.
* @property string $key The encrypted database key.
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
Expand Down
Loading