Plan: JWS Integrity Verification & Multi-Language SDKs

On this page

Status

COMPLETE — All 6 MRs merged.

  • Phase 2e (JWS Key Registry + Verification) — COMPLETE — MR 1 (signer key registry) complete, MR 2 (JWS verification) complete, MR 3 (key management web page + browser signing library) complete

  • Phase 2f (Multi-Language SDKs) — COMPLETE — MR 4 (Rust SDK signing) complete, MR 5 (TypeScript SDK) complete, MR 6 (Python SDK) complete


Context

Third-party vendors (police departments, hospitals, schools) submit child abuse reports to CRAIG via the partner API. API key authentication proves which vendor sent the request, but not that the individual reporter (e.g., a police officer) authored the content unaltered.

This plan adds cryptographic non-repudiation via ECDSA P-256 (ES256) client-side signing, a CRAIG-hosted public key registry, and multi-language SDKs (TypeScript, Python) with built-in signing support. See ADR-010 for the architecture decision.

Architecture

JWS Flow

Officer's Browser (vendor's web app)
  1. Load private key from .jwk file
  2. craig-sign.js: canonicalize(json) → sign with ECDSA P-256 → detached JWS
  3. Vendor's backend receives signed payload + JWS string

Vendor's Backend
  4. POST /partner/v1/reports with X-Api-Key + X-JWS-Signature headers
  5. Body = original report JSON (unmodified)

craig-intake
  6. Validate API key (existing middleware)
  7. Extract X-JWS-Signature → parse JWS header → extract kid
  8. Look up signer key by kid (must be approved, not expired)
  9. Canonicalize received body → verify ECDSA signature
  10. Store JWS + signer_key_id + payload_hash on report

Key Registration Flow

Officer visits https://craig.example.gov/report/keys
  1. Enters: name, badge number, email, selects partner organization
  2. Clicks "Generate Signing Key"
  3. WebCrypto generates ECDSA P-256 keypair in browser
  4. Public key POSTed to /public/v1/keys → returns key_id, status=pending
  5. Private key downloaded as .jwk file (never leaves device)
  6. CRAIG admin approves key → status=approved
  7. Officer provides key_id + .jwk file to IT department

Standalone Mode Interaction

The signer_keys table and all key management endpoints exist only on the central CRAIG instance (integrated mode). craig-intake in standalone mode is stateless and has no database — it does not store or verify signer keys.

  • Key registration: Officers register keys directly with the central CRAIG instance (via the /report/keys web page or POST /public/v1/keys). Standalone instances do not expose key registration endpoints.

  • JWS forwarding: When a standalone instance receives a partner report with an X-JWS-Signature header, it relays the header transparently to the upstream CRAIG instance via the ForwardingSink. The central instance performs verification against its own signer_keys table.

  • Admin key management: The /v1/intake/signer-keys admin endpoints are only available on the central CRAIG instance.

This preserves the trust model — the central CRAIG is the sole authority for key approval, and standalone instances remain stateless forwarders.

Canonicalization Contract

All signing implementations MUST produce identical output:

  • Object keys sorted lexicographically at every nesting level

  • No extra whitespace, no trailing commas

  • Python: json.dumps(obj, sort_keys=True, separators=(",", ":"))

  • Rust: serde_json::to_string(&serde_json::to_value(&obj)?)

  • JS/TS: Custom recursive sort (JSON.stringify does NOT sort keys)

  • Shared test vectors: sdks/test-vectors/canonical.json

Phase 2e: JWS Key Registry + Verification

MR 1: Signer Key Registry (Database + CRUD)

Migration

File: services/craig-intake/migrations/20260317000000_create_signer_keys.sql

CREATE TABLE signer_keys (
    id              UUID PRIMARY KEY DEFAULT uuidv7(),
    api_key_id      UUID NOT NULL REFERENCES api_keys(id),
    user_identifier TEXT NOT NULL,
    display_name    TEXT NOT NULL,
    public_key_jwk  TEXT NOT NULL,
    algorithm       TEXT NOT NULL DEFAULT 'ES256'
        CHECK (algorithm IN ('ES256')),
    key_id          TEXT NOT NULL UNIQUE,
    status          TEXT NOT NULL DEFAULT 'pending'
        CHECK (status IN ('pending', 'approved', 'revoked')),
    approved_by     TEXT,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    expires_at      TIMESTAMPTZ,
    last_used_at    TIMESTAMPTZ,
    revoked_at      TIMESTAMPTZ
);
CREATE INDEX idx_signer_keys_key_id ON signer_keys(key_id) WHERE status = 'approved';
CREATE INDEX idx_signer_keys_api_key ON signer_keys(api_key_id);

New Files

  • services/craig-intake/src/store/signer_keys.rs — CRUD functions (pattern: store/api_keys.rs)

  • Functions: create_signer_key, get_by_kid, get_by_id, list_all, list_by_api_key, approve, revoke, update_last_used

Models (in store/models.rs)

  • SignerKey (sqlx::FromRow) — all columns

  • SignerKeyPublic — includes public_key_jwk (for GET endpoint)

  • RegisterKeyRequest — api_key_id, user_identifier, display_name, public_key_jwk (Value)

  • SignerKeyCreated — key_id, status

Endpoints

Method Path Auth Purpose

POST

/public/v1/keys

none

Register public key (returns key_id, status=pending)

GET

/public/v1/keys/{kid}

none

Get public key by key_id

GET

/v1/intake/signer-keys

admin

List all signer keys

PUT

/v1/intake/signer-keys/{id}/approve

admin

Approve pending key

PUT

/v1/intake/signer-keys/{id}/revoke

admin

Revoke key

Validation

  • JWK must have kty: "EC", crv: "P-256", x, y

  • Must NOT contain d field (reject if private key submitted)

  • api_key_id must reference a valid, active API key

Events

  • intake.signer_key.registered

  • intake.signer_key.approved

  • intake.signer_key.revoked

Tests (~12)

Register key, approve, revoke, RBAC, private key rejection, expired API key, list by org

MR 2: JWS Verification + Report Signing Columns

Migration

File: services/craig-intake/migrations/20260318000000_add_jws_columns.sql

ALTER TABLE public_reports ADD COLUMN IF NOT EXISTS jws_signature TEXT;
ALTER TABLE public_reports ADD COLUMN IF NOT EXISTS signer_key_id UUID REFERENCES signer_keys(id);
ALTER TABLE public_reports ADD COLUMN IF NOT EXISTS signed_payload_hash TEXT;

New File: services/craig-intake/src/api/jws.rs

  • canonicalize_json(value: &Value) → String

  • verify_detached_jws(pool, jws_compact, payload_bytes) → Result<JwsVerification, String>

  • hash_payload(canonical: &str) → String (SHA-256)

  • Uses p256 crate with ecdsa + jwk features

Handler Modification

File: services/craig-intake/src/api/partner.rssubmit_report_partner()

  • Extract required X-JWS-Signature header (400 if missing)

  • Canonicalize body → verify JWS → store signature + key_id + hash

  • If verification fails: 400 with detail

Verification Audit Endpoint

Method Path Auth Purpose

GET

/v1/intake/reports/{id}/verify-signature

caseworker+

Re-verify stored JWS against report body

Returns: { verified, algorithm, key_id, user_identifier, original_hash, current_hash, verified_at }

Tests (~15)

Valid JWS, invalid JWS, revoked key, expired key, missing JWS (400), re-verification, tamper detection

MR 3: Key Management Web Page + Browser Signing Library

Browser Signing Library

File: services/craig-web/static/js/craig-sign.js (~5KB ES module, zero dependencies)

  • generateKeyPair() — WebCrypto ECDSA P-256

  • exportKey(key) — CryptoKey → JWK

  • loadPrivateKey(jwk) — JWK → CryptoKey

  • canonicalize(obj) — recursive sort + compact JSON

  • signPayload(privateKey, kid, payload) — detached JWS compact

Key Registration Page

  • Template: services/craig-web/templates/report/keys.html (extends report_base.html)

  • Handler: key_registration() in services/craig-web/src/routes/report.rs

  • Route: GET /report/keys in public_report_routes

  • Alpine.js component: org form → generate keypair → POST public key → download private key → show key_id

Tests (~3 E2E)

Page renders, key generation flow, download private key

Phase 2f: Multi-Language SDKs

MR 4: Rust SDK Signing Support

Dependencies

p256 = { version = "0.13", features = ["ecdsa", "jwk"] }, base64ct = "1"

New File: crates/craig-intake-sdk/src/signing.rs

  • SigningConfig — holds SigningKey + key_id

  • SigningConfig::from_jwk(jwk_json, key_id) — parse JWK private key

  • canonicalize_json(value) — same algorithm as server

  • sign_detached(config, payload) → Result<String>

Client Updates

  • Add signing: Option<SigningConfig> to IntakeClient

  • with_signing(config) builder method

  • Auto-sign in submit_report() when config present

Tests (~8)

Canonicalize, sign, round-trip verify, integration with devstack

MR 5: TypeScript SDK

Directory: sdks/typescript/

package.json          # @craig/intake-sdk
tsconfig.json
vitest.config.ts
src/
  index.ts, client.ts, types.ts, builder.ts, signing.ts, error.ts
tests/
  client.test.ts, signing.test.ts, builder.test.ts, canonical.test.ts

Dependencies: jose ^5, typescript ^5.4, vitest ^3

Tests: ~15 (builder, signing round-trip, canonicalize vs test vectors, mocked HTTP)

MR 6: Python SDK

Directory: sdks/python/

pyproject.toml        # craig-intake-sdk
src/craig_intake/
  __init__.py, client.py, types.py, builder.py, signing.py, error.py, py.typed
tests/
  test_client.py, test_signing.py, test_builder.py, test_canonical.py

Dependencies: httpx >=0.27, joserfc >=1.0; dev: pytest >=8, pytest-asyncio, respx

Tests: ~12 (builder, signing, canonicalize vs test vectors, mocked HTTP)

Shared Test Vectors

File: sdks/test-vectors/canonical.json

~10 test cases (simple objects, nested sorting, arrays, nulls, unicode). Each SDK’s test suite loads and verifies against these vectors.

MR 7: Intake Standalone Mode + Forward Adapter

Implements the standalone mode for craig-intake (stateless forwarder) with a pluggable adapter layer for connecting to non-CRAIG systems. Full design in Intake Standalone Mode plan.

Key components

  • ReportSink enum — DatabaseSink (integrated) vs ForwardingSink (standalone)

  • ApiKeyLookup enum — DbApiKeyLookup vs ConfigApiKeyLookup (file-based keys for standalone)

  • IntakeForwardAdapter trait — translates CRAIG report format to target system’s API contract

  • CraigAdapter — identity transform (CRAIG-to-CRAIG, default)

  • IntakeMode config — makes DB/MQ/Keycloak optional, adds forward_url + adapter config

  • Embedded HTML UI for standalone (no craig-web dependency)

  • Docker Compose service on port 8009

  • X-JWS-Signature header forwarded transparently through adapter layer

Tests

  • Unit tests for ForwardingSink with mock server (~6)

  • Unit tests for ConfigApiKeyLookup (~4)

  • Config validation tests (~3)

  • Integration: existing 42+ tests unchanged (integrated mode)

  • E2E: standalone submit + status check (~3)

Cross-Plan Impact

  • public-intake.adoc — Phase 2e/2f added

  • intake-standalone-mode.adoc — note added about X-JWS-Signature header forwarding

  • data-integrity-hardening.adoc — cross-reference to JWS integrity

Verification

After all MRs merged:

  1. Submit partner report with valid JWS → stored, verify-signature returns true

  2. Submit with tampered body → 400

  3. Submit without JWS → 400 (mandatory for partner API)

  4. TypeScript SDK: npm test passes, signing round-trip works

  5. Python SDK: pytest passes, signing round-trip works

  6. All 4 canonicalize implementations match shared test vectors

  7. Key registration page generates keypair and registers with CRAIG

  8. Admin approves/revokes signer keys via internal endpoints

  9. E2E tests pass

Edit this page · latest