Establish scoped KeyCape factor custody and verified automatic renewal
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
This commit is contained in:
parent
b75729b799
commit
2e2c31d237
22 changed files with 1169 additions and 1 deletions
26
.forgejo/workflows/keycape-factor-acceptance.yaml
Normal file
26
.forgejo/workflows/keycape-factor-acceptance.yaml
Normal file
|
|
@ -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'
|
||||||
8
Makefile
8
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
|
assurance-check: ## Classify freshness/failure; nonzero means incomplete or invalid evidence
|
||||||
python3 scripts/service_assurance.py evaluate "$(ASSURANCE_OBSERVATION)"
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
55
docs/evidence/2026-09-13-keycape-factor-custody.md
Normal file
55
docs/evidence/2026-09-13-keycape-factor-custody.md
Normal file
|
|
@ -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.
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
# NetKingdom credential custody contract
|
# 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`
|
Incident: `KEYCAPE-EXPOSURE-20260823-01`
|
||||||
Consumer procedure: NetKingdom `NK-WP-0033`, resolver reconciliation revision
|
Consumer procedure: NetKingdom `NK-WP-0033`, resolver reconciliation revision
|
||||||
`eec7007` / checkout `f2e578c`
|
`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
|
`warden access --fetch`, proxy execution, resolver retry, or predecessor
|
||||||
restoration is authorized until the OpenBao owner publishes the missing
|
restoration is authorized until the OpenBao owner publishes the missing
|
||||||
metadata and the attended handoff is approved.
|
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 <new-file>`,
|
||||||
|
`keycape_factor_bootstrap.py --receipt <new-file>` and
|
||||||
|
`keycape_factor_renewal_metadata.py --receipt <new-file>` 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.
|
||||||
|
|
|
||||||
267
manifests/keycape-factor-custody.yaml
Normal file
267
manifests/keycape-factor-custody.yaml
Normal file
|
|
@ -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 300<expiry-time.time()<=7200:raise Failure()\n receipt[\"phase\"\
|
||||||
|
]=\"provider_scope\"\n provider_headers={\"Authorization\":token}\n listing=success(*http(PI+\"\
|
||||||
|
/token/?tokenrealm=coulomb&active=True&pagesize=1\",headers=provider_headers))\n\
|
||||||
|
\ if not isinstance(listing.get(\"tokens\"),list) or not isinstance(listing.get(\"\
|
||||||
|
count\"),int):raise Failure()\n code,_=http(PI+\"/policy/\",headers=provider_headers)\n\
|
||||||
|
\ if code not in (401,403):raise Failure()\n receipt[\"phase\"]=\"publish\"\n\
|
||||||
|
\ code,body=http(BAO+\"/\"+TARGET,headers=headers)\n if code!=200 or body[\"\
|
||||||
|
data\"][\"data\"].get(\"REQUEST\")!=PROVENANCE:raise Failure()\n version=body[\"\
|
||||||
|
data\"][\"metadata\"][\"version\"]\n code,_=http(BAO+\"/\"+TARGET,{\"options\"\
|
||||||
|
:{\"cas\":version},\"data\":{\"TOKEN\":token,\"EXPIRES_AT\":expiry,\"REQUEST\"\
|
||||||
|
:PROVENANCE}},headers=headers)\n if code!=200:raise Failure()\n code,body=http(BAO+\"\
|
||||||
|
/\"+TARGET,headers=headers)\n if code!=200 or body[\"data\"][\"data\"][\"TOKEN\"\
|
||||||
|
]!=token:raise Failure()\n receipt.update(success=True,phase=\"complete\",expires_at=expiry,kv_version=body[\"\
|
||||||
|
data\"][\"metadata\"][\"version\"],cross_user_factor_visible=listing[\"count\"\
|
||||||
|
]>0)\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
|
||||||
11
openbao/policies/keycape-factor-renewer.hcl
Normal file
11
openbao/policies/keycape-factor-renewer.hcl
Normal file
|
|
@ -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"]
|
||||||
|
}
|
||||||
16
openbao/policies/workload-kv-read-keycape-factor-read.hcl
Normal file
16
openbao/policies/workload-kv-read-keycape-factor-read.hcl
Normal file
|
|
@ -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"]
|
||||||
|
}
|
||||||
39
scripts/keycape_factor_acceptance.py
Normal file
39
scripts/keycape_factor_acceptance.py
Normal file
|
|
@ -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()<deadline:
|
||||||
|
r=subprocess.run(["kubectl","-n","sso","logs","job/"+name],capture_output=True,text=True,timeout=15)
|
||||||
|
results=[]
|
||||||
|
if r.returncode==0:
|
||||||
|
for line in r.stdout.splitlines():
|
||||||
|
try:results.append(json.loads(line))
|
||||||
|
except ValueError:pass
|
||||||
|
if results and not rotating:
|
||||||
|
if results[0].get("phase")!="awaiting_rotation":raise ValueError()
|
||||||
|
renewal={"apiVersion":"batch/v1","kind":"Job","metadata":{"name":"keycape-factor-rotate-"+suffix,"namespace":"sso"},"spec":cron["spec"]["jobTemplate"]["spec"]}
|
||||||
|
kubectl("create","-f","-","-o","json",payload=renewal);rotating=True
|
||||||
|
print(json.dumps({"phase":"waiting_for_mounted_rotation","job":name}),flush=True)
|
||||||
|
if results and "phase" not in results[-1]:
|
||||||
|
result={k:v for k,v in results[-1].items() if isinstance(v,bool)}
|
||||||
|
result["job"]=name
|
||||||
|
return result
|
||||||
|
time.sleep(5)
|
||||||
|
raise TimeoutError()
|
||||||
|
if __name__=="__main__":
|
||||||
|
try:result=run()
|
||||||
|
except Exception:result={"success":False,"failure":"native acceptance failed or timed out"}
|
||||||
|
print(json.dumps(result),flush=True);raise SystemExit(0 if result.get("success") else 1)
|
||||||
57
scripts/keycape_factor_activate.py
Normal file
57
scripts/keycape_factor_activate.py
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
"""Guarded activation; credentials remain in memory and subprocess stdin."""
|
||||||
|
import argparse,base64,copy,json,re,subprocess,time
|
||||||
|
CLUSTER="a553c742-0115-43d4-99a4-a5ca56fe0786"
|
||||||
|
DEPLOYMENT="99ddd83c-cb3f-4847-bcf8-35f1aa87627f"
|
||||||
|
IMAGE="forgejo.coulomb.social/coulomb/key-cape@sha256:c9eb584d60efecfe00e1745a7e8cd3ebb4ae0f94faa715ef9594cd58a5dcebb6"
|
||||||
|
MOUNT={"name":"factor-token","mountPath":"/etc/keycape-factor","readOnly":True}
|
||||||
|
VOLUME={"name":"factor-token","secret":{"secretName":"keycape-factor-read","defaultMode":288,"items":[{"key":"admin-token","path":"admin-token"}]}}
|
||||||
|
def kubectl(*args,payload=None):
|
||||||
|
r=subprocess.run(["kubectl",*args],input=json.dumps(payload).encode() if payload is not None else None,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=40)
|
||||||
|
if r.returncode:raise RuntimeError("Kubernetes operation failed")
|
||||||
|
return json.loads(r.stdout) if r.stdout else {}
|
||||||
|
def rewrite_config(raw):
|
||||||
|
text=raw.decode();match=re.search(r"(?m)^privacyidea:[^\n]*\n(?:(?:[ \t]+[^\n]*|)[\n]|[ \t]+[^\n]*$)*",text)
|
||||||
|
if not match:raise ValueError("privacyidea section missing")
|
||||||
|
section=match.group()
|
||||||
|
if re.search(r"(?m)^ adminTokenFile:",section):
|
||||||
|
if not re.search(r"(?m)^ adminTokenFile: /etc/keycape-factor/admin-token$",section) or re.search(r"(?m)^ adminToken:",section):raise ValueError("unexpected credential configuration")
|
||||||
|
return raw
|
||||||
|
section,n=re.subn(r"(?m)^ adminToken:[^\n]*$"," adminTokenFile: /etc/keycape-factor/admin-token",section)
|
||||||
|
if n!=1:raise ValueError("expected exactly one inline token")
|
||||||
|
return (text[:match.start()]+section+text[match.end():]).encode()
|
||||||
|
def deployment_patch(obj):
|
||||||
|
if obj["metadata"]["uid"]!=DEPLOYMENT:raise ValueError("deployment identity mismatch")
|
||||||
|
spec=obj["spec"]["template"]["spec"];containers=spec["containers"]
|
||||||
|
i=next(i for i,c in enumerate(containers) if c["name"]=="keycape")
|
||||||
|
if containers[i]["image"]!=IMAGE:raise ValueError("deployment image mismatch")
|
||||||
|
mounts=copy.deepcopy(containers[i].get("volumeMounts",[]));volumes=copy.deepcopy(spec.get("volumes",[]))
|
||||||
|
for entries,expected in ((mounts,MOUNT),(volumes,VOLUME)):
|
||||||
|
existing=[x for x in entries if x["name"]==expected["name"]]
|
||||||
|
if existing and existing!=[expected]:raise ValueError("existing factor mount differs")
|
||||||
|
if not existing:entries.append(expected)
|
||||||
|
return [{"op":"test","path":"/metadata/uid","value":DEPLOYMENT},{"op":"test","path":"/metadata/resourceVersion","value":obj["metadata"]["resourceVersion"]},{"op":"add","path":f"/spec/template/spec/containers/{i}/volumeMounts","value":mounts},{"op":"add","path":"/spec/template/spec/volumes","value":volumes}]
|
||||||
|
def run(apply):
|
||||||
|
if kubectl("get","ns","kube-system","-o","json")["metadata"]["uid"]!=CLUSTER:raise ValueError("cluster identity mismatch")
|
||||||
|
es=kubectl("-n","sso","get","externalsecret","keycape-factor-read","-o","json")
|
||||||
|
if not any(x["type"]=="Ready" and x["status"]=="True" for x in es.get("status",{}).get("conditions",[])):raise ValueError("delivery not ready")
|
||||||
|
delivered=kubectl("-n","sso","get","secret","keycape-factor-read","-o","json")
|
||||||
|
expiry=int(base64.b64decode(delivered["data"]["expires-at"]))
|
||||||
|
if expiry-time.time()<600:raise ValueError("delivered token too near expiry")
|
||||||
|
secret=kubectl("-n","sso","get","secret","keycape-config","-o","json")
|
||||||
|
old=secret["data"]["config.yaml"];new=base64.b64encode(rewrite_config(base64.b64decode(old))).decode()
|
||||||
|
patch=[{"op":"test","path":"/metadata/uid","value":secret["metadata"]["uid"]},{"op":"test","path":"/metadata/resourceVersion","value":secret["metadata"]["resourceVersion"]},{"op":"replace","path":"/data/config.yaml","value":new}]
|
||||||
|
deployment=kubectl("-n","sso","get","deployment","keycape","-o","json");dp=deployment_patch(deployment)
|
||||||
|
for kind,name,ops in (("secret","keycape-config",patch),("deployment","keycape",dp)):
|
||||||
|
kubectl("-n","sso","patch",kind,name,"--type=json","--patch-file=/dev/stdin","--dry-run=server","-o","json",payload=ops)
|
||||||
|
if apply:
|
||||||
|
kubectl("-n","sso","patch","secret","keycape-config","--type=json","--patch-file=/dev/stdin","-o","json",payload=patch)
|
||||||
|
kubectl("-n","sso","patch","deployment","keycape","--type=json","--patch-file=/dev/stdin","-o","json",payload=dp)
|
||||||
|
current=kubectl("-n","sso","get","secret","keycape-config","-o","json")
|
||||||
|
expected=dict(secret["data"],**{"config.yaml":new})
|
||||||
|
if current["data"]!=expected:raise ValueError("config readback mismatch")
|
||||||
|
return {"success":True,"applied":apply,"unrelated_config_preserved":True,"credential_source":"mounted_file","expires_at":expiry}
|
||||||
|
if __name__=="__main__":
|
||||||
|
p=argparse.ArgumentParser();p.add_argument("--apply",action="store_true");a=p.parse_args()
|
||||||
|
try:result=run(a.apply)
|
||||||
|
except Exception:result={"success":False,"failure":"activation failed; inspect sanitized resource status"}
|
||||||
|
print(json.dumps(result));raise SystemExit(0 if result["success"] else 1)
|
||||||
55
scripts/keycape_factor_bootstrap.py
Normal file
55
scripts/keycape_factor_bootstrap.py
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Silent attended custody and dedicated provider bootstrap; no personal passwords."""
|
||||||
|
import argparse,json,os,secrets,subprocess,time,re
|
||||||
|
from pathlib import Path
|
||||||
|
from keycape_factor_metadata import call
|
||||||
|
ROOT=Path(__file__).resolve().parents[1]
|
||||||
|
ISSUER="platform/data/workloads/net-kingdom/keycape-factor-issuer"
|
||||||
|
TOKEN="platform/data/workloads/net-kingdom/keycape-factor-read"
|
||||||
|
REQUEST="RPF-WP-0040"
|
||||||
|
USER="keycape-factor-reader"
|
||||||
|
|
||||||
|
def read_optional(path):
|
||||||
|
p=subprocess.run(["bao","read","-format=json",path],capture_output=True,text=True,timeout=25)
|
||||||
|
if p.returncode:
|
||||||
|
# Only explicit 404 permits creation. Permission or transport failures never mean absent.
|
||||||
|
if "Code: 404" in p.stderr or "No value found" in p.stderr:return None
|
||||||
|
raise RuntimeError("custody read failed")
|
||||||
|
return json.loads(p.stdout)["data"]
|
||||||
|
|
||||||
|
def execute():
|
||||||
|
cluster=json.loads(subprocess.check_output(["kubectl","get","ns","kube-system","-o","json"]))
|
||||||
|
if cluster["metadata"]["uid"]!="a553c742-0115-43d4-99a4-a5ca56fe0786":raise RuntimeError("wrong cluster")
|
||||||
|
issuer=read_optional(ISSUER)
|
||||||
|
if issuer is None:
|
||||||
|
secret={"USERNAME":USER,"PASSWORD":secrets.token_urlsafe(48),"REQUEST":REQUEST}
|
||||||
|
call("write","-format=json",ISSUER,"-",payload={"options":{"cas":0},"data":secret})
|
||||||
|
issuer=read_optional(ISSUER)
|
||||||
|
secret=issuer["data"]
|
||||||
|
if set(secret)!={"USERNAME","PASSWORD","REQUEST"} or secret["USERNAME"]!=USER or secret["REQUEST"]!=REQUEST:raise RuntimeError("issuer custody mismatch")
|
||||||
|
source=(ROOT/"scripts/keycape_factor_provider.py").read_text()
|
||||||
|
p=subprocess.run(["kubectl","-n","mfa","exec","-i","deployment/privacyidea","-c","privacyidea","--","python3","-c",source],input=json.dumps({"operation":"bootstrap","username":USER,"password":secret["PASSWORD"]}),text=True,capture_output=True,timeout=60)
|
||||||
|
if p.returncode:
|
||||||
|
failure=json.loads(p.stdout)
|
||||||
|
permitted={"admin policy baseline changed","existing managed policy differs","service name already belongs to another setup","service authentication failed","unsupported service token lifetime","factor lookup failed","service has unexpected administration rights","provider response too large"}
|
||||||
|
reason=failure.get("failure")
|
||||||
|
raise RuntimeError(reason if reason in permitted or (isinstance(reason,str) and re.fullmatch(r"factor lookup failed [0-9]{3} (True|False) (True|False) (True|False)",reason)) else "provider setup failed without secret-bearing detail")
|
||||||
|
result=json.loads(p.stdout)
|
||||||
|
if not result.get("policy_read_denied") or result["expires_at"]<=time.time()+300:raise RuntimeError("provider verification incomplete")
|
||||||
|
current=read_optional(TOKEN)
|
||||||
|
if current and current["data"].get("REQUEST")!=REQUEST:raise RuntimeError("token custody belongs to another request")
|
||||||
|
cas=current["metadata"]["version"] if current else 0
|
||||||
|
call("write","-format=json",TOKEN,"-",payload={"options":{"cas":cas},"data":{"TOKEN":result["token"],"EXPIRES_AT":result["expires_at"],"REQUEST":REQUEST}})
|
||||||
|
stored=read_optional(TOKEN)
|
||||||
|
if stored["data"]["TOKEN"]!=result["token"]:raise RuntimeError("token custody readback failed")
|
||||||
|
return {"issuer_path":ISSUER,"token_path":TOKEN,"token_version":stored["metadata"]["version"],"expires_at":result["expires_at"],"provider_policy_read_denied":True,"cross_user_factor_visible":result["cross_user_factor_visible"],"values_emitted":False}
|
||||||
|
|
||||||
|
def main():
|
||||||
|
p=argparse.ArgumentParser();p.add_argument("--receipt",type=Path,required=True);args=p.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}
|
||||||
|
try:result.update(execute());result["success"]=True;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())
|
||||||
44
scripts/keycape_factor_delivery_probe.py
Normal file
44
scripts/keycape_factor_delivery_probe.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
"""Native proof job: only boolean scope and projection observations leave the pod."""
|
||||||
|
import json,time,sys,urllib.parse
|
||||||
|
from pathlib import Path
|
||||||
|
sys.path.insert(0,"/worker")
|
||||||
|
from renew import http,BAO,PI,TARGET,ISSUER,success
|
||||||
|
result={"success":False};session=None
|
||||||
|
try:
|
||||||
|
jwt=Path("/var/run/keycape-factor/token").read_text().strip()
|
||||||
|
code,body=http(BAO+"/auth/kubernetes/login",{"role":"keycape-factor-renewer","jwt":jwt})
|
||||||
|
result["wrong_service_account_denied"]=code in (400,403)
|
||||||
|
code,body=http(BAO+"/auth/kubernetes/login",{"role":"keycape-factor-workload-kv-read","jwt":jwt})
|
||||||
|
if code!=200:raise ValueError()
|
||||||
|
session=body["auth"]["client_token"];headers={"X-Vault-Token":session}
|
||||||
|
code,_=http(BAO+"/"+ISSUER,headers=headers);result["issuer_password_denied"]=code==403
|
||||||
|
code,_=http(BAO+"/platform/data/workloads/net-kingdom/unrelated",headers=headers);result["sibling_secret_denied"]=code==403
|
||||||
|
code,data=http(BAO+"/"+TARGET,headers=headers)
|
||||||
|
if code!=200:raise ValueError()
|
||||||
|
initial=Path("/factor/admin-token").read_text().strip()
|
||||||
|
result["mounted_token_matches_custody"]=initial==data["data"]["data"]["TOKEN"]
|
||||||
|
listing=success(*http(PI+"/token/?tokenrealm=coulomb&active=True&pagesize=1",headers={"Authorization":initial}))
|
||||||
|
user=listing["tokens"][0]["username"]
|
||||||
|
query=urllib.parse.urlencode({"user":user,"realm":"coulomb","active":"True"})
|
||||||
|
listing=success(*http(PI+"/token/?"+query,headers={"Authorization":initial}))
|
||||||
|
result["keycape_user_lookup_accepted"]=listing["count"]>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)
|
||||||
16
scripts/keycape_factor_eso_metadata.py
Normal file
16
scripts/keycape_factor_eso_metadata.py
Normal file
|
|
@ -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)
|
||||||
63
scripts/keycape_factor_metadata.py
Normal file
63
scripts/keycape_factor_metadata.py
Normal file
|
|
@ -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())
|
||||||
72
scripts/keycape_factor_provider.py
Normal file
72
scripts/keycape_factor_provider.py
Normal file
|
|
@ -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 300<expiry-time.time()<=7200:raise RuntimeError("unsupported service token lifetime")
|
||||||
|
code,listing=request("/token/?tokenrealm=coulomb&active=True&page=1&pagesize=1",token=token)
|
||||||
|
lv=listing.get("result",{}).get("value",{})
|
||||||
|
if code!=200 or not listing.get("result",{}).get("status") or not isinstance(lv.get("tokens"),list) or not isinstance(lv.get("count"),int):raise RuntimeError("factor lookup failed "+str(code)+" "+str(bool(listing.get("result",{}).get("status")))+" "+str(isinstance(lv.get("tokens"),list))+" "+str(isinstance(lv.get("count"),int)))
|
||||||
|
code,_=request("/policy/",token=token)
|
||||||
|
if code not in (401,403):raise RuntimeError("service has unexpected administration rights")
|
||||||
|
return {"token":token,"expires_at":expiry,"cross_user_factor_visible":lv["count"]>0,"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)
|
||||||
74
scripts/keycape_factor_renew.py
Normal file
74
scripts/keycape_factor_renew.py
Normal file
|
|
@ -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 300<expiry-time.time()<=7200:raise Failure()
|
||||||
|
receipt["phase"]="provider_scope"
|
||||||
|
provider_headers={"Authorization":token}
|
||||||
|
listing=success(*http(PI+"/token/?tokenrealm=coulomb&active=True&pagesize=1",headers=provider_headers))
|
||||||
|
if not isinstance(listing.get("tokens"),list) or not isinstance(listing.get("count"),int):raise Failure()
|
||||||
|
code,_=http(PI+"/policy/",headers=provider_headers)
|
||||||
|
if code not in (401,403):raise Failure()
|
||||||
|
receipt["phase"]="publish"
|
||||||
|
code,body=http(BAO+"/"+TARGET,headers=headers)
|
||||||
|
if code!=200 or body["data"]["data"].get("REQUEST")!=PROVENANCE:raise Failure()
|
||||||
|
version=body["data"]["metadata"]["version"]
|
||||||
|
code,_=http(BAO+"/"+TARGET,{"options":{"cas":version},"data":{"TOKEN":token,"EXPIRES_AT":expiry,"REQUEST":PROVENANCE}},headers=headers)
|
||||||
|
if code!=200:raise Failure()
|
||||||
|
code,body=http(BAO+"/"+TARGET,headers=headers)
|
||||||
|
if code!=200 or body["data"]["data"]["TOKEN"]!=token:raise Failure()
|
||||||
|
receipt.update(success=True,phase="complete",expires_at=expiry,kv_version=body["data"]["metadata"]["version"],cross_user_factor_visible=listing["count"]>0)
|
||||||
|
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)
|
||||||
34
scripts/keycape_factor_renewal_metadata.py
Normal file
34
scripts/keycape_factor_renewal_metadata.py
Normal file
|
|
@ -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())
|
||||||
15
tests/test_keycape_factor_activation.py
Normal file
15
tests/test_keycape_factor_activation.py
Normal file
|
|
@ -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"}})
|
||||||
31
tests/test_keycape_factor_metadata.py
Normal file
31
tests/test_keycape_factor_metadata.py
Normal file
|
|
@ -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"}}))
|
||||||
21
tests/test_keycape_factor_provider.py
Normal file
21
tests/test_keycape_factor_provider.py
Normal file
|
|
@ -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))
|
||||||
33
tests/test_keycape_factor_renewal.py
Normal file
33
tests/test_keycape_factor_renewal.py
Normal file
|
|
@ -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'])
|
||||||
64
workplans/RPF-WP-0040-keycape-factor-credential-custody.md
Normal file
64
workplans/RPF-WP-0040-keycape-factor-credential-custody.md
Normal file
|
|
@ -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.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue