diff --git a/credential-change-requests/CCR-2026-0050-flex-auth-envelope-signing.yaml b/credential-change-requests/CCR-2026-0050-flex-auth-envelope-signing.yaml new file mode 100644 index 0000000..ccccdb2 --- /dev/null +++ b/credential-change-requests/CCR-2026-0050-flex-auth-envelope-signing.yaml @@ -0,0 +1,128 @@ +id: CCR-2026-0050 +kind: credential-change-request +schema_version: 1 +request_type: workload-kv-read +title: Retained flex-auth lifecycle PDP Ed25519 seed ESO read lane +status: proposed +created: '2026-10-16' +updated: '2026-10-16' +requester: + agent: railiance-platform + reason: CUST-WP-0080 / RPF-WP-0035-T12 / FLEX-WP-0024-T06 source proposal. Exact + new custody/read scope requires founder review; no approval or native binding + is inferred. +review: + required: true + required_approvers: + - founder + - railiance-platform-owner + - access-engine-owner + - secrets-engine-owner + - cluster-gitops-owner + comments: [] +target: + domain: infotech + tenant: platform + workload: flex-auth-secrets-engine + environment: production + purpose: Deliver the Ed25519 seed only to the retained lifecycle PDP startup file. + No taskpilot enablement, caller policy changes or private consumer trust discovery. +openbao: + mount: platform + kv_path: platform/workloads/flex-auth/envelope-signing + fields: + - ED25519_PRIVATE_KEY + metadata_read: false + token_self_lifecycle: true + policy_name: workload-kv-read-flex-auth-envelope-signing + policy_file: openbao/policies/workload-kv-read-flex-auth-envelope-signing.hcl + auth: + method: kubernetes + mount: kubernetes + role: external-secrets-flex-auth-envelope-signing + bound_claims: + service_account_names: + - flex-auth-envelope-signing-eso + service_account_namespaces: + - flex-auth + bound_claims_confirmed: false + policies: + - workload-kv-read-flex-auth-envelope-signing + ttl: 15m + audience: openbao + token_max_ttl: 15m + token_explicit_max_ttl: 15m + token_no_default_policy: true +access_frontdoor: + type: external-secrets + catalog_id: flex-auth-decision-envelope-signing-key + selector: Retained flex-auth/flex-auth-secrets-engine signer only + readiness: pending-review + resolvable: false +delivery: + surface: external-secrets + target: Namespace flex-auth; dedicated SA flex-auth-envelope-signing-eso; SecretStore + openbao-flex-auth-envelope-signing; ExternalSecret/Secret flex-auth-secrets-engine-envelope-signing; + seed property only -> startup file /var/run/secrets/decision-envelope-signing/seed0440, + readOnly uid/gid/fsGroup65532; no envFrom + bootstrap_command: Attended writer is a distinct exact-source action proposal in + docs/credential-lane-designs/flex-auth-envelope-signing-native-plan.json; this + read CCR authorizes neither seed writes nor any execution by itself. +risk: + classification: high + notes: + - Possession of seed can forge lifecycle PDP envelopes; independent consumer public + trust admission and fixed policy pins remain required. + - 'KV read is document-granular: dedicated ESO identity can read all three fields; + only private field is projected. Signer and Secrets consumer have no KV read role.' + - No parent/list/metadata/write/delete capability on ESO role. No token/Secret read + or ExternalSecret mutation granted to signer/consumer. + - Dedicated role is new scope; founder approval required. No workload rapp tuple + invented; absent managed package declaration remains explicit migration debt. + - Controlled outage fence stops every signer pod and disables autoscaling before + creation/rotation. Recreate single writer and retained PVC remain Access-owned + review gates. + - fields declares only projected ED25519_PRIVATE_KEY. The same KV document also + has public key/kid, and OpenBao data-read is not field-filtered; do not claim + field-level authorization. +verification: + positive: + - Exact SA/namespace/audience login; native exact data read; ESO Ready/SecretSynced; + safe metadata resourceVersion and admitted KV version correspondence. + - All retained signer replicas restart with admitted kid/source/image and seed/public + correspondence; signed non-effect lifecycle allow/deny with durable originals + and independent consumer signature verification. + negative: + - Wrong SA/namespace/audience login refused; sibling/list/metadata/write/delete + denied; signer/consumer TokenRequest and Secret/ExternalSecret mutation denied. + - Unknown/old kid, wrong policy, tamper, expired/unsigned envelope, wrong caller + and missing seed refuse; no taskpilot activation. + - Receipt write failure/lost backend response retained unknown; no blind retry/remint; + stale CAS cannot overwrite version. + activation_conditions: + - All five required approvers independently dispose the exact CCR and native writer/fence + proposal; founder exact new custody scope approved. + - Current retained identity919ce25, immutable published producer image and explicit + new flex-auth-secrets-engine-outbox PVC1Gi/local-path, retention/reclaim and bounded-runtime + plan reviewed by Access; missing managed package migration debt explicitly accepted + for this retained workload only. + - Exact native bound claims verified without printing JWT/Secret; read grant policy + and role native readback match source. + - Protected child source pinned, silent attended OIDC context verified, target cluster/backend + pinned, fence verified and durable receipt custody installed. + - Consumer independently admits owner-authenticated public receipt and exact policy/image/trust + pins before protected acceptance. + evidence: [] +lifecycle: + deactivate: Fence all retained signer processes, stop consumers, disable delivery + role/ESO and online trust; retain private KV and audit receipts under explicit + owner retention until reviewed destruction. + rotate: Pre-admit successor public receipt offline; fence consumers/all signer replicas/no + HPA; CAS exact version with new in-memory seed; wait exact ESO version, restart + single writer, verify native allow/deny and old-key refusal before reopening. + compromised: Immediately remove old online trust and stop all signer processes; + forward-only CAS recovery with independently approved successor. Never restore + compromised seed; archive historical public keys for offline audit only. +state_hub: + workplan_id: RPF-WP-0035 + task_id: RPF-WP-0035-T12 diff --git a/docs/credential-lane-designs/flex-auth-envelope-signing-native-adapter.md b/docs/credential-lane-designs/flex-auth-envelope-signing-native-adapter.md new file mode 100644 index 0000000..9dcfe1b --- /dev/null +++ b/docs/credential-lane-designs/flex-auth-envelope-signing-native-adapter.md @@ -0,0 +1,187 @@ +# Attended native signer custody adapter source + +RPF-WP-0035-T12 / CUST-WP-0080. This source implements the protected native +adapter described by the separate frozen native-preparation R1. It does not +admit an execution. Its CLI is silent and intended only as the exact reviewed +child of `warden access openbao-platform-admin-login --exec` in the founder's +attended OIDC envelope. The existing Warden owner contains the token helper, +checks child output and revokes/removes the session on exit. No new service, +credential broker, standing grant, API or key-returning interface is created. + +## Current routing blocker + +During preparation the required `warden plan` query for attended OpenBao +platform administration of signer CAS0/metadata selected `openbao-structure-survey` +with an approve ceremony, rather than `openbao-platform-admin-login` with +`oidc_login`. The installed CLI reports wheel-bundled catalog0.1.2 fallback. +This is preserved as a live refusal, not routed around. Exact `route show` names +the existing active attended login, but that pointer does not cure dispatch. +The adapter requires an independently retained routing receipt selecting the +correct lane/verdict/identity act. No founder execution step is ready until the +routing defect and all other native approvals are resolved by their owners. + +## Independent input and authority boundary + +The CLI requires an absolute owner-private admission file, its independently +approved exact SHA256, the exact private receipt directory selected in the +reviewed native proposal, and an original receipt only for recovery. No default +image/package/caller trust, founder identity, approval, route or source pin is +usable. The admission schema is closed in `validate_admission`; it includes the +immutable published producer image, retained caller, deployment projection, +selected consumer trust baseline, exact native plan/CCR/source bundle, fresh +UTC authority expiry (5..120seconds), founder entity and five distinct externally +reviewed owner dispositions. Each disposition binds the same approval-free +scope digest. An admission shape, local digest or review string never approves +itself: the founder and owners must independently review the exact supplied +command/file hash through existing governance before attended invocation. + +The trust hash selects the independently reviewed consumer verifier/policy/trust +baseline for this custody action. It is not a claim that the newly generated +public key is already online: generation happens later, and independent delivery +and successor trust installation are required after the public receipt. The +writer never reads consumer private credentials or discovers trust from KV. +All successful custody states remain pending independent public admission. + +`checked_sources` verifies the actual selected native action plan, exact proposed +kid, read CCR hash/status/binding, generated policy source, and source bundle. +The read CCR must independently be approved/binding-confirmed; the canonical +proposed CCR remains unapproved, so ordinary source checkout refuses. Source +bundle pins execution modules, not arbitrary request-selected code. The native +image/caller/seed projection is compared to the actual stopped Deployment. +The lifecycle policy digest/version stays unchanged; execute-ccr probes must +prove no ALLOW/effect. Current generic runtime guard can return HTTP503 before +the lifecycle evaluator, so signed unknown_action is not promised. + +## Actual checks and effects + +Before private work, the child checks OpenBao cluster ID, unsealed state, exact +non-root platform-admin OIDC entity, enabled audit, actual exact read-policy HCL +and role bindings/15m limits. The selected fixed loopback transport is the existing +owner-admin bridge; Kubernetes reads use fixed strict-host SSH to railiance01. +The OpenBao token lookup-self identifier stays only in the contained custody +process to authenticate fixed HTTP calls; it never reaches the agent, a receipt, +argv or another token file. This is a native protected-process implementation, +not evidence that agent runtime isolation has been promoted or granted. + +The fence checks the pinned kube-system UID, stopped Deployment generation and +replica status, exact immutable image/caller/seed mount/kid projection, zero owned +ReplicaSet replicas, no live or terminating signer-SA/owned/seed-mount pods, and +no targeting HPA. It rechecks before metadata setup and immediately before CAS. +A private filesystem lock excludes a second local custody window. The lock and +observations do not provide atomic Kubernetes fencing: the independently reviewed +exclusive window must keep GitOps/Helm/other creators stopped. No rollout or +cluster mutation is performed by this adapter, and no concurrency guarantee is +inferred from source alone. Access/cluster owners retain that admission. + +Bootstrap requires the exact OpenBao empty-errors HTTP404 metadata absence. A +durable private intent precedes exact CAS-required/max_versions2/no-auto-delete +metadata setup, followed by exact native readback. Only then does the shared +Ed25519 primitive generate one in-memory seed and CAS0 the fixed three-field +KV document. Independent version1 private readback and final metadata verification +precede the durable success receipt. The seed is never put in argv/env/local +files or output. The receipt carries public key, ASCII-base64url fingerprint, +version, bindings and closed phases only. The old fixture's live-dev-process gate +is preserved; its shared receipt initialization was extracted without weakening +that gate. Python cannot promise seed zeroization. + +Every backend/body/close and tool call has a two-second absolute budget and +1MiB combined output bound. The complete operation is bounded by the supplied +fresh expiry. CLI validation and runtime failures remain silent. Existing0600/ +0700 no-follow/ownership checks, exclusive creation, fsync/atomic replacement and +directory fsync retain evidence. Same-UID hostile code is not isolated by modes; +Warden's contained home, trusted runtime and independently reviewed custody +location are required, not inferred from approval frequency or shell prefixes. + +## Process loss and independent read-only recovery + +Any failed/ambiguous metadata/write/readback/final receipt outcome remains +unconfirmed. A success receipt is withheld until final metadata verification. +A child death immediately after the real native CAS leaves the earlier public +intent with its derived public key and no private value. A fresh `recover` +admission must bind that exact original receipt SHA and the same immutable scope. +It reserves a new durable recovery intent before private reads, verifies current +version1 metadata and exact private seed/public/kid correspondence, and records +separate public reconciliation. It never creates a writer, generates a seed, +POSTs metadata/data, restores a seed, consumes approval or dispatches business +operations. Original unknown evidence is never rewritten. Changed versions, +damaged/typed-invalid receipts, missing fresh reviews and denied reads refuse; +replaying a recovery ID cannot repeat even the private backend read. + +This source implements bootstrap and read-only version1 reconciliation only. +Rotation needs a separately reviewed exact-version native action; the disposable +primitive's CAS1 test is not a native rotation grant. No automatic retry, remint, +rollback or general recovery API exists. Metadata-only setup uncertainty without +a public seed intent requires separate attended reconciliation, not this path. + +Access observed zero PVCs in flex-auth. Its proposed new1Gi/local-path outbox, +PV Retain/reclaim disposition and runtime process-lock/capacity/startup bounds +remain separate reviews. This writer grants no PVC, audit egress, policy role, +SecretStore/Secret or producer/consumer deployment admission. + +## R2 phase-aware recovery and source lineage + +Frozen R1's metadata-only crash counterexample remains valid historical evidence. +R2 accepts a strict original metadata-setup receipt with no public key, or a +seed-cas receipt with a canonical derived public key. Fresh recovery reserves +durable evidence before target reads. Exact current metadata absence AND exact +version1 data absence yields `current-custody-absent`. Exact configured metadata +with current_version0 AND exact version1 data absence yields +`metadata-only-confirmed`. Both results have no public key/fingerprint/KV version, +key_custody_verified=false, historical_effect=unresolved, original_unknown_preserved, +and an explicit fresh independently reviewed action requirement. A locally +generated but unstored seed's public key is not promoted into public admission. +Neither observation means the original attempt historically made no effects. + +Denied/malformed/misleading404 metadata or data, timeout, changed configuration, +unexpected versions, or version1 after a metadata-only/no-public phase keep the +new recovery attempt unconfirmed. They never grant generation, write, retry, +remint, consume or business authority. A subsequent metadata-only bootstrap is +not silently enabled: existing metadata still refuses CAS0 bootstrap admission. +A distinct exact reviewed action/source revision must choose any cleanup/reuse +or generation step. The original receipt remains byte-for-byte unchanged. + +Native plan bootstrap.source_primitive_sha256 now equals the actual shared +primitive in the admitted source bundle and writer receipt; checked_sources +enforces the join. The prior8ed pin is explicitly historical. The plan pins the +shared primitive, not itself or the complete native executor, so no self-hash +cycle exists. The exact native executor is independently pinned by source_bundle +and its digest in bindings. A recover admission must additionally supply +original_execution (original receipt bindings, writer SHA and CCR SHA). Fresh +owner reviews bind both original and current lineages. This can describe an older +executed source/proposal without labeling it current. Image/deployment/policy/ +consumer trust bindings must still match; recovery cannot change that authority. + +## Exact120-second expiry and attended scheduling assessment + +The unchanged validate_admission rule requires an absolute UTC expires_at with +5 < expires_at-now <=120seconds, and the entire child runs beneath that fixed +remaining absolute/monotonic budget. The five external reviews cover the scope +digest including expires_at. No deadline is generated, refreshed or widened by +the child; rechecking cannot extend it. + +Existing ops-warden proxy.proxy_attended_login_exec runs the OIDC client before the child. +Its _contained_run captures output through subprocess.run without a timeout. +Therefore neither OIDC/MFA nor five external reviews have a demonstrated bound +that fits120seconds from preparing an admission. Creating a now+120seconds +admission before unbounded review/login is not a reliable operational ceremony. +Source success tests start from synthetic ready identities; they prove no human +OIDC/review timing SLO. + +A coherent ceremony under the current rule is to pre-review one exact future +absolute expiry/window, including all artifact/source/image/trust/identity and +review references, then arrange attended login so the child starts within its +implicit [expires_at-120seconds, expires_at-5seconds) interval. Review may occur +before that interval; it does not change runtime authority. The founder must aim +for the beginning of the interval, not its last five seconds. The fixed path has +26 bounded native/tool requests before success (52seconds at their2second caps), +plus key/source/receipt work. Its expiry still wins if those operations cannot +finish; no completion or filesystem timing guarantee is inferred. + +If login completes too early/late, a review changes any bytes, or adequate time +remains uncertain, refuse this revision. A new expires_at changes the approved +scope digest and needs explicit dispositions for the new revision; existing +source-only reviews cannot be copied into native approval. Do not silently +refresh after OIDC, reinterpret expiry as start-only, widen120seconds, skip +reviews or start a general window-grant service. An alternative authority/window +contract would itself require separate owner/founder design and is not supplied +by R2. Current native admission remains unapproved. diff --git a/docs/credential-lane-designs/flex-auth-envelope-signing-native-plan.json b/docs/credential-lane-designs/flex-auth-envelope-signing-native-plan.json new file mode 100644 index 0000000..167408d --- /dev/null +++ b/docs/credential-lane-designs/flex-auth-envelope-signing-native-plan.json @@ -0,0 +1,152 @@ +{ + "schema": "railiance-platform.signer-native-action-proposal.v1", + "proposal_id": "RPF35-PDP-SIGNER-NATIVE-20261016-R1", + "status": "prepared-unapproved", + "read_ccr": "CCR-2026-0050", + "proposal_scope": "attended bootstrap of retained lifecycle PDP seed; no taskpilot enablement, rotation requires a new exact-version action revision", + "actor": "founder attended OIDC via warden openbao-platform-admin-login; reviewed silent Platform custody child", + "executor_ownership": "railiance-platform; founder executes exact child; agent receives closed metadata only", + "abort_owner": "founder; Platform preserves unknown custody/evidence, Access keeps signer/consumers fenced", + "target": { + "cluster": "primary/railiance01", + "kube_system_uid": "a553c742-0115-43d4-99a4-a5ca56fe0786", + "openbao_cluster_id": "fd28df5d-98ec-57dd-42ec-9b3e4f4e53bf", + "openbao_origin": "http://127.0.0.1:18200", + "namespace": "flex-auth", + "deployment": "flex-auth-secrets-engine", + "signer_sa": "flex-auth-secrets-engine" + }, + "credential_route": "openbao-platform-admin-login; flex-auth-decision-envelope-signing-key remains draft/nonresolvable; route is not a seed vending service", + "bootstrap": { + "action": "CAS0 fresh in-memory Ed25519 seed", + "kid": "flex-auth-lifecycle-20261016-r1", + "expected_version": 0, + "data_path": "platform/data/workloads/flex-auth/envelope-signing", + "document_fields": [ + "ED25519_PRIVATE_KEY", + "ED25519_PUBLIC_KEY", + "kid" + ], + "metadata_path": "platform/metadata/workloads/flex-auth/envelope-signing", + "metadata_contract": { + "cas_required": true, + "max_versions": 2, + "delete_version_after": "0s" + }, + "allowed_native_methods": [ + { + "method": "POST", + "path": "platform/metadata/workloads/flex-auth/envelope-signing", + "purpose": "CAS-required/version-retention configuration after absence/no-drift validation" + }, + { + "method": "GET", + "path": "platform/metadata/workloads/flex-auth/envelope-signing", + "purpose": "exact metadata readback, no private value output" + }, + { + "method": "POST", + "path": "platform/data/workloads/flex-auth/envelope-signing", + "purpose": "single CAS0 seed document write" + }, + { + "method": "GET", + "path": "platform/data/workloads/flex-auth/envelope-signing?version=1", + "purpose": "exact private readback in custody, closed public receipt only" + } + ], + "source_primitive_sha256": "sha256:f169ad12f90d729b647085c3bed2c0d8d005db23a2341b6d3d5f1793bcfd110c", + "historical_source_primitive_sha256": "sha256:8ed281a49c1f1cdb23772efe3593e75c62572a12b8d7a6a4c574f27b38722e75", + "source_lineage_rule": "source_primitive_sha256 must equal current admitted source_bundle[scripts/flex_auth_signer_custody.py] and exact writer receipt; historical_source_primitive_sha256 is non-authoritative R1 evidence only" + }, + "fencing": { + "before_key_generation": [ + "Verify cluster/backend IDs, exact current source/CCR and founder disposition hashes.", + "Read exact deployment/ReplicaSets/pods by ownerReference and SA; refuse a second workload/pod using signer SA or selecting signer Secret.", + "Access stops protected consumers and scales retained deployment0 through its reviewed release path; this child does not mutate replicas.", + "Verify deployment spec replicas0, observed generation caught up, no live or terminating owned ReplicaSet/pods, no matching signer-SA pods and no HPA targeting deployment.", + "Reject a differing/previously existing KV entry at bootstrap. Hold exclusive owner action receipt lock and recheck fence immediately before CAS." + ], + "after_cas": "Keep consumers/signer stopped until independent public admission and exact ESO/readback/source/image verification. Recreate1 is Access-owned; any drift or uncertain outcome keeps fence closed. Access reports current outbox lacks OS process lock, byte ceiling and bounded startup/compaction; Recreate1 alone is not bounded durable acceptance. Keep protected acceptance closed until reviewed source hardening or explicit bounded attended fixture disposition.", + "concurrency": "Single attended owner window/exclusive receipt; GitOps/Helm autosync/other writers must be paused by Access/cluster owner under distinct reviewed action. Plan is not a Kubernetes atomic fence or same-UID isolation." + }, + "protected_adapter": { + "state": "implementation/independent tests still required before executable approval; fixture code must never be promoted by removing its dev-process check", + "reuse": [ + "scripts/state_hub_preflight_lane.py attended silent child/cluster/platform-admin/fencing patterns", + "scripts/flex_auth_signer_custody.py protected Ed25519 CAS/readback and receipt state machine", + "scripts/task_execution_native_fixture.py absolute main-thread2s I/O budget", + "scripts/task_execution_recovery.py strict bounded JSON/no-follow/private custody patterns" + ], + "requirements": [ + "New fixed native adapter consumes exact approved CCR/source/native-action digest; cannot accept arbitrary URL/path/command/payload/seed.", + "Read OIDC token only within warden child from attended context; require platform-admin and reject root; never print/store bearer. No credential comes back to agent.", + "Pin exact kube-system/OpenBao cluster IDs before private calls, refuse ambient address override; enforce2s absolute reads and1MiB cap; no redirect/shell/tracing.", + "Verify metadata absence/exact admitted values before CAS-required change; any differing existing object refuses. Exact response contracts and duplicate/nonfinite JSON rejection.", + "Private seed is generated once, never imported or persisted outside OpenBao; no subprocess argv/env seed; readback compares exact version/fields and derived public correspondence.", + "Receipt intent is durable before native effect; final success requires exact KV/metadata correspondence and receipt fsync. Process loss/error remains unknown without remint/retry." + ] + }, + "receipts": { + "directory": "/home/worsch/.local/state/railiance-platform/flex-auth-envelope-signing", + "mode_directory": "0700", + "mode_file": "0600", + "native_execution_location": "founder workstation inside reviewed warden child; no agent-owned shared /tmp custody", + "basename": "RPF35-PDP-SIGNER-NATIVE-20261016-R1.json", + "maximum_bytes": 65536, + "atomic": "exclusive initial receipt, fsync-before-effect, atomic replacement and directory fsync, no-follow/owner/mode/nlink validation", + "public_fields": [ + "proposal/executed source/CCR/identity/cluster/image/policy/trust digests", + "actor/time", + "closed phase/status", + "exact KV path/version/kid", + "public key and explicitly encoded fingerprint", + "fence/metadata/readback checks", + "rollback or unknown recovery disposition" + ], + "fingerprint": "sha256: lowercase SHA256 of canonical unpadded base64url public-key ASCII, exactly43bytes; independent Secrets owner agreement before admission", + "delivery": "Founder/Platform separately authenticate metadata receipt to Secrets owner; public receipt shape/file does not create consumer trust; raw private receipt directory is not a shared agent intake" + }, + "rollback": { + "before_cas": "Abort without private write; retain receipt/fence; restore prior Access image/values only via reviewed Access action.", + "after_cas_unknown": "Retain unknown and fence; independently attended owner reconciles exact metadata/version/public correspondence. Never rerun CAS0 or restore prior seed automatically.", + "compromise": "Remove online old public key immediately; stop signer, independent new reviewed forward-only action; old private seed never restored." + }, + "requested_scope": { + "read_lane": "Exact CCR0050 policy/role/delivery source only after separate founder review. No extra ESO RBAC permitted.", + "custody_writer": "One attended exact-target CAS0 plus fixed KV metadata setup/readback; no standing writer grant or broker role. This authority is separate from read CCR.", + "kubernetes": "Metadata-only fence observations by child; Access/cluster separately approve stop/restart/delivery manifests and sync control.", + "new_infrastructure_spend_eur": 0, + "new_resources": "Platform creates no storage resources. Access reports zero flex-auth PVCs; Access proposes separate exact new flex-auth-secrets-engine-outbox1Gi local-path Helmkeep. Provisioning, PV reclaim/Delete/WaitForFirstConsumer retention and runtime log bounds require separate founder/cluster/Access review; no durable outbox acceptance yet.", + "risk_bound": "Controlled loss of retained PDP availability during explicit attended fence; seed compromise can forge lifecycle decisions until independent public trust withdrawal; founder must approve this concrete high-risk scope." + }, + "reviews_required": [ + { + "role": "railiance-platform-owner", + "disposition": "pending exact source/artifact review; no approval recorded" + }, + { + "role": "access-engine-owner", + "disposition": "pending exact source/artifact review; no approval recorded" + }, + { + "role": "secrets-engine-owner", + "disposition": "pending exact source/artifact review; no approval recorded" + }, + { + "role": "cluster-gitops-owner", + "disposition": "pending exact source/artifact review; no approval recorded" + }, + { + "role": "founder", + "disposition": "pending exact source/artifact review; no approval recorded" + } + ], + "authority_granted": false, + "candidate_delivery_amendments": { + "supersedes_unadmitted_R3_role": "external-secrets-flex-auth-envelope-signing replaces flex-auth-envelope-signing-eso to fit existing delegated-applier role convention; dedicated SA unchanged", + "read_ccr_field_projection": "Only ED25519_PRIVATE_KEY is delivered; public sibling fields remain visible to dedicated document-reader identity but never become consumer trust discovery", + "projection_source": "docs/credential-lane-designs/flex-auth-envelope-signing-native-projection.yaml.proposed", + "managed_declaration": "Missing rapp declaration remains migration debt; retain actual Access919ce25 workload identity, do not create package tuple" + } +} diff --git a/docs/credential-lane-designs/flex-auth-envelope-signing-native-plan.md b/docs/credential-lane-designs/flex-auth-envelope-signing-native-plan.md new file mode 100644 index 0000000..01853bb --- /dev/null +++ b/docs/credential-lane-designs/flex-auth-envelope-signing-native-plan.md @@ -0,0 +1,37 @@ +# Native signer action preparation + +CCR-2026-0050 is a schema-valid proposed ESO read grant. It does not authorize +secret writes. The separate exact native-action JSON requests one attended CAS0 +and fixed metadata setup/readback; founder disposition is still pending. No +standing writer/broker grant, private consumer lookup, rapp declaration or policy +change is introduced. Native private custody remains in the founder's reviewed +warden child, not an agent prompt or temporary file. + +The JSON fixes the target/cluster/backend, new kid, CAS version, exact native +methods, retention/CAS metadata, existing custody machinery, signer fence, +receipt location/encoding, abort owner and forward-only recovery. It separately +identifies Access/cluster-owned stop/restart/ESO/PVC and Secrets-owned independent +public admission. These owners must review their exact inputs; Platform cannot +sign their dispositions. The program that enforces this native contract still +requires source implementation and independent tests before executable approval. +The existing fixture adapter must not be promoted by deleting its process check. + +Source preparation validated the real credential-change schema and generated +HCL. The expected unconfirmed-bound-claims warning and proposed status preserve +apply refusal. No live observation/identity confirmation, credential read, CAS, +policy write, role, Kubernetes or route mutation occurred. Missing managed +package declaration remains explicit migration debt for the retained workload. + +The native candidate explicitly amends two unadmitted R3 read-lane choices: +role `external-secrets-flex-auth-envelope-signing` follows the existing delegated +applier convention; read CCR fields names the projected ED25519_PRIVATE_KEY only. +The same document has public sibling fields; document-granular read means ESO +can read those too, so this is not field-level authorization. The sibling native +projection proposal and CCR agree on the new role. Historical R3 is not mutated. + +Access metadata observation found zero PVCs in flex-auth. Its exact proposed +new outbox PVC1Gi/local-path is a separate resource/retention scope, with Delete/ +WaitForFirstConsumer PV behavior needing explicit treatment. Existing outbox code +lacks process locking/byte ceiling/bounded startup and compaction. Neither +Recreate1 nor Helmkeep supplies these guarantees; production durable acceptance +remains closed until Access-owned hardening and cluster/storage review. diff --git a/docs/credential-lane-designs/flex-auth-envelope-signing-native-projection.yaml.proposed b/docs/credential-lane-designs/flex-auth-envelope-signing-native-projection.yaml.proposed new file mode 100644 index 0000000..589a7ca --- /dev/null +++ b/docs/credential-lane-designs/flex-auth-envelope-signing-native-projection.yaml.proposed @@ -0,0 +1,50 @@ +# SOURCE PROPOSAL ONLY: no CCR admission, no apply, no plaintext key. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: flex-auth-envelope-signing-eso + namespace: flex-auth +automountServiceAccountToken: false +--- +apiVersion: external-secrets.io/v1 +kind: SecretStore +metadata: + name: openbao-flex-auth-envelope-signing + namespace: flex-auth +spec: + provider: + vault: + server: http://openbao.openbao.svc:8200 + path: platform + version: v2 + auth: + kubernetes: + mountPath: kubernetes + role: external-secrets-flex-auth-envelope-signing + serviceAccountRef: + name: flex-auth-envelope-signing-eso + audiences: [openbao] +--- +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: flex-auth-secrets-engine-envelope-signing + namespace: flex-auth +spec: + refreshInterval: 5m + secretStoreRef: + kind: SecretStore + name: openbao-flex-auth-envelope-signing + target: + name: flex-auth-secrets-engine-envelope-signing + creationPolicy: Owner + deletionPolicy: Retain + template: + metadata: + annotations: + railiance.io/custody-proposal: RPF35-PDP-SIGNER-NATIVE-20261016-R1 + data: + - secretKey: seed + remoteRef: + key: workloads/flex-auth/envelope-signing + property: ED25519_PRIVATE_KEY diff --git a/openbao/policies/workload-kv-read-flex-auth-envelope-signing.hcl b/openbao/policies/workload-kv-read-flex-auth-envelope-signing.hcl new file mode 100644 index 0000000..57425e7 --- /dev/null +++ b/openbao/policies/workload-kv-read-flex-auth-envelope-signing.hcl @@ -0,0 +1,11 @@ +path "platform/data/workloads/flex-auth/envelope-signing" { + capabilities = ["read"] +} + +path "auth/token/lookup-self" { + capabilities = ["read"] +} + +path "auth/token/revoke-self" { + capabilities = ["update"] +} diff --git a/scripts/flex_auth_signer_custody.py b/scripts/flex_auth_signer_custody.py index 1783a6f..1fd1197 100644 --- a/scripts/flex_auth_signer_custody.py +++ b/scripts/flex_auth_signer_custody.py @@ -58,6 +58,10 @@ class SignerWriter: or '-dev' not in process.args or '-dev-no-store-token' not in process.args or '-dev-listen-address=127.0.0.1:'+str(admission.port) not in process.args): raise Refused('live owner-spawned disposable dev process required') + self.admission=admission + self._initialize_receipt(directory,operation_id,kid=kid,expected_version=expected_version,bindings=bindings) + + def _initialize_receipt(self,directory,operation_id,*,kid,expected_version,bindings): if (type(operation_id) is not str or not re.fullmatch(r'[a-zA-Z0-9_-]{1,80}',operation_id) or type(kid) is not str or not re.fullmatch(r'[a-zA-Z0-9_-]{1,80}',kid) or type(expected_version) is not int or not 0<=expected_version<2**31 @@ -66,7 +70,7 @@ class SignerWriter: raise Refused('exact signer operation contract required') if directory.absolute()!=directory.resolve() or directory.is_symlink():raise Refused('indirect receipt custody refused') Journal._private(directory.stat(),directory=True) - self.admission=admission;self.directory=directory;self.path=directory/(operation_id+'.json') + self.directory=directory;self.path=directory/(operation_id+'.json') self.expected_version=expected_version;self.kid=kid;self.attempted=False self.receipt={'schema':'railiance-platform.signer-cas-receipt.v1','operation_id':operation_id, 'bindings':dict(bindings),'writer_sha256':'sha256:'+hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), diff --git a/scripts/flex_auth_signer_native.py b/scripts/flex_auth_signer_native.py new file mode 100644 index 0000000..c8cf934 --- /dev/null +++ b/scripts/flex_auth_signer_native.py @@ -0,0 +1,417 @@ +#!/usr/bin/env python3 +"""Silent attended retained-lifecycle signer custody; never a credential front door. + +Invoke only through the reviewed warden platform-admin envelope. The externally +reviewed admission SHA is an exact-command input, not approval by JSON shape. +No private value crosses stdout/stderr, argv, receipt or persistent local storage. +""" +from __future__ import annotations +import argparse +import base64 +from datetime import datetime,timezone +import fcntl +import hashlib +import hmac +import http.client +import importlib.util +import json +import os +from pathlib import Path +import re +import selectors +import shlex +import subprocess +import sys +import time + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from flex_auth_signer_custody import SignerWriter,PATH,FIELDS,BINDINGS,encoded,b64 +from task_execution_journal import Journal,Refused +from task_execution_native_fixture import _absolute_budget +from task_execution_recovery import _json_unique,_invalid_constant,_policy_absent,_same_policy + +ROOT=Path(__file__).resolve().parents[1] +META='platform/metadata/workloads/flex-auth/envelope-signing' +CCR='credential-change-requests/CCR-2026-0050-flex-auth-envelope-signing.yaml' +PLAN='docs/credential-lane-designs/flex-auth-envelope-signing-native-plan.json' +CLUSTER='a553c742-0115-43d4-99a4-a5ca56fe0786' +BAO_CLUSTER='fd28df5d-98ec-57dd-42ec-9b3e4f4e53bf' +NAME='flex-auth-secrets-engine' +SECRET='flex-auth-secrets-engine-envelope-signing' +ROLE='external-secrets-flex-auth-envelope-signing' +LIFECYCLE='sha256:bd11c5fe77ce6439c65fea225ad6b71d2110efc5e7b5bc9b499c59cd0a53b8b4' +REVIEWS={'founder','railiance-platform-owner','access-engine-owner','secrets-engine-owner','cluster-gitops-owner'} +SOURCE_FILES={'scripts/flex_auth_signer_native.py','scripts/flex_auth_signer_custody.py','scripts/task_execution_native_fixture.py','scripts/task_execution_journal.py','scripts/task_execution_recovery.py','scripts/credential-change.py'} + + +def require(ok,reason): + if not ok:raise Refused(reason) + + +def digest(raw):return 'sha256:'+hashlib.sha256(raw).hexdigest() +def is_digest(value):return type(value) is str and re.fullmatch(r'sha256:[0-9a-f]{64}',value) is not None + +def private_read(path,limit=65536): + path=Path(path) + require(path.is_absolute() and path.absolute()==path.resolve() and not path.is_symlink(),'indirect private input') + Journal._private(path.parent.stat(),directory=True) + fd=os.open(path,os.O_RDONLY|os.O_NOFOLLOW|os.O_NONBLOCK) + try: + before=os.fstat(fd);Journal._private(before) + raw=os.read(fd,limit+1);after=os.fstat(fd) + require((before.st_dev,before.st_ino,before.st_size,before.st_mtime_ns,before.st_ctime_ns)== + (after.st_dev,after.st_ino,after.st_size,after.st_mtime_ns,after.st_ctime_ns) + and len(raw)==before.st_size and len(raw)<=limit,'private input changed or oversized') + return raw + finally:os.close(fd) + + +def parsed(raw): + try:return json.loads(raw,object_pairs_hook=_json_unique,parse_constant=_invalid_constant) + except Exception:raise Refused('invalid closed JSON input') from None + + +def validate_admission(value): + fields={'schema','action','operation_id','kid','expires_at','founder_entity_id','ccr_sha256','source_bundle','bindings', + 'producer_image','caller_binding','deployment_projection_sha256','authority','routing','owner_reviews', + 'exclusive_window_sha256','consumer_trust_sha256','original_receipt_sha256','original_execution'} + require(type(value) is dict and set(value)==fields,'exact admission schema required') + require(value['schema']=='railiance-platform.signer-native-admission.v1' and value['action'] in {'bootstrap','recover'},'native admission action refused') + require(type(value['operation_id']) is str and re.fullmatch(r'[A-Za-z0-9_-]{1,80}',value['operation_id']) and + type(value['kid']) is str and re.fullmatch(r'[A-Za-z0-9_-]{1,80}',value['kid']),'native operation identity required') + require(type(value['founder_entity_id']) is str and re.fullmatch(r'[a-f0-9-]{36}',value['founder_entity_id']),'explicit attended identity required') + require(all(is_digest(value[x]) for x in ['ccr_sha256','deployment_projection_sha256','exclusive_window_sha256','consumer_trust_sha256']),'explicit custody/image/trust selections required') + require(type(value['producer_image']) is str and re.fullmatch(r'forgejo\.coulomb\.social/coulomb/flex-auth@sha256:[a-f0-9]{64}',value['producer_image']),'immutable producer image required') + require(value['caller_binding']=='secrets-engine=system:serviceaccount:secrets-engine:secrets-engine','retained caller binding required') + require(value['authority']=='founder-attended-exact-source-scope; no standing grant','independent attended authority required') + route=value['routing'];require(type(route) is dict and set(route)=={'lane_id','verdict','founder_act','receipt_sha256'} and + route['lane_id']=='openbao-platform-admin-login' and route['verdict']=='founder_required' and route['founder_act']=='oidc_login' and is_digest(route['receipt_sha256']),'routing mismatch refused') + reviews=value['owner_reviews'];require(type(reviews) is dict and set(reviews)==REVIEWS,'distinct owner reviews required') + for role,review in reviews.items(): + require(type(review) is dict and set(review)=={'approved_admission_sha256','approval_ref'} and + is_digest(review['approved_admission_sha256']) and type(review['approval_ref']) is str and + 00,'absolute custody command timeout') + for item,_ in sel.select(remaining): + chunk=os.read(item.fileobj.fileno(),65536) + if not chunk:sel.unregister(item.fileobj) + else: + data[item.fileobj].extend(chunk) + require(sum(map(len,data.values()))<=1048576,'custody command output bound') + code=proc.wait(timeout=max(.001,deadline-time.monotonic())) + require(code in allowed,'custody command unconfirmed') + return code,bytes(data[proc.stdout]) + finally:sel.close() + except BaseException: + proc.kill();proc.wait(timeout=2);raise Refused('custody command unconfirmed') from None + finally: + proc.stdout.close();proc.stderr.close() + def bao(self,args,payload=None,allowed=(0,)): + with _absolute_budget(2): + code,raw=self.captured(['bao',*args],payload,allowed=allowed) + return code,parsed(raw) + def kube(self,resource,name=None): + args=['kubectl','--request-timeout=2s'] + if resource!='namespace':args+=['-n','flex-auth'] + args+=['get',resource] + if name:args.append(name) + args+=['-o','json'] + with _absolute_budget(2): + _,raw=self.captured(['ssh','-o','BatchMode=yes','-o','StrictHostKeyChecking=yes','-o','ConnectTimeout=2','railiance01',shlex.join(args)]) + return parsed(raw) + def http(self,method,path,body=None): + require(method in {'GET','POST'} and path in {META,PATH,PATH+'?version=1'},'fixed custody path required') + require(type(getattr(self,'_token',None)) is str and self._token,'contained attended session required') + with _absolute_budget(2): + conn=http.client.HTTPConnection('127.0.0.1',18200,timeout=2) + try: + conn.request(method,'/v1/'+path,body=None if body is None else encoded(body), + headers={'X-Vault-Token':self._token,'Content-Type':'application/json'}) + response=conn.getresponse();raw=response.read(1048577) + require(len(raw)<=1048576,'custody response bound') + return response.status,parsed(raw) if raw else {} + finally:conn.close() + + +def deployment_projection(deployment): + spec=deployment['spec']['template']['spec'];containers=spec.get('containers',[]) + require(type(containers) is list and len(containers)==1 and not spec.get('initContainers'),'single fixed signer container required') + item=containers[0] + # Never hash arbitrary env values or an entire Secret/spec response. + return {'service_account':spec.get('serviceAccountName'),'image':item.get('image'), + 'args':item.get('args'),'volume_mounts':item.get('volumeMounts',[]), + 'volumes':spec.get('volumes',[]),'security_context':spec.get('securityContext')} + + +def fence(io,contract): + namespace=io.kube('namespace','kube-system') + require(namespace.get('metadata',{}).get('uid')==CLUSTER,'wrong target cluster') + deployment=io.kube('deployment',NAME);spec=deployment.get('spec',{});meta=deployment.get('metadata',{}) + require(type(spec.get('replicas')) is int and spec['replicas']==0 and + deployment.get('status',{}).get('observedGeneration',-1)==meta.get('generation') and + not deployment.get('status',{}).get('replicas',0),'all signer replicas must be stopped') + projection=deployment_projection(deployment) + require(digest(encoded(projection))==contract['deployment_projection_sha256'] and + projection['service_account']==NAME and projection['image']==contract['producer_image'],'fenced deployment source/image mismatch') + args=projection['args'];require(type(args) is list and all(type(x) is str for x in args),'exact signer args required') + def option(flag): + require(args.count(flag)==1 and args.index(flag)+1=3:self.pods=[{'spec':{'serviceAccountName':m.NAME}}] + return deepcopy(self.dep) + return {'items':deepcopy({'pods':self.pods,'hpa':self.hpas,'replicasets':self.rs}[resource])} + def http(self,method,path,body=None): + self.calls.append(('http',method,path)) + if self.denied and path in {m.PATH,m.PATH+'?version=1'} and method=='GET':return 403,{'errors':['secret backend detail']} + result=call(method,path,body) + if self.lost and method=='POST' and path==m.PATH:raise OSError('secret response lost') + return result + io=IO() + c={'schema':'railiance-platform.signer-native-admission.v1','action':'bootstrap','operation_id':'synthetic-native-operation','kid':'synthetic-native-kid', + 'expires_at':(datetime.now(timezone.utc)+timedelta(seconds=100)).isoformat(),'founder_entity_id':io.entity, + 'ccr_sha256':m.digest((source/m.CCR).read_bytes()),'source_bundle':{n:m.digest((source/n).read_bytes()) for n in m.SOURCE_FILES}, + 'bindings':{k:'sha256:'+'2'*64 for k in m.BINDINGS},'producer_image':image,'caller_binding':'secrets-engine=system:serviceaccount:secrets-engine:secrets-engine', + 'deployment_projection_sha256':m.digest(m.encoded(m.deployment_projection(dep))), + 'authority':'founder-attended-exact-source-scope; no standing grant', + 'routing':{'lane_id':'openbao-platform-admin-login','verdict':'founder_required','founder_act':'oidc_login','receipt_sha256':'sha256:'+'3'*64}, + 'owner_reviews':{},'exclusive_window_sha256':'sha256:'+'4'*64,'consumer_trust_sha256':'sha256:'+'5'*64,'original_receipt_sha256':None,'original_execution':None} + c['bindings'].update(proposal_sha256=m.digest((source/m.PLAN).read_bytes()),policy_sha256=m.LIFECYCLE,image_sha256='sha256:'+'1'*64,trust_sha256=c['consumer_trust_sha256'],deployment_sha256=c['deployment_projection_sha256'],executed_revision_sha256=m.digest(m.encoded(c['source_bundle']))) + def approve(value): + if value['action']=='recover' and value['original_execution'] is None: + old=json.loads((directory/(c['operation_id']+'.json')).read_bytes()) + value['original_execution']={key:deepcopy(old[key]) for key in ['bindings','writer_sha256','source_ccr_sha256']} + scope=m.digest(m.encoded({k:v for k,v in value.items() if k!='owner_reviews'})) + value['owner_reviews']={role:{'approved_admission_sha256':scope,'approval_ref':'synthetic-fixture-'+role} for role in m.REVIEWS} + return value + approve(c) + return c,io,directory,call,approve,source + + +def test_actual_native_bootstrap_exact_readback_and_no_retry(native): + c,io,directory,call,approve,source=native + result=m.execute(c,directory,io=io) + assert result['status']=='custody-verified-pending-independent-public-admission' and result['exact_readback'] + assert call('GET',m.META)[1]['data']['current_version']==1 + assert [(x[1],x[2]) for x in io.calls if x[0]=='http' and x[1]=='POST']==[('POST',m.META),('POST',m.PATH)] + private=call('GET',m.PATH)[1]['data']['data']['ED25519_PRIVATE_KEY'] + raw=(directory/(c['operation_id']+'.json')).read_bytes();assert private.encode() not in raw + before=len(io.calls) + with pytest.raises(FileExistsError):m.execute(c,directory,io=io) + assert not any(x[0]=='http' and x[1]=='POST' for x in io.calls[before:]) + + +def test_actual_process_loss_unknown_fresh_recovery_read_only(native): + c,io,directory,call,approve,source=native + child=os.fork() + if child==0: + original_http=io.http + def die_after_native_write(method,path,body=None): + result=original_http(method,path,body) + if method=='POST' and path==m.PATH:os._exit(73) + return result + io.http=die_after_native_write + try:m.execute(c,directory,io=io) + except BaseException:os._exit(74) + os._exit(75) + _,status=os.waitpid(child,0) + assert os.waitstatus_to_exitcode(status)==73 + old=directory/(c['operation_id']+'.json');original=old.read_bytes();assert json.loads(original)['status']=='write-intent-unknown' + recovery=deepcopy(c);recovery.update(action='recover',operation_id='synthetic-independent-recovery',original_receipt_sha256=m.digest(original));approve(recovery) + io.lost=False;io.calls.clear() + result=m.execute(recovery,directory,io=io,original_receipt=old) + assert result['status']=='custody-reconciled-pending-independent-public-admission' + assert result['no_write_or_remint'] and old.read_bytes()==original + assert not any(x[0]=='http' and x[1]=='POST' for x in io.calls) + assert call('GET',m.META)[1]['data']['current_version']==1 + assert (directory/(recovery['operation_id']+'.json')).stat().st_mode&0o777==0o600 + io.calls.clear() + with pytest.raises(FileExistsError):m.execute(recovery,directory,io=io,original_receipt=old) + assert not any(x[0]=='http' for x in io.calls) + + +@pytest.mark.parametrize('case',['missing-image','missing-trust','mismatched-source','bad-review','bad-route','expired','wrong-entity','wrong-backend','wrong-cluster','root','pod','terminating-pod','hpa','replicas','image-drift','role-drift','pod-after-metadata']) +def test_refusals_no_seed_effect(native,case): + c,io,directory,call,approve,source=native + if case=='missing-image':c['producer_image']='' + elif case=='missing-trust':c['consumer_trust_sha256']='' + elif case=='mismatched-source':(source/'scripts/flex_auth_signer_custody.py').write_text('changed source') + elif case=='bad-review':c['owner_reviews']['founder']['approved_admission_sha256']='sha256:'+'f'*64 + elif case=='bad-route':c['routing']['lane_id']='openbao-structure-survey';approve(c) + elif case=='expired':c['expires_at']=(datetime.now(timezone.utc)-timedelta(seconds=1)).isoformat();approve(c) + elif case=='wrong-entity':io.entity='other' + elif case=='wrong-backend':io.backend='other' + elif case=='wrong-cluster':io.cluster='other' + elif case=='root':io.root=True + elif case=='pod':io.pods=[{'spec':{'serviceAccountName':m.NAME}}] + elif case=='terminating-pod':io.pods=[{'metadata':{'deletionTimestamp':'now'},'spec':{'volumes':[{'secret':{'secretName':m.SECRET}}]}}] + elif case=='hpa':io.hpas=[{'spec':{'scaleTargetRef':{'name':m.NAME}}}] + elif case=='replicas':io.dep['spec']['replicas']=1 + elif case=='image-drift':io.dep['spec']['template']['spec']['containers'][0]['image']='unreviewed' + elif case=='role-drift':io.role_drift=True + elif case=='pod-after-metadata':io.pod_on_recheck=True + with pytest.raises(Refused):m.execute(c,directory,io=io) + assert not any(x[0]=='http' and x[1]=='POST' and x[2]==m.PATH for x in io.calls) + assert call('GET',m.PATH)[0]==404 + + +@pytest.mark.parametrize('case',['damaged-receipt','typed-damaged-receipt','changed-version','read-denied','no-fresh-review']) +def test_recovery_refuses_without_new_effect(native,case): + c,io,directory,call,approve,source=native;io.lost=True + with pytest.raises(Refused):m.execute(c,directory,io=io) + old=directory/(c['operation_id']+'.json');recovery=deepcopy(c);recovery.update(action='recover',operation_id='recovery',original_receipt_sha256=m.digest(old.read_bytes()));approve(recovery) + if case=='damaged-receipt':old.write_text('{}') + elif case=='typed-damaged-receipt': + damaged=json.loads(old.read_text());damaged['expected_version']=False;old.write_text(json.dumps(damaged));recovery['original_receipt_sha256']=m.digest(old.read_bytes());approve(recovery) + elif case=='changed-version': + doc=call('GET',m.PATH)[1]['data']['data'];assert call('POST',m.PATH,{'options':{'cas':1},'data':doc})[0]==200 + elif case=='read-denied':io.denied=True + elif case=='no-fresh-review':recovery['owner_reviews']['founder']['approved_admission_sha256']='sha256:'+'f'*64 + io.calls.clear();io.lost=False + with pytest.raises(Refused):m.execute(recovery,directory,io=io,original_receipt=old) + assert not any(x[0]=='http' and x[1]=='POST' for x in io.calls) + + +@pytest.mark.parametrize('bad_args',[False,True]) +def test_main_missing_approval_silent_and_no_io(tmp_path,capsys,bad_args): + if bad_args: + assert m.main(['--unrecognized'])==1 + output=capsys.readouterr();assert output.out==output.err=='' + return + assert m.main(['--admission',str(tmp_path/'missing'),'--admission-sha256','sha256:'+'0'*64,'--receipt-directory',str(tmp_path)])==1 + output=capsys.readouterr();assert output.out==output.err=='' + + +def test_native_command_byte_and_elapsed_bounds_no_output(capsys): + io=m.NativeIO() + with pytest.raises(Refused):io.captured([sys.executable,'-c',"import sys;sys.stdout.write('x'*1048577)"]) + import time + started=time.monotonic() + with pytest.raises(Refused):io.captured([sys.executable,'-c',"import time;time.sleep(10)"]) + assert time.monotonic()-started<3 + output=capsys.readouterr();assert output.out==output.err=='' + + +def test_exclusive_native_window_refuses_second_writer(native): + import fcntl + c,io,directory,call,approve,source=native + fd=os.open(directory/'.signer-window.lock',os.O_RDWR|os.O_CREAT,0o600) + try: + fcntl.flock(fd,fcntl.LOCK_EX|fcntl.LOCK_NB) + with pytest.raises(BlockingIOError):m.execute(c,directory,io=io) + assert io.calls==[] + finally:os.close(fd) + + +def test_native_fixed_http_transport_against_disposable_backend(bao_dev,monkeypatch,capsys): + origin,token,call,process=bao_dev + assert call('POST','sys/mounts/platform',{'type':'kv','options':{'version':'2'}})[0] in {200,204} + factory=m.http.client.HTTPConnection;port=int(origin.rsplit(':',1)[1]);destinations=[] + def fixture_socket(host,requested_port,*args,**kwargs): + destinations.append((host,requested_port)) + return factory(host,port if requested_port==18200 else requested_port,*args,**kwargs) + monkeypatch.setattr(m.http.client,'HTTPConnection',fixture_socket) + io=m.NativeIO();io._token=token + assert io.http('GET',m.META)==(404,{'errors':[]}) + assert io.http('POST',m.META,{'cas_required':True,'max_versions':2,'delete_version_after':'0s'})[0] in {200,204} + m.exact_metadata(*io.http('GET',m.META),created=False) + assert all(host=='127.0.0.1' and selected_port==18200 for host,selected_port in destinations) + with pytest.raises(Refused):io.http('GET','other/path') + output=capsys.readouterr();assert output.out==output.err=='' + + +def metadata_phase_crash(native,*,after): + c,io,directory,call,approve,source=native + child=os.fork() + if child==0: + original=io.http + def die(method,path,body=None): + if method=='POST' and path==m.META: + if after:original(method,path,body) + os._exit(73) + return original(method,path,body) + io.http=die + try:m.execute(c,directory,io=io) + except BaseException:os._exit(74) + os._exit(75) + _,status=os.waitpid(child,0);assert os.waitstatus_to_exitcode(status)==73 + old=directory/(c['operation_id']+'.json');raw=old.read_bytes();receipt=json.loads(raw) + assert receipt['phase']=='metadata-setup' and receipt['status']=='write-intent-unknown' + assert receipt['public_key'] is None and receipt['kv_version'] is None + recovery=deepcopy(c);recovery.update(action='recover',operation_id='metadata-recovery',original_receipt_sha256=m.digest(raw));approve(recovery) + return old,raw,recovery + + +@pytest.mark.parametrize('after',[False,True]) +def test_real_metadata_process_loss_read_only_phase_recovery(native,after): + c,io,directory,call,approve,source=native + old,raw,recovery=metadata_phase_crash(native,after=after) + io.calls.clear();result=m.execute(recovery,directory,io=io,original_receipt=old) + assert result['status']==('metadata-only-confirmed' if after else 'current-custody-absent') + assert result['public_key'] is result['public_fingerprint'] is result['kv_version'] is None + assert result['key_custody_verified'] is False and result['historical_effect']=='unresolved' + assert 'fresh independently reviewed exact action required' in result['next_action'] + assert old.read_bytes()==raw and result['original_unknown_preserved'] + assert not any(x[0]=='http' and x[1]=='POST' for x in io.calls) + assert call('GET',m.PATH+'?version=1')==(404,{'errors':[]}) + + +@pytest.mark.parametrize('case',['metadata-denied','metadata-misleading404','metadata-misleading200','metadata-malformed','metadata-timeout','data-denied','unexpected-version']) +def test_metadata_recovery_ambiguous_or_denied_never_classifies_or_writes(native,case): + c,io,directory,call,approve,source=native + old,raw,recovery=metadata_phase_crash(native,after=True) + if case=='unexpected-version': + assert call('POST',m.PATH,{'options':{'cas':0},'data':{'ED25519_PRIVATE_KEY':'synthetic-unadmitted','ED25519_PUBLIC_KEY':'synthetic-unadmitted','kid':c['kid']}})[0]==200 + original=io.http + def faulty(method,path,body=None): + if path==m.META: + if case=='metadata-denied':return 403,{'errors':['denied']} + if case=='metadata-misleading404':return 404,{'errors':['permission denied']} + if case=='metadata-misleading200': + result=original(method,path,body);result[1]['errors']=['denied'];return result + if case=='metadata-malformed':return 200,{'data':{'current_version':False}} + if case=='metadata-timeout':raise OSError('synthetic private backend detail') + if path==m.PATH+'?version=1' and case=='data-denied':return 404,{'errors':['denied']} + return original(method,path,body) + io.http=faulty;io.calls.clear() + with pytest.raises((Refused,OSError)):m.execute(recovery,directory,io=io,original_receipt=old) + evidence=json.loads((directory/(recovery['operation_id']+'.json')).read_bytes()) + assert evidence['status']=='recovery-read-intent-unconfirmed' and evidence['key_custody_verified'] is False + assert old.read_bytes()==raw + assert not any(x[0]=='http' and x[1]=='POST' for x in io.calls) + if case=='unexpected-version':assert not any(x[0]=='http' and x[2]==m.PATH+'?version=1' for x in io.calls) + + +def test_stale_current_primitive_lineage_refuses_before_native_io(native): + c,io,directory,call,approve,source=native + plan=json.loads((source/m.PLAN).read_bytes());plan['bootstrap']['source_primitive_sha256']='sha256:'+'8'*64 + (source/m.PLAN).write_text(json.dumps(plan));c['bindings']['proposal_sha256']=m.digest((source/m.PLAN).read_bytes());approve(c) + with pytest.raises(Refused,match='primitive lineage'):m.execute(c,directory,io=io) + assert io.calls==[] + + +def test_recovery_explicitly_binds_historical_execution_and_current_source(native): + c,io,directory,call,approve,source=native + old,raw,recovery=metadata_phase_crash(native,after=True) + # Model a separately retained older executed bundle/proposal. It must be + # explicitly selected in fresh recovery review, never treated as current. + original=json.loads(raw);original['bindings']['executed_revision_sha256']='sha256:'+'6'*64 + original['bindings']['proposal_sha256']='sha256:'+'7'*64 + old.write_text(json.dumps(original));recovery['original_receipt_sha256']=m.digest(old.read_bytes()) + recovery['original_execution']={key:deepcopy(original[key]) for key in ['bindings','writer_sha256','source_ccr_sha256']};approve(recovery) + result=m.execute(recovery,directory,io=io,original_receipt=old) + assert result['status']=='metadata-only-confirmed' + assert result['original_execution']['bindings']['executed_revision_sha256']!=recovery['bindings']['executed_revision_sha256'] + assert json.loads(old.read_text())==original + + +def test_generated_but_unstored_seed_phase_recovers_without_public_admission(native): + c,io,directory,call,approve,source=native + io.pod_on_recheck=True + with pytest.raises(Refused):m.execute(c,directory,io=io) + old=directory/(c['operation_id']+'.json');raw=old.read_bytes();receipt=json.loads(raw) + assert receipt['phase']=='seed-cas' and receipt['public_key'] is not None + assert call('GET',m.PATH+'?version=1')==(404,{'errors':[]}) + io.pod_on_recheck=False;io.pods=[];io.calls.clear() + recovery=deepcopy(c);recovery.update(action='recover',operation_id='unstored-seed-recovery',original_receipt_sha256=m.digest(raw));approve(recovery) + result=m.execute(recovery,directory,io=io,original_receipt=old) + assert result['status']=='metadata-only-confirmed' and result['key_custody_verified'] is False + assert result['public_key'] is None and result['public_fingerprint'] is None + assert old.read_bytes()==raw + assert not any(x[0]=='http' and x[1]=='POST' for x in io.calls) diff --git a/tests/test_flex_auth_signer_native_preparation.py b/tests/test_flex_auth_signer_native_preparation.py new file mode 100644 index 0000000..bd1b357 --- /dev/null +++ b/tests/test_flex_auth_signer_native_preparation.py @@ -0,0 +1,49 @@ +"""Real CCR schema/applier contract, no credentials or backend invocation.""" +from copy import deepcopy +import importlib.util +import json +from pathlib import Path +import yaml +ROOT=Path(__file__).resolve().parents[1] +spec=importlib.util.spec_from_file_location('signer_ccr',ROOT/'scripts/credential-change.py') +ccr_module=importlib.util.module_from_spec(spec);spec.loader.exec_module(ccr_module) +CCR=ROOT/'credential-change-requests/CCR-2026-0050-flex-auth-envelope-signing.yaml' + + +def test_real_schema_and_apply_refusal(): + ccr,errors,warnings=ccr_module.validate_ccr(CCR) + assert errors==[] + assert warnings==['OIDC/Kubernetes bound claim is not confirmed; apply is blocked'] + blockers=ccr_module.applier_readiness_blockers(ccr) + assert len(blockers)==2 + assert any('status, got proposed' in x for x in blockers) + assert any('confirmed OpenBao auth binding' in x for x in blockers) + assert ccr['review']['comments']==[] and ccr['access_frontdoor']['resolvable'] is False + # Pure synthetic candidate only: ensure admitted-pattern scope has no latent + # schema/applier role/field failures once the real owners eventually approve. + admitted=deepcopy(ccr);admitted['status']='approved';admitted['openbao']['auth']['bound_claims_confirmed']=True + assert ccr_module.applier_readiness_blockers(admitted)==[] + + +def test_generated_read_scope_and_projection_join(): + ccr,_,_=ccr_module.validate_ccr(CCR) + assert (ROOT/ccr['openbao']['policy_file']).read_text()==ccr_module.generated_policy_hcl(ccr) + hcl=ccr_module.generated_policy_hcl(ccr) + assert 'metadata/' not in hcl and 'list' not in hcl and 'create' not in hcl + assert ccr['openbao']['fields']==['ED25519_PRIVATE_KEY'] + docs=list(yaml.safe_load_all((ROOT/'docs/credential-lane-designs/flex-auth-envelope-signing-native-projection.yaml.proposed').read_text())) + store=next(x for x in docs if x['kind']=='SecretStore') + assert store['spec']['provider']['vault']['auth']['kubernetes']['role']==ccr['openbao']['auth']['role'] + external=next(x for x in docs if x['kind']=='ExternalSecret') + assert external['spec']['data']==[{'secretKey':'seed','remoteRef':{'key':'workloads/flex-auth/envelope-signing','property':'ED25519_PRIVATE_KEY'}}] + + +def test_native_scope_separates_read_grant_and_unadmitted_execution(): + plan=json.loads((ROOT/'docs/credential-lane-designs/flex-auth-envelope-signing-native-plan.json').read_text()) + assert plan['authority_granted'] is False and plan['status']=='prepared-unapproved' + assert plan['bootstrap']['expected_version']==0 + assert all(x['disposition'].startswith('pending ') for x in plan['reviews_required']) + assert 'implementation/independent tests still required' in plan['protected_adapter']['state'] + assert plan['receipts']['maximum_bytes']==65536 + assert all('platform/' in x['path'] for x in plan['bootstrap']['allowed_native_methods']) + assert 'zero flex-auth PVCs' in plan['requested_scope']['new_resources'] diff --git a/workplans/RPF-WP-0035-credential-lane-implementation.md b/workplans/RPF-WP-0035-credential-lane-implementation.md index 0a89a2b..b675d08 100644 --- a/workplans/RPF-WP-0035-credential-lane-implementation.md +++ b/workplans/RPF-WP-0035-credential-lane-implementation.md @@ -1344,3 +1344,34 @@ admission; RPF-WP-0035-T12 remains progress. R3 source metadata correction aligns proposal IDs across JSON/docs/Secret annotation and documents existing base64url-ASCII public fingerprint hashing. Frozen R2 source/evidence is preserved; no writer behavior or admission changes. + +2026-10-16 T12 native signer preparation: proposed CCR-2026-0050 and generated +read-only HCL pass real schema checks; current proposed status/unconfirmed claims +keep apply refused. Native candidate uses existing external-secrets-* role +convention with dedicated SA unchanged and projected private field only. Exact +attended CAS0/metadata/fence/receipt/forward-recovery action plan is separate from +read-grant authority; production adapter implementation/independent tests and +founder plus distinct owner reviews remain necessary. Access observed zero PVCs; +new outbox storage/retention/runtime bounds remain separate Access/cluster scope. +No live credentials, grants, roles, key or deployment changed. T12 stays progress. + +2026-10-16 T12 native signer adapter source: silent fixed-scope attended child +checks exact independent admission/source/CCR/image/caller/trust/identity inputs, +actual backend/reader role/policy, signer fence and private exclusive receipt +custody before fixed metadata/CAS0 effects. Separate fresh read-only recovery +reconciles real process loss after native write without remint/retry/new effects. +Disposable tests exercise actual KV while owner reviews/identity/fence facts are +explicitly synthetic. Current warden plan dispatch selects structure-survey, not +OIDC admin login; mismatch remains a live refusal. Canonical proposed CCR, native +owner/founder approval and Access/cluster/outbox/PVC gates stay open; no live +key, credential read, grant, role, storage or deployment action performed. + +2026-10-16 T12 native signer R2 source correction preserves R1 failures: exact +phase-aware read-only recovery classifies current metadata-only/absent states, +never admits a key or retry and preserves original uncertainty. Fresh recovery +explicitly binds historical execution plus current source; current primitive +plan/source/receipt join is enforced and old pin labeled historical. Real fork +metadata-loss and denied/ambiguous tests added.120second expiry is unchanged; +external reviews and unbounded OIDC require a pre-reviewed exact scheduled +window, otherwise refuse/new explicit revision. No native or owner approval +inferred; no live action.