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
2 changes: 1 addition & 1 deletion 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, 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`).
4. If decryption fails (e.g. key rotation), returns raw ciphertext and logs a warning.

### Addon Settings

Expand Down
12 changes: 1 addition & 11 deletions docs/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,7 @@ 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
8. Failed decrypt returns raw value and logs warning (no side-effects)

### Feature (SensitiveFieldsTest, 12 tests)
1. Sensitive field stored encrypted
Expand Down Expand Up @@ -244,12 +243,3 @@ Larger teams need per-form control (e.g. HR form vs. contact form handled by dif
- Both `DecryptingSubmissionRepository` and `DecryptingSubmissionQueryBuilder` check global then per-form permission via `isAuthorizedForForm(string $formHandle)`.

---

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

Decryption failures are now surfaced in the CP as an error toast in addition to the existing `Log::warning`.

- `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: 0 additions & 2 deletions lang/en/messages.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@
'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: 1 addition & 35 deletions src/Encryption/FieldEncryptor.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,16 @@

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 @@ -37,12 +31,7 @@ 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.
//
// $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
public function decrypt(string $value): string
{
if (! $this->isEncrypted($value)) {
return $value;
Expand All @@ -53,29 +42,6 @@ public function decrypt(string $value, string $context = ''): 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->form()->handle()));
$submission->set($handle, $this->encryptor->decrypt($value));
} 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->form()->handle()));
$submission->set($handle, $this->encryptor->decrypt($value));
} else {
$submission->set($handle, $this->encryptor->mask());
}
Expand Down
21 changes: 0 additions & 21 deletions tests/Unit/FieldEncryptorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,25 +66,4 @@ 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');
}
}