6.9 KiB
Notification payload at rest — threat model and decision (NTF-INT-007)
Wave 2 Task D6 offers two branches and requires that one be chosen and implemented fully:
- (a) encryption — a codec/port, ciphertext envelope, key id, rotation and history, row migration, and a decryption-failure contract;
- (b) restriction — this document plus a static restriction proving the variable types cannot carry sensitive values.
The plan recommends (b) "if and only if the variable types can genuinely be restricted to non-sensitive values". They cannot. This document records why, what that leaves, and when the remaining branch lands.
What is stored, and where
CanonicalNotificationPlanWriter.request(...) puts encoded.variablesPayload() into
NotificationRequestRecord verbatim. JpaNotificationRequestStore writes that record to
notification_request.variables_payload with no transformation. There is no encryption anywhere on
this path.
Why the restriction branch is unavailable
Template variables are a closed algebra — NotificationVariable permits TextValue, NumberValue,
BooleanValue, NullValue, ListValue, ObjectValue — which is a real improvement over the
Map<String, Object> it replaced. But TextValue holds an arbitrary UTF-8 string of up to 8 KiB,
and that is not an oversight to be tightened: the variables are the recipient-specific content of
the message. A password-reset code, an order total, a delivery address, a patient's appointment
time — those are what a notification is for.
A restriction to "non-sensitive values" would therefore be one of two things, and both are worse than the problem:
- unenforceable — a comment saying callers should not put sensitive data in a field designed to carry the message's content, which is a policy no type checks and no reviewer can see violated;
- enforced and useless — a type that refuses free text, which does not restrict the capability so much as delete it.
The precondition on the plan's recommendation is false. Branch (b) is not available.
Status: branch (a) implemented at the storage boundary.
NotificationPayloadProtectionis the application-owned port,AesGcmNotificationPayloadProtectionthe AES-GCM implementation, andNotificationRecordMapperapplies it — as a required constructor argument, so a composition cannot assemble the notification stores while leaving the payload in plaintext. What remains before the facade can be imported is the row migration for any deployment that already has plaintext rows, and thelocal-notification-*lanes. The analysis below is kept as written, because it is what the decision rests on.
Decision: branch (a), landing with the persistence wiring
Encryption is therefore the required branch. Its scope is unchanged from the plan: a codec behind an application port, a ciphertext envelope carrying its key id, key rotation with history so an old row stays readable, a migration for existing rows, and an explicit contract for what a decryption failure does to a request.
It lands in the change unit that makes the write path reachable, and not before. The reason is a
fact the spec did not have: NotificationJpaPersistenceFacade, which assembles
JpaNotificationRequestStore, is imported by nothing. The composition root's component scan excludes
the persistence package by design, and no configuration imports the facade — so the notification
capability has no JPA persistence at all, and no deployment currently writes this payload
anywhere. The defect is real in the code and latent in the runtime.
Designing key rotation and a row migration for rows that no deployment produces would be building the migration before the table. Worse, it would settle the envelope's shape before the store that has to read it is wired, which is the order that produces an envelope the store cannot use.
One correction, learned by trying it. This section said the envelope "lands with the wiring".
Wiring the facade first — to register the SMTP assembler — made
NotificationPayloadAtRestContractTest fail on the case asserting the write path is reachable from
no composition, which is exactly what that case is for. The wave forbids connecting wiring over a
known security finding on a runtime path, so the wiring was reverted and the envelope built first.
The honest ordering is envelope before or with the wiring, never after, and the contract test now
enforces it by failing on the wiring alone.
The envelope, and why it has a key id
byte version always 1
byte keyIdLength 1..255 UTF-8 bytes
byte[] keyId
byte[12] nonce
byte[] ciphertext + GCM tag
The key id is the reason there is a format at all. This repository's callback protection stores nonce
and ciphertext and nothing else, so the day the active key changes, every row written under the
previous one becomes unreadable and nothing in the row can say which key it needed — that is not a
rotation story with a gap in it, it is the absence of one. SecretMaterialProvider already exposes
keyById, so reading the id back and asking for that specific key makes rotation a change of default
rather than a data migration. The version byte costs one byte and is what allows the format to change
at all.
The header is passed as AAD, not merely prefixed: without that, the key id is attacker-editable and an envelope could be redirected at a key of the attacker's choosing.
A failed decryption throws NotificationPayloadUnreadableException rather than returning empty. A
caller handed an empty payload renders every variable as nothing and sends "Hello , your code is " to
a real person — the failure delivered instead of reported. All three causes (unknown key, wrong key,
modified ciphertext) collapse into one message, because telling them apart tells an attacker which of
the three they achieved.
What must not be done instead
Requiring PAYLOAD_ENCRYPTION in INGEST_ONLY is not a fix. That secret is consumed by exactly
one thing — AesGcmCallbackPayloadProtection, which protects raw callback bodies — and by nothing on
the accept path. Demanding it would make a deployment supply a key that protects nothing while the
payload it appears to be about stays in plaintext. The repository already has one defect of that
exact shape: backend.graphql.cursor.key-ids, which production refuses to start without and which no
code signs a cursor with (GQL-INT-003). Adding a second would make the pattern a habit.
Consequence
Notification is not promoted to Stable, per the index's scope boundaries, until branch (a) is
complete. The three notification Compose lanes stay non-blocking. NotificationPayloadAtRestContractTest
holds every fact this decision rests on, so the decision expires automatically if any of them stops
being true — in particular, the assertion that no encryption sits on the accept path fails the moment
somebody adds one, which is the change this document is waiting for.