diff --git a/.env.example b/.env.example deleted file mode 100644 index 6d2a6b0..0000000 --- a/.env.example +++ /dev/null @@ -1,28 +0,0 @@ -# cwl-idp bring-up environment — BOOTSTRAP TRANSPORT ONLY. -# -# These values are injected from your platform secret manager (KV) at deploy -# time. They exist only to hand secrets to the container at start; the running -# services do not scatter os.getenv calls for real config — the admin service -# reads everything from the KV/DB config store (see deploy/bootstrap/). -# -# cp .env.example .env # then populate from KV, never commit .env - -# ---- PostgreSQL (Keycloak system of record) ---- -IDP_DB_NAME=keycloak -IDP_DB_USER=keycloak -IDP_DB_PASSWORD= # from KV: secret/idp/db - -# ---- Keycloak engine ---- -IDP_EXTERNAL_PORT=8080 -# Public base URL / hostname Keycloak advertises (behind the WAF in prod). -IDP_EXTERNAL_HOSTNAME=http://localhost:8080 -# Cache stack: `local` (default; single-node standalone compose) or `ispn` -# for clustered deployments. See the KC_CACHE note in docker-compose.yml. -IDP_CACHE_MODE=local - -# Bootstrap admin. Created ONCE; retire after registering a passkey. -IDP_BOOTSTRAP_ADMIN_USERNAME=idp-admin -IDP_BOOTSTRAP_ADMIN_PASSWORD= # from KV: secret/idp/bootstrap-admin - -# ---- account-unification admin service ---- -UNIFICATION_PORT=8099 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 84e52aa..7a4edd6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,3 +80,36 @@ jobs: env: IDP_DB_PASSWORD: ci-placeholder IDP_BOOTSTRAP_ADMIN_PASSWORD: ci-placeholder + + key-custody-tests: + if: ${{ github.event_name != 'pull_request' || github.event.action != 'closed' }} + runs-on: ubuntu-24.04 + defaults: + run: + working-directory: services/key_custody + steps: + - name: Checkout exact native source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - name: Install pinned native toolchain + run: rustup toolchain install 1.97.1 --profile minimal --component rustfmt --component clippy + - name: Resolve initial candidate dependency lock + run: | + cargo +1.97.1 generate-lockfile + printf '\nBEGIN_CUSTODY_CANDIDATE_LOCK\n' + cat Cargo.lock + printf '\nEND_CUSTODY_CANDIDATE_LOCK\n' + - name: Execute native custody contract + run: cargo +1.97.1 test --locked --all-targets + - name: Reject native lint warnings + run: cargo +1.97.1 clippy --locked --all-targets -- -D warnings + - name: Validate native API documentation + env: + RUSTDOCFLAGS: -D warnings + run: cargo +1.97.1 doc --locked --no-deps + - name: Require reviewed committed dependency lock + run: | + git ls-files --error-unmatch Cargo.lock + git diff --exit-code -- Cargo.lock diff --git a/CHANGELOG.md b/CHANGELOG.md index 4639f1a..23be335 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ Keep a Changelog, and releases use semantic versioning. ### Added +- ADR-0017 and a bounded Keycloak secret entrypoint define the root-only + supervisor/KMS bootstrap exception without creating a general application + secret-file fallback. - ADR-0008 and the non-fork RP authorization matrix, requiring explicit Keyverse token validation, tenant/resource ABAC, bounded RBAC, and cross-tenant acceptance evidence per application. @@ -55,6 +58,11 @@ Keep a Changelog, and releases use semantic versioning. ### Changed +- Standalone Compose no longer uses a repository-local `.env` credential + template. PostgreSQL consumes its root bootstrap password through `_FILE`, + while Keycloak reads exactly three supervisor/KMS-mounted bootstrap files at + its final process boundary. Non-secret deployment configuration remains + separate from Keyverse secret custody. - Federation PUT and apply now report `applied_to_keycloak: true` only after a fresh live Keycloak identity-provider observation matches the desired observable representation. Keycloak's fixed mask for the known @@ -111,6 +119,9 @@ Keep a Changelog, and releases use semantic versioning. ### Fixed +- Removed the standalone `.env.example` credential path so root bootstrap + credentials cannot silently become a reusable dotenv authority for CWL + consumers. - Prevented relying-party inventory from silently accepting a KV key/body identity mismatch, rejected unsafe live or `Location`-derived client UUIDs, and aligned exact client discovery with Keycloak's documented diff --git a/CLAUDE.md b/CLAUDE.md index f53ebe3..f847921 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,10 @@ authenticator is removed from the login flow). cwl-idp is the hub; employer and corporate identity systems are external deployment data and compatibility targets, never the hub. +Keyverse is also the canonical CWL secret-lifecycle owner. Identity, +authorization, ordinary configuration, and secret custody remain separate +bounded contexts even though they live under one product authority. + ## Common commands Make targets work with Docker or Podman (`COMPOSE="podman compose" make up`): @@ -25,17 +29,18 @@ make up # bring up Keycloak + Postgres + admin service make down # tear down while retaining volumes make logs # follow logs make ready # poll readiness (deploy/scripts/healthz.sh) -make install # install the admin service development environment -make test # run account-unification unit tests -make lint # run Ruff + interrogate docstring coverage -make validate-realm # validate deploy/keycloak/realm-cwl.json -make seed-bootstrap # create a local SQLite KV bootstrap store +make install # install the admin service development environment +make test # run account-unification unit tests +make lint # run Ruff + interrogate docstring coverage +make validate-realm # validate deploy/keycloak/realm-cwl.json +make seed-bootstrap # create a local SQLite configuration bootstrap store ``` -Compose bring-up needs `.env` (from `.env.example`) and -`deploy/bootstrap/bootstrap.yaml` (from `bootstrap.example.yaml`). Keycloak -console: `http://localhost:8080`; admin service: -`http://localhost:8099/healthz`. +Compose bring-up does **not** use `.env`. A trusted supervisor/KMS adapter first +materializes the three root-bootstrap files documented in `README.md` under +`/run/keyverse-bootstrap`; `deploy/bootstrap/bootstrap.yaml` remains a +non-secret locator/configuration descriptor. Keycloak console: +`http://localhost:8080`; admin service: `http://localhost:8099/healthz`. Per-service commands matching CI, from `services/account_unification/`: @@ -58,6 +63,9 @@ export CWL_IDP_BOOTSTRAP=/path/to/bootstrap.yaml uvicorn app.main:app --port 8099 ``` +`CWL_IDP_BOOTSTRAP` is a non-secret locator, not credential transport. Do not add +secret-valued environment variables or dotenv discovery to the account service. + ## CI gates (`.github/workflows/ci.yml`) 1. **account-unification-tests** — locked dependencies, Ruff, 100% interrogate @@ -70,8 +78,9 @@ uvicorn app.main:app --port 8099 registration and reset-password remain off; no external IdP or user-storage federation may be committed; public RP access-token lifetime is bounded; real client secrets are forbidden. -3. **compose-config-validates** — validates `docker-compose.yml` with placeholder - bootstrap passwords. +3. **compose-config-validates** — validates `docker-compose.yml` with the + supervisor/KMS bootstrap secret mounts. CI must not fabricate production + credential values merely to make Compose parsing succeed. CodeQL, Semgrep, Security Scan, current-head review, and unresolved-thread gates remain authoritative. `.clusterfuzzlite/` is a discovery marker; the fuzz @@ -86,10 +95,15 @@ Three runtime containers run on two networks (`docker-compose.yml`; the Helm chart has the same shape): - **idp_database** — Postgres 17, Keycloak's system of record. Internal network - only. + only. In standalone Compose its password is read with PostgreSQL's `_FILE` + mechanism from the root-bootstrap secret mount. - **idp_engine** — Keycloak 26, `start --import-realm`; imports the portable, passwordless-first `cwl` realm. Health is exposed on management port 9000. - TLS terminates at the WAF edge, so HTTP is enabled internally. + TLS terminates at the WAF edge, so HTTP is enabled internally. Because + Keycloak consumes bootstrap credentials through native environment options, + `deploy/keycloak/secret-entrypoint.sh` reads only the three mounted bootstrap + files and immediately `exec`s Keycloak. This exception must not spread to + ordinary CWL product credentials. - **account_unification_service** — FastAPI admin service (Python ≥3.11) on port 8099. It talks to Keycloak only through the Admin REST API using a confidential service-account client. It provides account inspect/link/merge, inbound SCIM, @@ -106,27 +120,37 @@ is required by the normal suite. ### Deployment layout -- `deploy/keycloak/` — portable realm config-as-code and - `kcadm-bootstrap.sh`. The realm contains no employer-specific federation. +- `deploy/keycloak/` — portable realm config-as-code, `kcadm-bootstrap.sh`, and + the narrow Compose bootstrap adapter. The realm contains no employer-specific + federation. - `deploy/templates/` — explicit private deployment contracts. SAML/OIDC use Keyverse desired-state endpoints. `oidc-rp-naruon.json` is the reviewed public Naruon runtime RP profile with one audience mapper and bounded routing claims. LDAP is preflighted through Keyverse and then applied through private Keycloak - Admin REST in this release. All `{{placeholders}}` are resolved from KV before - use. -- `deploy/bootstrap/` — the bootstrap pointer locating the KV/DB config store. + Admin REST in this release. Private placeholders are resolved by the approved + deployment secret authority before use; do not introduce dotenv as an + intermediate store. +- `deploy/bootstrap/` — non-secret bootstrap pointer locating the typed + configuration store. - `helm/cwl-idp/` — the same three components; Keycloak and Postgres may be - disabled in favor of externally managed services. Secrets come from - pre-created Kubernetes secrets populated from KV. + disabled in favor of externally managed services. Bootstrap secrets come from + pre-created Kubernetes Secret objects populated by a deployment secret + controller/KMS integration, never repository-local dotenv files. - The repository is **standalone AND submodule-embeddable**: a parent compose can `include:` `docker-compose.yml`, or depend on `helm/cwl-idp`. ## Key conventions -- **Config and secrets come from the KV/DB store, never runtime `os.getenv`.** - Environment variables are bootstrap transport only. The admin service reads - `CWL_IDP_BOOTSTRAP`, which points at the bootstrap file and then the typed KV - configuration. +- **Keyverse owns CWL application secret lifecycle; typed configuration is not a + secret store.** Ordinary consumers use only an immutable released Keyverse + workload-resolution contract and never query Keyverse persistence directly. +- **No dotenv credential authority.** Do not add `.env`, home-directory dotenv + discovery, `env_file`, or secret-valued environment fallback. Non-secret + deployment settings and locators may remain explicit typed configuration. +- **Root bootstrap is a narrow exception.** Keyverse cannot obtain the secrets + required to start its own database/Keycloak engine from its locked API. The + supervisor/KMS mount is allowed only for those bootstrap inputs and must + converge toward managed workload identity plus external KMS/HSM custody. - **SAML/OIDC federation is desired state.** Validate registrations through `POST /federation/identity-providers:validate`, persist with `PUT`, and converge through the federation service. Preflight must not write, call diff --git a/README.md b/README.md index 1961d5e..18ed362 100644 --- a/README.md +++ b/README.md @@ -34,16 +34,16 @@ account-unification admin service, the product: > Employer ADFS and corporate directories are **external compatibility > targets**, not peer hubs. Customer-specific federation stays in the -> deployment controller and KV store. +> deployment controller and Keyverse-owned secret/configuration boundaries. OAuth 2.0 ([RFC 6749](https://www.rfc-editor.org/rfc/rfc6749)) is the official authorization-framework record. [OAuth 2.1](https://datatracker.ietf.org/doc/draft-ietf-oauth-v2-1/) is an IETF Internet-Draft (`draft-ietf-oauth-v2-1-15`, work in progress) and is not cited here as a final RFC. -RP client registrations and confidential values live in the **IdP DB / KV**, -never in an RP's environment. Authorized identity data stays usable under -purpose-bound access control, encryption, and audit. +RP client registrations and confidential values live in the **IdP / Keyverse +secret boundary**, never in an RP's dotenv file. Authorized identity data stays +usable under purpose-bound access control, encryption, and audit. ## Architecture @@ -65,17 +65,39 @@ Trust boundaries: [`ARCHITECTURE.md`](ARCHITECTURE.md). Network diagram: ## Run this repository alone -No sibling repository checkout is required. Docker or Podman with the compose -plugin is enough: +No sibling repository checkout is required. Docker or Podman with the Compose +plugin is enough, but Keyverse deliberately does not bootstrap itself from a +repository-local `.env` file. A trusted supervisor, host credential agent, or +KMS/HSM adapter must first materialize the three **root-bootstrap-only** values +below as private files outside the repository: + +```text +/run/keyverse-bootstrap/idp_database_password +/run/keyverse-bootstrap/idp_bootstrap_admin_username +/run/keyverse-bootstrap/idp_bootstrap_admin_password +``` + +These files exist only to break Keyverse's own bootstrap cycle. They are not the +credential distribution mechanism for other CWL products. Do not commit them, +copy them into a bootstrap YAML, or expose them in shell arguments, logs, +artifacts, screenshots, or model context. + +Then create the non-secret account-service bootstrap descriptor and start the +stack: ```bash -cp .env.example .env # populate values from your KV (bootstrap transport) cp deploy/bootstrap/bootstrap.example.yaml deploy/bootstrap/bootstrap.yaml docker compose up -d # or: podman compose up -d ./deploy/scripts/healthz.sh # waits for Keycloak realm + admin service to be READY ``` +PostgreSQL receives its password through the image's `_FILE` contract. Keycloak +requires the database and one-time bootstrap-admin values in its native process +environment, so `deploy/keycloak/secret-entrypoint.sh` reads the mounted files at +the final container boundary and immediately `exec`s Keycloak. There is no +dotenv discovery or fallback. + - Keycloak console: `http://localhost:8080` - Admin service health: `http://localhost:8099/healthz` @@ -84,7 +106,12 @@ The stack imports the **passwordless-first** realm at first start WebAuthn passwordless authenticator and **no password authenticator**, plus `registrationAllowed:false` / `resetPasswordAllowed:false`. -Production-shaped clusters use [`helm/cwl-idp/`](helm/cwl-idp/). +Production-shaped clusters use [`helm/cwl-idp/`](helm/cwl-idp/). Their bootstrap +secret objects must likewise be populated by a deployment secret controller or +KMS integration, not hand-maintained dotenv files. The longer-term Key Vault +roadmap moves the root encryption key to managed workload identity plus external +KMS/HSM custody; ordinary application credentials resolve through the released +Keyverse workload API. ### Optional parent include @@ -124,11 +151,11 @@ returned in ordinary Keyverse responses. See ### Register external federation The portable realm contains no employer ADFS, LDAP/AD source, or other -customer-specific federation. Render deployment values from KV and preflight -every private payload before apply. +customer-specific federation. Resolve private values through the deployment +secret boundary and preflight every private payload before apply. LDAP preflight redacts `bindDn` and `bindCredential` and must never be used -as the apply payload; apply the original private file only. The first +as the apply payload; apply the original private payload only. The first directory profile is LDAPS-only, read-only, Kerberos-disabled, and `trustEmail=false`. @@ -144,10 +171,17 @@ Design: [`docs/merge-unification-flow.md`](docs/merge-unification-flow.md). ## Configuration and secrets -Config and secrets are read from the **KV / DB store**, not from runtime -`os.getenv`. Environment variables are **bootstrap transport** only -(`CWL_IDP_BOOTSTRAP` → `deploy/bootstrap/bootstrap.yaml`). Database objects -use two-word-or-longer snake_case names (`idp_config_entries`, +Non-secret configuration and bootstrap locators remain typed configuration. +Application credentials are not configuration: Keyverse is the canonical CWL +secret-lifecycle owner, and consumers must adopt only an immutable released +workload-resolution contract. The account service's `CWL_IDP_BOOTSTRAP` value is +a non-secret locator to `deploy/bootstrap/bootstrap.yaml`, not a secret value. + +Keyverse's own root bootstrap cannot depend on the locked Keyverse API. Compose +therefore uses the three protected files described above as a narrow +self-bootstrap exception. It does **not** restore `.env`, a plaintext config DB, +or consumer-side secret files as fallback authorities. Database objects use +two-word-or-longer snake_case names (`idp_config_entries`, `account_merge_audit`, `user_operation_lock_state`). ## Engine and licensing @@ -160,8 +194,8 @@ use two-word-or-longer snake_case names (`idp_config_entries`, | Path | What | | --- | --- | -| [`docs/adr/`](docs/adr/README.md) | Accepted architecture decisions (0001–0008 on this branch) | -| [`docs/REFERENCES.md`](docs/REFERENCES.md) | APA 7th bibliography for ADR 0001–0007 | +| [`docs/adr/`](docs/adr/README.md) | Architecture decisions and proposed changes | +| [`docs/REFERENCES.md`](docs/REFERENCES.md) | APA 7th bibliography | | [`docs/doctoring/`](docs/doctoring/) | Feature-specific standards interpretation | | [`docs/product-technical-gap-baseline.md`](docs/product-technical-gap-baseline.md) | Current buyer-visible product and technical gap register | | [`docs/papers/`](docs/papers/README.md) | Offline copies of selected primary sources | @@ -174,10 +208,10 @@ use two-word-or-longer snake_case names (`idp_config_entries`, | Path | What | | --- | --- | -| `docker-compose.yml` | Standalone bring-up: Keycloak + Postgres + admin service (pinned by digest) | -| `deploy/keycloak/` | Portable Keycloak realm config-as-code and service-account bootstrap | +| `docker-compose.yml` | Standalone bring-up with protected root-bootstrap mounts | +| `deploy/keycloak/` | Portable realm config and bounded Keycloak bootstrap adapter | | `deploy/templates/` | Private deployment templates for preflight and desired state | -| `deploy/bootstrap/` | Bootstrap pointer to the KV/DB config store | +| `deploy/bootstrap/` | Non-secret bootstrap pointer to the account-service config store | | `deploy/scripts/healthz.sh` | Cross-component readiness probe | | `scripts/validate_realm.py` | Realm config-as-code validator | | `services/account_unification/` | FastAPI admin service (link, merge, SCIM, federation, RP desired state) | diff --git a/deploy/bootstrap/bootstrap.example.yaml b/deploy/bootstrap/bootstrap.example.yaml index a35fc89..16b46f3 100644 --- a/deploy/bootstrap/bootstrap.example.yaml +++ b/deploy/bootstrap/bootstrap.example.yaml @@ -1,28 +1,28 @@ # Bootstrap pointer for the account-unification service. # -# This is the ONE file the service is allowed to read directly from the -# filesystem (its path is passed via the single CWL_IDP_BOOTSTRAP env var). -# Its sole job is to tell the service WHERE the real config/secret store is. -# No application secrets live here — only enough to reach the KV/DB store. +# This file is configuration only. Its path is supplied through the non-secret +# CWL_IDP_BOOTSTRAP locator. It tells the service where its typed configuration +# store lives; it is not a credential authority and must contain no secret value. # # Copy to bootstrap.yaml and mount read-only at /bootstrap/bootstrap.yaml. -# The store is expected to hold, under the namespace below, the Keycloak wiring -# keys: keycloak_server_url, keycloak_realm, keycloak_client_id, -# keycloak_client_secret (+ merge_conflict_policy, allow_unverified_email_link). +# The configuration namespace can contain endpoints, realm/client identifiers, +# policy choices and Keyverse secret references. Confidential values themselves +# are resolved through the Keyverse secret-lifecycle boundary as those released +# contracts become available. config_store: - # Backends: "sqlite" (dev/standalone), "postgres" (prod), "env-kv" (12-factor - # shim that reads a KV-injected blob). All real config is fetched from here. + # Supported standalone compatibility backend. Production deployments may ship + # another reviewed adapter, but a backend must never silently fall back to + # dotenv or a secret-valued environment blob. backend: sqlite - # For backend: sqlite — path to the KV database file. sqlite: path: /bootstrap/idp_config_store.db - # For backend: postgres — DSN is itself fetched from the platform secret - # manager and injected here at deploy time (bootstrap transport only). + # Reserved locator for a deployment-owned PostgreSQL configuration adapter. + # dsn_secret_ref is an opaque Keyverse/external bootstrap reference, never a + # DSN string. The standalone image does not currently ship that adapter. postgres: dsn_secret_ref: secret/idp/config-store-dsn - # Namespace/prefix under which this service's keys live in the store. namespace: account_unification diff --git a/deploy/keycloak/secret-entrypoint.sh b/deploy/keycloak/secret-entrypoint.sh new file mode 100644 index 0000000..b9f59dd --- /dev/null +++ b/deploy/keycloak/secret-entrypoint.sh @@ -0,0 +1,35 @@ +#!/bin/sh +# Root bootstrap adapter for the upstream Keycloak image. +# +# Keycloak natively consumes these bootstrap values from its process environment. +# The deployment contract therefore mounts supervisor/KMS-materialized files and +# converts them only in this final process boundary. This is not a general CWL +# application-secret path and is never a dotenv fallback. +set -eu +umask 077 + +read_required_secret() { + secret_path="$1" + if [ ! -f "$secret_path" ] || [ ! -r "$secret_path" ]; then + exit 78 + fi + secret_value="" + IFS= read -r secret_value < "$secret_path" || [ -n "$secret_value" ] || exit 78 + if [ -z "$secret_value" ]; then + exit 78 + fi +} + +read_required_secret /run/secrets/idp_database_password +export KC_DB_PASSWORD="$secret_value" +unset secret_value + +read_required_secret /run/secrets/idp_bootstrap_admin_username +export KC_BOOTSTRAP_ADMIN_USERNAME="$secret_value" +unset secret_value + +read_required_secret /run/secrets/idp_bootstrap_admin_password +export KC_BOOTSTRAP_ADMIN_PASSWORD="$secret_value" +unset secret_value secret_path + +exec /opt/keycloak/bin/kc.sh "$@" diff --git a/docker-compose.yml b/docker-compose.yml index dfcbe84..33256c8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,33 +3,29 @@ # Engine: Keycloak (Apache-2.0) + its own PostgreSQL (MIT). # Images are pinned by tag AND digest for reproducibility. # -# Runs standalone: docker compose up -d (or: podman compose up -d) -# Embeddable submodule: the same file is included from a parent compose via -# `include:` or `-f cwl-idp/docker-compose.yml`. +# Runs standalone after a trusted supervisor/KMS adapter materializes the three +# root-bootstrap files under /run/keyverse-bootstrap. These files are the +# explicit self-bootstrap exception; ordinary CWL application secrets resolve +# through released Keyverse contracts and never through dotenv fallback. # # Readiness: Keycloak exposes a management port (9000) with /health/ready and -# /health/live when KC_HEALTH_ENABLED=true; `deploy/scripts/healthz.sh` and the +# /health/live when KC_HEALTH_ENABLED=true; deploy/scripts/healthz.sh and the # compose healthchecks below poll it. name: cwl-idp services: - # --------------------------------------------------------------------- # - # PostgreSQL — Keycloak system-of-record. Not exposed outside the network. - # --------------------------------------------------------------------- # idp_database: image: postgres:17-alpine@sha256:67f624a4ad70edba8d65c82341124fab7054b277b4f7dea4b04be6f939ce2314 container_name: cwl_idp_database restart: unless-stopped environment: POSTGRES_USER: ${IDP_DB_USER:-keycloak} - # Bootstrap-only transport: this value is injected from your secret - # manager (KV) at deploy time, never committed. See .env.example. Plain - # interpolation (no required-variable error operator) so `docker compose - # config` validates with no env present; the postgres image itself rejects - # an empty password at runtime. - POSTGRES_PASSWORD: ${IDP_DB_PASSWORD} + POSTGRES_PASSWORD_FILE: /run/secrets/idp_database_password POSTGRES_DB: ${IDP_DB_NAME:-keycloak} + secrets: + - source: idp_database_password + target: idp_database_password volumes: - idp_database_data:/var/lib/postgresql/data healthcheck: @@ -41,25 +37,16 @@ services: networks: - idp_internal_network - # --------------------------------------------------------------------- # - # Keycloak — the IdP engine. The passwordless-first realm (WebAuthn - # passwordless flow, passwords disabled) is imported as-code at start from - # deploy/keycloak/realm-cwl.json via --import-realm. - # --------------------------------------------------------------------- # idp_engine: image: quay.io/keycloak/keycloak:26.3.2@sha256:98fab020a3a490aba0978f237e2a06cd0ea42bf149c6cf10f11c0aaf27728ff2 container_name: cwl_idp_engine restart: unless-stopped - command: > - start - --import-realm + entrypoint: ["/bin/sh", "/opt/keycloak/bin/keyverse-secret-entrypoint.sh"] + command: ["start", "--import-realm"] environment: KC_DB: postgres KC_DB_URL: jdbc:postgresql://idp_database:5432/${IDP_DB_NAME:-keycloak} KC_DB_USERNAME: ${IDP_DB_USER:-keycloak} - KC_DB_PASSWORD: ${IDP_DB_PASSWORD} - KC_BOOTSTRAP_ADMIN_USERNAME: ${IDP_BOOTSTRAP_ADMIN_USERNAME:-idp-admin} - KC_BOOTSTRAP_ADMIN_PASSWORD: ${IDP_BOOTSTRAP_ADMIN_PASSWORD} KC_HEALTH_ENABLED: "true" KC_METRICS_ENABLED: "true" KC_HTTP_ENABLED: "true" @@ -67,8 +54,16 @@ services: KC_HOSTNAME_STRICT: "false" KC_PROXY_HEADERS: xforwarded KC_CACHE: ${IDP_CACHE_MODE:-local} + secrets: + - source: idp_database_password + target: idp_database_password + - source: idp_bootstrap_admin_username + target: idp_bootstrap_admin_username + - source: idp_bootstrap_admin_password + target: idp_bootstrap_admin_password volumes: - ./deploy/keycloak/realm-cwl.json:/opt/keycloak/data/import/realm-cwl.json:ro + - ./deploy/keycloak/secret-entrypoint.sh:/opt/keycloak/bin/keyverse-secret-entrypoint.sh:ro ports: - "${IDP_EXTERNAL_PORT:-8080}:8080" depends_on: @@ -79,8 +74,8 @@ services: - CMD-SHELL - >- exec 3<>/dev/tcp/127.0.0.1/9000; - echo -e 'GET /health/ready HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n' >&3; - cat <&3 | grep -q '"status": "UP"' + printf 'GET /health/ready HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n' >&3; + grep -q '"status": "UP"' <&3 interval: 10s timeout: 5s retries: 30 @@ -89,11 +84,6 @@ services: - idp_internal_network - idp_edge_network - # --------------------------------------------------------------------- # - # account-unification admin service (this repo). Fills the gap Keycloak - # does not cover natively: MERGE two pre-existing accounts into one, and a - # minimal inbound SCIM 2.0 provisioning shim into Keycloak. - # --------------------------------------------------------------------- # account_unification_service: build: context: ./services/account_unification @@ -101,14 +91,12 @@ services: container_name: cwl_account_unification_service restart: unless-stopped environment: - # ONLY bootstrap transport: a pointer to the KV/DB config store. - # All real config + secrets are read from that store at runtime, - # never from scattered os.getenv calls. See services/.../app/config.py. + # This is a non-secret bootstrap locator only. Real configuration is read + # from the mounted bootstrap descriptor/config store. The Key Vault root + # itself moves to the protected bootstrap contract in the stacked owner PR. CWL_IDP_BOOTSTRAP: /bootstrap/bootstrap.yaml volumes: - ./deploy/bootstrap:/bootstrap:ro - # Audit events and the user-operation lock sidecar survive container - # replacement. The image runs as a non-root user that owns this path. - account_unification_data:/var/lib/account-unification ports: - "${UNIFICATION_PORT:-8099}:8099" @@ -125,6 +113,17 @@ services: - idp_internal_network - idp_edge_network +# These are self-bootstrap inputs only. A trusted supervisor, host credential +# agent, or KMS/HSM integration must materialize them outside the repository. +# The fixed absolute path deliberately avoids Compose's implicit .env mechanism. +secrets: + idp_database_password: + file: /run/keyverse-bootstrap/idp_database_password + idp_bootstrap_admin_username: + file: /run/keyverse-bootstrap/idp_bootstrap_admin_username + idp_bootstrap_admin_password: + file: /run/keyverse-bootstrap/idp_bootstrap_admin_password + volumes: idp_database_data: account_unification_data: diff --git a/docs/adr/0017-keyverse-root-bootstrap-secret-transport.md b/docs/adr/0017-keyverse-root-bootstrap-secret-transport.md new file mode 100644 index 0000000..daa52b4 --- /dev/null +++ b/docs/adr/0017-keyverse-root-bootstrap-secret-transport.md @@ -0,0 +1,192 @@ +# ADR-0017: Independent Keyverse custody and self-bootstrap; files are not migration + +**Status:** Proposed; owner requirement clarified, runtime acceptance outstanding. +**Date:** 2026-09-10. +**Related:** Keyverse #129, #151, #153; ContextualWisdomLab/.github#2063. + +## Problem and correction + +The owner requires Keyverse to replace dotenv-dependent secret management across +CWL and to be usable itself as the KMS/cryptographic trust service on which other +systems depend. A mandatory external vault or KMS would make Keyverse only an +adapter. Replacing a static dotenv password with a static mounted password does +not implement custody, authorization, rotation, revocation or audit. + +The previous revision of this ADR, at +`cf8498dd33b7d64363a8359d76cab4cb47577ba9`, incorrectly selected protected host +files as the solution and required eventual external KMS/HSM custody. That +architecture direction is superseded by this correction. Its useful no-logging, +no-argv, no-dotenv-discovery and least-disclosure controls remain requirements; +no valid source delta or deployment data is discarded. + +The same revision of PR #153 deletes `.env.example`, mounts three static host +files, uses `POSTGRES_PASSWORD_FILE`, and exports Keycloak passwords from an +entrypoint. These are implemented transport changes only. The producer of those +files has no implemented Keyverse lifecycle contract. The deployment-contract +tests check wiring; they do not prove Keyverse-managed credentials. Current +Compose still reflects that earlier design and is NOT accepted as the final +standalone profile. No rollout or completion may be inferred from a GREEN test +of that wiring. + +## Alternatives and choice + +1. Mandatory external KMS/HSM: reject as the default product dependency. Keep + provider integration as an optional, explicitly selected protection profile. +2. Static credentials in a different file or Kubernetes Secret: reject as the + target architecture. Files may only be a narrowly justified transport for a + legacy client, never the authority or proof of migration. +3. Independent Keyverse custody with explicit initialization, seal/unseal, + native key operations and governed workload credentials: selected direction. + The implementation remains Proposed, not an accepted or released capability. + +## Responsibility and product boundaries + +Keyverse remains the canonical owner. Its identity/federation, authorization, +secret lifecycle, cryptographic key operations, and root custody are separate +bounded contexts with narrow interfaces. New key custody and cryptographic +runtime are implemented in Rust using reviewed cryptographic implementations; +do not create a new cipher, KDF or threshold scheme. Existing Python adapters +are compatibility surfaces, not the new cryptographic engine. + +The custody core must be independently startable inside the Keyverse product. +Its sealed-state administration must not require a live Keycloak, a PostgreSQL +password supplied by that same locked vault, an external vault, or another +Keyverse deployment. Otherwise the circular dependency has only been moved. +Minimal local durable storage may contain encrypted key material, authenticated +metadata and public trust anchors, not the plaintext key that unlocks it. If a +relational backend is later attached, the bootstrap record and storage-connection +path must still pass the no-circular-dependency acceptance test. + +Org/product domain truth is not moved here. `.github` owns reusable verification, +AppGuardrail owns source detection, and enterprise-architecture-core records the +cross-context map. Consumers use immutable released contracts, not this branch, +Keyverse source copies or cross-service SQL. Authorization-plane #103 remains +its existing owner line. + +## Independent initialization and recovery + +The standalone software profile generates root key material inside the custody +boundary with a cryptographically secure random source. It supports an explicit +lifecycle: uninitialized -> sealed -> unsealed -> sealed, with a separate +recovery/rekey workflow. Before unseal, only the authenticated local or pinned +administrative initialization/status/unseal surface is available; general +secret reads, key operations and credential issuance are denied. + +A reviewed threshold-unseal profile is a suitable standalone design: independent +custodians receive protected shares during initialization and provide a quorum +through an authenticated/pinned channel. The service does not write the complete +unseal key or enough shares to reconstruct it into its own persistent storage, +repository, environment or backup. Quorum values are deployment-policy decisions, +not an invented fixed security score. Quorum unseal is NOT threshold signing: +it can reconstruct a key in the custody process and must be documented as such. + +Protect initial enrollment against takeover; bind it to operator presence and a +verified instance fingerprint. Do not use trust-on-first-network-request, an +unverified JWT payload, a shared static admin password or disabling TLS as the +initial identity solution. Local operating-system peer identity may be one +explicit administration profile, not a network `trust` authentication rule. + +A fully unattended restart requires a separately available unlocking factor. +That factor may be locally hardware-sealed or held by an independently governed +cluster quorum; it need not be an external cloud KMS. Without hardware, an +independent live quorum or operator input, this design does not promise both +unattended full-cluster cold recovery and protection from an attacker possessing +all host state. External KMS/HSM and a separate Keyverse deployment can be optional +unseal providers, but dependency cycles must be rejected and the standalone +profile must remain usable with those integrations absent. + +Software, locally hardware-backed and externally backed profiles must report +their actual protection boundary. A software cryptographic service, including +one exposing a PKCS#11-compatible interface, is not automatically a physical +HSM, tamper-resistant appliance or FIPS-validated module. Any hardware/FIPS claim +requires evidence for the exact module, version, configuration and environment. + +## Key operations are distinct from secret retrieval + +Key management supports generation, controlled import, versions, cryptoperiods, +rotation, disablement, recovery and authorized destruction. Managed root, +wrapping and signing private keys are non-exportable through the normal API. +Clients use an opaque key handle and scoped encrypt/decrypt, wrap/unwrap, +sign/verify or MAC operation; they do not fetch every key as a string. Public +verification material may be published. Envelope data-key export is a separate, +explicitly authorized capability, not implied by permission to use the wrapping +key. Cryptographic context binds tenant, environment, purpose and key version. + +Secret management separately handles values that an external protocol actually +requires, such as a provider API credential. Only the authorized execution +boundary receives necessary plaintext for the allowed operation and lifetime. +CO retains the provider-execution boundary; siblings do not receive copies of +model-provider keys. Returned plaintext cannot be retroactively erased from a +compromised caller by revoking a Keyverse lease; actual upstream revocation and +credential lifetime must be part of the contract. + +## PostgreSQL: replace the credential authority, not the filename + +`POSTGRES_PASSWORD_FILE` in PR #153 supplies the image's database initialization +password. It is not a per-workload database login lease, and it does not issue, +rotate or revoke credentials. Database administrative initialization and later +application logins are separate contracts. + +For consumer logins, prefer Keyverse-issued, narrowly mapped short-lived client +certificates where the actual PostgreSQL driver supports the complete TLS/key +integration. PostgreSQL `cert` authentication checks trusted client certificates +and the database-user mapping without requesting a password. Certificate signing +keys stay in Keyverse; any workload private key has a separate generated-key, +proof-of-possession, transport and destruction policy. Merely replacing a +password file with a static private-key file is not acceptance. + +Where certificate authentication is incompatible, an explicit dynamic-role +profile may issue short-lived, least-privilege PostgreSQL credentials via a +Keyverse-owned database adapter. The adapter is the intentional privileged +integration boundary, not permission for arbitrary consumers to query sibling +DBs. Issuance, renewal, disablement, cleanup and audit must reflect observed DB +state. No long-lived shared superuser password is returned to applications. + +Neither certificate expiry nor password expiration is treated as proof that an +already-authenticated pooled connection has ended. Define and test revocation +for new connections AND existing sessions, including pool draining/termination, +maximum session lifetime, outage and reconnect behavior. The adapter must also +have a separately authenticated initial provisioning path so its own login does +not recursively require an unavailable credential lease. + +Prefer direct client integration or authenticated local IPC. A driver-mandated +file is only an optional compatibility sink after verified Keyverse issuance: +short-lived scoped material, private ephemeral storage, explicit replacement +and cleanup, no source authority, and no fallback after lease invalidation. +The current static host mounts satisfy none of that lifecycle by themselves. + +## Acceptance and current gaps + +| Gate | Required executable evidence | Current disposition | +| --- | --- | --- | +| Standalone custody | Initialize, seal/unseal and operate with external KMS/vault access denied and Keycloak/database authentication dependencies unavailable | Not implemented by #153 | +| Bootstrap integrity | Reject duplicate initialization, wrong/insufficient/replayed shares, unauthorized enrollment and tampered bootstrap records | Required native owner work | +| Non-exportable key use | Authorized operations succeed; private-key export, wrong tenant/purpose/version, disabled key and audit failure deny correctly | Required native owner work | +| Recovery | Full cold restart and restore work under the declared custody profile without plaintext unlock material in persisted state | Required native owner work | +| PostgreSQL lifecycle | Correct real driver/DB login, wrong-role denial, expiry, rotation, revocation of new and existing sessions, reconnect and cleanup | `_FILE` wiring is not acceptance | +| Client compatibility | Any ephemeral materialization is traceable to a valid issued lease and fails closed without static fallback | Current host files remain a gap | +| Consumer migration | Released owner version + exact consumer revision + real clean startup/outage/recovery evidence | Not delivered by deleting `.env.example` | + +Keep #153 Draft. Do not close or discard #129/#151/#153 to conceal the gap. +Their existing regression/security controls remain valuable, but native custody +and lifecycle must replace or explicitly confine the compatibility behavior +before deployment. PRD/TRD/UML/ERD/operability and the product gap baseline must be +reconciled across those owner stacks before protected integration and release. + +## References and interpretation + +HashiCorp. (n.d.-a). *Seal stanza*. https://developer.hashicorp.com/vault/docs/configuration/seal + +HashiCorp. (n.d.-b). *Seal/Unseal*. https://developer.hashicorp.com/vault/docs/concepts/seal + +HashiCorp. (n.d.-c). *Transit secrets engine*. https://developer.hashicorp.com/vault/docs/secrets/transit + +HashiCorp. (n.d.-d). *PostgreSQL database secrets engine*. https://developer.hashicorp.com/vault/docs/secrets/databases/postgresql + +PostgreSQL Global Development Group. (n.d.). *Certificate authentication (PostgreSQL 17)*. https://www.postgresql.org/docs/17/auth-cert.html + +National Institute of Standards and Technology. (n.d.). *FIPS 140-3 standards*. https://csrc.nist.gov/projects/cryptographic-module-validation-program/fips-140-3-standards + +These are primary-source architectural precedents and assurance boundaries, +not dependencies adopted by this ADR. They do not demonstrate that Keyverse has +implemented or passed the target controls. See the associated doctoring record. diff --git a/docs/adr/README.md b/docs/adr/README.md index aad6a6a..f54396a 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -18,16 +18,24 @@ authorization boundary and is not rewritten by that expansion. | [0007](0007-automation-authority.md) | Autonomous development remains separate from review/merge/release authority | Accepted | | [0008](0008-keyverse-rp-authorization-boundary.md) | Every non-fork RP explicitly validates Keyverse identity and manages ABAC/RBAC at its own boundary | Accepted | | [0013](0013-mcp-oauth-client-authorization.md) | Use Keycloak-backed authorization code plus PKCE and exact resource binding for MCP clients | Proposed | +| [0017](0017-keyverse-root-bootstrap-secret-transport.md) | Independent Keyverse custody and self-bootstrap; external KMS is optional and static file transport is not migration | Proposed | ADR numbering note: protected `main` currently ends at ADR-0008. ADR-0009 is -proposed in the open LineageWeave claim-profile PR, and ADR-0010 through -ADR-0012 are proposed in the open authorization-plane PR. ADR-0013 preserves -the next intended number without renumbering parallel work; it must be -reconciled after those PRs land, and none of the absent records is accepted -architecture on protected `main` yet. +proposed in the open LineageWeave claim-profile PR, ADR-0010 through ADR-0012 +are proposed in the authorization-plane PR, ADR-0013 is the open MCP decision, +and ADR-0014 through ADR-0016 are reserved by the Key Vault foundation stack. +ADR-0017 records self-bootstrap and the 2026-09-10 owner correction of the +external-KMS/static-file direction without allocating a conflicting ADR number. +Every proposed record must be reconciled after its owning PR lands; an absent +record is not accepted architecture on protected `main`. + +The implementation scope of #153 remains transport-only until the native +custody and credential-lifecycle acceptance in ADR-0017 is demonstrated. The +owner's requirement supersedes earlier proposed external-KMS prerequisites; +it does not promote any open PR to Accepted or erase its valid source delta. ## ADR triggers -Create or update an ADR for changes to authenticator policy, federation hub ownership, identity matching evidence, merge/tombstone semantics, SCIM authority, directory write/trust policy, RP credential/claim ownership, desired-state mutation order, persistent state, secret handling, or autonomous/release authority. +Create or update an ADR for changes to authenticator policy, federation hub ownership, identity matching evidence, merge/tombstone semantics, SCIM authority, directory write/trust policy, RP credential/claim ownership, desired-state mutation order, persistent state, secret handling, root custody, self-bootstrap, or autonomous/release authority. Each implementation PR should reconcile PRD/TRD/Architecture/UML/ERD/Threat/Test/Operability/Traceability and the relevant `docs/doctoring/`, `docs/papers/`, or `docs/operations/` research/standards/runbook record when those contracts move. diff --git a/docs/doctoring/key_custody_barrier.md b/docs/doctoring/key_custody_barrier.md new file mode 100644 index 0000000..d8e49d4 --- /dev/null +++ b/docs/doctoring/key_custody_barrier.md @@ -0,0 +1,62 @@ +# Native custody barrier evidence — 2026-09-10 + +Status: source prepared on Keyverse #153; native execution and production acceptance unverified. +Organization owner register: ContextualWisdomLab/.github#2063. + +## Implemented scope and exclusions + +The internal Rust module in `services/key_custody` owns the wrapping-root seal boundary. It initializes without Keycloak, a database, a credential file, another Keyverse or an external KMS. It uses pinned existing cryptographic implementations rather than custom cipher/KDF/Shamir arithmetic. It is not a public workload resolver or a deployment service. + +Required public state sequence: + +```mermaid +stateDiagram-v2 + [*] --> Sealed: initialize root record and separate shares + Sealed --> Sealed: reject invalid / duplicate / insufficient / unauthenticated shares + Sealed --> Unsealed: authenticate complete quorum and encrypted root + Unsealed --> Unsealed: authenticate context-bound record operation + Unsealed --> Sealed: drop zeroizing root buffer + Sealed --> [*]: dispose local instance +``` + +A separately restored root record starts sealed. Authenticated encryption does not prevent replay of an old valid record. Caller authorization, authenticated custodian enrollment, durable audit, atomic storage, monotonic rollback evidence and key/credential lifecycle remain necessary service-layer work. No network route or consumer wiring is added before those gates. + +## Source and verification chronology + +Initial source head `61b30595bea4639517387d3ad55c2464a5d8f3d8` had no Rust custody crate. The test-first scaffold at `a66a65f71c8c048c9590e70bfbaf42d11d64ef56` compiles conceptually to an unavailable initialization result and includes an assertion requiring availability. Actual CI run `34424727022`, native job `102707407636`, remained queued at the observed reads. It was NOT observed failing before the implementation was prepared. Do not rewrite that chronology as a completed TDD cycle. + +The current local container has no cargo/rustc, and compiler/dependency download attempts failed. Local Rust compilation, native behavioral tests, Clippy, rustdoc, formatting and native statement/branch coverage are NOT verified. A pinned Rust job has been wired into the existing CI; no CI result may be inferred from that wiring. + +The candidate dependency lock is also outstanding. The job may generate and print it only for review/capture, but the final committed-lock step must fail while it is untracked. Commit the real lock and remove candidate generation before merge; do not fabricate checksums or claim unlocked resolution is immutable verification. + +## Independent C reference actually executed locally + +An independent unit-test oracle was generated through the installed libsodium 1.0.18 C API, using only public synthetic unit-test values. It is not a new Python runtime dependency and not a recommendation to deploy that local library version. + +- Data wrapping key: bytes 0 through 31; nonce: bytes 0 through 23. +- Instance ID: sixteen bytes `0x11`; context: `tenant_alpha`, `production`, `provider_credentials`, `gateway_api_key`, `version_1`, `provider_request`. +- Associated data: ASCII `keyverse_custody_record_v1`, `KVD1` plus instance ID, then each context field prefixed by its unsigned two-byte big-endian UTF-8 length. +- Plaintext: ASCII `keyverse-native-custody-known-answer`. +- Root wrapper: recovery factor 32 bytes `0x42`, root nonce bytes 48 through 71, authenticated `KVC1` header with quorum 2/3 and the same instance ID. + +The C API successfully encrypted and decrypted both the root and data fixtures. All 52 one-bit-per-byte mutations of the data ciphertext/tag were rejected, and changed associated data was rejected. This proves the reference fixture's local checks only; it does NOT prove that the new Rust code runs or consumes it correctly. + +Reference data ciphertext SHA-256: +`f0c7741d91af2169b76364651df07ba095badb72bf491b43d9b9222082d84e30`. + +Reference complete sealed-root record SHA-256: +`db8a8fe02cc3fe71e7342ca186af3a6f8e377106c854c60f2d82dd57b8219da9`. + +`tests/crypto_interop.rs` feeds those exact C-generated root/data records through the Rust barrier's actual reconstruction, quorum unseal and record-open methods. Its execution is pending. The other 17 test functions cover policy bounds, distinct quorums, invalid shares, root/header/data mutation, each context dimension, length framing, reseal/recovery, limits, entropy faults and redacted diagnostics. Test count is not a coverage percentage. + +## Primary sources and assurance limits + +RustCrypto Contributors. (n.d.). *chacha20poly1305 0.11.0*. https://docs.rs/chacha20poly1305/0.11.0/chacha20poly1305/ + +sharks Contributors. (n.d.). *sharks 0.5.0: Source and features*. https://docs.rs/sharks/0.5.0/sharks/ ; https://docs.rs/crate/sharks/0.5.0/features + +RustCrypto Contributors. (n.d.). *zeroize 1.9.0*. https://docs.rs/zeroize/1.9.0/zeroize/ + +Rust Release Team. (2026, July 16). *Announcing Rust 1.97.1*. https://blog.rust-lang.org/2026/07/16/Rust-1.97.1/ + +Dependency documentation is not an audit of this composition. Shamir recovery is not threshold signing. Memory copies inside dependencies/RNG state, registers, swap and crash dumps require explicit review; no complete zeroization, FIPS validation or physical-HSM assurance is claimed. The older rand-chacha trait version is confined to sharks' rand-0.8 compatibility boundary and must be re-evaluated with its owner library before release. diff --git a/docs/doctoring/keyverse_standalone_custody.md b/docs/doctoring/keyverse_standalone_custody.md new file mode 100644 index 0000000..94337e0 --- /dev/null +++ b/docs/doctoring/keyverse_standalone_custody.md @@ -0,0 +1,77 @@ +# Keyverse independent custody: correction and evidence boundary + +Date: 2026-09-10. Status: design correction, not executable KMS acceptance. + +## Owner correction + +The owner explicitly rejected mandatory external KMS/HSM reliance and rejected +`POSTGRES_PASSWORD_FILE` as equivalent to completing the dotenv migration. +Keyverse itself must be usable as a key-management and cryptographic trust +service. The software-only, hardware-backed and external-provider deployment +profiles require distinct, honest assurance statements. + +## Exact source evidence + +PR #153 was read at `cf8498dd33b7d64363a8359d76cab4cb47577ba9`, tree +`767564c02bbd4953f91e912c1b7ddaf2bc9fef91`. It is open and Draft. Its ADR-0017 +requires host materialization and eventual external KMS/HSM custody; its PR +body identifies PostgreSQL `_FILE` and three static host-mounted bootstrap +credentials. That is transport, not a native issuer or key-service boundary. + +PR #151 was read at `0fe44cfcda9cbd1cf2d81f4b9360630449ea3a70`, open and +Draft. Its root-file protection remains an interim compatibility repair and +cannot become a required external-custody architecture merely because its file +checks pass. Preserve its hardening, tests and migration/recovery protections. + +The central rollout issue is ContextualWisdomLab/.github#2063. Its former +external-provider preference and work-package wording require reconciliation +with independent native custody, not a new duplicate tracking issue. + +## Primary-source findings versus design choices + +HashiCorp's seal documentation makes external seal-provider configuration +optional and describes quorum-based unseal. This supports the feasibility of +independent software-vault startup; it is not a mandate to depend on HashiCorp +or evidence that Keyverse implements its algorithms. + +The Transit documentation distinguishes remote cryptographic operations and +key management from retrieving application secret values. That distinction +informs the proposed Keyverse key-operation port, non-exportable root/wrapping +keys, and separately authorized data-key export. + +PostgreSQL 17 certificate authentication documents a trusted client-certificate +and database-user mapping path without password prompting. HashiCorp separately +documents dynamically generated PostgreSQL credentials. These establish viable +alternatives to one static password file. Actual Keycloak/driver integration, +issuance authority, renewal, revocation and existing-session termination still +require executable acceptance; no driver compatibility is assumed here. + +NIST CMVP describes testing and validation for concrete cryptographic modules. +A software service, a compatible API, or use of a cryptographic library does not +by itself establish physical HSM protection or product validation. + +## Verification and remaining source repair + +This correction changes ADR text, its index and this evidence record only. It +does not implement native unseal, key operations, workload attestation, +PostgreSQL certificate/role provisioning or deployment. Existing Compose and +entrypoint source remain unaccepted compatibility work and must not be promoted +by this documentation commit. No credentials were read, migrated or rotated. + +A local clone attempt failed at DNS resolution for github.com. Repository reads +and publication use the connected GitHub API instead. No local full checkout, +unit-test, cryptographic-test, hosted-GREEN or release claim is made here. + +The source owner must reconcile ADR-0014, its operations and gap baseline, the +#153 Compose/entrypoint behavior, and all consumer assumptions before protected +integration. Native custody starts before the dependent Keycloak/database +plane; a consumer secret-string resolver cannot stand in for a non-exportable +key-operation port. The acceptance matrix in ADR-0017 remains unfulfilled until +real exact-version tests and independent security review demonstrate it. + +## References + +The complete APA-style primary-source bibliography is in +[ADR-0017](../adr/0017-keyverse-root-bootstrap-secret-transport.md#references-and-interpretation). +All external documentation was consulted on 2026-09-10. Architecture precedents +are not a choice of runtime dependency, license or cryptographic implementation. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 9604326..ecaeaa7 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,45 @@ # Keyverse product and technical gap baseline +## Native custody delta — 2026-09-10, active PR #153 + +Keyverse is the canonical independent custody/KMS and secret-lifecycle owner. +External KMS/HSM is optional. Static password files, `_FILE` wiring, or merely +removing a dotenv template do not satisfy that requirement. + +`services/key_custody` adds the native internal custody barrier source and 17 +behavioral test functions: an independent OS-random wrapping root, encrypted +root record, bounded quorum recovery, sealed-by-default reconstruction, no root +export API, and context-bound record protection. Tenant/environment/namespace/ +key/version/purpose binding is cryptographic association, NOT caller permission. +The component has no network, database, dotenv or credential-file reader. + +This is **active-PR / unverified source**, not implemented-main or a released KMS. +The test-first scaffold is commit `a66a65f71c8c048c9590e70bfbaf42d11d64ef56`; +CI run `34424727022`, native job `102707407636`, was still queued at the last +read. No observed native RED, compile success, test pass or coverage is claimed. +The local container has no Rust toolchain and cannot download one. The initial +candidate Cargo lock must be generated by an actual build, committed, and its +temporary generation step removed before final locked verification. This is an +outstanding supply-chain/verification gate, not a completed lock migration. + +See [the component boundary and reproduction](../services/key_custody/README.md) +and [the implementation plan](superpowers/plans/2026-09-10-native-custody-barrier.md). + +Remaining release gates are authenticated custodian enrollment, verified workload +identity and authorization, durable transactional audit, atomic persistence, +monotonic rollback protection, key catalogue/rotation/rekey, credential issuance, +real PostgreSQL authentication/session revocation, security/memory review, +complete native coverage and immutable release. Existing unmerged #129/#151 +compatibility work is preserved. No consumer is activated or given a source/DB +shortcut. #154 remains an unmerged prerequisite; older #143 contains a superset +cleanup and must be reconciled without discarding valid changes. ADR-0017 also +collides with #128's passkey proposal; that numbering finding remains open. + +The entire inventory below is the preserved 2026-08-21 historical snapshot. +Its counts, heads, approvals and queue descriptions are not current merge evidence. + +--- + **Evidence snapshot:** 2026-08-21T16:47:10Z (UTC) **Repository:** `ContextualWisdomLab/keyverse` **Protected-main head observed:** `ce207dfd42975db61c82a5963e206fc1db14ac2b` @@ -64,7 +104,7 @@ success, skipped, or non-terminal results. | [#103](https://github.com/ContextualWisdomLab/keyverse/pull/103) | Hierarchical authorization, login helper, and PATs | `ce207dfd42975db61c82a5963e206fc1db14ac2b` | `77b8f4ea9995329f1c55b916d110b460b4bc7649` | 22 success / 8 skipped | `REVIEW_REQUIRED`; retain fail-closed security boundary and obtain current approval. | | [#101](https://github.com/ContextualWisdomLab/keyverse/pull/101) | Coupled Python dependency updates | `ce207dfd42975db61c82a5963e206fc1db14ac2b` | `50dd9c96cab5c230f775685e8baea939fba390dd` | 22 success / 8 skipped | `REVIEW_REQUIRED`; obtain exact-head approval. | | [#100](https://github.com/ContextualWisdomLab/keyverse/pull/100) | LineageWeave account-derived RP profile | `ce207dfd42975db61c82a5963e206fc1db14ac2b` | `2fd5a77cf3765f933debd244f457e13241726929` | 14 queued / 7 skipped | `REVIEW_REQUIRED`; downstream issuer/audience/tenant acceptance remains unclaimed. | -| [#83](https://github.com/ContextualWisdomLab/keyverse/pull/83) | Remove runtime application RPs from portable realm | `ce207dfd42975db61c82a5963e206fc1db14ac2b` | `dd1ab7444a75342b42e3af013ccda6d1dbfb359d` | 22 success / 8 skipped | `REVIEW_REQUIRED`; confirm exact-head approval and latest-pusher policy before merge. | +| [#83](https://github.com/ContextualWisdomLab/keyverse/pull/83) | Remove runtime application RPs from portable import | `ce207dfd42975db61c82a5963e206fc1db14ac2b` | `dd1ab7444a75342b42e3af013ccda6d1dbfb359d` | 22 success / 8 skipped | `REVIEW_REQUIRED`; confirm exact-head approval and latest-pusher policy before merge. | PR #104 is closed by squash merge at `44c2adb18687f8df457bd4bafade551533cee5b9`, which advanced the #112 feature diff --git a/docs/superpowers/plans/2026-09-10-native-custody-barrier.md b/docs/superpowers/plans/2026-09-10-native-custody-barrier.md new file mode 100644 index 0000000..d43b98d --- /dev/null +++ b/docs/superpowers/plans/2026-09-10-native-custody-barrier.md @@ -0,0 +1,25 @@ +# Independent custody barrier implementation plan + +**Goal:** Implement the first native internal custody barrier in Keyverse, not another credential-file transport. + +**Architecture:** A sealed root record and separately held recovery shares are the initial trust split. An unsealed, non-exported wrapping root authenticates bounded records and their explicit context. The module has no database, network, dotenv, filesystem credential source or external KMS client. It is an internal primitive, not an authenticated/audited remote KMS service. + +**Spec:** `docs/adr/0017-keyverse-root-bootstrap-secret-transport.md` on this owner branch. Its number collides with #128's passkey proposal and must be reconciled before protected integration; this plan allocates no further ADR number. + +**Tech stack:** Rust 1.97.1; pinned RustCrypto XChaCha20Poly1305, getrandom, sharks with zeroize_memory, zeroize, and rand_chacha 0.3 for the sharks rand-0.8 trait boundary. No custom cryptographic algorithms. A software implementation is not an HSM/FIPS certification. + +## Task and test order + +- [ ] Run the compiling, deliberately unavailable scaffold against the initialization assertion and record its actual assertion failure, not a missing-tool or compiler failure. +- [ ] Capture Cargo's generated dependency lock as public build metadata, commit it, and remove the temporary lock-generation step. Final verification is locked and must not rewrite source or dependency locks. +- [ ] Implement bounded quorum validation, random root/recovery factors, authenticated encrypted root record, separately encoded recovery shares and sealed-by-default reconstruction. +- [ ] Observe negative cases for insufficient/duplicate/mixed/corrupt shares, corrupted root metadata/ciphertext, sealed operations and oversized input. Never install recovered state until authentication succeeds. +- [ ] Implement context-bound record protection and verify wrong context/instance, nonce/ciphertext/header mutation and reseal/recovery. No root-export API. +- [ ] Run native tests, Clippy, rustdoc and actual coverage; retain exact-head receipts. Local source inspection is not execution evidence. +- [ ] Update the product baseline and architecture with the implemented boundary and unresolved authenticated enrollment, workload authorization, durable audit, atomic persistence, rollback resistance, credential issuance, secure memory/hardware and immutable release gates. + +## Verification environment and integration + +The current local container has no cargo/rustc and cannot resolve download hosts. The native job is added to the existing product `ci` workflow, with contents-read only, no credentials and no mutation/publication step. Draft runs are necessary to execute deliberate RED tests without claiming Ready. It does not duplicate central security/review/release workflows; reuse an appropriate published Rust verification contract when the central owner provides one. + +All existing #153 changes remain on this branch. #154 is still an unmerged prerequisite and #143 is the older superset cleanup; neither is closed, auto-merged or silently discarded. #129/#151 retain their compatibility scope. No production deployment or consumer cutover occurs in this increment. diff --git a/services/account_unification/tests/test_deployment_contracts.py b/services/account_unification/tests/test_deployment_contracts.py index b346d29..1ce0032 100644 --- a/services/account_unification/tests/test_deployment_contracts.py +++ b/services/account_unification/tests/test_deployment_contracts.py @@ -21,6 +21,13 @@ def _helm_values() -> dict: ) +def _compose_values() -> dict: + """Return the standalone Compose document without runtime interpolation.""" + return yaml.safe_load( + (_repository_root() / "docker-compose.yml").read_text(encoding="utf-8") + ) + + def _seed_tool_source() -> str: """Return the local configuration seed tool source.""" return ( @@ -34,9 +41,7 @@ def _seed_tool_source() -> str: def test_compose_persists_account_unification_state() -> None: """Standalone restarts retain audit and user-operation lock databases.""" - compose = yaml.safe_load( - (_repository_root() / "docker-compose.yml").read_text(encoding="utf-8") - ) + compose = _compose_values() service = compose["services"]["account_unification_service"] assert ( "account_unification_data:/var/lib/account-unification" @@ -45,6 +50,66 @@ def test_compose_persists_account_unification_state() -> None: assert "account_unification_data" in compose["volumes"] +def test_standalone_distribution_has_no_dotenv_credential_template() -> None: + """Operators are not instructed to materialize repository-local dotenv secrets.""" + assert not (_repository_root() / ".env.example").exists() + readme = (_repository_root() / "README.md").read_text(encoding="utf-8") + assert "cp .env.example .env" not in readme + + +def test_compose_bootstrap_credentials_are_file_mounted_not_interpolated() -> None: + """Self-bootstrap secrets enter only through the explicit supervisor mount.""" + compose = _compose_values() + postgres_environment = compose["services"]["idp_database"]["environment"] + keycloak_environment = compose["services"]["idp_engine"]["environment"] + keycloak_secrets = compose["services"]["idp_engine"]["secrets"] + postgres_secrets = compose["services"]["idp_database"]["secrets"] + + assert "POSTGRES_PASSWORD" not in postgres_environment + assert postgres_environment["POSTGRES_PASSWORD_FILE"] == "/run/secrets/idp_database_password" + assert "KC_DB_PASSWORD" not in keycloak_environment + assert "KC_BOOTSTRAP_ADMIN_USERNAME" not in keycloak_environment + assert "KC_BOOTSTRAP_ADMIN_PASSWORD" not in keycloak_environment + assert {item["target"] for item in postgres_secrets} == {"idp_database_password"} + assert {item["target"] for item in keycloak_secrets} == { + "idp_database_password", + "idp_bootstrap_admin_username", + "idp_bootstrap_admin_password", + } + + +def test_compose_bootstrap_secret_sources_live_outside_repository() -> None: + """The standalone profile consumes supervisor/KMS materialized files only.""" + compose = _compose_values() + secret_files = { + secret_name: secret_config["file"] + for secret_name, secret_config in compose["secrets"].items() + } + assert secret_files == { + "idp_database_password": "/run/keyverse-bootstrap/idp_database_password", + "idp_bootstrap_admin_username": "/run/keyverse-bootstrap/idp_bootstrap_admin_username", + "idp_bootstrap_admin_password": "/run/keyverse-bootstrap/idp_bootstrap_admin_password", + } + + +def test_keycloak_secret_entrypoint_has_bounded_secret_transport() -> None: + """Keycloak gets only its native process environment immediately before exec.""" + entrypoint_path = _repository_root() / "deploy" / "keycloak" / "secret-entrypoint.sh" + assert entrypoint_path.is_file() + source = entrypoint_path.read_text(encoding="utf-8") + assert "set -eu" in source + assert "umask 077" in source + assert "/run/secrets/idp_database_password" in source + assert "/run/secrets/idp_bootstrap_admin_username" in source + assert "/run/secrets/idp_bootstrap_admin_password" in source + assert "export KC_DB_PASSWORD" in source + assert "export KC_BOOTSTRAP_ADMIN_USERNAME" in source + assert "export KC_BOOTSTRAP_ADMIN_PASSWORD" in source + assert 'exec /opt/keycloak/bin/kc.sh "$@"' in source + assert "cat " not in source + assert "echo " not in source + + def test_helm_can_fail_closed_on_missing_account_image_digest() -> None: """Production values can require an immutable account-service image.""" image = _helm_values()["accountUnification"]["image"] diff --git a/services/key_custody/AGENTS.md b/services/key_custody/AGENTS.md new file mode 100644 index 0000000..cc6b048 --- /dev/null +++ b/services/key_custody/AGENTS.md @@ -0,0 +1,13 @@ +# Native custody maintenance boundary + +This is an internal, unreleased Keyverse component, not a remote KMS service. +Read the repository's AGENTS.md and the corrected independent-custody ADR. + +- Keep all new custody/crypto runtime in Rust. Do not introduce custom cipher, KDF, RNG or Shamir arithmetic. +- Do not add environment/file credential fallback, external-vault startup dependence, or a public plaintext-root export. +- Context authentication is not workload authorization. A network adapter needs verified identity, narrow policy and durable audit before invoking this library. +- Keep recovery shares separate from encrypted root storage. No automatic unseal by persisting the complete unlocking factor on the same host state. +- Inject entropy faults only through private test seams. Never expose deterministic production RNG configuration. +- Preserve actual RED/GREEN chronology. A queued job, a source review or the libsodium reference fixture is not Rust execution evidence. +- Commit a real reviewed Cargo lock, remove temporary lock generation, and run current-head tests, Clippy, documentation, complete coverage and security review before promotion. +- Record rollback/rekey, persistence/audit atomicity, enrollment and dependency-memory gaps explicitly. No deployment, HSM/FIPS claim or consumer adoption based on this component alone. diff --git a/services/key_custody/CHANGELOG.md b/services/key_custody/CHANGELOG.md new file mode 100644 index 0000000..1e16db5 --- /dev/null +++ b/services/key_custody/CHANGELOG.md @@ -0,0 +1,8 @@ +# Keyverse custody component changelog + +## Unreleased — not a published artifact + +- Added native internal quorum initialization, sealed root-record reconstruction, unseal/reseal, and context-bound record protection without a root export API or external secret authority. +- Added 17 behavioral test functions plus one independent libsodium ciphertext interoperability test. They are written, not reported as passing: Rust execution and coverage remain unverified. +- Added a contents-read-only Draft verification job to existing product CI. Candidate dependency-lock generation must be removed after a real Cargo lock is captured and committed; a mandatory committed-lock gate prevents candidate resolution from being called complete. +- Kept the existing `.env`/static-mount migration incomplete. No remote KMS API, enrollment, authorization, durable audit, rollback resistance, PostgreSQL credential issuance or consumer cutover is delivered by this internal component. diff --git a/services/key_custody/Cargo.toml b/services/key_custody/Cargo.toml new file mode 100644 index 0000000..7488157 --- /dev/null +++ b/services/key_custody/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "keyverse_custody" +version = "0.1.0" +edition = "2024" +rust-version = "1.97" +license = "Apache-2.0" +publish = false +description = "Internal independently unsealed Keyverse custody barrier" + +[lib] +path = "src/custody_barrier.rs" + +[dependencies] +chacha20poly1305 = { version = "=0.11.0", default-features = false, features = ["alloc"] } +getrandom = "=0.4.3" +rand_chacha = "=0.3.1" +sharks = { version = "=0.5.0", default-features = false, features = ["zeroize_memory"] } +zeroize = { version = "=1.9.0", features = ["std"] } diff --git a/services/key_custody/README.md b/services/key_custody/README.md new file mode 100644 index 0000000..a456ae9 --- /dev/null +++ b/services/key_custody/README.md @@ -0,0 +1,103 @@ +# Keyverse native custody barrier + +Internal, unreleased cryptographic component for the independent custody profile +in PR #153. Not a network service, identity verifier, secret-issuance authority, +or completed CWL migration. Consumers must not depend on this PR or copy this +crate into their repositories. `publish = false` is deliberate. + +## Boundary + +Initialization takes only a quorum policy. It generates an OS-random wrapping +root and an independent recovery factor, produces a root record protected by +XChaCha20Poly1305, and returns separately protected recovery shares. Initialization +returns a **sealed** barrier. Reconstructing a barrier from its record never +implicitly unseals it. The root has no export, Clone or serialization API. + +A trusted operator must separately protect and distribute the returned shares. +Do not persist all shares with the encrypted root record. This crate does not +read/write `.env`, files, environment variables or networks; deployment enrollment +and authenticated custodian transport are not implemented here. Possession of +quorum shares authorizes local unseal, not arbitrary remote workload requests. + +After successful quorum recovery and authentication of the complete root record, +the barrier can protect/open internal records with six exact associated context +dimensions: tenant, environment, namespace, key name, immutable version and +purpose. Context binding prevents ciphertext substitution; it is **not** caller +authorization. The eventual authenticated application service must validate +workload identity, authorize scope and durably audit before invoking this module. + +## Encoding and resource limits + +`KVC1` root record: 4-byte magic, required/total share counts, 16-byte random +instance ID, 24-byte nonce, 48-byte encrypted root and authentication tag. Total: +94 bytes. `KVS1` custodian share: same public policy/instance metadata and one +33-byte Shamir share; total 55 bytes. All sizes are checked before parsing. + +`KVD1` protected data: magic, instance ID, nonce and ciphertext/tag. The complete +header and separately length-framed context are authenticated. Context fields +are bounded to 128 UTF-8 bytes and contain no whitespace/control characters; +plaintext is bounded to 1 MiB. These are explicit resource-admission constraints, +not a claim of cryptographic maximum message size or statistical accuracy. +The initial quorum profile supports 2 through 16 shares with threshold <= count. + +Every selected share must have a unique nonzero in-range index and match the +root record's policy/instance. Malformed/mixed/duplicate inputs fail before +interpolation. Extra corrupt shares fail rather than trying combinations. +Recovery authenticates first, then installs the root. Resealing drops the +zeroizing root buffer. Returned plaintext and explicitly exported custodian +shares are redacted buffers requiring deliberate exposure by their owner. + +## Explicit nonclaims and release gates + +There is no durable transaction/audit adapter, monotonic anti-rollback anchor, +key catalogue/rotation, workload authentication/authorization, rekey ceremony, +credential issuance, PostgreSQL adapter, network listener or external KMS client. +A valid old record may be replayed: authenticated encryption is not rollback +protection. These are unimplemented gates, not optional production safeguards. + +Rust-owned sensitive buffers are cleared on drop. Copies inside third-party +cryptographic/RNG/interpolation implementations, registers, crash dumps, swap, +allocator history and host compromise require independent memory review; this +module does not claim complete zeroization or hardware custody. Review the exact +resolved dependencies and their advisories, especially the Shamir implementation +and its older rand-trait dependency, before any release. + +The CPU profile must meet the cipher implementation's constant-time assumptions. +No FIPS validation, CSAP certification, physical-HSM equivalence or fully unattended +cold recovery is claimed. Unit tests use only generated test material. + +## Verification status + +Source and tests are prepared. The test-first scaffold run is queued, so neither +observed RED nor GREEN is claimed. Rust compilation and all behavioral results +remain unverified until the hosted job actually executes. + +## Reproduction + +```sh +cd services/key_custody +cargo +1.97.1 test --locked --all-targets +cargo +1.97.1 clippy --locked --all-targets -- -D warnings +RUSTDOCFLAGS='-D warnings' cargo +1.97.1 doc --locked --no-deps +``` + +The initial RED commit includes temporary dependency-lock preparation in CI. +That is not release-ready verification; final CI must consume the committed lock +without generating or changing it. A queued runner or inspected source is not a +passing test, and no historical Python coverage certifies this Rust module. + +## Primary references + +RustCrypto Contributors. (n.d.). *chacha20poly1305 0.11.0*. +https://docs.rs/chacha20poly1305/0.11.0/chacha20poly1305/ + +sharks Contributors. (n.d.). *sharks 0.5.0: Source and feature flags*. +https://docs.rs/sharks/0.5.0/sharks/ +https://docs.rs/crate/sharks/0.5.0/features + +RustCrypto Contributors. (n.d.). *zeroize 1.9.0*. +https://docs.rs/zeroize/1.9.0/zeroize/ + +These sources document dependency behavior, not an audit of this new composition. +Shamir recovery is not threshold signing. The standalone architecture and its +broader key-operation/lifecycle acceptance remain in the governing custody ADR. diff --git a/services/key_custody/src/custody_barrier.rs b/services/key_custody/src/custody_barrier.rs new file mode 100644 index 0000000..cc64036 --- /dev/null +++ b/services/key_custody/src/custody_barrier.rs @@ -0,0 +1,406 @@ +//! Internal Keyverse custody barrier; no network, credential file or remote KMS. +//! +//! This primitive requires a trusted in-process caller. Associated context is +//! cryptographic binding, not workload authentication or access authorization. +//! Durable audit, enrollment, persistence and rollback protection are separate +//! release gates. The wrapping root deliberately has no export API. +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +use std::fmt; + +use chacha20poly1305::{AeadInOut, KeyInit, XChaCha20Poly1305, XNonce}; +use rand_chacha::{ChaCha20Rng, rand_core::SeedableRng}; +use sharks::{Share, Sharks}; +use zeroize::Zeroizing; + +const ROOT_HEADER_BYTES: usize = 22; +const ROOT_RECORD_BYTES: usize = 94; +const SHARE_RECORD_BYTES: usize = 55; +const DATA_HEADER_BYTES: usize = 20; +const NONCE_BYTES: usize = 24; +const AUTH_TAG_BYTES: usize = 16; +const MAX_RECORD_BYTES: usize = 1_048_576; +const MAX_CONTEXT_BYTES: usize = 128; +const MAX_RECOVERY_SHARES: u8 = 16; + +/// Value-free errors; none contains supplied material or an upstream diagnostic. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CustodyError { + /// A bounded record, context or quorum is malformed. + InvalidInput, + /// Operating-system entropy was unavailable; there is no substitute source. + EntropyUnavailable, + /// The wrapping root is not present in memory. + BarrierSealed, + /// Unseal cannot replace an already-open root. + AlreadyUnsealed, + /// Shares, context or ciphertext did not authenticate. + AuthenticationFailed, +} + +impl fmt::Display for CustodyError { + fn fmt(&self, format_buffer: &mut fmt::Formatter<'_>) -> fmt::Result { + let error_message = match self { + Self::InvalidInput => "invalid custody input", + Self::EntropyUnavailable => "custody entropy unavailable", + Self::BarrierSealed => "custody is sealed", + Self::AlreadyUnsealed => "custody is already unsealed", + Self::AuthenticationFailed => "custody authentication failed", + }; + format_buffer.write_str(error_message) + } +} + +impl std::error::Error for CustodyError {} + +/// Zeroizing plaintext with deliberate exposure and a nonreflective Debug form. +/// +/// This wrapper does not guarantee erasure of registers, swap, caller copies, +/// crash dumps or third-party implementation temporaries. +pub struct SecretBytes(Zeroizing>); + +impl SecretBytes { + /// Borrow plaintext only at its explicitly authorized consumption boundary. + pub fn expose_secret(&self) -> &[u8] { + &self.0 + } +} + +impl fmt::Debug for SecretBytes { + fn fmt(&self, format_buffer: &mut fmt::Formatter<'_>) -> fmt::Result { + format_buffer.write_str("SecretBytes([REDACTED])") + } +} + +/// One custodian's recovery material, never automatically logged or serialized. +pub struct RecoveryShare(SecretBytes); + +impl RecoveryShare { + /// Validate a bounded custodian envelope without asserting its authenticity. + pub fn from_custodian_bytes(encoded_share: &[u8]) -> Result { + if encoded_share.len() != SHARE_RECORD_BYTES || &encoded_share[..4] != b"KVS1" { + return Err(CustodyError::InvalidInput); + } + validate_quorum(encoded_share[4], encoded_share[5])?; + if encoded_share[22] == 0 || encoded_share[22] > encoded_share[5] { + return Err(CustodyError::InvalidInput); + } + Ok(Self(SecretBytes(Zeroizing::new(encoded_share.to_vec())))) + } + + /// Explicitly copy this one share for a protected custodian delivery channel. + /// + /// Never store all returned shares with the root record. Authenticated + /// custodian enrollment and transport must be implemented by the service. + pub fn export_for_custodian(&self) -> SecretBytes { + SecretBytes(Zeroizing::new(self.0.expose_secret().to_vec())) + } +} + +impl fmt::Debug for RecoveryShare { + fn fmt(&self, format_buffer: &mut fmt::Formatter<'_>) -> fmt::Result { + format_buffer.write_str("RecoveryShare([REDACTED])") + } +} + +/// Exact tenant/environment/namespace/key/version/purpose binding for one record. +/// +/// Constructing a context is not authorization. A remote adapter must verify +/// its caller and policy independently before invoking the internal barrier. +pub struct RecordContext { + encoded_fields: Vec, +} + +impl RecordContext { + /// Validate and length-frame six identifiers without normalizing their bytes. + pub fn new(context_fields: [&str; 6]) -> Result { + let mut encoded_fields = Vec::new(); + for context_field in context_fields { + if context_field.is_empty() + || context_field.len() > MAX_CONTEXT_BYTES + || context_field.chars().any(|input_char| input_char.is_control() || input_char.is_whitespace()) + { + return Err(CustodyError::InvalidInput); + } + encoded_fields.extend_from_slice(&(context_field.len() as u16).to_be_bytes()); + encoded_fields.extend_from_slice(context_field.as_bytes()); + } + Ok(Self { encoded_fields }) + } + + fn associated_data(&self, record_header: &[u8]) -> Vec { + let mut associated_data = b"keyverse_custody_record_v1".to_vec(); + associated_data.extend_from_slice(record_header); + associated_data.extend_from_slice(&self.encoded_fields); + associated_data + } +} + +impl fmt::Debug for RecordContext { + fn fmt(&self, format_buffer: &mut fmt::Formatter<'_>) -> fmt::Result { + format_buffer.write_str("RecordContext([REDACTED])") + } +} + +/// Independently recoverable, sealed-by-default wrapping-root custody. +/// +/// The encrypted record is exportable; the unsealed root is not. This is an +/// internal library, not a complete KMS service or a persistence transaction. +pub struct CustodyBarrier { + sealed_record: [u8; ROOT_RECORD_BYTES], + root_key: Option, +} + +impl CustodyBarrier { + /// Generate an independent root and separate quorum shares, returning sealed. + pub fn initialize_custody( + required_shares: u8, + total_shares: u8, + ) -> Result<(Self, Vec), CustodyError> { + Self::initialize_with_entropy(required_shares, total_shares, &SystemEntropy) + } + + fn initialize_with_entropy( + required_shares: u8, + total_shares: u8, + entropy_source: &impl EntropySource, + ) -> Result<(Self, Vec), CustodyError> { + validate_quorum(required_shares, total_shares)?; + let mut root_key = Zeroizing::new(vec![0u8; 32]); + let mut recovery_key = Zeroizing::new(vec![0u8; 32]); + let mut dealer_seed = Zeroizing::new([0u8; 32]); + entropy_source.fill_bytes(&mut root_key)?; + entropy_source.fill_bytes(&mut recovery_key)?; + entropy_source.fill_bytes(&mut dealer_seed[..])?; + let mut sealed_record = [0u8; ROOT_RECORD_BYTES]; + sealed_record[..4].copy_from_slice(b"KVC1"); + sealed_record[4] = required_shares; + sealed_record[5] = total_shares; + entropy_source.fill_bytes(&mut sealed_record[6..ROOT_HEADER_BYTES])?; + let mut root_nonce = [0u8; NONCE_BYTES]; + entropy_source.fill_bytes(&mut root_nonce)?; + let wrapped_root = encrypt_payload( + &recovery_key, &root_nonce, &sealed_record[..ROOT_HEADER_BYTES], &root_key, + )?; + sealed_record[ROOT_HEADER_BYTES..ROOT_HEADER_BYTES + NONCE_BYTES] + .copy_from_slice(&root_nonce); + sealed_record[ROOT_HEADER_BYTES + NONCE_BYTES..].copy_from_slice(&wrapped_root); + // sharks uses the rand-0.8 trait family. This independently OS-seeded + // ChaCha20 generator supplies that compatibility boundary only. + let mut dealer_random = ChaCha20Rng::from_seed(*dealer_seed); + let share_dealer = Sharks(required_shares); + let mut custodian_shares = Vec::with_capacity(usize::from(total_shares)); + for raw_share in share_dealer.dealer_rng(&recovery_key, &mut dealer_random) + .take(usize::from(total_shares)) + { + let encoded_share = Zeroizing::new(Vec::from(&raw_share)); + let mut custodian_record = Zeroizing::new(Vec::with_capacity(SHARE_RECORD_BYTES)); + custodian_record.extend_from_slice(b"KVS1"); + custodian_record.extend_from_slice(&sealed_record[4..ROOT_HEADER_BYTES]); + custodian_record.extend_from_slice(&encoded_share); + custodian_shares.push(RecoveryShare(SecretBytes(custodian_record))); + } + Ok((Self { sealed_record, root_key: None }, custodian_shares)) + } + + /// Parse only the bounded public envelope; authentication occurs on unseal. + pub fn from_sealed_record(encoded_record: &[u8]) -> Result { + if encoded_record.len() != ROOT_RECORD_BYTES || &encoded_record[..4] != b"KVC1" { + return Err(CustodyError::InvalidInput); + } + validate_quorum(encoded_record[4], encoded_record[5])?; + let mut sealed_record = [0u8; ROOT_RECORD_BYTES]; + sealed_record.copy_from_slice(encoded_record); + Ok(Self { sealed_record, root_key: None }) + } + + /// Export encrypted root state, never recovery shares or plaintext root bytes. + pub fn export_sealed_record(&self) -> Vec { + self.sealed_record.to_vec() + } + + /// Report local root availability, not remote authorization or readiness. + pub fn is_sealed(&self) -> bool { + self.root_key.is_none() + } + + /// Authenticate a complete, distinct quorum before installing any root state. + pub fn unseal_custody(&mut self, recovery_shares: &[RecoveryShare]) -> Result<(), CustodyError> { + if self.root_key.is_some() { + return Err(CustodyError::AlreadyUnsealed); + } + let required_shares = self.sealed_record[4]; + let total_shares = self.sealed_record[5]; + if recovery_shares.len() < usize::from(required_shares) + || recovery_shares.len() > usize::from(total_shares) + { + return Err(CustodyError::AuthenticationFailed); + } + let mut seen_indices = [false; 256]; + let mut parsed_shares = Vec::with_capacity(recovery_shares.len()); + for recovery_share in recovery_shares { + let encoded_share = recovery_share.0.expose_secret(); + let share_index = usize::from(encoded_share[22]); + if encoded_share[4..ROOT_HEADER_BYTES] != self.sealed_record[4..ROOT_HEADER_BYTES] + || seen_indices[share_index] + { + return Err(CustodyError::AuthenticationFailed); + } + seen_indices[share_index] = true; + parsed_shares.push(Share::try_from(&encoded_share[ROOT_HEADER_BYTES..]) + .map_err(|_| CustodyError::AuthenticationFailed)?); + } + let recovery_key = Zeroizing::new(Sharks(required_shares).recover(&parsed_shares) + .map_err(|_| CustodyError::AuthenticationFailed)?); + let mut root_nonce = [0u8; NONCE_BYTES]; + root_nonce.copy_from_slice(&self.sealed_record[ROOT_HEADER_BYTES..ROOT_HEADER_BYTES + NONCE_BYTES]); + let recovered_root = decrypt_payload( + &recovery_key, &root_nonce, &self.sealed_record[..ROOT_HEADER_BYTES], + &self.sealed_record[ROOT_HEADER_BYTES + NONCE_BYTES..], + )?; + self.root_key = Some(recovered_root); + Ok(()) + } + + /// Idempotently drop the locally held root; recovery still requires a quorum. + pub fn seal_custody(&mut self) { + self.root_key = None; + } + + /// Protect one bounded internal record with its exact associated context. + pub fn protect_record( + &self, record_context: &RecordContext, plain_bytes: &[u8], + ) -> Result, CustodyError> { + self.protect_with_entropy(record_context, plain_bytes, &SystemEntropy) + } + + fn protect_with_entropy( + &self, record_context: &RecordContext, plain_bytes: &[u8], entropy_source: &impl EntropySource, + ) -> Result, CustodyError> { + let root_key = self.root_key.as_ref().ok_or(CustodyError::BarrierSealed)?; + if plain_bytes.len() > MAX_RECORD_BYTES { + return Err(CustodyError::InvalidInput); + } + let mut record_header = b"KVD1".to_vec(); + record_header.extend_from_slice(&self.sealed_record[6..ROOT_HEADER_BYTES]); + let mut record_nonce = [0u8; NONCE_BYTES]; + entropy_source.fill_bytes(&mut record_nonce)?; + let protected_body = encrypt_payload( + root_key.expose_secret(), &record_nonce, + &record_context.associated_data(&record_header), plain_bytes, + )?; + record_header.extend_from_slice(&record_nonce); + record_header.extend_from_slice(&protected_body); + Ok(record_header) + } + + /// Authenticate context, instance and ciphertext before exposing plaintext. + pub fn open_record( + &self, record_context: &RecordContext, protected_record: &[u8], + ) -> Result { + let root_key = self.root_key.as_ref().ok_or(CustodyError::BarrierSealed)?; + let minimum_size = DATA_HEADER_BYTES + NONCE_BYTES + AUTH_TAG_BYTES; + if !(minimum_size..=minimum_size + MAX_RECORD_BYTES).contains(&protected_record.len()) + || &protected_record[..4] != b"KVD1" + || protected_record[4..DATA_HEADER_BYTES] != self.sealed_record[6..ROOT_HEADER_BYTES] + { + return Err(CustodyError::AuthenticationFailed); + } + let mut record_nonce = [0u8; NONCE_BYTES]; + record_nonce.copy_from_slice(&protected_record[DATA_HEADER_BYTES..DATA_HEADER_BYTES + NONCE_BYTES]); + decrypt_payload( + root_key.expose_secret(), &record_nonce, + &record_context.associated_data(&protected_record[..DATA_HEADER_BYTES]), + &protected_record[DATA_HEADER_BYTES + NONCE_BYTES..], + ) + } +} + +impl fmt::Debug for CustodyBarrier { + fn fmt(&self, format_buffer: &mut fmt::Formatter<'_>) -> fmt::Result { + format_buffer.debug_struct("CustodyBarrier") + .field("sealed", &self.is_sealed()).finish_non_exhaustive() + } +} + +fn validate_quorum(required_shares: u8, total_shares: u8) -> Result<(), CustodyError> { + if required_shares < 2 || required_shares > total_shares || total_shares > MAX_RECOVERY_SHARES { + return Err(CustodyError::InvalidInput); + } + Ok(()) +} + +trait EntropySource { + fn fill_bytes(&self, output_bytes: &mut [u8]) -> Result<(), CustodyError>; +} + +struct SystemEntropy; + +impl EntropySource for SystemEntropy { + fn fill_bytes(&self, output_bytes: &mut [u8]) -> Result<(), CustodyError> { + getrandom::fill(output_bytes).map_err(|_| CustodyError::EntropyUnavailable) + } +} + +fn encrypt_payload( + secret_key: &[u8], nonce_bytes: &[u8; NONCE_BYTES], associated_data: &[u8], plain_bytes: &[u8], +) -> Result, CustodyError> { + let record_cipher = XChaCha20Poly1305::new_from_slice(secret_key) + .map_err(|_| CustodyError::AuthenticationFailed)?; + let mut protected_buffer = Zeroizing::new(Vec::with_capacity(plain_bytes.len() + AUTH_TAG_BYTES)); + protected_buffer.extend_from_slice(plain_bytes); + record_cipher.encrypt_in_place(&XNonce::from(*nonce_bytes), associated_data, &mut *protected_buffer) + .map_err(|_| CustodyError::AuthenticationFailed)?; + Ok(protected_buffer.to_vec()) +} + +fn decrypt_payload( + secret_key: &[u8], nonce_bytes: &[u8; NONCE_BYTES], associated_data: &[u8], cipher_bytes: &[u8], +) -> Result { + let record_cipher = XChaCha20Poly1305::new_from_slice(secret_key) + .map_err(|_| CustodyError::AuthenticationFailed)?; + let mut protected_buffer = Zeroizing::new(cipher_bytes.to_vec()); + // Even an authentication failure drops a zeroizing buffer, not an ordinary + // Vec that might contain partially transformed sensitive data. + record_cipher.decrypt_in_place(&XNonce::from(*nonce_bytes), associated_data, &mut *protected_buffer) + .map_err(|_| CustodyError::AuthenticationFailed)?; + Ok(SecretBytes(protected_buffer)) +} + +#[cfg(test)] +mod failure_contracts { + use super::*; + + struct FailedEntropy; + + impl EntropySource for FailedEntropy { + fn fill_bytes(&self, _: &mut [u8]) -> Result<(), CustodyError> { + Err(CustodyError::EntropyUnavailable) + } + } + + #[test] + fn failed_entropy_never_creates_fallback_keys_or_nonces() { + assert!(matches!(CustodyBarrier::initialize_with_entropy(2, 3, &FailedEntropy), Err(CustodyError::EntropyUnavailable))); + let (mut custody, shares) = CustodyBarrier::initialize_custody(2, 3).unwrap(); + custody.unseal_custody(&shares[..2]).unwrap(); + let context = RecordContext::new(["tenant", "env", "namespace", "key", "version", "purpose"]).unwrap(); + assert!(matches!(custody.protect_with_entropy(&context, b"test", &FailedEntropy), Err(CustodyError::EntropyUnavailable))); + } + + #[test] + fn errors_are_nonreflective_and_invalid_key_lengths_fail() { + for (error, text) in [ + (CustodyError::InvalidInput, "invalid custody input"), + (CustodyError::EntropyUnavailable, "custody entropy unavailable"), + (CustodyError::BarrierSealed, "custody is sealed"), + (CustodyError::AlreadyUnsealed, "custody is already unsealed"), + (CustodyError::AuthenticationFailed, "custody authentication failed"), + ] { + assert_eq!(error.to_string(), text); + } + assert!(encrypt_payload(b"bad", &[0; 24], b"", b"").is_err()); + assert!(decrypt_payload(b"bad", &[0; 24], b"", b"").is_err()); + } +} diff --git a/services/key_custody/tests/crypto_interop.rs b/services/key_custody/tests/crypto_interop.rs new file mode 100644 index 0000000..15e24ef --- /dev/null +++ b/services/key_custody/tests/crypto_interop.rs @@ -0,0 +1,48 @@ +//! Fixed unit-only records generated independently with libsodium 1.0.18. +//! The production Rust barrier, not a copied encoder, consumes the C ciphertext. +use keyverse_custody::{CustodyBarrier, RecordContext, RecoveryShare}; +use rand_chacha::{ChaCha20Rng, rand_core::SeedableRng}; +use sharks::Sharks; + +#[test] +fn opens_independent_libsodium_root_and_context_bound_record() { + let root_record = [ + 0x4b, 0x56, 0x43, 0x31, 0x02, 0x03, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x30, 0x31, + 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c, 0x3d, + 0x3e, 0x3f, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x13, 0xba, + 0x0e, 0xd8, 0x04, 0xb2, 0x16, 0xc3, 0xe6, 0x14, 0x99, 0x56, 0x1b, 0xaa, + 0x59, 0x7a, 0x11, 0x01, 0x12, 0xd7, 0xe6, 0x90, 0x13, 0xd6, 0xb3, 0xeb, + 0x83, 0x12, 0xee, 0xe1, 0xde, 0xcb, 0xef, 0xad, 0x88, 0x37, 0xa3, 0x60, + 0xde, 0xe7, 0x47, 0xb6, 0x89, 0x48, 0xb2, 0xf8, 0x1d, 0x82, + ]; + let protected_record = [ + 0x4b, 0x56, 0x44, 0x31, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x00, 0x01, 0x02, 0x03, + 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, + 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0xf5, 0xa7, 0x76, 0x09, + 0xf5, 0xa0, 0xfe, 0xcb, 0x1e, 0x2a, 0x47, 0xba, 0xa2, 0x24, 0xcd, 0xc5, + 0x28, 0x32, 0x5b, 0xd1, 0x92, 0xb2, 0x64, 0xc6, 0x14, 0x74, 0x50, 0xc1, + 0x53, 0x80, 0x94, 0x37, 0x2d, 0x71, 0xd3, 0x96, 0x07, 0xcd, 0xac, 0x04, + 0x7b, 0x18, 0xfa, 0x6f, 0x4c, 0x5e, 0x11, 0xd6, 0x42, 0x3c, 0x7d, 0x08, + ]; + let mut dealer_random = ChaCha20Rng::from_seed([0x55; 32]); + let share_dealer = Sharks(2); + let recovery_shares: Vec<_> = share_dealer.dealer_rng(&[0x42; 32], &mut dealer_random) + .take(2).map(|raw_share| { + let mut encoded_share = b"KVS1".to_vec(); + encoded_share.extend_from_slice(&root_record[4..22]); + encoded_share.extend_from_slice(&Vec::from(&raw_share)); + RecoveryShare::from_custodian_bytes(&encoded_share).unwrap() + }).collect(); + let mut barrier_state = CustodyBarrier::from_sealed_record(&root_record).unwrap(); + barrier_state.unseal_custody(&recovery_shares).unwrap(); + let record_scope = RecordContext::new([ + "tenant_alpha", "production", "provider_credentials", + "gateway_api_key", "version_1", "provider_request", + ]).unwrap(); + assert_eq!( + barrier_state.open_record(&record_scope, &protected_record).unwrap().expose_secret(), + b"keyverse-native-custody-known-answer", + ); +} diff --git a/services/key_custody/tests/custody_lifecycle.rs b/services/key_custody/tests/custody_lifecycle.rs new file mode 100644 index 0000000..de5c485 --- /dev/null +++ b/services/key_custody/tests/custody_lifecycle.rs @@ -0,0 +1,202 @@ +//! Behavioral contracts use only generated test keys; no deployment credentials. +use keyverse_custody::{CustodyBarrier, CustodyError, RecordContext, RecoveryShare}; + +fn record_context() -> RecordContext { + RecordContext::new([ + "tenant_alpha", "production", "provider_credentials", + "gateway_api_key", "version_1", "provider_request", + ]).unwrap() +} + +fn opened_barrier() -> (CustodyBarrier, Vec) { + let (mut custody, shares) = CustodyBarrier::initialize_custody(2, 3).unwrap(); + custody.unseal_custody(&shares[..2]).unwrap(); + (custody, shares) +} + +#[test] +fn initializes_without_external_secret_authority() { + let (custody, shares) = CustodyBarrier::initialize_custody(2, 3).unwrap(); + assert!(custody.is_sealed()); + assert_eq!(shares.len(), 3); + assert_eq!(custody.export_sealed_record().len(), 94); +} + +#[test] +fn rejects_invalid_quorum_before_initializing() { + for (threshold, count) in [(0, 3), (1, 3), (3, 2), (2, 0), (2, 17)] { + assert!(matches!(CustodyBarrier::initialize_custody(threshold, count), Err(CustodyError::InvalidInput))); + } +} + +#[test] +fn all_two_of_three_quorums_recover_the_same_instance() { + let (custody, shares) = CustodyBarrier::initialize_custody(2, 3).unwrap(); + let snapshot = custody.export_sealed_record(); + for indices in [[0, 1], [0, 2], [1, 2]] { + let selected: Vec<_> = indices.into_iter().map(|index| { + RecoveryShare::from_custodian_bytes(shares[index].export_for_custodian().expose_secret()).unwrap() + }).collect(); + let mut restored = CustodyBarrier::from_sealed_record(&snapshot).unwrap(); + restored.unseal_custody(&selected).unwrap(); + assert!(!restored.is_sealed()); + } +} + +#[test] +fn insufficient_or_duplicate_shares_do_not_change_sealed_state() { + let (mut custody, shares) = CustodyBarrier::initialize_custody(2, 3).unwrap(); + assert!(custody.unseal_custody(&shares[..1]).is_err()); + let duplicated = [ + RecoveryShare::from_custodian_bytes(shares[0].export_for_custodian().expose_secret()).unwrap(), + RecoveryShare::from_custodian_bytes(shares[0].export_for_custodian().expose_secret()).unwrap(), + ]; + assert!(custody.unseal_custody(&duplicated).is_err()); + assert!(custody.is_sealed()); + custody.unseal_custody(&shares[..2]).unwrap(); +} + +#[test] +fn foreign_shares_and_damaged_shares_never_unlock() { + let (mut custody, shares) = CustodyBarrier::initialize_custody(2, 3).unwrap(); + let (_, foreign) = CustodyBarrier::initialize_custody(2, 3).unwrap(); + assert!(custody.unseal_custody(&foreign[..2]).is_err()); + let mut changed = shares[0].export_for_custodian().expose_secret().to_vec(); + changed[54] ^= 1; + let bad = RecoveryShare::from_custodian_bytes(&changed).unwrap(); + let good = RecoveryShare::from_custodian_bytes(shares[1].export_for_custodian().expose_secret()).unwrap(); + assert!(custody.unseal_custody(&[bad, good]).is_err()); + assert!(custody.is_sealed()); +} + +#[test] +fn tampered_root_record_never_installs_key_material() { + let (custody, shares) = CustodyBarrier::initialize_custody(2, 3).unwrap(); + let original = custody.export_sealed_record(); + for offset in 0..original.len() { + let mut damaged = original.clone(); + damaged[offset] ^= 1; + if let Ok(mut restored) = CustodyBarrier::from_sealed_record(&damaged) { + assert!(restored.unseal_custody(&shares[..2]).is_err(), "accepted mutation at {offset}"); + assert!(restored.is_sealed()); + } + } +} + +#[test] +fn malformed_root_records_and_shares_are_rejected() { + for length in [0, 1, 54, 56, 93, 95, 4096] { + assert!(CustodyBarrier::from_sealed_record(&vec![0; length]).is_err()); + assert!(RecoveryShare::from_custodian_bytes(&vec![0; length]).is_err()); + } + let (_, shares) = CustodyBarrier::initialize_custody(2, 3).unwrap(); + for index in [0, 4] { + let mut malformed = shares[0].export_for_custodian().expose_secret().to_vec(); + malformed[22] = index; + assert!(RecoveryShare::from_custodian_bytes(&malformed).is_err()); + } +} + +#[test] +fn sealed_operations_fail_and_reseal_retains_recovery() { + let (mut custody, shares) = opened_barrier(); + let context = record_context(); + let protected = custody.protect_record(&context, b"test-only provider credential").unwrap(); + custody.seal_custody(); + custody.seal_custody(); + assert!(matches!(custody.protect_record(&context, b"x"), Err(CustodyError::BarrierSealed))); + assert!(matches!(custody.open_record(&context, &protected), Err(CustodyError::BarrierSealed))); + custody.unseal_custody(&shares[..2]).unwrap(); + assert_eq!(custody.open_record(&context, &protected).unwrap().expose_secret(), b"test-only provider credential"); + assert!(matches!(custody.unseal_custody(&shares[..2]), Err(CustodyError::AlreadyUnsealed))); +} + +#[test] +fn instance_and_every_context_dimension_are_authenticated() { + let (custody, _) = opened_barrier(); + let (foreign, _) = opened_barrier(); + let context = record_context(); + let protected = custody.protect_record(&context, b"test-only credential").unwrap(); + assert!(foreign.open_record(&context, &protected).is_err()); + let original = ["tenant_alpha", "production", "provider_credentials", "gateway_api_key", "version_1", "provider_request"]; + for field_index in 0..6 { + let mut fields = original; + fields[field_index] = "wrong_context"; + assert!(custody.open_record(&RecordContext::new(fields).unwrap(), &protected).is_err()); + } +} + +#[test] +fn context_encoding_has_no_concatenation_ambiguity() { + let (custody, _) = opened_barrier(); + let first = RecordContext::new(["ab", "c", "namespace", "key_name", "version", "purpose"]).unwrap(); + let second = RecordContext::new(["a", "bc", "namespace", "key_name", "version", "purpose"]).unwrap(); + let protected = custody.protect_record(&first, b"value").unwrap(); + assert!(custody.open_record(&second, &protected).is_err()); +} + +#[test] +fn record_mutation_and_truncation_are_rejected() { + let (custody, _) = opened_barrier(); + let context = record_context(); + let original = custody.protect_record(&context, b"non-production credential").unwrap(); + for offset in 0..original.len() { + let mut damaged = original.clone(); + damaged[offset] ^= 1; + assert!(custody.open_record(&context, &damaged).is_err()); + } + for end in 0..original.len() { + assert!(custody.open_record(&context, &original[..end]).is_err()); + } +} + +#[test] +fn same_plaintext_uses_distinct_nonces_and_encrypted_records() { + let (custody, _) = opened_barrier(); + let context = record_context(); + let first = custody.protect_record(&context, b"test value").unwrap(); + let second = custody.protect_record(&context, b"test value").unwrap(); + assert_ne!(&first[20..44], &second[20..44]); + assert_ne!(first, second); +} + +#[test] +fn reconstructed_barrier_starts_sealed_and_opens_existing_data() { + let (custody, shares) = opened_barrier(); + let context = record_context(); + let value = custody.protect_record(&context, b"persisted test value").unwrap(); + let mut restored = CustodyBarrier::from_sealed_record(&custody.export_sealed_record()).unwrap(); + assert!(restored.is_sealed()); + restored.unseal_custody(&shares).unwrap(); + assert_eq!(restored.open_record(&context, &value).unwrap().expose_secret(), b"persisted test value"); +} + +#[test] +fn metadata_and_payload_limits_are_enforced() { + for bad in ["", " ", "line\nbreak", "bad\0name"] { + assert!(RecordContext::new([bad, "env", "ns", "key", "ver", "purpose"]).is_err()); + } + let long = "a".repeat(129); + assert!(RecordContext::new([&long, "env", "ns", "key", "ver", "purpose"]).is_err()); + let (custody, _) = opened_barrier(); + let context = record_context(); + let full = vec![0u8; 1_048_576]; + let encrypted = custody.protect_record(&context, &full).unwrap(); + assert_eq!(custody.open_record(&context, &encrypted).unwrap().expose_secret(), full); + assert!(custody.protect_record(&context, &vec![0; 1_048_577]).is_err()); + assert!(custody.open_record(&context, &vec![0; 1_048_637]).is_err()); + let empty = custody.protect_record(&context, b"").unwrap(); + assert!(custody.open_record(&context, &empty).unwrap().expose_secret().is_empty()); +} + +#[test] +fn debug_output_never_displays_recovery_or_plaintext_material() { + let (custody, shares) = opened_barrier(); + let context = record_context(); + let protected = custody.protect_record(&context, b"confidential-test-value").unwrap(); + let plaintext = custody.open_record(&context, &protected).unwrap(); + assert_eq!(format!("{plaintext:?}"), "SecretBytes([REDACTED])"); + assert_eq!(format!("{:?}", shares[0]), "RecoveryShare([REDACTED])"); + assert_eq!(format!("{context:?}"), "RecordContext([REDACTED])"); + assert!(!format!("{custody:?}").contains("confidential")); +}