diff --git a/.forgejo/workflows/keycape-factor-acceptance.yaml b/.forgejo/workflows/keycape-factor-acceptance.yaml new file mode 100644 index 0000000..19d27a1 --- /dev/null +++ b/.forgejo/workflows/keycape-factor-acceptance.yaml @@ -0,0 +1,26 @@ +name: KeyCape factor custody acceptance +on: + push: + branches: [main] + paths: ["scripts/keycape_factor_*.py", "tests/test_keycape_factor_*.py", "openbao/policies/*keycape-factor*.hcl", "manifests/keycape-factor-custody.yaml", ".forgejo/workflows/keycape-factor-acceptance.yaml"] + workflow_dispatch: +jobs: + acceptance: + runs-on: ubuntu-latest + container: + image: python:3.12-slim@sha256:d764629ce0ddd8c71fd371e9901efb324a95789d2315a47db7e4d27e78f1b0e9 + steps: + - name: Test exact source revision + run: | + set -eu + python3 - <<'PYCODE' + import os,tarfile,tempfile,urllib.request + from pathlib import Path + root=Path('/tmp/factor-acceptance');root.mkdir() + url='https://forgejo.coulomb.social/'+os.environ['GITHUB_REPOSITORY']+'/archive/'+os.environ['GITHUB_SHA']+'.tar.gz' + with urllib.request.urlopen(url,timeout=30) as response,tempfile.TemporaryFile() as archive: + archive.write(response.read());archive.seek(0) + with tarfile.open(fileobj=archive,mode='r:gz') as tar:tar.extractall(root,filter='data') + PYCODE + cd /tmp/factor-acceptance/* + PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -p 'test_keycape_factor_*.py' diff --git a/Makefile b/Makefile index 97eece7..3b1eb71 100644 --- a/Makefile +++ b/Makefile @@ -574,3 +574,11 @@ assurance-capture: ## Capture selected status metadata from the pinned railiance assurance-check: ## Classify freshness/failure; nonzero means incomplete or invalid evidence python3 scripts/service_assurance.py evaluate "$(ASSURANCE_OBSERVATION)" + +.PHONY: keycape-factor-tests +keycape-factor-tests: ## Validate factor custody, scope, renewal and config preservation offline + PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -p 'test_keycape_factor_*.py' + +.PHONY: keycape-factor-live-acceptance +keycape-factor-live-acceptance: ## Verify native scope and rotation (creates bounded proof/renewal Jobs) + PYTHONDONTWRITEBYTECODE=1 python3 scripts/keycape_factor_acceptance.py diff --git a/credential-change-requests/CCR-2026-0023-keycape-factor-read.yaml b/credential-change-requests/CCR-2026-0023-keycape-factor-read.yaml new file mode 100644 index 0000000..20e309e --- /dev/null +++ b/credential-change-requests/CCR-2026-0023-keycape-factor-read.yaml @@ -0,0 +1,94 @@ +id: CCR-2026-0023 +kind: credential-change-request +schema_version: 1 +request_type: workload-kv-read +title: KeyCape realm-scoped factor credential delivery +status: active +created: '2026-09-13' +updated: '2026-09-13' +requester: + agent: codex + reason: User authorized establishing provider credential custody and delivery for + platform P05; RPF-WP-0040 and KEY-WP-0035. +review: + required: true + required_approvers: + - platform-operator + comments: + - at: '2026-09-13T00:00:00+00:00' + reviewer: user (platform operator) + decision: approved + comment: User offered administrative authentication and replied "ok, lets do that" + to establishing credential storage and delivery. Exact dedicated path, least-privilege + workload binding and secret-free attended execution implement that authorized + scope. Provider recovery and policy acceptance remain separate gates. +target: + domain: infotech + tenant: platform + workload: key-cape + environment: production + purpose: Deliver only a provider-issued coulomb factor-read JWT to KeyCape, separating + it from issuer credentials and signing keys. +openbao: + mount: platform + kv_path: platform/workloads/net-kingdom/keycape-factor-read + fields: + - TOKEN + - EXPIRES_AT + policy_name: workload-kv-read-keycape-factor-read + policy_file: openbao/policies/workload-kv-read-keycape-factor-read.hcl + metadata_read: true + token_self_lifecycle: true + auth: + method: kubernetes + mount: kubernetes + role: keycape-factor-workload-kv-read + bound_claims: + service_account_names: + - keycape-factor-eso + service_account_namespaces: + - sso + bound_claims_confirmed: true + policies: + - workload-kv-read-keycape-factor-read + ttl: 15m +access_frontdoor: + type: external-secrets + catalog_id: keycape-factor-read + readiness: ready + resolvable: true +delivery: + surface: external-secrets + target: Namespace-restricted ClusterSecretStore openbao-keycape-factor-read -> sso/keycape-factor-read + Secret admin-token. Mount only the JWT in KeyCape; provider password remains in + separate custody. +risk: + classification: high + notes: + - JWT can list factors only in coulomb; enforce provider policy before activation. + - OpenBao TTL does not renew or revoke the privacyIDEA JWT. + - No personal admin credentials delivered to the issuer. +verification: + positive: + - Exact metadata readback and correct-SA Kubernetes login. + - Provider-issued JWT accepted and projected file reread after renewal. + negative: + - Sibling KV paths and writes denied; wrong SA or namespace cannot authenticate. + - Provider mutation permission denied and expired credential fails closed. + activation_conditions: + - Attended metadata apply and exact readback. + - Dedicated provider identity with verified rights/expiry and separate renewable + custody. + - Native delivery and positive/negative factor lookup evidence. + evidence: + - docs/evidence/2026-09-13-keycape-factor-custody.md +lifecycle: + deactivate: Detach reader role, stop renewal and revoke/expire provider token; preserve + custody history. + rotate: Issue replacement before expiry, CAS update exact KV, verify ESO projection + and consumer acceptance; retain no plaintext artifacts. + compromised: Disable the dedicated provider principal and reconcile JWT revocation + or expiry before recovery. +state_hub: + workplan_id: RPF-WP-0040 + task_id: RPF-WP-0040-T01 diff --git a/docs/evidence/2026-09-13-keycape-factor-custody.md b/docs/evidence/2026-09-13-keycape-factor-custody.md new file mode 100644 index 0000000..7a7b52c --- /dev/null +++ b/docs/evidence/2026-09-13-keycape-factor-custody.md @@ -0,0 +1,55 @@ +# KeyCape factor credential custody — 2026-09-13 + +User-authorized attended setup is live under RPF-WP-0040 / CCR-2026-0023. +The operator authenticated through the contained OpenBao login lane. No +credential value was included in source, receipts, chat or ordinary logs. + +Dedicated privacyIDEA identity `keycape-factor-reader` has `tokenlist` permission +for `coulomb`. Its password is in a separate OpenBao issuer path; KeyCape +receives only a provider-issued, expiring JWT via External Secrets. Explicit +fallback policy preserves existing administrators' prior rights and excludes +this service identity. The service cannot read provider administration policy. + +Live checks passed: + +- Exact OpenBao policy/role readback, audience and service-account bindings. +- Provider-issued credential, cross-user factor visibility, and the actual + KeyCape `user` + `realm` + `active=True` lookup shape. +- Wrong service account rejected; issuer password and sibling KV reads denied. +- ExternalSecret `sso/keycape-factor-read`: Ready=True / SecretSynced. +- Initial renewal Job published KV version 2 and revoked its OpenBao session. + A second Job published version 3; the existing mounted file received the + replacement and provider lookup accepted it without a pod restart. +- KeyCape config activation passed server dry-run and UID/resource-version CAS; + readback confirmed all other Secret data and configuration bytes preserved. + Deployment rolled out successfully with the existing image + `sha256:c9eb584d60efecfe00e1745a7e8cd3ebb4ae0f94faa715ef9594cd58a5dcebb6`. +- Fourteen offline tests cover identity/expiry/status rejection, scope boundaries, + failed-provider preservation, CAS failure, session-cleanup failure, retry, + ambiguous config rejection and byte-preserving activation. + +Runtime source: `manifests/keycape-factor-custody.yaml`; renewal every ten minutes, +ESO refresh every minute. Renewal runs as non-root with an immutable ConfigMap, +digest-pinned image, dedicated service account and restricted network policy. +The reader has no issuer-password permission; the renewer cannot write that +password. OpenBao sessions last at most fifteen minutes (ESO) or five minutes +(renewer). Successful renewal workers revoke their sessions explicitly. + +Repeat: `make keycape-factor-tests` and `make keycape-factor-live-acceptance`. +Exact-commit CI is `.forgejo/workflows/keycape-factor-acceptance.yaml`. +The first proof Job was `keycape-factor-delivery-acceptance`; its short-lived +reader session expires by its fifteen-minute maximum TTL. The final reusable +proof also revokes its session; its result is recorded below after execution. + +Limits: this establishes credential custody, renewal and native delivery. +Genuine provider JWT expiry/revocation drills remain RPF-WP-0040-T04; renewing a +JWT does not revoke its predecessor. Optional-after-enrollment policy remains +unchanged. OTP onboarding, lost-factor recovery and their complete platform-admin +browser journeys remain KEY-WP-0035 / USER-WP-0030. Historical resolver incident +NK-WP-0033 is not closed by this new service lane. + +Final reusable proof passed: `keycape-factor-proof-0c4a6929`. All eight checks +were true: wrong-SA rejection, issuer-password denial, sibling-secret denial, +mounted-token/custody equality, KeyCape per-user lookup, provider administration +denial, renewed projection acceptance, and explicit reader-session revocation. +KeyCape remained Ready 1/1 at deployment generation 46. CCR validation passed. diff --git a/docs/net-kingdom-credential-custody-contract.md b/docs/net-kingdom-credential-custody-contract.md index 65598ec..f428902 100644 --- a/docs/net-kingdom-credential-custody-contract.md +++ b/docs/net-kingdom-credential-custody-contract.md @@ -1,6 +1,7 @@ # NetKingdom credential custody contract -Status: **draft / blocked pending OpenBao owner confirmation** +Historical resolver lanes: **draft / blocked**. +KeyCape factor service lane: **active**, established and verified 2026-09-13 (below). Incident: `KEYCAPE-EXPOSURE-20260823-01` Consumer procedure: NetKingdom `NK-WP-0033`, resolver reconciliation revision `eec7007` / checkout `f2e578c` @@ -43,3 +44,75 @@ The routing lane is registered but remains `resolvable: false`. No `warden access --fetch`, proxy execution, resolver retry, or predecessor restoration is authorized until the OpenBao owner publishes the missing metadata and the attended handoff is approved. + +## KeyCape factor service lane — authorized setup, 2026-09-13 + +RPF-WP-0040 / CCR-2026-0023 establish a new dedicated service lane. The user, +acting as platform operator, authorized establishing storage/delivery and performed +attended OpenBao authentication. This is separate from the historical resolver +repair lanes above; their incident receipts are not retroactively completed. + +| Purpose | KV v2 CLI path | Fields | Consumer | +| --- | --- | --- | --- | +| Scoped provider JWT | platform/workloads/net-kingdom/keycape-factor-read | TOKEN, EXPIRES_AT, REQUEST | KeyCape via namespace-scoped ESO and adminTokenFile | +| Renewable service login | platform/workloads/net-kingdom/keycape-factor-issuer | USERNAME, PASSWORD, REQUEST | Owner bootstrap, then dedicated renewal worker; never KeyCape | + +Provider identity: keycape-factor-reader (local privacyIDEA service admin). +Effective permission: tokenlist for realm coulomb only. Preserve the existing +no-admin-policy baseline for other administrators using an explicit fallback +that excludes this service identity. Only the two reviewed named policies may +be introduced; unexpected existing admin policies abort setup. + +Generate the service password in owner-process memory and persist with KV CAS=0 +before provider creation, allowing interrupted setup to resume. The attended +worker passes values through stdin/captured pipes only, never argv or receipts. +Only explicit KV absence permits creation. Existing request provenance and +policy definitions must match; retries do not rotate an existing identity. + +Reader policy: workload-kv-read-keycape-factor-read, role +keycape-factor-workload-kv-read, bound sso/keycape-factor-eso, audience openbao, +15-minute maximum OpenBao session, no default policy. Metadata apply/readback +passed via the attended owner envelope. Provider issuance, native ESO delivery, +KeyCape mounted-file activation, per-user lookup and rotation without restart +passed. The reader can inspect and revoke only its own OpenBao session in +addition to reading the exact JWT data/metadata paths. See the acceptance record +`docs/evidence/2026-09-13-keycape-factor-custody.md`. + +Provider JWTs expire independently of OpenBao sessions. Require 5 minutes to +2 hours remaining lifetime at issuance; renew well before expiry. Disable the +service's tokenlist policy (while retaining fallback exclusion) to withdraw its +factor access; do not rotate privacyIDEA's global signing key as routine renewal. +Verify expiry/permission refusal separately from OpenBao self-revocation. + +Renewal: `sso/keycape-factor-renewer` runs every ten minutes under its own +service account, with a five-minute maximum OpenBao session. Its separate policy +reads the issuer password and reads/writes only the delivered JWT path. The +worker verifies provider identity, expiry, factor lookup and denied policy +administration before a CAS write and readback, then revokes its session. +ESO refreshes once per minute. KeyCape mounts only `admin-token`, mode 0440, +through fsGroup 65534; the issuer password never enters its namespace Secret. + +Operations: `make keycape-factor-tests` runs offline failure/retry and config +preservation tests. `make keycape-factor-live-acceptance` creates bounded Jobs +that verify native delivery scope and a second renewal reaching the mounted +file. The runner emits booleans and resource identifiers only. CronJob failures +and ExternalSecret Ready=False are the native degraded-state signals. A failed +renewal leaves the last verified token in custody; expiry causes authentication +to fail closed. Restore provider/custody connectivity and launch a renewal Job +from the CronJob; verify SecretSynced and repeat live acceptance. + +Attended setup is reproducible through `openbao-attended-exec.py` with +`keycape_factor_metadata.py --apply --receipt `, +`keycape_factor_bootstrap.py --receipt ` and +`keycape_factor_renewal_metadata.py --receipt ` in that order, followed +by the custody manifest and guarded `keycape_factor_activate.py --apply`. +Use unique receipt paths. Existing mismatched policies/roles/provenance fail +closed; never replace an unknown owner credential. The separate +`keycape_factor_eso_metadata.py` records the migration from initial read-only +metadata to ESO's self-session permissions; fresh setup uses the final policy. + +Outstanding acceptance is explicit in RPF-WP-0040-T04: genuine provider JWT +expiry and provider-policy revocation/recovery drills. A newly issued JWT does +not itself revoke its predecessor. Native mounted rotation has passed, but it +is not evidence of predecessor revocation. Optional MFA rollout and lost-factor +recovery remain KEY-WP-0035 / USER-WP-0030, not completed by this service lane. diff --git a/manifests/keycape-factor-custody.yaml b/manifests/keycape-factor-custody.yaml new file mode 100644 index 0000000..9ef5ad9 --- /dev/null +++ b/manifests/keycape-factor-custody.yaml @@ -0,0 +1,267 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: keycape-factor-eso + namespace: sso + labels: + app.kubernetes.io/part-of: net-kingdom-sso-mfa +automountServiceAccountToken: false +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: keycape-factor-renewer + namespace: sso + labels: + app.kubernetes.io/part-of: net-kingdom-sso-mfa +automountServiceAccountToken: false +--- +apiVersion: external-secrets.io/v1 +kind: ClusterSecretStore +metadata: + name: openbao-keycape-factor-read +spec: + conditions: + - namespaces: + - sso + provider: + vault: + server: http://openbao.openbao.svc.cluster.local:8200 + path: platform + version: v2 + auth: + kubernetes: + mountPath: kubernetes + role: keycape-factor-workload-kv-read + serviceAccountRef: + name: keycape-factor-eso + namespace: sso + audiences: + - openbao +--- +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: keycape-factor-read + namespace: sso + labels: + app.kubernetes.io/part-of: net-kingdom-sso-mfa +spec: + refreshInterval: 1m + secretStoreRef: + kind: ClusterSecretStore + name: openbao-keycape-factor-read + target: + name: keycape-factor-read + creationPolicy: Owner + data: + - secretKey: admin-token + remoteRef: + key: workloads/net-kingdom/keycape-factor-read + property: TOKEN + - secretKey: expires-at + remoteRef: + key: workloads/net-kingdom/keycape-factor-read + property: EXPIRES_AT +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: keycape-factor-renewer-2cf770000e + namespace: sso + labels: + app.kubernetes.io/part-of: net-kingdom-sso-mfa +immutable: true +data: + renew.py: "\"\"\"Dedicated Kubernetes renewal worker. Only sanitized outcome metadata\ + \ is emitted.\"\"\"\nimport base64,json,sys,time,urllib.request,urllib.error,urllib.parse\n\ + from pathlib import Path\nBAO=\"http://openbao.openbao.svc.cluster.local:8200/v1\"\ + \nPI=\"http://privacyidea.mfa.svc.cluster.local:8080\"\nISSUER=\"platform/data/workloads/net-kingdom/keycape-factor-issuer\"\ + \nTARGET=\"platform/data/workloads/net-kingdom/keycape-factor-read\"\nUSER=\"\ + keycape-factor-reader\"\nPROVENANCE=\"RPF-WP-0040\"\nclass Failure(Exception):pass\n\ + class NoRedirect(urllib.request.HTTPRedirectHandler):\n def redirect_request(self,*args,**kwargs):return\ + \ None\n\ndef http(url,body=None,headers=None,method=None,form=False):\n data=None\n\ + \ headers=dict(headers or {})\n if body is not None:\n data=(urllib.parse.urlencode(body)\ + \ if form else json.dumps(body)).encode()\n headers[\"Content-Type\"]=\"application/x-www-form-urlencoded\"\ + \ if form else \"application/json\"\n req=urllib.request.Request(url,data=data,headers=headers,method=method)\n\ + \ try:\n with urllib.request.build_opener(NoRedirect()).open(req,timeout=15)\ + \ as r:\n raw=r.read(1048577)\n if len(raw)>1048576:raise Failure()\n return\ + \ r.status,json.loads(raw) if raw else {}\n except urllib.error.HTTPError as e:return\ + \ e.code,{}\n\ndef success(code,data):\n if code!=200 or not data.get(\"result\"\ + ,{}).get(\"status\"):raise Failure()\n return data[\"result\"][\"value\"]\n\n\ + def run():\n session=None;receipt={\"success\":False,\"phase\":\"workload_login\"\ + }\n try:\n jwt=Path(\"/var/run/keycape-factor/token\").read_text().strip()\n\ + \ code,body=http(BAO+\"/auth/kubernetes/login\",{\"role\":\"keycape-factor-renewer\"\ + ,\"jwt\":jwt})\n if code!=200:raise Failure()\n session=body[\"auth\"][\"client_token\"\ + ];headers={\"X-Vault-Token\":session}\n receipt[\"phase\"]=\"issuer_custody\"\ + \n code,body=http(BAO+\"/\"+ISSUER,headers=headers)\n if code!=200:raise Failure()\n\ + \ issuer=body[\"data\"][\"data\"]\n if issuer.get(\"REQUEST\")!=PROVENANCE or\ + \ issuer.get(\"USERNAME\")!=USER:raise Failure()\n receipt[\"phase\"]=\"provider_authentication\"\ + \n value=success(*http(PI+\"/auth\",{\"username\":USER,\"password\":issuer[\"\ + PASSWORD\"]},form=True))\n if value.get(\"role\")!=\"admin\" or value.get(\"\ + username\")!=USER:raise Failure()\n token=value[\"token\"];claims=json.loads(base64.urlsafe_b64decode(token.split('.')[1]+'==='));expiry=int(claims[\"\ + exp\"])\n if not 3000)\n except Exception:pass\n finally:\n if session:\n try:\n code,_=http(BAO+\"\ + /auth/token/revoke-self\",headers={\"X-Vault-Token\":session},method=\"POST\"\ + )\n receipt[\"session_revoked\"]=code in (200,204)\n except Exception:receipt[\"\ + session_revoked\"]=False\n if not receipt[\"session_revoked\"]:receipt[\"success\"\ + ]=False;receipt[\"phase\"]=\"session_cleanup\"\n return receipt\nif __name__==\"\ + __main__\":\n receipt=run();print(json.dumps(receipt));raise SystemExit(0 if receipt[\"\ + success\"] else 1)\n" +--- +apiVersion: batch/v1 +kind: CronJob +metadata: + name: keycape-factor-renewer + namespace: sso + labels: + app.kubernetes.io/part-of: net-kingdom-sso-mfa +spec: + schedule: '*/10 * * * *' + suspend: false + concurrencyPolicy: Forbid + startingDeadlineSeconds: 120 + successfulJobsHistoryLimit: 2 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + activeDeadlineSeconds: 120 + backoffLimit: 1 + ttlSecondsAfterFinished: 3600 + template: + metadata: + labels: + app.kubernetes.io/name: keycape-factor-renewer + spec: + serviceAccountName: keycape-factor-renewer + automountServiceAccountToken: false + restartPolicy: Never + securityContext: + runAsNonRoot: true + runAsUser: 65534 + runAsGroup: 65534 + fsGroup: 65534 + seccompProfile: + type: RuntimeDefault + containers: + - name: renew + image: python:3.12-slim@sha256:d764629ce0ddd8c71fd371e9901efb324a95789d2315a47db7e4d27e78f1b0e9 + command: + - python3 + - /worker/renew.py + env: + - name: PYTHONDONTWRITEBYTECODE + value: '1' + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + resources: + requests: + cpu: 10m + memory: 24Mi + limits: + cpu: 100m + memory: 64Mi + volumeMounts: + - name: worker + mountPath: /worker + readOnly: true + - name: identity + mountPath: /var/run/keycape-factor + readOnly: true + volumes: + - name: worker + configMap: + name: keycape-factor-renewer-2cf770000e + - name: identity + projected: + defaultMode: 288 + sources: + - serviceAccountToken: + path: token + audience: openbao + expirationSeconds: 600 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: keycape-factor-renewer + namespace: sso + labels: + app.kubernetes.io/part-of: net-kingdom-sso-mfa +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: keycape-factor-renewer + policyTypes: + - Ingress + - Egress + ingress: [] + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: openbao + ports: + - port: 8200 + protocol: TCP + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: mfa + podSelector: + matchLabels: + app.kubernetes.io/name: privacyidea + ports: + - port: 8080 + protocol: TCP + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - port: 53 + protocol: UDP + - port: 53 + protocol: TCP +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-keycape-factor-renewal + namespace: mfa + labels: + app.kubernetes.io/part-of: net-kingdom-sso-mfa +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: privacyidea + policyTypes: + - Ingress + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: sso + podSelector: + matchLabels: + app.kubernetes.io/name: keycape-factor-renewer + ports: + - port: 8080 + protocol: TCP diff --git a/openbao/policies/keycape-factor-renewer.hcl b/openbao/policies/keycape-factor-renewer.hcl new file mode 100644 index 0000000..380300c --- /dev/null +++ b/openbao/policies/keycape-factor-renewer.hcl @@ -0,0 +1,11 @@ +# Dedicated renewable service password read and JWT-only publish. +path "platform/data/workloads/net-kingdom/keycape-factor-issuer" { + capabilities = ["read"] +} +path "platform/data/workloads/net-kingdom/keycape-factor-read" { + capabilities = ["read", "create", "update"] +} + +path "auth/token/revoke-self" { + capabilities = ["update"] +} diff --git a/openbao/policies/workload-kv-read-keycape-factor-read.hcl b/openbao/policies/workload-kv-read-keycape-factor-read.hcl new file mode 100644 index 0000000..d91b225 --- /dev/null +++ b/openbao/policies/workload-kv-read-keycape-factor-read.hcl @@ -0,0 +1,16 @@ +# Exact-path JWT delivery; no issuer password or sibling paths. +path "platform/data/workloads/net-kingdom/keycape-factor-read" { + capabilities = ["read"] +} +path "platform/metadata/workloads/net-kingdom/keycape-factor-read" { + capabilities = ["read"] +} + +# ESO validates its own short-lived workload session. +path "auth/token/lookup-self" { + capabilities = ["read"] +} + +path "auth/token/revoke-self" { + capabilities = ["update"] +} diff --git a/scripts/keycape_factor_acceptance.py b/scripts/keycape_factor_acceptance.py new file mode 100644 index 0000000..08ae9bb --- /dev/null +++ b/scripts/keycape_factor_acceptance.py @@ -0,0 +1,39 @@ +"""Run native delivery/scope/rotation acceptance; no attended admin or secret output.""" +import copy,json,subprocess,time,uuid +from pathlib import Path +from keycape_factor_activate import CLUSTER,kubectl +ROOT=Path(__file__).resolve().parents[1] +def run(): + if kubectl("get","ns","kube-system","-o","json")["metadata"]["uid"]!=CLUSTER:raise ValueError() + cron=kubectl("-n","sso","get","cronjob","keycape-factor-renewer","-o","json") + spec=copy.deepcopy(cron["spec"]["jobTemplate"]["spec"]) + spec.update(activeDeadlineSeconds=300,backoffLimit=0) + p=spec["template"]["spec"];p["serviceAccountName"]="keycape-factor-eso" + p["containers"][0]["command"]=["python3","-c",(ROOT/"scripts/keycape_factor_delivery_probe.py").read_text()] + p["volumes"].append({"name":"factor","secret":{"secretName":"keycape-factor-read","defaultMode":288}}) + p["containers"][0]["volumeMounts"].append({"name":"factor","mountPath":"/factor","readOnly":True}) + suffix=uuid.uuid4().hex[:8];name="keycape-factor-proof-"+suffix + kubectl("create","-f","-","-o","json",payload={"apiVersion":"batch/v1","kind":"Job","metadata":{"name":name,"namespace":"sso"},"spec":spec}) + deadline=time.monotonic()+310;rotating=False + while time.monotonic()0 and any(x.get("active") is True for x in listing["tokens"]) + code,_=http(PI+"/policy/",headers={"Authorization":initial});result["provider_administration_denied"]=code in (401,403) + print(json.dumps({"phase":"awaiting_rotation",**result}),flush=True) + for _ in range(48): + time.sleep(5) + current=Path("/factor/admin-token").read_text().strip() + if current!=initial: + listing=success(*http(PI+"/token/?"+query,headers={"Authorization":current})) + result["rotated_projection_accepted"]=listing["count"]>0 + break + result["success"]=all(v for k,v in result.items() if k!="success") and result.get("rotated_projection_accepted",False) +except Exception:pass +finally: + if session: + try: + code,_=http(BAO+"/auth/token/revoke-self",headers={"X-Vault-Token":session},method="POST") + result["session_revoked"]=code in (200,204) + except Exception:result["session_revoked"]=False + if not result["session_revoked"]:result["success"]=False +print(json.dumps(result),flush=True) +raise SystemExit(0 if result["success"] else 1) diff --git a/scripts/keycape_factor_eso_metadata.py b/scripts/keycape_factor_eso_metadata.py new file mode 100644 index 0000000..2932ff7 --- /dev/null +++ b/scripts/keycape_factor_eso_metadata.py @@ -0,0 +1,16 @@ +import json,os,sys +from pathlib import Path +from keycape_factor_metadata import call,normalized,policy_text +NAME="workload-kv-read-keycape-factor-read" +OLD='# Exact-path JWT delivery; no issuer password or sibling paths.\npath "platform/data/workloads/net-kingdom/keycape-factor-read" {\n capabilities = ["read"]\n}\npath "platform/metadata/workloads/net-kingdom/keycape-factor-read" {\n capabilities = ["read"]\n}\n\n# ESO validates its own short-lived workload session.\npath "auth/token/lookup-self" {\n capabilities = ["read"]\n}\n' +NEW=Path(__file__).resolve().parents[1].joinpath("openbao/policies/"+NAME+".hcl").read_text() +fd=os.open(sys.argv[1],os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600) +result={"success":False} +try: + current=policy_text(call("policy","read","-format=json",NAME)) + if normalized(current) not in (normalized(OLD),normalized(NEW)):raise RuntimeError() + call("write","-format=json","sys/policies/acl/"+NAME,"-",payload={"policy":NEW}) + if normalized(policy_text(call("policy","read","-format=json",NAME)))!=normalized(NEW):raise RuntimeError() + result={"success":True,"change":"ESO self-session lookup and revocation only","secret_values_read":False} +finally: + os.write(fd,json.dumps(result).encode());os.close(fd) diff --git a/scripts/keycape_factor_metadata.py b/scripts/keycape_factor_metadata.py new file mode 100644 index 0000000..d5551b8 --- /dev/null +++ b/scripts/keycape_factor_metadata.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Reviewed silent metadata apply inside the attended OpenBao envelope.""" +import argparse,json,os,subprocess +from pathlib import Path +ROOT=Path(__file__).resolve().parents[1] +POLICY="workload-kv-read-keycape-factor-read" +ROLE="keycape-factor-workload-kv-read" +ROLE_PATH="auth/kubernetes/role/"+ROLE +POLICY_PATH="sys/policies/acl/"+POLICY +KV="platform/workloads/net-kingdom/keycape-factor-read" +ROLE_CONFIG={"bound_service_account_names":["keycape-factor-eso"],"bound_service_account_namespaces":["sso"],"audience":"openbao","token_policies":[POLICY],"token_ttl":900,"token_max_ttl":900,"token_no_default_policy":True} + +def call(*args,payload=None): + p=subprocess.run(["bao",*args],input=json.dumps(payload) if payload is not None else None,capture_output=True,text=True,timeout=25) + if p.returncode:raise RuntimeError("OpenBao metadata command rejected: "+args[0]+" "+args[1]) + return json.loads(p.stdout) if p.stdout.strip() else {} + +def normalized(value):return "".join(value.split()) + +def policy_text(value):return value.get("data",value).get("policy","") + +def verify_role(data): + return all(data.get(k)==v for k,v in ROLE_CONFIG.items()) + +def execute(apply=False): + mounts=call("secrets","list","-format=json") + if mounts.get("platform/",{}).get("type")!="kv" or str(mounts["platform/"].get("options",{}).get("version"))!="2":raise RuntimeError("Expected existing KV v2 mount") + auth=call("auth","list","-format=json") + if auth.get("kubernetes/",{}).get("type")!="kubernetes":raise RuntimeError("Expected existing Kubernetes auth") + # Capability checks are individual: the CLI interprets multiple arguments as TOKEN PATH. + capabilities={p:call("token","capabilities","-format=json",p) for p in [POLICY_PATH,ROLE_PATH]} + if any(not {"create","update"}.issubset(set(c)) and "root" not in c for c in capabilities.values()):raise RuntimeError("Missing metadata administration capabilities") + policy=(ROOT/"openbao/policies"/(POLICY+".hcl")).read_text() + if apply: + # New dedicated names only. Existing objects must match; never overwrite foreign metadata. + policies=call("policy","list","-format=json") + if POLICY in policies: + current=call("policy","read","-format=json",POLICY) + if normalized(current.get("data",current).get("policy",""))!=normalized(policy):raise RuntimeError("Existing policy differs") + else:call("write","-format=json",POLICY_PATH,"-",payload={"policy":policy}) + roles=call("list","-format=json","auth/kubernetes/role") + role_names=roles if isinstance(roles,list) else roles.get("data",{}).get("keys",[]) + if ROLE in role_names: + if not verify_role(call("read","-format=json",ROLE_PATH).get("data",{})):raise RuntimeError("Existing role differs") + else:call("write","-format=json",ROLE_PATH,"-",payload=ROLE_CONFIG) + if not verify_role(call("read","-format=json",ROLE_PATH).get("data",{})):raise RuntimeError("Role readback differs") + if normalized(policy_text(call("policy","read","-format=json",POLICY)))!=normalized(policy):raise RuntimeError("Policy readback differs") + return {"operation":"apply" if apply else "preflight","policy":POLICY,"role":ROLE_PATH,"kv_path":KV,"field":"TOKEN","metadata_verified":apply,"secret_values_read":False,"secret_values_written":False} + +def main(): + parser=argparse.ArgumentParser();parser.add_argument("--apply",action="store_true");parser.add_argument("--receipt",type=Path,required=True);args=parser.parse_args() + if any(os.environ.get(k) for k in ["OPENBAO_TOKEN","BAO_TOKEN","VAULT_TOKEN"]):return 2 + fd=os.open(args.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600) + result={"success":False,"phase":"metadata"} + try: + result.update(execute(args.apply));result["success"]=True;return 0 + except RuntimeError as exc: + result["failure"]=str(exc);return 1 + except Exception as exc: + result["failure_type"]=type(exc).__name__;return 1 + finally: + os.write(fd,json.dumps(result,indent=2).encode());os.close(fd) +if __name__=="__main__":raise SystemExit(main()) diff --git a/scripts/keycape_factor_provider.py b/scripts/keycape_factor_provider.py new file mode 100644 index 0000000..53eac0c --- /dev/null +++ b/scripts/keycape_factor_provider.py @@ -0,0 +1,72 @@ +"""Run only inside the provider; secret-bearing stdin/stdout stay in owner pipes.""" +import contextlib,io,json,logging,sys,time,base64,urllib.request,urllib.error,urllib.parse +USER="keycape-factor-reader" +BASELINE="keycape-preserve-existing-admins" +READER="keycape-factor-reader-coulomb" +REALM="coulomb" + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self,*args,**kwargs):return None + +def request(path,payload=None,token=None): + headers={} + if token:headers["Authorization"]=token + data=urllib.parse.urlencode(payload).encode() if payload is not None else None + req=urllib.request.Request("http://127.0.0.1:8080"+path,data=data,headers=headers) + try: + with urllib.request.build_opener(NoRedirect()).open(req,timeout=15) as r: + raw=r.read(1048577) + if len(raw)>1048576:raise RuntimeError("provider response too large") + return r.status,json.loads(raw) + except urllib.error.HTTPError as e:return e.code,{} + +def token_result(password): + code,auth=request("/auth",{"username":USER,"password":password}) + value=auth.get("result",{}).get("value",{}) + if code!=200 or not auth.get("result",{}).get("status") or value.get("role")!="admin" or value.get("username")!=USER:raise RuntimeError("service authentication failed") + token=value.get("token","") + claims=json.loads(base64.urlsafe_b64decode(token.split('.')[1]+'===')) + expiry=int(claims['exp']) + if not 3000,"policy_read_denied":True} + +def bootstrap(password): + logging.disable(logging.CRITICAL) + from privacyidea.app import create_app + from privacyidea.lib.policy import PolicyClass,set_policy + from privacyidea.lib.auth import get_db_admins,create_db_admin + app=create_app(config_name="production") + with app.app_context(): + policies=PolicyClass().list_policies(scope="admin",active=True) + # Source preflight found no policies. Retry accepts only our exact two policies. + if any(p['name'] not in [BASELINE,READER] for p in policies):raise RuntimeError("admin policy baseline changed") + for p in policies: + expected=({"adminuser":["*","!"+USER],"realm":[],"action":{"*":True}} if p['name']==BASELINE else {"adminuser":[USER],"realm":[REALM],"action":{"tokenlist":True}}) + if any(p.get(k)!=v for k,v in expected.items()):raise RuntimeError("existing managed policy differs") + existing={a.username for a in get_db_admins()} + if USER in existing and not policies:raise RuntimeError("service name already belongs to another setup") + if BASELINE not in {p['name'] for p in policies}: + set_policy(name=BASELINE,scope="admin",action="*",adminuser=["*","!"+USER],description="Preserve default rights for existing administrative identities; exclude factor reader") + if READER not in {p['name'] for p in policies}: + set_policy(name=READER,scope="admin",action="tokenlist",adminuser=USER,realm=REALM,description="KeyCape factor listing in coulomb only") + if USER not in existing:create_db_admin(USER,password=password) + return token_result(password) + +def main(): + data=json.load(sys.stdin) + if data.get('username')!=USER or not isinstance(data.get('password'),str) or len(data['password'])<32:return 2 + with contextlib.redirect_stdout(io.StringIO()),contextlib.redirect_stderr(io.StringIO()): + result=bootstrap(data['password']) if data.get('operation')=='bootstrap' else token_result(data['password']) + sys.stdout.write(json.dumps(result));return 0 +if __name__=='__main__': + try:code=main() + except RuntimeError as exc: + sys.stdout.write(json.dumps({"failure":str(exc)}));code=1 + except Exception as exc: + sys.stdout.write(json.dumps({"failure_type":type(exc).__name__}));code=1 + raise SystemExit(code) diff --git a/scripts/keycape_factor_renew.py b/scripts/keycape_factor_renew.py new file mode 100644 index 0000000..e75ed12 --- /dev/null +++ b/scripts/keycape_factor_renew.py @@ -0,0 +1,74 @@ +"""Dedicated Kubernetes renewal worker. Only sanitized outcome metadata is emitted.""" +import base64,json,sys,time,urllib.request,urllib.error,urllib.parse +from pathlib import Path +BAO="http://openbao.openbao.svc.cluster.local:8200/v1" +PI="http://privacyidea.mfa.svc.cluster.local:8080" +ISSUER="platform/data/workloads/net-kingdom/keycape-factor-issuer" +TARGET="platform/data/workloads/net-kingdom/keycape-factor-read" +USER="keycape-factor-reader" +PROVENANCE="RPF-WP-0040" +class Failure(Exception):pass +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self,*args,**kwargs):return None + +def http(url,body=None,headers=None,method=None,form=False): + data=None + headers=dict(headers or {}) + if body is not None: + data=(urllib.parse.urlencode(body) if form else json.dumps(body)).encode() + headers["Content-Type"]="application/x-www-form-urlencoded" if form else "application/json" + req=urllib.request.Request(url,data=data,headers=headers,method=method) + try: + with urllib.request.build_opener(NoRedirect()).open(req,timeout=15) as r: + raw=r.read(1048577) + if len(raw)>1048576:raise Failure() + return r.status,json.loads(raw) if raw else {} + except urllib.error.HTTPError as e:return e.code,{} + +def success(code,data): + if code!=200 or not data.get("result",{}).get("status"):raise Failure() + return data["result"]["value"] + +def run(): + session=None;receipt={"success":False,"phase":"workload_login"} + try: + jwt=Path("/var/run/keycape-factor/token").read_text().strip() + code,body=http(BAO+"/auth/kubernetes/login",{"role":"keycape-factor-renewer","jwt":jwt}) + if code!=200:raise Failure() + session=body["auth"]["client_token"];headers={"X-Vault-Token":session} + receipt["phase"]="issuer_custody" + code,body=http(BAO+"/"+ISSUER,headers=headers) + if code!=200:raise Failure() + issuer=body["data"]["data"] + if issuer.get("REQUEST")!=PROVENANCE or issuer.get("USERNAME")!=USER:raise Failure() + receipt["phase"]="provider_authentication" + value=success(*http(PI+"/auth",{"username":USER,"password":issuer["PASSWORD"]},form=True)) + if value.get("role")!="admin" or value.get("username")!=USER:raise Failure() + token=value["token"];claims=json.loads(base64.urlsafe_b64decode(token.split('.')[1]+'==='));expiry=int(claims["exp"]) + if not 3000) + except Exception:pass + finally: + if session: + try: + code,_=http(BAO+"/auth/token/revoke-self",headers={"X-Vault-Token":session},method="POST") + receipt["session_revoked"]=code in (200,204) + except Exception:receipt["session_revoked"]=False + if not receipt["session_revoked"]:receipt["success"]=False;receipt["phase"]="session_cleanup" + return receipt +if __name__=="__main__": + receipt=run();print(json.dumps(receipt));raise SystemExit(0 if receipt["success"] else 1) diff --git a/scripts/keycape_factor_renewal_metadata.py b/scripts/keycape_factor_renewal_metadata.py new file mode 100644 index 0000000..3eb253d --- /dev/null +++ b/scripts/keycape_factor_renewal_metadata.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Exact reviewed renewal metadata; owner-admin operation, not generic KV-read delegation.""" +import argparse,json,os +from pathlib import Path +from keycape_factor_metadata import call,normalized,policy_text +ROOT=Path(__file__).resolve().parents[1] +NAME="keycape-factor-renewer" +ROLE={"bound_service_account_names":[NAME],"bound_service_account_namespaces":["sso"],"audience":"openbao","token_policies":[NAME],"token_ttl":300,"token_max_ttl":300,"token_no_default_policy":True} +def execute(): + policy=(ROOT/"openbao/policies"/(NAME+".hcl")).read_text() + existing=call("policy","list","-format=json") + if NAME in existing: + if normalized(policy_text(call("policy","read","-format=json",NAME)))!=normalized(policy):raise RuntimeError("existing renewal policy differs") + else:call("write","-format=json","sys/policies/acl/"+NAME,"-",payload={"policy":policy}) + roles=call("list","-format=json","auth/kubernetes/role") + names=roles if isinstance(roles,list) else roles.get("data",{}).get("keys",[]) + path="auth/kubernetes/role/"+NAME + if NAME in names: + data=call("read","-format=json",path)["data"] + if any(data.get(k)!=v for k,v in ROLE.items()):raise RuntimeError("existing renewal role differs") + else:call("write","-format=json",path,"-",payload=ROLE) + data=call("read","-format=json",path)["data"] + if any(data.get(k)!=v for k,v in ROLE.items()):raise RuntimeError("renewal role readback failed") + if normalized(policy_text(call("policy","read","-format=json",NAME)))!=normalized(policy):raise RuntimeError("renewal policy readback failed") + return {"success":True,"policy":NAME,"role":path,"token_max_ttl":300,"secret_values_read":False,"secret_values_written":False} +def main(): + p=argparse.ArgumentParser();p.add_argument("--receipt",type=Path,required=True);a=p.parse_args() + if any(os.environ.get(k) for k in ["VAULT_TOKEN","BAO_TOKEN","OPENBAO_TOKEN"]):return 2 + fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600);result={"success":False} + try:result=execute();return 0 + except RuntimeError as e:result["failure"]=str(e);return 1 + except Exception as e:result["failure_type"]=type(e).__name__;return 1 + finally:os.write(fd,json.dumps(result,indent=2).encode());os.close(fd) +if __name__=="__main__":raise SystemExit(main()) diff --git a/tests/test_keycape_factor_activation.py b/tests/test_keycape_factor_activation.py new file mode 100644 index 0000000..0b91e1d --- /dev/null +++ b/tests/test_keycape_factor_activation.py @@ -0,0 +1,15 @@ +import importlib.util,unittest +from pathlib import Path +spec=importlib.util.spec_from_file_location("activation",Path(__file__).resolve().parents[1]/"scripts/keycape_factor_activate.py") +m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m) +class ActivationTests(unittest.TestCase): + def test_preserves_other_bytes_and_is_idempotent(self): + old=b"other: secret\nprivacyidea:\n baseURL: http://provider\n adminToken: expired\n realm: coulomb\n requireForAll: true\nclients:\n - secret: unchanged\n" + new=m.rewrite_config(old) + self.assertEqual(old.replace(b"adminToken: expired",b"adminTokenFile: /etc/keycape-factor/admin-token"),new) + self.assertEqual(new,m.rewrite_config(new)) + def test_rejects_ambiguous_source(self): + for text in (b"other: value\n",b"privacyidea:\n adminToken: one\n adminToken: two\n",b"privacyidea:\n adminTokenFile: /unexpected\n"): + with self.assertRaises(ValueError):m.rewrite_config(text) + def test_refuses_unexpected_deployment(self): + with self.assertRaises(ValueError):m.deployment_patch({"metadata":{"uid":"replacement"}}) diff --git a/tests/test_keycape_factor_metadata.py b/tests/test_keycape_factor_metadata.py new file mode 100644 index 0000000..0b0a7bd --- /dev/null +++ b/tests/test_keycape_factor_metadata.py @@ -0,0 +1,31 @@ +import importlib.util,unittest +from pathlib import Path +from unittest.mock import patch +spec=importlib.util.spec_from_file_location("factor_metadata",Path(__file__).resolve().parents[1]/"scripts/keycape_factor_metadata.py") +m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m) +class FactorMetadataTests(unittest.TestCase): + def test_exact_role_and_policy_scope(self): + self.assertEqual(["keycape-factor-eso"],m.ROLE_CONFIG["bound_service_account_names"]) + self.assertEqual(["sso"],m.ROLE_CONFIG["bound_service_account_namespaces"]) + self.assertTrue(m.ROLE_CONFIG["token_no_default_policy"]) + self.assertEqual(900,m.ROLE_CONFIG["token_max_ttl"]) + policy=(m.ROOT/"openbao/policies"/(m.POLICY+".hcl")).read_text() + self.assertNotIn("*",policy);self.assertNotIn("issuer",policy.splitlines()[1:]) + self.assertEqual(3,policy.count('capabilities = ["read"]')) + def test_denied_capabilities_prevent_writes(self): + calls=[] + def fake(*args,**kwargs): + calls.append(args) + if args[:2]==("secrets","list"):return {"platform/":{"type":"kv","options":{"version":"2"}}} + if args[:2]==("auth","list"):return {"kubernetes/":{"type":"kubernetes"}} + return ["deny"] + with patch.object(m,"call",side_effect=fake): + with self.assertRaises(RuntimeError):m.execute(True) + self.assertFalse(any(c[0]=="write" for c in calls)) + def test_mismatched_role_fails_readback(self): + self.assertTrue(m.verify_role(dict(m.ROLE_CONFIG))) + self.assertFalse(m.verify_role(dict(m.ROLE_CONFIG,token_policies=["root"]))) + + def test_policy_readback_accepts_cli_and_api_shapes(self): + self.assertEqual("expected",m.policy_text({"policy":"expected"})) + self.assertEqual("expected",m.policy_text({"data":{"policy":"expected"}})) diff --git a/tests/test_keycape_factor_provider.py b/tests/test_keycape_factor_provider.py new file mode 100644 index 0000000..4d27ae5 --- /dev/null +++ b/tests/test_keycape_factor_provider.py @@ -0,0 +1,21 @@ +import base64,importlib.util,json,time,unittest +from pathlib import Path +from unittest.mock import patch +spec=importlib.util.spec_from_file_location("factor_provider",Path(__file__).resolve().parents[1]/"scripts/keycape_factor_provider.py") +m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m) +class FactorProviderTests(unittest.TestCase): + def check(self,role="admin",auth_status=True,expiry=3600,policy_status=403,listing_status=True): + token="synthetic."+base64.urlsafe_b64encode(json.dumps({"exp":int(time.time())+expiry}).encode()).decode().rstrip("=")+".signature" + def request(path,payload=None,token=None): + if path=="/auth":return 200,{"result":{"status":auth_status,"value":{"role":role,"username":m.USER,"token":self.token}}} + if path=="/policy/":return policy_status,{} + return 200,{"result":{"status":listing_status,"value":{"tokens":[{}],"count":1}}} + self.token=token + with patch.object(m,"request",side_effect=request):return m.token_result("synthetic-password-not-a-real-secret") + def test_success_requires_scoped_admin_and_expiry(self): + r=self.check();self.assertTrue(r["policy_read_denied"]);self.assertTrue(r["cross_user_factor_visible"]) + def test_rejects_user_token_expiry_and_privilege_leak(self): + for kwargs in [dict(role="user"),dict(auth_status=False),dict(expiry=-1),dict(expiry=9000),dict(policy_status=200),dict(policy_status=500),dict(listing_status=False)]: + with self.subTest(kwargs=kwargs): + with self.assertRaises(RuntimeError):self.check(**kwargs) + def test_no_redirect_forwarding(self):self.assertIsNone(m.NoRedirect().redirect_request(None,None,None,None,None,None)) diff --git a/tests/test_keycape_factor_renewal.py b/tests/test_keycape_factor_renewal.py new file mode 100644 index 0000000..e6928d5 --- /dev/null +++ b/tests/test_keycape_factor_renewal.py @@ -0,0 +1,33 @@ +import base64,importlib.util,json,time,unittest +from pathlib import Path +from unittest.mock import patch +spec=importlib.util.spec_from_file_location("factor_renew",Path(__file__).resolve().parents[1]/"scripts/keycape_factor_renew.py") +m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m) +class FactorRenewalTests(unittest.TestCase): + def drive(self,provider_failure=False,publish_failure=False,revoke_failure=False): + self.published=False;token="synthetic."+base64.urlsafe_b64encode(json.dumps({"exp":int(time.time())+3600}).encode()).decode()+".sig" + def http(url,body=None,headers=None,method=None,form=False): + if url.endswith('/auth/kubernetes/login'):return 200,{"auth":{"client_token":"synthetic-bao-session"}} + if url.endswith('/auth/token/revoke-self'):return (403 if revoke_failure else 204),{} + if url.endswith(m.ISSUER):return 200,{"data":{"data":{"REQUEST":m.PROVENANCE,"USERNAME":m.USER,"PASSWORD":"synthetic-service-password"}}} + if url.endswith('/auth'): + return (401,{}) if provider_failure else (200,{"result":{"status":True,"value":{"role":"admin","username":m.USER,"token":token}}}) + if '/token/?' in url:return 200,{"result":{"status":True,"value":{"tokens":[{}],"count":1}}} + if url.endswith('/policy/'):return 403,{} + if url.endswith(m.TARGET): + if body: + if publish_failure:return 400,{} + self.assertEqual(1,body['options']['cas']);self.assertNotIn('PASSWORD',body['data']);self.published=True;return 200,{} + return 200,{"data":{"data":{"REQUEST":m.PROVENANCE,"TOKEN":token if self.published else "synthetic-old"},"metadata":{"version":2 if self.published else 1}}} + raise AssertionError('Unexpected endpoint') + with patch.object(m,'http',side_effect=http),patch.object(Path,'read_text',return_value='synthetic-kubernetes-jwt'):return m.run() + def test_renewal_publishes_verified_token_and_revokes_session(self): + r=self.drive();self.assertTrue(r['success']);self.assertEqual(2,r['kv_version']);self.assertTrue(r['session_revoked']) + self.assertNotIn('synthetic',json.dumps(r)) + def test_rejected_provider_retains_current_token(self): + r=self.drive(provider_failure=True);self.assertFalse(r['success']);self.assertFalse(self.published);self.assertTrue(r['session_revoked']) + self.assertTrue(self.drive()['success']) + def test_cas_failure_is_not_success(self): + r=self.drive(publish_failure=True);self.assertFalse(r['success']);self.assertFalse(self.published);self.assertEqual('publish',r['phase']) + def test_cleanup_failure_is_reported(self): + r=self.drive(revoke_failure=True);self.assertFalse(r['success']);self.assertEqual('session_cleanup',r['phase']) diff --git a/workplans/RPF-WP-0040-keycape-factor-credential-custody.md b/workplans/RPF-WP-0040-keycape-factor-credential-custody.md new file mode 100644 index 0000000..02d37da --- /dev/null +++ b/workplans/RPF-WP-0040-keycape-factor-credential-custody.md @@ -0,0 +1,64 @@ +--- +id: RPF-WP-0040 +type: workplan +title: "Establish KeyCape factor credential custody and renewal" +domain: financials +repo: railiance-platform +status: active +owner: codex +topic_slug: financials +created: "2026-09-13" +updated: "2026-09-13" +--- + +User authorized establishing custody/delivery on 2026-09-13 and offered attended administrative authentication. Supports USER-WP-0030-T03 and KEY-WP-0035-T02. Existing incident lanes are not silently repurposed. + +## Establish exact-path custody and workload delivery metadata + +```task +id: RPF-WP-0040-T01 +status: done +priority: high +``` + +New KV v2 path platform/workloads/net-kingdom/keycape-factor-read, field TOKEN, provider expiry metadata. Reader role binds only sso/keycape-factor-eso, audience openbao, exact-path read policy and fifteen-minute OpenBao tokens. Attended admin metadata apply, exact readback and capability checks. No wildcard secret access or secret value in output. + +## Provision a bounded provider identity and renewable credential + +```task +id: RPF-WP-0040-T02 +status: done +priority: high +``` + +Create dedicated privacyIDEA keycape-factor-reader with coulomb tokenlist permission. Preserve existing admin rights; baseline has no admin policies. Store renewable issuer credential separately from the delivered JWT. Use protected stdin and in-memory handling, provider-issued JWT with expiry, positive cross-user scope and negative admin policy-read checks. No personal admin password in runtime. + +## Activate and verify renewal and consumer delivery + +```task +id: RPF-WP-0040-T03 +status: done +priority: high +``` + +Deliver via namespace-scoped ESO, activate KeyCape adminTokenFile with byte-preserving config CAS, schedule renewal before expiry and verify mounted reload and native scope. Predecessor expiry/revocation and recovery drills remain T04. Verify effective scope and real factor acceptance before optional client policy. Preserve existing incident residuals in NK-WP-0033. + +Live implementation and evidence: docs/evidence/2026-09-13-keycape-factor-custody.md. +Fourteen offline tests pass; native renewal, exact-path ESO delivery, negative +scope checks, provider per-user lookup and mounted rotation passed. KeyCape +rolled out Ready 1/1 without changing MFA policy or signing/client secrets. + +## Verify provider expiry and revocation recovery + +```task +id: RPF-WP-0040-T04 +status: todo +priority: high +``` + +Exercise genuine JWT expiry, provider-side permission withdrawal and recovery +with an isolated fixture, preserving real users' factors and admin access. +Verify rejected predecessor handling independently of OpenBao token revocation. +Include provider interruption/retry evidence and maintain visible failed-Job / +ExternalSecret failure signals. This is remaining acceptance, not another owner +handoff. Historical resolver incident NK-WP-0033 remains separately open.