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/keysweb page orPOST /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-Signatureheader, it relays the header transparently to the upstream CRAIG instance via theForwardingSink. The central instance performs verification against its ownsigner_keystable. -
Admin key management: The
/v1/intake/signer-keysadmin 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— includespublic_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 |
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
p256crate withecdsa+jwkfeatures
Handler Modification
File: services/craig-intake/src/api/partner.rs — submit_report_partner()
-
Extract required
X-JWS-Signatureheader (400 if missing) -
Canonicalize body → verify JWS → store signature + key_id + hash
-
If verification fails: 400 with detail
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(extendsreport_base.html) -
Handler:
key_registration()inservices/craig-web/src/routes/report.rs -
Route:
GET /report/keysin public_report_routes -
Alpine.js component: org form → generate keypair → POST public key → download private key → show key_id
Phase 2f: Multi-Language SDKs
MR 4: Rust SDK Signing Support
New File: crates/craig-intake-sdk/src/signing.rs
-
SigningConfig— holdsSigningKey+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>
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
-
ReportSinkenum —DatabaseSink(integrated) vsForwardingSink(standalone) -
ApiKeyLookupenum —DbApiKeyLookupvsConfigApiKeyLookup(file-based keys for standalone) -
IntakeForwardAdaptertrait — translates CRAIG report format to target system’s API contract -
CraigAdapter— identity transform (CRAIG-to-CRAIG, default) -
IntakeModeconfig — 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-Signatureheader forwarded transparently through adapter layer
Cross-Plan Impact
-
public-intake.adoc— Phase 2e/2f added -
intake-standalone-mode.adoc— note added aboutX-JWS-Signatureheader forwarding -
data-integrity-hardening.adoc— cross-reference to JWS integrity
Verification
After all MRs merged:
-
Submit partner report with valid JWS → stored, verify-signature returns true
-
Submit with tampered body → 400
-
Submit without JWS → 400 (mandatory for partner API)
-
TypeScript SDK:
npm testpasses, signing round-trip works -
Python SDK:
pytestpasses, signing round-trip works -
All 4 canonicalize implementations match shared test vectors
-
Key registration page generates keypair and registers with CRAIG
-
Admin approves/revokes signer keys via internal endpoints
-
E2E tests pass