Plan: SHINES Signed-Path Attachments (Signed Hash Manifest)
On this page
Epic: &65 SHINES signed-path attachments. Children: #940–#946.
Summary
The CRAIG standalone-SHINES portal currently drops attachments entirely — the SHINES CpsRequest has no documents field and no attachment route is mounted under the SHINES profile, so a reporter cannot send the supporting documents (certificates, photos) the legacy SHINES portal accepts.
This adds attachment support to the signed/mandated SHINES path using a signed SHA-256 hash manifest, a single multipart request, and server-side edge verification. The reporter’s existing detached JWS covers a manifest of file hashes, so documents are tamper-evident without signing the upload transport; craig-intake recomputes the hashes from the received bytes, verifies them against the signed manifest, and forwards report + files to SHINES — atomically.
Invariants (non-negotiable)
-
Atomic — JWS + all hashes verified before any forward; on any failure, reject (4xx/401) with zero POSTs to SHINES.
-
Exactly-once — one POST to SHINES per submit (JSON when no files, multipart when files); no retry loop.
-
Edge-authoritative — craig-intake verifies the hashes; SHINES is a dumb sink (no SHINES-side re-verification required).
-
No new BOLA surface — attachments ride the atomic signed submit; no follow-up
/{id}/attachmentscapability (SHINES record ids are guessable). -
Signing contract UNCHANGED (canonical-JSON). The JWS is verified over the canonical form of the report (as today). Pass the raw
report-part bytes to the unchangedverify_signed_jws(it canonicalizes internally, tolerating transit key-order); never reparse-then-reserialize before verifying — the typed struct drops the SHINES extra keys + manifest, changing the canonical form and 401-ing the reporter. Existing browser/SDK signers are unaffected. -
No PII leak — filenames/hashes never appear in logs or error bodies; the debug superset is emitted only under the existing ack flag,
Cache-Control: no-store, never logged.
Decisions (locked) and scope
| Decision | Value |
|---|---|
Transport |
multipart/form-data (SHINES adapts; not inline base64) |
Verification site |
craig-intake edge (recompute + compare) |
Reporter paths |
signed/mandated only |
SDK helpers |
Python + TS + Rust — in this work |
Signed handler |
content-negotiates (must still accept SDKs' JSON-only signed submits) |
Report-JSON key |
top-level |
File matching |
on content hash (multiset, counts-exact); the signed manifest’s |
Multipart part names |
client→intake: |
Out of scope: unsigned concerned-citizen SHINES attachments (no signature to anchor a manifest); cases-backed attachment behavior (unchanged; the separate #933 relay-redaction fix is not folded in); any GET /{id}/attachments retrieval endpoint.
End-to-end flow (signed path)
-
Browser (secure context): select files → SHA-256 each via
crypto.subtle→ addsupporting_documents: [{fileName, fileType, sha256}]to the report JSON → sign the report with the existing detached JWS → send one multipart POST to/signed/v1/reports(areportpart = the exact signed JSON bytes, thex-jws-signatureheader, and Nfileparts). -
craig-intake: content-negotiate (JSON-only signed or multipart signed); buffer all parts; verify the JWS over the raw
reportbytes; parse the manifest; recompute each file’s SHA-256; verify bidirectional multiset correspondence + per-file cap; sanitize the signed filename; forward report + files to SHINES. -
Sink → SHINES: one multipart POST (
requestCpsRequest part +fileparts) when attachments present; the existing JSON-only POST otherwise. The manifest also travels inside the CpsRequest. -
SHINES stores; it may re-verify but need not.
Design detail
Full implementation detail (per-unit steps, exact file anchors, reused helpers, test matrix) is tracked in the child issues #940–#946 and mirrors the reviewed working plan. Key points:
-
Contracts (#940):
Sha256Hexnewtype in a backend-agnostic home (so theIntakeSinktrait references no backend type, ADR-042);SupportingDocument;ExtraFields.supporting_documents(MAX_ATTACHMENTScompile-time const = 5); allowlist + fixture drift;CpsRequest.supportingDocumentsalways-emitted +mapped_request_hashre-bless; round-trip in all 3 SDKs. -
Edge handler (#941): swap
body_bytes: Bytes→request: axum::extract::Request, branch on content-type; strict multipart envelope (onereport, onlyreport/file); per-file cap enforced while buffering;verify_manifest → Result<Vec<VerifiedAttachment>>; sanitize the signed filename; newproblem_types::ATTACHMENT_MANIFEST_MISMATCH.VerifiedAttachment.content_typeis the receivedfile-part’s transport content-type (a hint, defaulting toapplication/octet-stream) — the authoritative per-file extension is the signed manifestfileType, which travels in the CpsRequest JSON, so the multipart part MIME is not security-relevant (file identity is the hash). The handler returnsResponseso a body-size rejection stays a 413 (the extractor’s own response), while envelope-contract violations are a typed 400 and unverifiable inputs a 401. -
Sink forward (#942): backend-agnostic
VerifiedAttachment+ForwardRequest.attachments; one multipart POST when non-empty, JSON otherwise; mock content-type branch. -
Debug (#943): layer
DebugEmitCpson the signed nest;SignedDebugConfirmationsuperset (recomputed hashes + signed manifest),no-store, never logged, absent-when-off. -
Browser (#944): file input on the signing step +
sha256Hex(secure-context) →supporting_documentsmanifest → sign → ONE multipart submit; success-view debug rows; i18n. The attachment browser flow ships with its Playwright e2e here (only a real browser exercisescrypto.subtle+FormData). Visual baselines re-bless with the unit that changes snapshotted pixels — step 6 / success-view changes are outside the snapshot set (/reportstep 1 +/keygen), so no re-bless. -
SDKs (#945), docs/ADR/CHANGELOG + body-limit test + epic close (#946).
Verification
-
cargo fmt --all→cargo xtask validate+check-docs+plan-lint+quality-budgets+axis-coverage, all bare (pre-push is the gate; CI skipped). Fresh J1–J8 subagent over each staged diff. -
Body-limit override proven by a test (>11 MB ≤30 MB signed multipart ok; >30 MB → 413; >11 MB non-signed → 413).
-
Manual (SHINES +
self-signed-tls, ADR-046): a two-file signed submit lands report + both files at the mock; a tampered file → atomic 400 with zero SHINES POSTs; a 29 MB single file → rejected; withCRAIG_INTAKE__DEBUG_EMIT_CPS_REQUEST=EXPOSE_SSN_PIIthe response carries the recomputed hashes + manifest withno-storeand nothing in logs.
Status
| Step | Description | Status |
|---|---|---|
0 |
Committed Antora plan (this file) + nav-link + epic &65 + child issues #940–#946 |
Done (2026-07-01) — plan committed + nav-linked; epic &65 + #940–#946 filed |
1 |
#940 contracts + validation (Sha256Hex, SupportingDocument, ExtraFields field, allowlist+fixture, CpsRequest mapping+re-bless, 3-SDK round-trip) |
Done (2026-07-01) — MR !870, commit 73a91cc8 |
2 |
#941 edge handler: content-negotiation + multipart parse + JWS + verify_manifest + per-file cap + atomic reject. Per-file cap |
Done (2026-07-01) — MR !871, commit 58142633 |
3 |
#942 sink→SHINES multipart forward ( |
Done (2026-07-01) — MR !872, commit 00cde182 |
4 |
#943 debug output on the signed path ( |
Done (2026-07-01) — MR !873, commit 16cff2e |
5 |
#944 browser UI (file input on the signing step, |
Done (2026-07-01) — MR !874, commit 2af2e88c |
6 |
#945 SDK signed-multipart submit helpers (Python + TS + Rust) — |
Done (2026-07-01) — MR !875, commit e2e0c76d |
7 |
#946 docs/ADR/CHANGELOG + body-limit override test ( |
Done (2026-07-01) — this MR (#946) |