Maps the 31-module plan onto the registry's 19 leaves as packages; the two edges the registry forbids (provider->httpclient, inbox->messaging) are replaced by application-owned ports. See docs/notification/module-mapping.md. Acceptance is not delivery: ProviderSubmissionResult refuses to carry a delivery outcome, and AMBIGUOUS is a first-class terminal state that blocks automatic retry and fallback until reconciliation resolves it. Providers: SES (SigV4 + SNS callback), Twilio (X-Twilio-Signature + reconciliation), FCM (FID-primary batch), APNs, Web Push (RFC 8030/8291/8292), SMTP and webhook. Contact points are AES-256-GCM encrypted with a separate HMAC lookup fingerprint; nothing raw reaches a log, metric tag or exception. Dispatch commits the attempt row, calls the provider with no transaction open, then records the outcome; the durable queue uses FOR UPDATE SKIP LOCKED. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
57 lines
2.5 KiB
Markdown
57 lines
2.5 KiB
Markdown
# Security and privacy
|
|
|
|
## Protected values
|
|
|
|
Email addresses, phone numbers, FCM installation ids and legacy tokens, APNs device tokens, Web Push
|
|
endpoints and keys, VAPID private keys, provider credentials, callback signing secrets, template
|
|
variables, rendered bodies, attachment references and unsubscribe tokens.
|
|
|
|
## At rest
|
|
|
|
Contact points are encrypted with AES-256-GCM. Equality lookup uses a separate HMAC-SHA-256
|
|
fingerprint.
|
|
|
|
Two keys, not one, because the requirements are opposite: the ciphertext must be non-deterministic so
|
|
two records of the same address are not visibly identical, while equality lookup must be
|
|
deterministic. The fingerprint is keyed rather than a plain digest because phone numbers and email
|
|
addresses come from a small, enumerable space — an unkeyed hash of a phone number is recoverable in
|
|
seconds.
|
|
|
|
The contact point kind is bound into the GCM associated data, so a ciphertext cannot be moved between
|
|
contact kinds without failing the authentication tag.
|
|
|
|
An unknown key id is refused rather than silently falling back to the current key: a silent fallback
|
|
would turn every historical row into a tag failure at read time.
|
|
|
|
## Never logged, never a metric tag
|
|
|
|
Addresses, tokens, Web Push endpoints and keys, message bodies, template variables, provider
|
|
credentials, unsubscribe tokens, attachment URLs, raw callback payloads and raw provider request ids.
|
|
|
|
Two mechanisms enforce this rather than convention:
|
|
|
|
- `CardinalityGuard` validates every metric tag against a closed allowlist.
|
|
- `SafeDiagnosticContext` rejects any structured-diagnostic field outside its allowlist.
|
|
|
|
An allowlist rather than a denylist, because the failure mode of a denylist is that the one field
|
|
nobody thought of is the one that leaks.
|
|
|
|
Every contact point value type overrides `toString()` to print `[redacted]`. That covers the case a
|
|
central redactor cannot: a value interpolated into a log line by accident.
|
|
|
|
## Web Push endpoints
|
|
|
|
RFC 8030 defines the push URI as a capability URL — knowing it is sufficient to push to the
|
|
subscriber. It is handled as a secret, not as a URL.
|
|
|
|
## Callbacks
|
|
|
|
TLS, provider signature verification over the exact received bytes and external URL, replay defence
|
|
where a timestamp or nonce is available, body-size and content-type limits, profile binding, rate
|
|
limiting, idempotent ingestion and a security audit trail for rejections.
|
|
|
|
## Tenant isolation
|
|
|
|
Every store port carries the tenant boundary in its signature. Administrative operations require an
|
|
explicit tenant or a global authority.
|