Skip to content
Merged
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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Instructions for AI agents working in this repository.
- Listeners auto-discovered from `src/Listeners/`, translations from `lang/`.
- Encryption: `Crypt::encryptString` with `enc:v1:` marker prefix. No double encryption.
- Handle decrypt failures gracefully — log warning, return raw ciphertext.
- `DecryptingSubmissionRepository` and `DecryptingSubmissionQueryBuilder` mirror identical `decryptSubmission()` / `isAuthorizedForForm()` logic. Any change to either file **must** be applied to the other.
- Field config toggle scoped to `Text` and `Textarea` fieldtypes only (not global `Fieldtype::`).
- Permission: `view decrypted sensitive fields`. Super admins always authorized.
- Compatible with both Stache (flat-file) and Eloquent Driver.
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ This changelog is used as the base for GitHub Release notes.

- [new] PRO: `sensitive-fields:rekey` — re-encrypts sensitive field values from an old `APP_KEY` to the current one (supports `--old-key`, `--form`, `--dry-run`)
- [new] PRO: per-form permission granularity — `view decrypted {form-handle} sensitive fields` grants access to a single form; the global `view decrypted sensitive fields` acts as a wildcard (backward-compatible)
- [new] CP error toast on decrypt failure — shown once per form per hour when a sensitive field cannot be decrypted (e.g. after an APP_KEY rotation); suppressed in CLI context

## 1.0.0 (2026-02-17)

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ Go to **CP → Tools → Addons → Sensitive Form Fields → Settings**:
- **Pro, authorized** — decrypts and returns plain text
- **Pro, unauthorized** — returns the configured mask string
3. Values already prefixed with `enc:v1:` are never double-encrypted.
4. If decryption fails (e.g. after `APP_KEY` rotation), the raw ciphertext is returned and a warning is logged.
4. If decryption fails (e.g. after `APP_KEY` rotation), the raw ciphertext is returned, a warning is logged, and an error toast is shown in the CP (once per form per hour to avoid notification spam).

---

Expand Down
5 changes: 3 additions & 2 deletions docs/OVERVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ tests/
- Checks the current user's permission (`view decrypted sensitive fields`).
- **Authorized**: strips `enc:v1:` prefix and decrypts the value.
- **Unauthorized**: replaces the value with the mask string (default `••••••`).
4. If decryption fails (e.g. key rotation), returns raw ciphertext and logs a warning.
4. If decryption fails (e.g. key rotation), returns raw ciphertext, logs a warning, and dispatches a CP error toast to the current user (HTTP context only; deduplicated to once per form per hour via `Cache::add`).

### Addon Settings

Expand Down Expand Up @@ -98,8 +98,9 @@ vendor/bin/phpunit

### Test Coverage

- **Unit tests** (`FieldEncryptorTest`, 7 tests): marker detection, encrypt/decrypt round-trip, double-encryption prevention, non-string skipping, decrypt failure handling, mask value.
- **Unit tests** (`FieldEncryptorTest`, 9 tests): marker detection, encrypt/decrypt round-trip, double-encryption prevention, non-string skipping, decrypt failure handling, mask value, CP toast console guard.
- **Feature tests** (`SensitiveFieldsTest`, 12 tests): full write/read flow, free/pro mode, permission-based masking, query-builder decryption, per-form permission scoping.
- **Unit tests** (`FieldEncryptorTest`, 9 tests): marker detection, encrypt/decrypt round-trip, double-encryption prevention, non-string skipping, decrypt failure handling, mask value, CP toast console guard.
- **PRO command tests** (`ProCommandsTest`, 6 tests): bulk encrypt/decrypt, dry-run, skip-already-encrypted.

### PRO Commands
Expand Down
16 changes: 9 additions & 7 deletions docs/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,14 +172,16 @@ Edition is detected via the **Statamic Editions API**: `Addon::edition()` reads

## Tests

### Unit (FieldEncryptorTest, 7 tests)
### Unit (FieldEncryptorTest, 9 tests)
1. Encrypts value with marker prefix
2. Decrypts back to plaintext
3. No double encryption
4. Failed decrypt returns raw + logs warning
5. isEncrypted detects prefix
6. mask returns configured value
7. decrypt returns non-encrypted as-is
8. Failed decrypt does not dispatch toast in console context
9. Failed decrypt without context does not dispatch toast in console context

### Feature (SensitiveFieldsTest, 12 tests)
1. Sensitive field stored encrypted
Expand Down Expand Up @@ -243,11 +245,11 @@ Larger teams need per-form control (e.g. HR form vs. contact form handled by dif

---

### [FREE/PRO] CP notification on decrypt failure — Planned
### [FREE/PRO] CP notification on decrypt failure — Implemented

Currently, decryption failures (e.g. after an unrecovered APP_KEY rotation) are only logged via `Log::warning`. A Statamic CP notification dispatched to super admins would make data corruption visible without requiring log monitoring.
Decryption failures are now surfaced in the CP as an error toast in addition to the existing `Log::warning`.

Implementation sketch:
- Hook into the existing `catch (\Throwable)` path in `FieldEncryptor::decrypt()`.
- Dispatch a Statamic `Notification` to super admins (or use a Statamic flash/CP alert).
- Add a rate-limit or deduplication guard to avoid notification spam.
- `FieldEncryptor::decrypt()` accepts an optional `string $context` parameter (form handle) for deduplication.
- In HTTP context, `Cache::add('sffields.decrypt_failure_notified.{context}', true, 3600)` is used as an atomic set-if-not-exists guard — at most one toast per form per hour.
- `Toast::error()` is skipped entirely when `app()->runningInConsole()` is true (commands, queue workers).
- `DecryptingSubmissionRepository` passes the form handle as `$context` when calling `decrypt()`.
2 changes: 2 additions & 0 deletions lang/en/messages.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
'permission_form_label' => 'View Decrypted Sensitive Fields',
'permission_form_description' => 'Allow viewing decrypted values of sensitive fields in this form only',

'decrypt_failure_toast' => 'One or more sensitive field values could not be decrypted. Your APP_KEY may have changed. Use sensitive-fields:rekey (Pro) to recover.',

'settings_enabled_display' => 'Enabled',
'settings_enabled_instructions' => 'Enable or disable field encryption.',
'settings_mask_display' => 'Mask String',
Expand Down
36 changes: 35 additions & 1 deletion src/Encryption/FieldEncryptor.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,22 @@

namespace Isapp\SensitiveFormFields\Encryption;

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Log;
use Statamic\Addons\Addon;
use Statamic\Facades\CP\Toast;
use Statamic\Statamic;

class FieldEncryptor
{
// Versioned prefix prepended to every ciphertext.
// Allows detecting already-encrypted values and future format migrations.
protected const PREFIX = 'enc:v1:';

// Suppress repeated CP toasts for the same form within this window (seconds).
protected const NOTIFY_TTL = 3600;

public function __construct(
protected Addon $addon,
) {}
Expand All @@ -31,7 +37,12 @@ public function encrypt(string $value): string
// Returns the value unchanged (with a log warning) on decryption failure,
// e.g. after an APP_KEY rotation. Callers should treat the raw ciphertext
// as an opaque fallback rather than a fatal error.
public function decrypt(string $value): string
//
// $context is an optional form handle used to deduplicate CP toasts so that
// bulk failures (e.g. many submissions after an APP_KEY rotation) produce at
// most one toast per form per hour. Pass the form handle from repository callers;
// omit from CLI callers (toast is suppressed in console context anyway).
public function decrypt(string $value, string $context = ''): string
{
if (! $this->isEncrypted($value)) {
return $value;
Expand All @@ -42,6 +53,29 @@ public function decrypt(string $value): string
} catch (\Throwable $e) {
Log::warning('Failed to decrypt sensitive field value: ' . $e->getMessage());

// Only dispatch CP toasts on actual CP requests — not API or frontend
// requests, which could consume the dedup window before a CP user visits.
// Cache::add() is atomic set-if-not-exists; the key is rolled back via
// Cache::forget() when toast delivery fails so the dedup window is not
// consumed without a toast being shown. Wrapped in try/catch so that
// cache or session failures never break the graceful fallback.
try {
if (Statamic::isCpRoute()) {
$cacheKey = 'sffields.decrypt_failure_notified.' . ($context ?: 'unknown');
if (Cache::add($cacheKey, true, self::NOTIFY_TTL)) {
try {
Toast::error(__('statamic-sensitive-form-fields::messages.decrypt_failure_toast'));
} catch (\Throwable) {
// Roll back the dedup key so a future request with an active
// CP session can still deliver the toast.
Cache::forget($cacheKey);
}
}
}
} catch (\Throwable) {
// Cache failure must never convert a recoverable decrypt error into a fatal one.
}

return $value;
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/Repositories/DecryptingSubmissionQueryBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ protected function decryptSubmission(Submission $submission): void
}

if ($canDecrypt) {
$submission->set($handle, $this->encryptor->decrypt($value));
$submission->set($handle, $this->encryptor->decrypt($value, $submission->form()->handle()));
} else {
$submission->set($handle, $this->encryptor->mask());
}
Expand Down
2 changes: 1 addition & 1 deletion src/Repositories/DecryptingSubmissionRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ protected function decryptSubmission(Submission $submission): void
}

if ($canDecrypt) {
$submission->set($handle, $this->encryptor->decrypt($value));
$submission->set($handle, $this->encryptor->decrypt($value, $submission->form()->handle()));
Comment thread
andrii-trush marked this conversation as resolved.
} else {
$submission->set($handle, $this->encryptor->mask());
}
Expand Down
21 changes: 21 additions & 0 deletions tests/Unit/FieldEncryptorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,25 @@ public function test_decrypt_returns_non_encrypted_value_as_is()
{
$this->assertSame('plaintext', $this->encryptor->decrypt('plaintext'));
}

public function test_failed_decrypt_does_not_dispatch_toast_in_console_context()
{
// The test suite runs in console context (app()->runningInConsole() === true),
// so the Toast must never be called — verified by spying on the facade.
\Statamic\Facades\CP\Toast::spy();

$this->encryptor->decrypt('enc:v1:corrupted-ciphertext', 'contact');

\Statamic\Facades\CP\Toast::shouldNotHaveReceived('error');
}

public function test_failed_decrypt_without_context_does_not_dispatch_toast_in_console_context()
{
\Statamic\Facades\CP\Toast::spy();

// Backward-compatible: context parameter is optional.
$this->encryptor->decrypt('enc:v1:corrupted-ciphertext');

\Statamic\Facades\CP\Toast::shouldNotHaveReceived('error');
}
}