diff --git a/AGENTS.md b/AGENTS.md index 209f4d4..5812d18 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f6321c..03de790 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index 30f1fe3..5cee628 100644 --- a/README.md +++ b/README.md @@ -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). --- diff --git a/docs/OVERVIEW.md b/docs/OVERVIEW.md index c999fa0..fe94b30 100644 --- a/docs/OVERVIEW.md +++ b/docs/OVERVIEW.md @@ -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 @@ -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 diff --git a/docs/PLAN.md b/docs/PLAN.md index a0849a6..0692e3a 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -172,7 +172,7 @@ 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 @@ -180,6 +180,8 @@ Edition is detected via the **Statamic Editions API**: `Addon::edition()` reads 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 @@ -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()`. diff --git a/lang/en/messages.php b/lang/en/messages.php index 873e101..b7e9f60 100644 --- a/lang/en/messages.php +++ b/lang/en/messages.php @@ -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', diff --git a/src/Encryption/FieldEncryptor.php b/src/Encryption/FieldEncryptor.php index c9cddfa..26c9b42 100644 --- a/src/Encryption/FieldEncryptor.php +++ b/src/Encryption/FieldEncryptor.php @@ -4,9 +4,12 @@ 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 { @@ -14,6 +17,9 @@ class FieldEncryptor // 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, ) {} @@ -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; @@ -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; } } diff --git a/src/Repositories/DecryptingSubmissionQueryBuilder.php b/src/Repositories/DecryptingSubmissionQueryBuilder.php index c2d4788..0dc9e50 100644 --- a/src/Repositories/DecryptingSubmissionQueryBuilder.php +++ b/src/Repositories/DecryptingSubmissionQueryBuilder.php @@ -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()); } diff --git a/src/Repositories/DecryptingSubmissionRepository.php b/src/Repositories/DecryptingSubmissionRepository.php index 28eadd7..48d2fa1 100644 --- a/src/Repositories/DecryptingSubmissionRepository.php +++ b/src/Repositories/DecryptingSubmissionRepository.php @@ -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())); } else { $submission->set($handle, $this->encryptor->mask()); } diff --git a/tests/Unit/FieldEncryptorTest.php b/tests/Unit/FieldEncryptorTest.php index 217fdb7..a3c63a4 100644 --- a/tests/Unit/FieldEncryptorTest.php +++ b/tests/Unit/FieldEncryptorTest.php @@ -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'); + } }