Manifest signing keys and Object Lock bucket

Task 5921 · Done · Coder · created · updated

Part 1 of 5 of the signed AI-provenance compliance manifest epic (C1). Siblings: C2 PR provenance capture, C3 payload builder + HTML renderer, C4 seal pipeline + routes + erase, C5 console UI + live end-to-end. ## Original requirement (epic body, verbatim) > legal teams panic over AI introducing hallucinated vulnerabilities or copyrighted code patterns. > > Expanding on its receipt-based tracking, Jaah logs every single keystroke, > > Generates a **cryptographically signed, tamper-proof compliance manifest** Settled reading (BA): "signed AI-provenance manifest per task + epic, auto-sealed on done, regenerable, verifiable offline (public key) and in console; summary + transcript hashes; provenance only." ## Requirements this child serves (PRD, verbatim) - R4 The manifest is canonical JSON signed with an asymmetric signature; the signature covers every byte of the canonical payload. - R5 The public key(s) are published at a stable unauthenticated URL per environment with key id, so a third party verifies offline; a documented verification procedure (a few commands with standard tools) is linked from the HTML rendering. - R8 (part) … the public-key verification needs no login. - R9 Feature ON by default in dev and prod; no flag left off. - AS1 Signing key is Jaah-held per environment (one key on dev, one on prod), KMS-backed; per-org keys are not required. ## Scope — what to build (Architect §3.1, §3.4 infra, §2.6, §2.9, §2.10) 1. **`apps/api/app/aws/kms.py`**: add `sign(key_arn, digest32) -> der_bytes` (`TrentService.Sign`, `SigningAlgorithm=ECDSA_SHA_256`, `MessageType=DIGEST`). Generalise `get_public_key` (RSA-only today, ~`kms.py:142-147`) so the caller states the key spec it requires and `ECC_NIST_P256` is accepted. 2. **`apps/api/app/compliance_signing.py`** (new): a `Signer` protocol with `KmsSigner` and `LocalSigner` (the latter on the already-pinned `cryptography`), plus `kid_of(spki_der)`, `published_keys()` (active + retired, cached per process), `verify_envelope(raw)`, and `resolve_signer()` called at startup like `credential_keys.resolve_secret_key_provider` — startup must NOT crash on missing config; it returns a "not configured" state with the reason the seal will record ("signing key not configured" / "manifest storage not configured" / "public keys URL not configured"). Pinned seam (§2.10, verbatim — C3/C4 build against it): ```python class Signer(Protocol): kid: str # "sha256:<hex of SPKI DER>" alg: str # "ECDSA_P256_SHA256" def sign_digest(self, digest32: bytes) -> bytes: ... # DER signature def published_keys() -> list[PublishedKey] # PublishedKey(kid, alg, environment, status, public_key_pem) def verify_envelope(raw: bytes) -> EnvelopeCheck # EnvelopeCheck(signature: Literal["valid","invalid","unknown_key","malformed"], # kid: str|None, payload: dict|None, payload_sha256: str|None) # payload is set only when signature == "valid"; inner/outer kid or alg mismatch -> "invalid". ``` Pinned envelope (§2.1, verbatim) that `verify_envelope` parses: ```json {"format": "jaah.compliance-manifest.v1", "kid": "sha256:<64 hex of the SPKI DER>", "alg": "ECDSA_P256_SHA256", "payload": "<standard base64 of the canonical payload bytes>", "signature": "<standard base64 of the DER ECDSA signature>", "public_keys_url": "https://app.jaah.ai/api/public/compliance/keys"} ``` `kid`/`alg` are repeated INSIDE the signed payload as `signing.kid` / `signing.alg`; a verifier trusts the inner copy and an outer/inner mismatch is `invalid`. Manifest digest = lowercase hex sha256 of the decoded payload bytes. Signing = `kms:Sign` / local sign over sha256(payload bytes), DIGEST mode. 3. **Env names (§2.9, verbatim contract):** `COMPLIANCE_SIGNING_KMS_KEY_ID` (the key ARN, never an alias — `kms.region_of` refuses aliases); `COMPLIANCE_SIGNING_PREVIOUS_KEY_IDS` (comma list of ARNs published as `retired`); `COMPLIANCE_SIGNING_LOCAL_PRIVATE_KEY` (PEM, local `./dc` stack and tests only, mirrors `CREDENTIAL_LOCAL_PRIVATE_KEY`); `COMPLIANCE_MANIFEST_BUCKET`; `COMPLIANCE_VERIFY_DOC_URL` (decided by the planner: this environment's docs-portal URL of the "Verifying a Jaah compliance manifest" page from item 8 — the link the HTML's "How to verify" section carries; C4 passes it to `render_html(verify_doc_url=…)`); `COMPLIANCE_PUBLIC_KEYS_URL` (`https://app.jaah.ai/api/public/compliance/keys` prod, `https://app.dev.jaah.ai/api/public/compliance/keys` dev; `infra/local` sets it to the `./dc` stack's own API origin). Wire all of them into the ECS task definition in BOTH environments with no flag, and into `infra/local`. 4. **Public routes in `apps/api/app/routes/public.py`** (no session, rate-limited like `/settings`): `GET /api/public/compliance/keys` → `{keys:[{kid, alg, environment, status:"active"|"retired", public_key_pem}]}`; `GET /api/public/compliance/keys/{kid}.pem` → `text/plain` PEM, unknown kid → 404. 5. **Terraform, `infra/jaah/terraform/modules/ecs/main.tf`** beside `credential_secrets`: `aws_kms_key.compliance_signing` (`customer_master_key_spec = "ECC_NIST_P256"`, `key_usage = "SIGN_VERIFY"`) + alias; key policy: account root `kms:*`, task role `kms:Sign` + `kms:GetPublicKey`; task-role IAM: `kms:Sign` conditioned on `kms:SigningAlgorithm = ECDSA_SHA_256`, and `kms:GetPublicKey`, on that key ARN only. 6. **Terraform, new `infra/jaah/terraform/compliance-manifests.tf`** following `security-archive.tf`'s shape: bucket `${local.name}-compliance-manifests-${account_id}`, `object_lock_enabled = true` in both environments; versioning on; default retention `GOVERNANCE`, `days = 2557`; SSE-KMS under a NEW dedicated symmetric CMK `aws_kms_key.compliance_manifests` (yearly rotation, S3 Bucket Key on; key policy grants the API task role `kms:GenerateDataKey` + `kms:Decrypt` conditioned on `kms:ViaService = s3.<region>.amazonaws.com`; matching IAM on that key ARN only; do not reuse `security_archive`'s key); public access fully blocked; abort-multipart lifecycle rule only. Task-role grants: `s3:PutObject`, `s3:GetObject` on `manifests/*`; `s3:DeleteObjectVersion` + `s3:BypassGovernanceRetention` on `manifests/*` (for C4's erase path only); `s3:ListBucket` + `s3:ListBucketVersions` on the bucket conditioned `StringLike` `s3:prefix` = `manifests/*` (`StringLike`, never `StringEquals`). Add CloudTrail data events for this bucket to the existing `security` trail's selectors. Key layout used by C4: `manifests/org=<org>/task=<id>/v<N>.json` and `.html`. 7. **Bucket self-test run AS the API task role**: a one-shot, operator-triggered check (e.g. in the console's existing system checks) that puts an object under `manifests/`, gets it, lists its versions, and bypass-deletes it, reporting each step's result. The self-test also signs a test payload with the real signer and offers the resulting envelope as a download, so the real-key AC6 leg can be run offline. It proves the grants before C4 depends on them. 8. **Reader doc** in `apps/docs`: "Verifying a Jaah compliance manifest" — the §2.6 commands below, key URLs per environment, note that `base64 -d` is `-D` on older macOS. Frontmatter and route coverage per `apps/docs/README.md`. Pinned procedure (§2.6, verbatim): ``` jq -r .payload jaah-manifest.json | base64 -d > payload.json jq -r .signature jaah-manifest.json | base64 -d > signature.der curl -fsS "<public_keys_url>/$(jq -r .kid jaah-manifest.json).pem" > key.pem openssl dgst -sha256 -verify key.pem -signature signature.der payload.json >/dev/null && echo valid || echo invalid ``` 9. **Key rotation rule** into `docs/compliance/key-rotation.md` (edit the existing doc, pointer not copy): never schedule deletion of a signing key; rotation adds a new key and moves the old ARN to `COMPLIANCE_SIGNING_PREVIOUS_KEY_IDS` so it stays published as `retired`. Decided (planner): this child adds NO DB migration (the single migration of wave 1 is C2's). ## Acceptance criteria (PRD verbatim, scoped to this child) - AC6 Offline verification: with only the downloaded JSON and the published public key, the documented procedure prints "valid"; flipping any single byte of the payload makes it print "invalid". → Verify: run the documented commands on a machine with no Jaah credentials. - AC11 (part) Availability (R29): feature is reachable and ON for an operator on dev.jaah.ai with no flag flip — for this child: the keys URL is live on dev. - AC12 (part) No secrets: no private key material is ever exposed by the keys routes or logged. Credentials & reach: - AC6 offline verification — no credential, by design; tools `jq`, `base64`, `openssl` (OpenSSL 3.5.5 present on the node); public-key URL on `app.dev.jaah.ai` — **verified**: tools present and host reachable. The keys route itself ships in this child, and its oracle is this child's. - KMS key and Object Lock bucket creation — No runner credential: Terraform applies through the `Jaah Deploy` workflow on merge to `develop` (`.github/workflows/jaah-deploy.yml`), with the per-environment deploy role (`org/ci-roles.tf`, policy `modules/deploy-policy`). — **verified (static), live confirmation is this child's oracle.** The deploy policy grants `kms` as an id-addressed service and `s3:*` on its own named resources; the same role already creates an asymmetric KMS key (`modules/ecs/main.tf:709`) and Object Lock buckets (`security-archive.tf`). Do NOT run cloud CLI calls against Jaah resources (hub rule) — prove it through the Jaah Deploy run and the console. - Dev console flows — `jaah/debug/main/operator-login` (operator) · dev console `https://app.dev.jaah.ai` — **verified**. Out of scope: Do NOT touch the payload builder/HTML renderer (`compliance_manifest.py`, C3), the `compliance_manifests` table / `tasks.done_at` schema (C3), the seal workflow, task routes, erase paths (C4), the daemon PR capture (C2), or any web UI (C5). Epic fences (PRD §6, verbatim): NO vulnerability, dependency or licence/code-similarity scanning; NO raw OS/terminal keystroke logging; NO change to transcript retention (180 days), redaction rules, or the `sessions:edit` gate on full transcript download; NO embedding of full transcripts in the manifest; NO per-org signing keys, customer-supplied keys, or blockchain/timestamp-authority anchoring; NO modification of the existing task lifecycle states or settle semantics; NO new permission key; NO marketing/site copy. Never schedule deletion of any KMS key; do not touch `security_archive`'s key policy. Done means: - Unit tests: `LocalSigner` signs a digest; `verify_envelope` returns `valid` for an untouched envelope, `invalid` for a one-byte payload flip and for an outer/inner kid or alg mismatch, `unknown_key` for an unpublished kid, `malformed` for garbage — `./dc check full` green. - Unit test: a test-signed envelope written to disk verifies with the four §2.6 shell commands (`openssl` subprocess) → prints `valid`; after flipping one payload byte → `invalid` (AC6, fixture leg). - `resolve_signer()` with nothing configured: API starts, and the resolver reports "signing key not configured" (test). - `GET /api/public/compliance/keys` and `/{kid}.pem` covered by route tests (no session needed; unknown kid → 404; no private material in the response). - The PR's `Jaah Deploy` run on merge to `develop` succeeds (Terraform applied: signing key, dedicated CMK, Object Lock bucket); if it fails, fix and merge again. - On dev: `curl -fsS https://app.dev.jaah.ai/api/public/compliance/keys` returns one `active` ECDSA P-256 key with a `sha256:` kid (AC11 part). - On dev: an envelope signed by the REAL KMS key (downloaded from the self-test in the console's system checks) verifies with the §2.6 commands on a machine with no Jaah credentials → `valid`; one flipped byte → `invalid` (AC6, real-key leg). - On dev, the bucket self-test run as the API task role reports put / get / list-versions / bypass-delete all succeeded. - On dev the API task definition carries `COMPLIANCE_VERIFY_DOC_URL` pointing at the reader doc page, and that URL opens the page. - The reader doc page renders in the docs portal and its route coverage check passes.

Outcome

C1 shipped: KMS P-256 signing key, Object Lock bucket, public keys routes, operator self-test and verify doc. PR #5133 merged; dev deploy green. Live on dev: AC6 real-key valid→invalid, self-test 5/5. Full gate red only on host /tmp inode exhaustion. 5 UI follow-ups in report.

Pull request

Artifacts

Activity

  • Draft
  • Ready
  • Runner started
  • Runner ended (done)