fix(security): record the Stripe last-4 hint as deliberate and lock its ceiling (BUNYIP-443) #435

Merged
longjacksonle merged 1 commit from fix/BUNYIP-443-stripe-secret-last4-hint into main 2026-08-02 02:20:45 +02:00

Decision (AC1): keep the last-four hint for the Stripe secret key and webhook secret, rather than moving them to the write-only posture BUNYIP-432 chose for the SMTP password. PMS-342 shipped the last-four hint deliberately for operator convenience in identifying which credential is set, and we are retaining it. This PR makes that decision explicit and unregressable; it does not change behaviour.

Why no behaviour change was needed

The read and save paths are already safe for a kept hint:

  • Read: StripeConfigResponse carries only the mask_secret form (prefix + *** + last 4) and the has_* booleans. The full plaintext is never a field on the response, so it cannot be serialized to the client.
  • Save (web): the Stripe form renders the masked value as the input placeholder, never as the value attribute, so the field posts empty unless the admin types a new secret; empty is dropped before the request body is built (admin.rs).
  • Save (api + repo): update_stripe_config filters empty to None, and StripeConfigRepository::update uses CASE WHEN $1 IS NOT NULL THEN $1 ELSE secret_key END, so an untouched field leaves the stored secret unchanged. The masked value can never be written back as the real secret.

Changes

  • Document on StripeConfigResponse that the last-four hint is a reviewed, deliberate posture (PMS-342 kept it, BUNYIP-443 retained it over write-only), with a pointer to how to move to write-only later if revisited. Stops a future security pass re-flagging it as the same defect class as BUNYIP-432.
  • Add masked_response_never_carries_the_full_secret: build a response from full secrets, serialize it, and assert the full value and its middle are absent while only the prefix + last-4 hint is present. Locks the ceiling so a change to mask_secret or the response cannot silently widen the exposure back toward the full value.

mask_secret sweep (AC)

After BUNYIP-432 removed the SMTP use, mask_secret has exactly one client-facing caller left: this Stripe response (secret_key_masked / webhook_secret_masked). Every other reference is its own unit tests. No storage change (secrets remain AES-256-GCM encrypted at rest).

Acceptance criteria

  • Decision recorded on the issue: keep the last-four hint.
  • Save path verified: new secret saves; untouched field leaves the stored secret unchanged (placeholder-only input + CASE WHEN ... ELSE secret_key).
  • Test asserting the full secret (and its middle) is absent from the response body; only the deliberate last-4 hint is present.
  • mask_secret client-facing sweep recorded (Stripe response only).

just check-container green (fmt + clippy -D warnings + workspace tests, 313 domain tests).

🤖 Generated with Claude Code

https://claude.ai/code/session_018TXaT3P192nDsZzbzHETb9

Decision (AC1): **keep the last-four hint** for the Stripe secret key and webhook secret, rather than moving them to the write-only posture BUNYIP-432 chose for the SMTP password. PMS-342 shipped the last-four hint deliberately for operator convenience in identifying which credential is set, and we are retaining it. This PR makes that decision explicit and unregressable; it does not change behaviour. ## Why no behaviour change was needed The read and save paths are already safe for a kept hint: - **Read**: `StripeConfigResponse` carries only the `mask_secret` form (prefix + `***` + last 4) and the `has_*` booleans. The full plaintext is never a field on the response, so it cannot be serialized to the client. - **Save (web)**: the Stripe form renders the masked value as the input **placeholder**, never as the `value` attribute, so the field posts empty unless the admin types a new secret; empty is dropped before the request body is built (`admin.rs`). - **Save (api + repo)**: `update_stripe_config` filters empty to `None`, and `StripeConfigRepository::update` uses `CASE WHEN $1 IS NOT NULL THEN $1 ELSE secret_key END`, so an untouched field leaves the stored secret unchanged. The masked value can never be written back as the real secret. ## Changes - Document on `StripeConfigResponse` that the last-four hint is a reviewed, deliberate posture (PMS-342 kept it, BUNYIP-443 retained it over write-only), with a pointer to how to move to write-only later if revisited. Stops a future security pass re-flagging it as the same defect class as BUNYIP-432. - Add `masked_response_never_carries_the_full_secret`: build a response from full secrets, serialize it, and assert the full value and its middle are **absent** while only the prefix + last-4 hint is present. Locks the ceiling so a change to `mask_secret` or the response cannot silently widen the exposure back toward the full value. ## `mask_secret` sweep (AC) After BUNYIP-432 removed the SMTP use, `mask_secret` has exactly **one** client-facing caller left: this Stripe response (`secret_key_masked` / `webhook_secret_masked`). Every other reference is its own unit tests. No storage change (secrets remain AES-256-GCM encrypted at rest). ## Acceptance criteria - [x] Decision recorded on the issue: keep the last-four hint. - [x] Save path verified: new secret saves; untouched field leaves the stored secret unchanged (placeholder-only input + `CASE WHEN ... ELSE secret_key`). - [x] Test asserting the **full** secret (and its middle) is absent from the response body; only the deliberate last-4 hint is present. - [x] `mask_secret` client-facing sweep recorded (Stripe response only). `just check-container` green (fmt + clippy `-D warnings` + workspace tests, 313 domain tests). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_018TXaT3P192nDsZzbzHETb9
fix(security): record the Stripe last-4 hint as deliberate and lock its ceiling (BUNYIP-443)
All checks were successful
E2E PR gate / Install + reachability (no deployment secrets) (pull_request) Successful in 24s
Check / fmt + clippy + build + tests (pull_request) Successful in 4m30s
Create release / Create release from merged PR (pull_request) Has been skipped
13b6593f6d
BUNYIP-443 asked whether the Stripe secret key and webhook secret should move to the write-only posture BUNYIP-432 chose for the SMTP password, or keep the last-four hint PMS-342 shipped. Decision: keep the last-four hint (operator convenience in identifying which credential is set). This change makes that decision explicit and unregressable rather than changing behaviour.

Verified the read and save paths are already safe for a kept hint, so no behaviour change was needed:
- Read: StripeConfigResponse carries only the mask_secret form (prefix + *** + last 4) and the has_* booleans. The full plaintext is never a field on the response, so it cannot be serialized to the client.
- Save (web): the Stripe form renders the masked value as the input placeholder, never as the value attribute, so the field posts empty unless the admin types a new secret. Empty is dropped before the request is built.
- Save (api + repo): update_stripe_config filters empty to None, and StripeConfigRepository::update uses CASE WHEN $1 IS NOT NULL THEN $1 ELSE secret_key END, so an untouched field leaves the stored secret unchanged. The masked value can never be written back as the real secret.

Changes:
- Document on StripeConfigResponse that the last-four hint is a reviewed, deliberate posture (PMS-342 kept it, BUNYIP-443 retained it over write-only), with a pointer to how to move to write-only later if revisited. This stops a future security pass re-flagging it as the BUNYIP-432 defect.
- Add masked_response_never_carries_the_full_secret: build a response from full secrets, serialize it, and assert the full value and its middle are absent while only the prefix + last-4 hint is present. This locks the ceiling so a change to mask_secret or the response cannot silently widen the exposure back toward the full value.

Sweep: after BUNYIP-432 removed the SMTP use, mask_secret has exactly one client-facing caller left, this Stripe response; every other reference is its own unit tests. No storage change (secrets remain AES-256-GCM encrypted at rest).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018TXaT3P192nDsZzbzHETb9
longjacksonle scheduled this pull request to auto merge when all checks succeed 2026-08-02 02:17:22 +02:00
longjacksonle deleted branch fix/BUNYIP-443-stripe-secret-last4-hint 2026-08-02 02:20:45 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/bunyip!435
No description provided.