ADR-026: Privacy-First Applicant Portal (Client-Encrypted Drafts + Redis-Primary Sessions)
On this page
Amends
ADRs are immutable once accepted, so this ADR amends ADR-009 rather than editing it. ADR-009 mandates PostgreSQL-primary sessions and bans MemoryStore project-wide for all BFFs. ADR-026 narrows that decision for the applicant portal (canopy-portal) only: its session backend becomes Redis-primary. The worker portal (canopy-web) is unchanged — it remains PostgreSQL-primary per ADR-009 (its SameSite posture is whatever ADR-009 / the worker code specify; this ADR does not restate it).
This decision also refines ADR-008 (applicant portal architecture), builds on ADR-017 (the encrypted-at-rest precedent), and is bounded by ADR-004: the applicant portal is outside IRS Pub 1075 scope by design, so the encryption introduced here is defense-in-depth that exceeds requirement. It honours ADR-016 (forward-only migrations) and ADR-019 (service identity / X-Canopy-Actor). Per ADR-018 the portal publishes no domain events and carries no event_outbox — a Postgres-free Dioxus BFF with no database is exempt from the uniform outbox provisioning.
Context
The applicant portal is a Backend-For-Frontend over canopy-applications and canopy-verification. An applicant fills a multi-step single-streamlined application; that in-progress data is PII-laden (names, income, household composition, possibly SSN). The prior plan stored it as a plaintext draft plus a PostgreSQL applicant_sessions table, which (a) forces the portal to own a database, and (b) durably persists half-finished PII for every applicant who starts the flow — the majority of whom abandon it.
The goal is no data loss within a session, plus resume across sessions for applicants who saved their credential — without accumulating a readable hoard of incomplete PII, and with a portal that owns nothing until there is an official record. (The resume guarantee is deliberately conditional; see Consequences.)
Decision
The applicant portal owns no operator-readable durable state until an application is submitted. Concretely:
Reserved-ID lifecycle
The credential and the encrypted draft are minted at draft-start under a reserved application_id (a UUID that does not yet exist in the applications table). The applications row — whose household_id, submitted_by, programs_requested, and submission_channel columns are NOT NULL and unknown until the form is filled — is created only at finalize, using that same reserved id. Drafts therefore never enter the applications table, so there is no draft application status and no worker-queue filtering (workers never see drafts).
| Phase | application_drafts |
application_id_codes / passcode_hashes |
applications |
|---|---|---|---|
draft-start |
INSERT (reserved_id, kdf_salt, …) |
INSERT (reserved_id, code / argon2id-hash) |
— none |
patch (per step) |
UPDATE ciphertext + bump |
— |
— none |
finalize (submit) |
DELETE |
keep (now the submitted app’s login) |
INSERT id = reserved_id, status |
reap (inactive) |
DELETE |
DELETE |
— none |
Because no applications row exists during the draft window, the credential tables cannot carry a foreign key to it; their REFERENCES applications(id) is dropped. Referential integrity is enforced by construction (finalize always creates the row; the reaper deletes everything for an unfinalized reserved id), not by a database FK.
1. Client-side-encrypted JSONB drafts
A new application_drafts table in canopy-applications:
application_drafts (
application_id UUID PRIMARY KEY, -- the reserved id; NO FK to applications
kdf_salt BYTEA NOT NULL, -- per-draft Argon2id salt (non-secret)
ciphertext BYTEA NOT NULL,
nonce BYTEA NOT NULL,
enc_version SMALLINT NOT NULL,
current_step INT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_saved_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL -- sliding deadline; see reaper
)
with an index on expires_at. The WASM client encrypts each step’s partial payload and PATCH`es the ciphertext; the server stores and serves the blob blind. `expires_at is the sliding deadline last_saved_at + 30 days, rewritten on every save; the reaper deletes WHERE expires_at < now(). kdf_salt is the per-draft Argon2id salt — non-secret and persisted so a cross-session resume re-derives the same key.
2. Cipher / KDF
Argon2id(passcode, kdf_salt) → 256-bit key → XChaCha20-Poly1305 AEAD (a fresh nonce per save), consistent with ADR-017’s ChaCha20-Poly1305 choice. enc_version provides crypto agility. The client-side crypto is WebAssembly (argon2 + chacha20poly1305 compiled to wasm32) — it needs no eval and no new CSP directive ('wasm-unsafe-eval', already permitted for WASM instantiation, covers it); it does not threaten the strict-CSP gate.
3. Server-side credential mint at draft-start (Model S)
create-draft mints the reserved application_id (UUID) plus the HH-[a-f0-9]{8} code (bounded collision-retry on the application_id_codes.code UNIQUE constraint) plus the NNNN-NNNN-NNNN passcode (twelve uniform-random digits; format per ADR-008 Amendment 3). It writes application_drafts, application_id_codes, and passcode_hashes (the passcode argon2id-hashed) — none foreign-keyed to applications — and returns {application_id, code, raw passcode} to the client over TLS. The client derives the draft key from the passcode (with a distinct KDF salt context) and encrypts. The server discards the raw passcode, persisting only the one-way hash and the ciphertext.
4. Reveal-at-submit-or-continue-later
The raw passcode is held in client memory / sessionStorage only (see key handling in Consequences) and is not displayed through the flow. It is revealed on the Submitted screen at finalize, or earlier if the applicant explicitly chooses "continue later." One credential serves two roles: the draft-encryption-key source and the post-submission login.
5. Materialize-at-finalize
Submit = the client decrypts the full draft and POST`s plaintext to a `finalize endpoint. canopy-applications creates persons → household → members → income and the applications row with id set to the reserved application_id (status submitted). Today’s create path always generates a fresh id (services/canopy-applications/src/store/mod.rs, ApplicationId::new()), so finalize needs a create path that accepts an explicit id.
The applications INSERT and the application_drafts DELETE MUST be one canopy-applications transaction. The no-orphan guarantee depends on it: a "create, then separately delete" sequence opens a window in which the reserved id is in neither table. The finalize path takes a SELECT … FOR UPDATE row lock on the draft so it serialises against the reaper (§6). The credentials are kept (they become the submitted application’s login). The server sees plaintext PII only here.
(The persons/household writes span canopy-persons, a separate service and database per ADR-001; that cross-service ordering is the pre-existing orchestration concern, not introduced by this ADR. The single-transaction guarantee above is specifically the intra-canopy-applications applications-INSERT + draft-DELETE pair.)
6. Sliding reaper
A background job deletes application_drafts WHERE expires_at < now() and, in the same step, the matching application_id_codes and passcode_hashes rows — explicit ordered deletes, not ON DELETE CASCADE. (Finalize must delete the draft while keeping the credentials, so a draft → credential cascade would be wrong.) The reaper serialises with finalize on the draft row: it takes the same SELECT … FOR UPDATE lock and re-checks expires_at inside its transaction, so it cannot delete the credentials of a draft whose finalize is in flight (which would leave a submitted application whose login was reaped). There is no draft application status and no worker-queue filtering.
7. Redis-primary applicant sessions (the ADR-009 amendment)
The authenticated session is an opaque 256-bit CSPRNG token. Redis holds session:{token_hash} → {application_id, code, device_id, flow_kind, expires_at} (the token is hashed at rest; the raw token lives only in the cookie). The TTL is flow_kind-derived (30 min apply/recovery/renewal · 2 h steady-state · 15 min kiosk); DEL is the server-side kill-switch; a device:{id} secondary index drives the rate-limit cascade. The session is minted at draft-start and tied to the reserved application_id. There is no anonymous PostgreSQL session — pre-draft browsing (the Welcome screen) is stateless.
Hard prerequisite: a noeviction Redis session keyspace must exist before Redis-primary sessions ship. The devstack Redis is configured maxmemory-policy allkeys-lru (a cache), which would silently evict live sessions; the session keyspace must be a separate Redis logical database (or instance) with maxmemory-policy noeviction. The worker portal is unchanged. The portal is Postgres-free: no canopy-api / canopy-db / canopy-mq, no sqlx, no migrations — it owns only Redis (sessions + rate-limit counters).
8. Session audit over HTTP
Session security events (mint / revoke / kill-switch) must reach canopy-security, which today ingests audit only via the RabbitMQ wildcard subscriber and exposes only read / export / archive routes. The portal has no broker, so this requires a net-new best-effort POST /v1/security/audit/ingest on canopy-security (service-token auth per ADR-019). It is fire-and-forget / non-blocking: a canopy-security outage must never break the applicant flow, so failures are logged and dropped, not retried into the request path.
9. Lost-credential recovery and confidentiality
The recovery flow is a safety-critical intimate-threat defense (the dominant threat is the person who knows the applicant — partner, ex, family — not a stranger; see the design applicant-portal design ref §3.4-3.8). It applies to submitted applications only: a reserved-id draft (no applications row) is never recoverable.
The security boundary is not the challenge answers. Per applicant-portal design ref §3.7 the challenge inputs "are not a security boundary on their own." The boundary is the Application-ID gate (HH-… — random hex an intimate threat is unlikely to have memorised) + a date-of-birth second factor (verified against the submitter’s canopy-persons record) + a confidential-case block + a 24-hour delayed reveal + a one-tap kill-switch + a side-channel notification to the application-time contact (the contact on file at original application, never a recently-changed — possibly attacker-controlled — one) + the rate-limit cascade. A CAPTCHA gate on the /recover initiate step is defense-in-depth against wide-net automated guessing; it ships as a config-driven verifier abstraction (noop default) with the real provider deferred (see the Plan 3 plan + issue #663).
Confidentiality flag. applications.confidentiality ∈ {standard, confidential, address_confidential, both}. confidential and both disable self-serve recovery (the applicant is routed to phone/in-person verification, applicant-portal design ref §3.8); address_confidential is about address echoing (ACP routing), not recovery access, so on its own it does not disable self-serve. The recovery Lookup still accepts input for confidential cases and the server short-circuits to a confidential_blocked outcome after the App-ID matches — hiding the affordance would itself leak case existence; this is a deliberate, accepted disclosure that routes protected cases to safe human verification, and the attempt is flagged for worker visibility.
Kill-switch and case-lock. The "this wasn’t me" link cancels the pending recovery and sets a durable applications.recovery_locked boolean (a worker must clear it before self-serve is available again). It is a boolean, not a new application-status value — a recovery_locked status would pollute every status consumer. Existing logged-in sessions are not invalidated by an initiation.
Oracle safety. POST /v1/applicants/recover/initiate always returns 200 with the outcome in the body (pending / confidential_blocked / challenge_failed), never in the status code, so the endpoint is not a case-enumeration oracle: an unknown code and a wrong date-of-birth both return the uniform challenge_failed.
Event/secret hygiene (extends ADR-004). The recovery outbox events (application.applicant.recovery_{initiated,killed,confidential_blocked}) carry IDs + the reveal timestamp only. The kill-switch token (a capability secret) and the notify contact are deliberately kept off the broadcast bus + the security audit log — the notification subscriber reads them from the recovery_pending row by recovery_id via a service-token call.
This subsection is the ADR home for the recovery/confidentiality model that the canopy-applications recovery artifacts (store::recovery, api::recovery, the recovery_pending + gate-column migrations, the Confidentiality enum, the recovery contracts) cite.
Consequences
The honest security bound (stated plainly so it is never over-claimed):
-
The draft is encrypted at rest under a key derived from the applicant’s passcode; the server persists neither the raw passcode nor the key — only the one-way argon2id hash, the ciphertext, and the non-secret
kdf_salt. This defeats bulk mining of the draft store, stolen backups, and casual insider access: each record costs a separate offline brute-force. -
This is NOT literal zero-knowledge. The passcode is ~40 bits (
NNNN-NNNN-NNNN— twelve uniform-random digits, 10^12 ≈ 39.9 bits), so a determined insider holding the ciphertext could offline-brute-force a single targeted record against the memory-hard KDF — expensive per record, infeasible at scale. The defensible claim is "no operator-readable PII hoard at rest / not bulk-readable," not "we can never read it." It must not be marketed as zero-knowledge. -
Transient plaintext exposures (named explicitly). Model S means the server generates and transmits the raw passcode at draft-start (then discards it) and sees plaintext PII at finalize. Neither is "at rest," but both are real processing-time exposures. The rejected client-generated alternative (Model C) existed precisely to close the mint-time window.
-
Resume is conditional, not unconditional. The draft ciphertext is durable, but the key is ephemeral until the passcode is saved. Within a session,
sessionStoragesurvives a refresh, so resume works. But a tab-close or crash before the applicant saved their code (continue-later) leaves a durable encrypted draft that is unreachable until the reaper clears it; and a lost passcode means an unrecoverable draft (the server cannot help — that is the privacy property). The mitigation is a prominent, proactive "save your code to come back" affordance. Draft recovery is not offered; the lost-credential recovery flow applies to submitted applications only. -
Key handling respects the intimate-threat model (the applicant-portal design reference). The derived key lives in client memory /
sessionStorageonly — neverlocalStorage(shared/kiosk devices). -
Costs / deferred follow-ups: a net-new Rust Redis client; a hand-rolled Redis token store (no ecosystem
tower-sessions-redis-storeexists); the noeviction keyspace; thePOST /v1/security/audit/ingestendpoint; client-side crypto latency on low-end devices; the incrementalcreate-draft/patch-draft/finalizeendpoints plus the explicit-id create path; the reaper job. The agent-facing cheat-sheets (services.adoc,.claude/docs/services.md,.claude/docs/architecture.md, and the CLAUDE.md ADR-009 line) are corrected in this MR. Thecanopy_portaldevstack database and thedevstack/redis/redis.conf"session fallback caching" comment are deprovisioned/retuned as part of MR5 (they pair with the actual Redis session migration), not this docs MR. (NOTE: thecanopy-portalrunning devstack service — its compose block carryingCANOPY_PORTAL__DATABASE_URL, plus theKEYCLOAK_REALM_AFFECTING_SERVICES/${env.CANOPY_PORTAL_HOST_PORT}realm templating and the Prometheus/k6 references — was removed earlier in MR1b, because excluding the portal binary from the musl image made that service unrunnable and left it destabilising the Keycloak realm port-reconcile; only the inert empty database + comment remain for MR5.) -
Compliance: the applicant portal is outside Pub 1075 scope (ADR-004); drafts hold applicant-entered data, not Federal Tax Information, so the encryption exceeds requirement. Sessions carry no PII (opaque token plus identifiers).
Alternatives Considered
-
Redis-ephemeral drafts (drafts in Redis with a TTL) — rejected: weaker durability and cross-session resume; Redis-as-primary-durable-store raises an ops burden the JSONB-in-Postgres model avoids.
-
Real-time relational write-through (materialise persons/household/income on every step) — rejected: partial-valid relational invariants, partial records spread across services, a cascading reaper, and durable abandoner-PII without the encryption benefit.
-
Plaintext drafts + reaper only — rejected: still a durable PII hoard until the reaper runs.
-
A
draftrow inapplications— rejected: blocked by theNOT NULLcolumns (household_idetc.) unknown at draft-start; the reserved-id model avoids needing one. -
Model C — client-generated passcode (the client generates the passcode locally; the server never sees it at mint) — considered for its stronger blindness (it closes the mint-time window), rejected for Argon2id-in-WASM latency on low-end devices, loss of server-side passcode-quality control, and collision-oracle coherence (the
HH-…code’s uniqueness oracle lives server-side regardless). -
PostgreSQL-primary applicant sessions (ADR-009 as written) — rejected for the portal: it forces a database the portal otherwise does not need; Redis is TTL-native, server-side-revocable, and sufficient for non-worker ephemeral sessions.
References
-
The Plan 3 plan: Applicant intake + verification
-
The applicant-portal design reference
-
GitLab #630 (strict CSP)