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)

  1. Atomic — JWS + all hashes verified before any forward; on any failure, reject (4xx/401) with zero POSTs to SHINES.

  2. Exactly-once — one POST to SHINES per submit (JSON when no files, multipart when files); no retry loop.

  3. Edge-authoritative — craig-intake verifies the hashes; SHINES is a dumb sink (no SHINES-side re-verification required).

  4. No new BOLA surface — attachments ride the atomic signed submit; no follow-up /{id}/attachments capability (SHINES record ids are guessable).

  5. 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 unchanged verify_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.

  6. 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 supporting_documents (snake_case; SHINES camelCase only at the mapper)

File matching

on content hash (multiset, counts-exact); the signed manifest’s fileName is authoritative after sanitization

Multipart part names

client→intake: report + file; intake→SHINES: request + file

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)

  1. Browser (secure context): select files → SHA-256 each via crypto.subtle → add supporting_documents: [{fileName, fileType, sha256}] to the report JSON → sign the report with the existing detached JWS → send one multipart POST to /signed/v1/reports (a report part = the exact signed JSON bytes, the x-jws-signature header, and N file parts).

  2. craig-intake: content-negotiate (JSON-only signed or multipart signed); buffer all parts; verify the JWS over the raw report bytes; 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.

  3. Sink → SHINES: one multipart POST (request CpsRequest part + file parts) when attachments present; the existing JSON-only POST otherwise. The manifest also travels inside the CpsRequest.

  4. 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): Sha256Hex newtype in a backend-agnostic home (so the IntakeSink trait references no backend type, ADR-042); SupportingDocument; ExtraFields.supporting_documents (MAX_ATTACHMENTS compile-time const = 5); allowlist + fixture drift; CpsRequest.supportingDocuments always-emitted + mapped_request_hash re-bless; round-trip in all 3 SDKs.

  • Edge handler (#941): swap body_bytes: Bytesrequest: axum::extract::Request, branch on content-type; strict multipart envelope (one report, only report/file); per-file cap enforced while buffering; verify_manifest → Result<Vec<VerifiedAttachment>>; sanitize the signed filename; new problem_types::ATTACHMENT_MANIFEST_MISMATCH. VerifiedAttachment.content_type is the received file-part’s transport content-type (a hint, defaulting to application/octet-stream) — the authoritative per-file extension is the signed manifest fileType, which travels in the CpsRequest JSON, so the multipart part MIME is not security-relevant (file identity is the hash). The handler returns Response so 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 DebugEmitCps on the signed nest; SignedDebugConfirmation superset (recomputed hashes + signed manifest), no-store, never logged, absent-when-off.

  • Browser (#944): file input on the signing step + sha256Hex (secure-context) → supporting_documents manifest → sign → ONE multipart submit; success-view debug rows; i18n. The attachment browser flow ships with its Playwright e2e here (only a real browser exercises crypto.subtle + FormData). Visual baselines re-bless with the unit that changes snapshotted pixels — step 6 / success-view changes are outside the snapshot set (/report step 1 + /keygen), so no re-bless.

  • SDKs (#945), docs/ADR/CHANGELOG + body-limit test + epic close (#946).

Verification

  • cargo fmt --allcargo 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; with CRAIG_INTAKE__DEBUG_EMIT_CPS_REQUEST=EXPOSE_SSN_PII the response carries the recomputed hashes + manifest with no-store and 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 signed_max_file_bytes (10 MB) config lands here; signed_body_limit + the nest DefaultBodyLimit override + its 413 test + docs stay in step 7 (§8).

Done (2026-07-01) — MR !871, commit 58142633

3

#942 sink→SHINES multipart forward (request + file parts) + mock content-type branch + sink→mock integration tests. The full-JWS-stack signed multipart submit rides the step-7 e2e (no working-signer router harness exists to unit-test it).

Done (2026-07-01) — MR !872, commit 00cde182

4

#943 debug output on the signed path (DebugEmitCps on the signed nest; SignedDebugConfirmation superset with recomputed hashes + signed manifest; no-store; absent-when-off; never-logged; EXPOSE_SSN_PII doc/boot-warning updated)

Done (2026-07-01) — MR !873, commit 16cff2e

5

#944 browser UI (file input on the signing step, sha256Hex, hash→manifest→sign→FormData submit, success-view debug rows, i18n). The attachment browser flow ships with its e2e HERE (extends intake-edge-crypto.spec.ts — only Playwright can exercise crypto.subtle + FormData; a feature ships with its test). Visual baselines are unaffected: the attachment input is on step 6 + the debug rows are in the success view, both outside the snapshot set (/report step 1 + /keygen), so no re-bless — the pre-push e2e confirms it.

Done (2026-07-01) — MR !874, commit 2af2e88c

6

#945 SDK signed-multipart submit helpers (Python + TS + Rust) — sha256Hex per file → signed supporting_documents manifest → one multipart POST (report + file parts); Attachment type; submit_report_signed extended with attachments. Cross-language attachment-hash.json parity vector + a per-SDK parity test.

Done (2026-07-01) — MR !875, commit e2e0c76d

7

#946 docs/ADR/CHANGELOG + body-limit override test (signed_body_limit + the nest DefaultBodyLimit) + .claude/CLAUDE.md status + epic close (final MR). The attachment e2e ships in step 5 with the browser flow; visual baselines are re-blessed by whichever unit changes snapshotted pixels (step 1 / /keygen) — none so far.

Done (2026-07-01) — this MR (#946)

Edit this page · latest