fix(security): reconcile KeyCape bootstrap with scoped factor custody
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a1346b-72a4-7d40-b8bd-8b8ca346cfa2
This commit is contained in:
parent
19a1c75d30
commit
7d4bb1653e
17 changed files with 528 additions and 613 deletions
|
|
@ -101,7 +101,7 @@ Hostnames: kc.coulomb.social (KeyCape), auth.coulomb.social (Authelia), lldap.co
|
|||
|
||||
### In Progress (this session)
|
||||
- [ ] Run `bootstrap-realm.sh` on live cluster (requires T04 applied)
|
||||
- [ ] Run `keycape/create-pi-token.sh` then `keycape/create-secrets.sh` (inject real PI token)
|
||||
- [ ] Establish RPF-WP-0040 scoped factor delivery, then first-bootstrap `keycape/create-secrets.sh` only if config is absent (old admin-token helpers retired).
|
||||
- [ ] Restart KeyCape with updated keycape-config
|
||||
- [ ] Enroll a TOTP token for pi-admin via pink-account.coulomb.social
|
||||
- [ ] Test end-to-end login via kc.coulomb.social
|
||||
|
|
|
|||
|
|
@ -10,9 +10,8 @@
|
|||
# 2. lldap (needs: secrets/lldap/secrets.env)
|
||||
# 3. authelia (needs: lldap/secrets.env → LLDAP_LDAP_USER_PASS)
|
||||
# 4. privacyidea (needs: secrets/privacyidea/secrets.env)
|
||||
# 5. keycape SKIPPED — requires PI_ADMIN_TOKEN from post-T04 bootstrap.
|
||||
# Run sso-mfa/k8s/keycape/create-pi-token.sh, then
|
||||
# sso-mfa/k8s/keycape/create-secrets.sh manually.
|
||||
# 5. keycape SKIPPED — requires owner-established RPF-WP-0040 factor delivery.
|
||||
# create-secrets.sh is first-creation only, never live replacement.
|
||||
#
|
||||
# After each successful component, creds-state.yaml is updated.
|
||||
|
||||
|
|
@ -93,9 +92,9 @@ if [[ ! -f "$SECRETS_DIR/privacyidea/secrets.env" ]]; then
|
|||
fi
|
||||
run_component "privacyidea" "privacyidea"
|
||||
|
||||
# ── Step 5: KeyCape — SKIPPED (requires PI_ADMIN_TOKEN) ──────────────────────
|
||||
# ── Step 5: KeyCape — SKIPPED (requires scoped factor delivery) ──────────────────────
|
||||
echo "──────────────────────────────────────────────"
|
||||
echo "SKIPPED: keycape (requires PI_ADMIN_TOKEN from post-T04 bootstrap)"
|
||||
echo "SKIPPED: keycape (requires RPF-WP-0040 scoped factor delivery)"
|
||||
echo ""
|
||||
echo " After privacyIDEA is Running and bootstrapped:"
|
||||
echo " 1. TIME-SENSITIVE: run enckey-bootstrap.sh while pod is live:"
|
||||
|
|
@ -104,10 +103,9 @@ echo ""
|
|||
echo " 2. Create the pi-admin user:"
|
||||
echo " bash $K8S_DIR/privacyidea/bootstrap-admin.sh"
|
||||
echo ""
|
||||
echo " 3. Generate the PI admin token for KeyCape:"
|
||||
echo " bash $K8S_DIR/keycape/create-pi-token.sh"
|
||||
echo " 3. Establish scoped factor custody and ESO delivery through railiance-platform RPF-WP-0040."
|
||||
echo ""
|
||||
echo " 4. Apply KeyCape secrets:"
|
||||
echo " 4. Create KeyCape bootstrap secrets only if keycape-config is absent:"
|
||||
echo " bash $K8S_DIR/keycape/create-secrets.sh $SECRETS_DIR"
|
||||
echo ""
|
||||
echo " 5. Update state:"
|
||||
|
|
|
|||
|
|
@ -287,22 +287,18 @@ fi
|
|||
|
||||
# ── Phase 6: Apply keycape secrets ────────────────────────────────────────────
|
||||
|
||||
step "6 — Apply KeyCape secrets (requires pi-admin)"
|
||||
step "6 — Create KeyCape bootstrap secrets (requires scoped factor delivery)"
|
||||
|
||||
if [[ "$(state_get_nested keycape)" == "true" ]]; then
|
||||
ok "keycape secrets already applied — skipping"
|
||||
else
|
||||
if [[ "$DRY_RUN" == false ]]; then
|
||||
log "fetching PI admin token..."
|
||||
(cd "$K8S_DIR/keycape" && bash create-pi-token.sh "$SECRETS_DIR")
|
||||
ok "PI admin token fetched"
|
||||
|
||||
log "applying keycape secrets..."
|
||||
(cd "$K8S_DIR/keycape" && bash create-secrets.sh "$SECRETS_DIR")
|
||||
state_set_nested "keycape" "true"
|
||||
ok "keycape secrets applied"
|
||||
else
|
||||
echo " [dry-run] would run create-pi-token.sh + keycape/create-secrets.sh"
|
||||
echo " [dry-run] would verify RPF-WP-0040 delivery and create first-bootstrap keycape-config"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
|
|
|||
|
|
@ -262,68 +262,15 @@ AUTHELIA_SESSION_SECRET)
|
|||
;;
|
||||
|
||||
AUTHELIA_KEYCAPE_CLIENT_SECRET)
|
||||
header
|
||||
echo ""
|
||||
echo "Impact: Authelia↔KeyCape OIDC client secret."
|
||||
echo " Must be updated atomically in:"
|
||||
echo " 1. Authelia K8s Secret (bcrypt hash of the new plaintext)"
|
||||
echo " 2. KeyCape K8s Secret (plaintext, stored as authelia.clientSecret)"
|
||||
echo " Both pods must be restarted before the new secret takes effect."
|
||||
echo ""
|
||||
prepare_value authelia b64
|
||||
confirm "Apply rotation?" || exit 0
|
||||
store_value authelia
|
||||
echo " Updating Authelia secrets (bcrypt hash)..."
|
||||
(cd "$K8S_DIR/authelia" && quiet bash create-secrets.sh "$SECRETS_DIR")
|
||||
echo " Updating KeyCape secrets (plaintext)..."
|
||||
(cd "$K8S_DIR/keycape" && quiet bash create-secrets.sh "$SECRETS_DIR")
|
||||
echo " Restarting Authelia and KeyCape..."
|
||||
quiet kubectl rollout restart deployment authelia -n sso
|
||||
quiet kubectl rollout restart deployment keycape -n sso
|
||||
quiet kubectl rollout status deployment authelia -n sso
|
||||
quiet kubectl rollout status deployment keycape -n sso
|
||||
update_last_rotated "AUTHELIA_KEYCAPE_CLIENT_SECRET"
|
||||
post_rotation_reminder
|
||||
echo "ERROR: legacy KeyCape replacement rotation is retired; no credentials were changed." >&2
|
||||
echo "Use an owner-reviewed configuration-preserving rotation with rollback and consumer acceptance (NK-WP-0049-T02)." >&2
|
||||
exit 1
|
||||
;;
|
||||
|
||||
KEYCAPE_RSA_KEY)
|
||||
header
|
||||
echo ""
|
||||
echo " ⚠ WARNING: Rotating the KeyCape RSA signing key immediately invalidates"
|
||||
echo " ALL issued JWT tokens. This causes a brief authentication outage."
|
||||
echo " All downstream applications will reject existing tokens until they"
|
||||
echo " refresh against the new JWKS endpoint."
|
||||
echo ""
|
||||
echo " Coordination required:"
|
||||
echo " 1. Notify downstream application owners of the planned outage window."
|
||||
echo " 2. Delete secrets/keycape/key.pem to trigger regeneration."
|
||||
echo " 3. Re-run keycape/create-secrets.sh."
|
||||
echo " 4. Restart KeyCape pod."
|
||||
echo " 5. Confirm downstream apps recover (they should auto-refresh JWKS)."
|
||||
echo ""
|
||||
confirm "Proceed with RSA key rotation? (causes auth outage)" || exit 0
|
||||
KEY_FILE="$SECRETS_DIR/keycape/key.pem"
|
||||
if [[ -f "$KEY_FILE" ]]; then
|
||||
shred -u "$KEY_FILE"
|
||||
echo " Old key shredded."
|
||||
fi
|
||||
echo " Regenerating RSA-2048 signing key..."
|
||||
mkdir -p "$(dirname "$KEY_FILE")"
|
||||
openssl genrsa -out "$KEY_FILE" 2048 2>/dev/null
|
||||
chmod 600 "$KEY_FILE"
|
||||
echo " Updating KeyCape secrets..."
|
||||
(cd "$K8S_DIR/keycape" && quiet bash create-secrets.sh "$SECRETS_DIR")
|
||||
echo " Restarting KeyCape pod..."
|
||||
quiet kubectl rollout restart deployment keycape -n sso
|
||||
quiet kubectl rollout status deployment keycape -n sso
|
||||
echo ""
|
||||
if [[ "$NON_INTERACTIVE" == false ]]; then
|
||||
echo " ✔ RSA key rotated. Store the new key in your password manager: net-kingdom/KeyCape/jwt-signing-key"
|
||||
else
|
||||
echo " ✔ RSA key rotated."
|
||||
fi
|
||||
update_last_rotated "KEYCAPE_RSA_KEY"
|
||||
post_rotation_reminder
|
||||
echo "ERROR: legacy KeyCape replacement rotation is retired; no credentials were changed." >&2
|
||||
echo "Use an owner-reviewed configuration-preserving rotation with rollback and consumer acceptance (NK-WP-0049-T02)." >&2
|
||||
exit 1
|
||||
;;
|
||||
|
||||
BREAKGLASS_PASSWORD)
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ else
|
|||
val="$(nested_val "$component")"
|
||||
note=""
|
||||
[[ "$component" == "keycape" && "$val" == "false" ]] && \
|
||||
note=" (requires PI_ADMIN_TOKEN — post-T04)"
|
||||
note=" (requires RPF-WP-0040 scoped factor delivery)"
|
||||
printf " %-28s %s%s\n" "$component" "$(status_icon "$val")" "$note"
|
||||
done
|
||||
echo ""
|
||||
|
|
|
|||
|
|
@ -129,16 +129,16 @@ EOF
|
|||
# ── KeyCape ────────────────────────────────────────────────────────────────────
|
||||
# KeyCape has no generated secrets here:
|
||||
# key.pem — RSA signing key, generated by keycape/create-secrets.sh on first run
|
||||
# pi_admin_token — generated by keycape/create-pi-token.sh AFTER privacyIDEA is bootstrapped
|
||||
# Factor JWT — delivered through RPF-WP-0040 ESO, never stored in this directory.
|
||||
# We create the directory and a placeholder file so operators know to check it.
|
||||
|
||||
cat > "$OUT_DIR/keycape/secrets.env" <<EOF
|
||||
# KeyCape secrets — KeePassXC group: net-kingdom/KeyCape
|
||||
# Entry: jwt-signing-key → binary attachment key.pem (generated by keycape/create-secrets.sh)
|
||||
# Entry: pi-admin-token → password=PI_ADMIN_TOKEN (generated by keycape/create-pi-token.sh)
|
||||
# Factor credential: owner-managed ESO mount; no personal admin token.
|
||||
#
|
||||
# This file is a placeholder. No values to set here manually.
|
||||
# Both secrets are generated by the respective create-*.sh scripts.
|
||||
# First-bootstrap create-secrets.sh generates only a missing signing key.
|
||||
EOF
|
||||
|
||||
# ── Break-glass ────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -151,6 +151,8 @@ cd postgresql && ./create-secrets.sh && cd ..
|
|||
cd privacyidea && ./create-secrets.sh && cd ..
|
||||
cd lldap && ./create-secrets.sh && cd ..
|
||||
cd authelia && ./create-secrets.sh && cd ..
|
||||
# Restore owner-managed RPF-WP-0040 factor delivery before first KeyCape creation.
|
||||
# If keycape-config already exists, use the reviewed configuration-preserving restore.
|
||||
cd keycape && ./create-secrets.sh && cd ..
|
||||
|
||||
# 4. Apply workloads in restore order
|
||||
|
|
@ -169,8 +171,8 @@ kubectl rollout status deployment/keycape -n sso --timeout=60s
|
|||
# 6. Re-run bootstrap scripts if PVC data was lost
|
||||
cd privacyidea && ./enckey-bootstrap.sh && ./bootstrap-admin.sh && ./bootstrap-realm.sh
|
||||
cd ../lldap && ./bootstrap-users.sh && ./break-glass.sh
|
||||
cd ../keycape && ./create-pi-token.sh && ./create-secrets.sh
|
||||
kubectl rollout restart deployment/keycape -n sso
|
||||
# Verify owner-managed factor renewal, ESO delivery and mounted reload (RPF-WP-0040).
|
||||
# Do not mint a personal admin token or replace existing KeyCape configuration.
|
||||
|
||||
# 7. Verify
|
||||
./verify-t04.sh && ./verify-t05.sh && ./verify-t06.sh --user platform-root && ./verify-t07.sh && ./verify-t08.sh
|
||||
|
|
|
|||
|
|
@ -64,68 +64,52 @@ After building, update `deployment.yaml` line:
|
|||
image: keycape:v0.1 # replace with your actual tag
|
||||
```
|
||||
|
||||
## Apply order
|
||||
## First-bootstrap creation and factor custody
|
||||
|
||||
```bash
|
||||
# 1. Create Secrets (config.yaml + key.pem)
|
||||
# Run this AFTER T04 bootstrap if you want the privacyIDEA token included.
|
||||
# If T04 is not yet done, run it now and re-run after create-pi-token.sh.
|
||||
cd sso-mfa/k8s/keycape
|
||||
chmod +x create-secrets.sh create-pi-token.sh
|
||||
bash ./create-secrets.sh
|
||||
KeyCape uses the scoped `keycape-factor-reader` provider identity. Railiance
|
||||
Platform RPF-WP-0040 owns its renewable credential, the `keycape-factor-read`
|
||||
ExternalSecret, and the mounted `/etc/keycape-factor/admin-token` file. See the
|
||||
[owner custody contract](../../../../railiance-platform/docs/net-kingdom-credential-custody-contract.md).
|
||||
Neither a personal pi-admin JWT nor a local `pi_admin_token` file belongs in
|
||||
KeyCape configuration. `create-pi-token.sh` and `refresh-pi-token-live.sh` are
|
||||
retired refusal entry points; they perform no login or changes.
|
||||
|
||||
# 2. Apply manifests
|
||||
kubectl apply -f deployment.yaml
|
||||
kubectl apply -f middleware.yaml
|
||||
kubectl apply -f ingress.yaml
|
||||
Under an approved first-bootstrap procedure, establish owner factor delivery
|
||||
before running `bash sso-mfa/k8s/keycape/create-secrets.sh /absolute/private/secrets`.
|
||||
The helper requires Python 3 with PyYAML, OpenSSL and kubectl; owned private
|
||||
directories/files; literal generator-format LLDAP and Authelia credentials;
|
||||
Ready factor delivery; and an absent
|
||||
`keycape-config`. It preserves an existing valid private RSA key or atomically
|
||||
creates a missing one. It sends Secret data only over kubectl stdin, suppresses
|
||||
child output, and uses API creation without a last-applied annotation. Existing
|
||||
configuration is refused; concurrent creation cannot overwrite it. A failed API
|
||||
call can have an unknown remote outcome: reconcile metadata before retrying.
|
||||
Retain any newly generated private key for recovery, including after a failure.
|
||||
|
||||
# 3. Wait for pod to be ready
|
||||
kubectl rollout status deployment/keycape -n sso --timeout=60s
|
||||
```
|
||||
The helper does not restart workloads or establish native login acceptance.
|
||||
Deployment and fresh factor/login checks belong to the reviewed bootstrap or
|
||||
recovery ceremony. Do not use it to restore an existing installation: preserve
|
||||
its current clients, policy, signing key and independently held backup.
|
||||
|
||||
## Post-deploy: inject privacyIDEA admin token
|
||||
## Factor credential renewal and recovery
|
||||
|
||||
If T04 was not complete when you ran `create-secrets.sh`, the privacyIDEA admin
|
||||
token is a placeholder. After T04 bootstrap is done:
|
||||
|
||||
```bash
|
||||
# 1. Fetch the token from privacyIDEA and store it
|
||||
chmod +x create-pi-token.sh
|
||||
./create-pi-token.sh
|
||||
|
||||
# 2. Re-run create-secrets.sh to update keycape-config with the real token
|
||||
bash ./create-secrets.sh
|
||||
|
||||
# 3. Restart KeyCape to pick up the new Secret
|
||||
kubectl rollout restart deployment/keycape -n sso
|
||||
```
|
||||
|
||||
If the browser flow reaches the KeyCape OTP screen and then reports
|
||||
`mfa check error`, refresh the live privacyIDEA token without printing it:
|
||||
|
||||
```bash
|
||||
cd sso-mfa/k8s/keycape
|
||||
KEYCAPE_PI_REALM=coulomb KUBECTL="${KUBECTL:-kubectl}" \
|
||||
bash ./refresh-pi-token-live.sh platform-root
|
||||
```
|
||||
|
||||
The helper prompts for the `pi-admin` password, writes the token only into
|
||||
Kubernetes Secrets, and restarts KeyCape. The current live privacyIDEA realm is
|
||||
`coulomb`; use `KEYCAPE_PI_REALM=netkingdom` only for an explicit future realm
|
||||
migration. The helper also restores `privacyidea.requireForAll: true`, which
|
||||
keeps KeyCape from using the admin token-list API as the MFA-required check.
|
||||
For expired factor credentials or `mfa check error`, use the owner renewal and
|
||||
recovery procedure in the custody contract: inspect the renewer and ESO status,
|
||||
repair the underlying provider/custody failure, then verify mounted delivery and
|
||||
native factor acceptance. A fresh credential is not predecessor revocation.
|
||||
Never restore the retired personal-admin-token path, change realm/MFA policy as
|
||||
part of a token refresh, or replace the full KeyCape configuration.
|
||||
|
||||
## OIDC client registration
|
||||
|
||||
Downstream applications are registered in the `clients:` block in
|
||||
`keycape/create-secrets.sh`. The NetKingdom bootstrap console and Railiance
|
||||
OpenBao admin clients are code-defined there; operators should not create
|
||||
those clients manually in a separate UI. After changing the block:
|
||||
|
||||
```bash
|
||||
bash ./create-secrets.sh # regenerates keycape-config Secret
|
||||
kubectl rollout restart deployment/keycape -n sso
|
||||
```
|
||||
`bootstrap-config.json` is the initial configuration only. The helper fills its
|
||||
empty LLDAP/Authelia credential fields in memory and serializes ordinary YAML
|
||||
for KeyCape and the scoped configuration update tools. Its registrations are not
|
||||
a complete inventory of current clients.
|
||||
Live client changes require a scoped owner procedure that preserves the rest of
|
||||
the configuration and verifies the affected consumer. The legacy client-secret
|
||||
and RSA replacement rotations refuse before mutation; coordinated rotation,
|
||||
rollback custody and native multi-consumer acceptance remain NK-WP-0049-T02 work.
|
||||
|
||||
The `openbao-admin` client is intentionally a public PKCE client for the
|
||||
current operator flow. It registers the OpenBao CLI callback URIs and the
|
||||
|
|
@ -245,9 +229,9 @@ chosen; see NK-WP-0046.
|
|||
|
||||
| Secret name | Keys | Purpose |
|
||||
|-------------|------|---------|
|
||||
| `keycape-config` | `config.yaml` | Full KeyCape configuration (LLDAP URL + creds, Authelia URL + client secret, privacyIDEA URL + admin token, OIDC clients) |
|
||||
| `keycape-config` | `config.yaml` | KeyCape configuration (LLDAP credentials, Authelia client secret, privacyIDEA URL + scoped credential-file reference, OIDC clients) |
|
||||
| | `key.pem` | RSA-2048 private key for signing OIDC tokens issued to downstream applications |
|
||||
| `keycape-pi-token` | `pi_admin_token` | privacyIDEA admin JWT — created by `create-pi-token.sh`, referenced in `config.yaml` |
|
||||
| `keycape-factor-read` | `admin-token` | Scoped provider JWT delivered by owner-managed ESO; mounted and reloaded by KeyCape |
|
||||
|
||||
Store `key.pem` in KeePassXC as a binary attachment. If it is lost, all active
|
||||
sessions become invalid (tokens cannot be verified) and all applications must
|
||||
|
|
|
|||
123
sso-mfa/k8s/keycape/bootstrap-config.json
Normal file
123
sso-mfa/k8s/keycape/bootstrap-config.json
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
{
|
||||
"issuer": "https://kc.coulomb.social",
|
||||
"port": 8080,
|
||||
"tokenLifetime": "15m",
|
||||
"privateKeyPem": "/etc/keycape/key.pem",
|
||||
"environment": "production",
|
||||
"lldap": {
|
||||
"url": "ldap://lldap.sso.svc.cluster.local:3890",
|
||||
"bindDN": "uid=admin,ou=people,dc=netkingdom,dc=local",
|
||||
"bindPW": "",
|
||||
"baseDN": "dc=netkingdom,dc=local",
|
||||
"userOU": "ou=people",
|
||||
"groupOU": "ou=groups"
|
||||
},
|
||||
"authelia": {
|
||||
"baseURL": "http://authelia.sso.svc.cluster.local:9091",
|
||||
"browserBaseURL": "https://auth.coulomb.social",
|
||||
"tokenBaseURL": "http://authelia.sso.svc.cluster.local:9091",
|
||||
"clientId": "keycape",
|
||||
"clientSecret": "",
|
||||
"redirectURI": "https://kc.coulomb.social/authorize/callback"
|
||||
},
|
||||
"privacyidea": {
|
||||
"baseURL": "http://privacyidea.mfa.svc.cluster.local:8080",
|
||||
"realm": "coulomb",
|
||||
"requireForAll": true,
|
||||
"adminTokenFile": "/etc/keycape-factor/admin-token"
|
||||
},
|
||||
"clients": [
|
||||
{
|
||||
"clientId": "demo-app",
|
||||
"displayName": "Demo Application",
|
||||
"redirectUris": [
|
||||
"http://localhost:3000/callback",
|
||||
"http://127.0.0.1:8876/oidc/callback",
|
||||
"http://localhost:8876/oidc/callback",
|
||||
"https://demo.coulomb.social/callback"
|
||||
],
|
||||
"allowedScopes": [
|
||||
"openid",
|
||||
"profile",
|
||||
"email",
|
||||
"groups"
|
||||
],
|
||||
"grantTypes": [
|
||||
"authorization_code"
|
||||
],
|
||||
"clientType": "public"
|
||||
},
|
||||
{
|
||||
"clientId": "user-engine-portal",
|
||||
"displayName": "User Engine Portal",
|
||||
"redirectUris": [
|
||||
"https://users.92-205-62-239.nip.io/oidc/callback",
|
||||
"https://users.coulomb.social/oidc/callback"
|
||||
],
|
||||
"allowedScopes": [
|
||||
"openid",
|
||||
"profile",
|
||||
"email",
|
||||
"groups"
|
||||
],
|
||||
"grantTypes": [
|
||||
"authorization_code"
|
||||
],
|
||||
"clientType": "public"
|
||||
},
|
||||
{
|
||||
"clientId": "netkingdom-bootstrap-console",
|
||||
"displayName": "NetKingdom Bootstrap Console",
|
||||
"redirectUris": [
|
||||
"http://127.0.0.1:8876/oidc/callback",
|
||||
"http://localhost:8876/oidc/callback"
|
||||
],
|
||||
"allowedScopes": [
|
||||
"openid",
|
||||
"profile",
|
||||
"email",
|
||||
"groups"
|
||||
],
|
||||
"grantTypes": [
|
||||
"authorization_code"
|
||||
],
|
||||
"clientType": "public"
|
||||
},
|
||||
{
|
||||
"clientId": "openbao-admin",
|
||||
"displayName": "Railiance OpenBao Admin",
|
||||
"redirectUris": [
|
||||
"http://localhost:8250/oidc/callback",
|
||||
"http://127.0.0.1:8250/oidc/callback",
|
||||
"http://127.0.0.1:18200/ui/vault/auth/netkingdom/oidc/callback"
|
||||
],
|
||||
"allowedScopes": [
|
||||
"openid",
|
||||
"profile",
|
||||
"email",
|
||||
"groups"
|
||||
],
|
||||
"grantTypes": [
|
||||
"authorization_code"
|
||||
],
|
||||
"clientType": "public"
|
||||
},
|
||||
{
|
||||
"clientId": "rapp-qonto-client",
|
||||
"displayName": "rapp-qonto workload",
|
||||
"allowedScopes": [
|
||||
"qonto:read"
|
||||
],
|
||||
"grantTypes": [
|
||||
"client_credentials"
|
||||
],
|
||||
"clientType": "confidential",
|
||||
"secretRef": "env:KEYCAPE_RAPP_QONTO_CLIENT_SECRET",
|
||||
"serviceSubject": "rapp-qonto",
|
||||
"tenant": "tenant:friendly:binky",
|
||||
"roles": [
|
||||
"qonto-reader"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,118 +1,6 @@
|
|||
#!/usr/bin/env bash
|
||||
# create-pi-token.sh — fetch a privacyIDEA admin JWT and store it for KeyCape
|
||||
#
|
||||
# Usage:
|
||||
# ./create-pi-token.sh [secrets-dir]
|
||||
#
|
||||
# Run this script AFTER T04 bootstrap (privacyIDEA admin account created).
|
||||
# It authenticates to the privacyIDEA API, fetches a long-lived admin JWT,
|
||||
# and writes it to secrets/keycape/pi_admin_token.
|
||||
#
|
||||
# After running this script, re-run create-secrets.sh to update the
|
||||
# keycape-config K8s Secret with the real token, then restart KeyCape:
|
||||
# ./create-secrets.sh
|
||||
# kubectl rollout restart deployment/keycape -n sso
|
||||
#
|
||||
# The privacyIDEA admin token may expire depending on the live privacyIDEA
|
||||
# policy/configuration. Refresh it if KeyCape reports "mfa check error".
|
||||
# Store the current token in KeePassXC as:
|
||||
# net-kingdom/KeyCape/pi-admin-token
|
||||
#
|
||||
# Requires: kubectl, curl, jq
|
||||
|
||||
# Retired: KeyCape uses the scoped, renewable factor-reader lane.
|
||||
set +x
|
||||
set -euo pipefail
|
||||
|
||||
SECRETS_DIR="${1:-../../bootstrap/secrets}"
|
||||
PI_ENV="$SECRETS_DIR/privacyidea/secrets.env"
|
||||
TOKEN_FILE="$SECRETS_DIR/keycape/pi_admin_token"
|
||||
|
||||
if [[ ! -f "$PI_ENV" ]]; then
|
||||
echo "ERROR: $PI_ENV not found — run sso-mfa/bootstrap/gen-secrets.sh first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read_env() { bash -c "source '$1' 2>/dev/null; echo \${$2}"; }
|
||||
PI_ADMIN_PASSWORD=$(read_env "$PI_ENV" PI_ADMIN_PASSWORD)
|
||||
|
||||
if [[ -z "$PI_ADMIN_PASSWORD" ]]; then
|
||||
echo "ERROR: PI_ADMIN_PASSWORD is empty in $PI_ENV" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Fetch token by exec-ing into the privacyIDEA pod (localhost call, bypasses
|
||||
# NetworkPolicy which restricts external ingress to the service).
|
||||
PI_POD=$(kubectl get pod -n mfa \
|
||||
-l app.kubernetes.io/name=privacyidea \
|
||||
--field-selector=status.phase=Running \
|
||||
-o jsonpath='{.items[0].metadata.name}' 2>/dev/null || echo "")
|
||||
|
||||
if [[ -z "$PI_POD" ]]; then
|
||||
# Fall back to public hostname if pod is not directly accessible
|
||||
PI_POD=""
|
||||
fi
|
||||
|
||||
echo "Fetching privacyIDEA admin token..."
|
||||
|
||||
if [[ -n "$PI_POD" ]]; then
|
||||
echo " Method: kubectl exec into $PI_POD (avoids NetworkPolicy restriction)"
|
||||
# Pass the password via env var. Retry up to 3 times — the PostgreSQL connection
|
||||
# pool can return 500 if the idle SSL connection was dropped; it recovers on retry.
|
||||
TOKEN=""
|
||||
for _ATTEMPT in 1 2 3; do
|
||||
TOKEN=$(kubectl exec -n mfa "$PI_POD" -- \
|
||||
env PI_ADMIN_PASSWORD="${PI_ADMIN_PASSWORD}" \
|
||||
python3 -c '
|
||||
import urllib.request, urllib.parse, json, os, sys
|
||||
pw = os.environ["PI_ADMIN_PASSWORD"]
|
||||
data = urllib.parse.urlencode({"username": "pi-admin", "password": pw}).encode()
|
||||
req = urllib.request.Request("http://localhost:8080/auth", data=data)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
body = json.load(r)
|
||||
print(body["result"]["value"]["token"])
|
||||
except Exception as e:
|
||||
print(str(e), file=__import__("sys").stderr)
|
||||
sys.exit(1)
|
||||
' 2>/dev/null || echo "")
|
||||
if [[ -n "$TOKEN" ]]; then break; fi
|
||||
echo " Attempt $_ATTEMPT failed (likely transient DB connection drop) — retrying in 5s..."
|
||||
sleep 5
|
||||
done
|
||||
else
|
||||
echo " Method: public URL https://pink.coulomb.social"
|
||||
TOKEN=$(curl -sf \
|
||||
-X POST "https://pink.coulomb.social/auth" \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "username=pi-admin&password=${PI_ADMIN_PASSWORD}" \
|
||||
| python3 -c "import sys,json; data=json.load(sys.stdin); print(data['result']['value']['token'])" \
|
||||
2>/dev/null || echo "")
|
||||
fi
|
||||
|
||||
if [[ -z "$TOKEN" ]]; then
|
||||
echo "ERROR: failed to fetch token from privacyIDEA." >&2
|
||||
echo " Verify that privacyIDEA is Running and the pi-admin account exists." >&2
|
||||
echo " Check: kubectl logs -n mfa \$(kubectl get pod -n mfa -l app.kubernetes.io/name=privacyidea -o name | head -1)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$TOKEN_FILE")"
|
||||
echo -n "$TOKEN" > "$TOKEN_FILE"
|
||||
chmod 600 "$TOKEN_FILE"
|
||||
|
||||
# Create the keycape-pi-token K8s Secret (KeyCape reads it at startup)
|
||||
echo "Creating K8s Secret: keycape-pi-token (namespace: sso)"
|
||||
kubectl create secret generic keycape-pi-token \
|
||||
--namespace=sso \
|
||||
--from-literal=token="$TOKEN" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
echo " Done."
|
||||
|
||||
echo ""
|
||||
echo "Token written to: $TOKEN_FILE"
|
||||
echo ""
|
||||
echo "IMPORTANT: Store this token in KeePassXC → net-kingdom/KeyCape/pi-admin-token"
|
||||
echo " as a password entry. It cannot be recovered without re-authenticating."
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Re-run create-secrets.sh to update keycape-config with the real token."
|
||||
echo " 2. Restart KeyCape: kubectl rollout restart deployment/keycape -n sso"
|
||||
printf '%s\n' 'ERROR: personal privacyIDEA admin-token delivery is retired.' 'Use railiance-platform RPF-WP-0040 scoped factor custody, ESO delivery and renewal recovery.' 'This helper performs no login, credential read, Secret change or restart.' >&2
|
||||
exit 1
|
||||
|
|
|
|||
137
sso-mfa/k8s/keycape/create-secrets.py
Normal file
137
sso-mfa/k8s/keycape/create-secrets.py
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Create first-bootstrap config with private inputs and scoped factor delivery.
|
||||
|
||||
Never replace an existing Secret, mint a provider token or rotate a signing key.
|
||||
Child output is captured, including Kubernetes error bodies containing Secret data.
|
||||
"""
|
||||
import base64
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
class Refused(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def private(info, directory=False):
|
||||
kind = stat.S_ISDIR if directory else stat.S_ISREG
|
||||
if not kind(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077:
|
||||
raise Refused()
|
||||
if not directory and info.st_nlink != 1:
|
||||
raise Refused()
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def custody(root):
|
||||
root = Path(os.path.abspath(root))
|
||||
if any(p.is_symlink() for p in (root, *root.parents)):
|
||||
raise Refused()
|
||||
descriptors = []
|
||||
try:
|
||||
flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
|
||||
fd = os.open(root, flags); descriptors.append(fd)
|
||||
private(os.fstat(fd), True)
|
||||
children = {}
|
||||
for name in ('lldap', 'authelia', 'keycape'):
|
||||
child = os.open(name, flags, dir_fd=fd); descriptors.append(child)
|
||||
private(os.fstat(child), True)
|
||||
children[name] = child
|
||||
yield children
|
||||
finally:
|
||||
for fd in reversed(descriptors): os.close(fd)
|
||||
|
||||
|
||||
def read_file(parent, name):
|
||||
fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent)
|
||||
try:
|
||||
private(os.fstat(fd))
|
||||
raw = os.read(fd, 65537)
|
||||
if len(raw) > 65536: raise Refused()
|
||||
return raw
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def env_value(parent, name):
|
||||
values = [line.split('=', 1)[1] for line in read_file(parent, 'secrets.env').decode().splitlines()
|
||||
if line.startswith(name + '=')]
|
||||
# Consume the generator's literal format, never execute a shell env file.
|
||||
if len(values) != 1 or not re.fullmatch(r'[A-Za-z0-9]{32,128}', values[0]):
|
||||
raise Refused()
|
||||
return values[0]
|
||||
|
||||
|
||||
def run(args, payload=None):
|
||||
result = subprocess.run(args, input=payload, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL, timeout=60, check=False)
|
||||
if result.returncode or len(result.stdout) > 65536: raise Refused()
|
||||
return result.stdout
|
||||
|
||||
|
||||
def signing_key(parent):
|
||||
try:
|
||||
key = read_file(parent, 'key.pem')
|
||||
except FileNotFoundError:
|
||||
key = run(['openssl', 'genrsa', '2048'])
|
||||
run(['openssl', 'rsa', '-check', '-noout'], key)
|
||||
# Publish atomically without replacement, so concurrent creation cannot
|
||||
# overwrite a pre-existing key. Keep this private key after API failure.
|
||||
temp = '.key-' + secrets.token_hex(12)
|
||||
fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=parent)
|
||||
try:
|
||||
with os.fdopen(fd, 'wb') as file:
|
||||
file.write(key); file.flush(); os.fsync(file.fileno())
|
||||
os.link(temp, 'key.pem', src_dir_fd=parent, dst_dir_fd=parent, follow_symlinks=False)
|
||||
os.fsync(parent)
|
||||
finally:
|
||||
os.unlink(temp, dir_fd=parent)
|
||||
run(['openssl', 'rsa', '-check', '-noout'], key)
|
||||
return key
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) > 2: raise Refused()
|
||||
here = Path(__file__).resolve().parent
|
||||
root = sys.argv[1] if len(sys.argv) == 2 else here / '../../bootstrap/secrets'
|
||||
with custody(root) as dirs:
|
||||
bind_password = env_value(dirs['lldap'], 'LLDAP_LDAP_USER_PASS')
|
||||
client_secret = env_value(dirs['authelia'], 'AUTHELIA_KEYCAPE_CLIENT_SECRET')
|
||||
config = json.loads((here / 'bootstrap-config.json').read_text())
|
||||
config['lldap']['bindPW'] = bind_password
|
||||
config['authelia']['clientSecret'] = client_secret
|
||||
# Existence is not an update grant. API create also closes the race
|
||||
# between this preflight and publication; AlreadyExists stays a failure.
|
||||
existing = run(['kubectl', 'get', 'secret', 'keycape-config', '-n', 'sso', '--ignore-not-found', '-o', 'name'])
|
||||
if existing.strip(): raise Refused()
|
||||
ready = run(['kubectl', 'get', 'externalsecret', 'keycape-factor-read', '-n', 'sso',
|
||||
'-o', 'jsonpath={.status.conditions[?(@.type=="Ready")].status}'])
|
||||
factor = run(['kubectl', 'get', 'secret', 'keycape-factor-read', '-n', 'sso', '-o', 'name'])
|
||||
if ready.strip() != b'True' or factor.strip() != b'secret/keycape-factor-read': raise Refused()
|
||||
key = signing_key(dirs['keycape'])
|
||||
data = {'config.yaml': yaml.safe_dump(config, sort_keys=False).encode(), 'key.pem': key}
|
||||
manifest = {'apiVersion': 'v1', 'kind': 'Secret', 'type': 'Opaque',
|
||||
'metadata': {'name': 'keycape-config', 'namespace': 'sso'},
|
||||
'data': {name: base64.b64encode(value).decode() for name, value in data.items()}}
|
||||
response = run(['kubectl', 'create', '-o', 'name', '-f', '-'], json.dumps(manifest).encode())
|
||||
if response.strip() != b'secret/keycape-config': raise Refused()
|
||||
print('KeyCape bootstrap Secret created with mounted factor credential configuration.')
|
||||
print('Preserve the private signing key in approved custody. Native login and factor acceptance remain required.')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except (Refused, OSError, ValueError, KeyError, TypeError, subprocess.SubprocessError):
|
||||
print('ERROR: KeyCape bootstrap refused or failed. Existing configuration is never replaced; '
|
||||
'check private inputs and RPF-WP-0040 delivery using metadata-only diagnostics. '
|
||||
'If creation was attempted, its remote outcome must be reconciled before retry.', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
|
@ -1,175 +1,7 @@
|
|||
#!/usr/bin/env bash
|
||||
# create-secrets.sh — create the keycape-config K8s Secret
|
||||
#
|
||||
# Usage:
|
||||
# ./create-secrets.sh [secrets-dir]
|
||||
#
|
||||
# Creates ONE Secret in the sso namespace:
|
||||
# keycape-config — config.yaml (full KeyCape config) + key.pem (RSA signing key)
|
||||
#
|
||||
# The privacyIDEA admin token is a separate Secret (keycape-pi-token) created
|
||||
# by create-pi-token.sh AFTER privacyIDEA is bootstrapped (T04 complete).
|
||||
# The PI admin token is read from that Secret at startup via config.yaml.
|
||||
#
|
||||
# Re-run this script to:
|
||||
# - Rotate the Authelia client secret (update secrets/authelia/secrets.env first)
|
||||
# - Add or modify OIDC client registrations (edit CLIENTS block below)
|
||||
# - Rotate the RSA signing key (delete and regenerate secrets/keycape/key.pem)
|
||||
|
||||
# First creation only. Existing KeyCape configuration requires a scoped owner update.
|
||||
set +x
|
||||
set -euo pipefail
|
||||
|
||||
SECRETS_DIR="${1:-../../bootstrap/secrets}"
|
||||
KEYCAPE_ENV="$SECRETS_DIR/keycape/secrets.env"
|
||||
LLDAP_ENV="$SECRETS_DIR/lldap/secrets.env"
|
||||
AUTHELIA_ENV="$SECRETS_DIR/authelia/secrets.env"
|
||||
KEY_FILE="$SECRETS_DIR/keycape/key.pem"
|
||||
|
||||
for f in "$KEYCAPE_ENV" "$LLDAP_ENV" "$AUTHELIA_ENV"; do
|
||||
if [[ ! -f "$f" ]]; then
|
||||
echo "ERROR: $f not found — run sso-mfa/bootstrap/gen-secrets.sh first." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
read_env() { bash -c "source '$1' 2>/dev/null; echo \${$2}"; }
|
||||
|
||||
LLDAP_BIND_PW=$(read_env "$LLDAP_ENV" LLDAP_LDAP_USER_PASS)
|
||||
AUTHELIA_CLIENT_SECRET=$(read_env "$AUTHELIA_ENV" AUTHELIA_KEYCAPE_CLIENT_SECRET)
|
||||
|
||||
if [[ -z "$LLDAP_BIND_PW" || -z "$AUTHELIA_CLIENT_SECRET" ]]; then
|
||||
echo "ERROR: could not read LLDAP_LDAP_USER_PASS or AUTHELIA_KEYCAPE_CLIENT_SECRET" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The privacyIDEA admin token is read from a separate Secret at runtime.
|
||||
# Placeholder here — create-pi-token.sh populates the real value.
|
||||
PI_ADMIN_TOKEN="PENDING_create-pi-token.sh"
|
||||
if [[ -f "$SECRETS_DIR/keycape/pi_admin_token" ]]; then
|
||||
PI_ADMIN_TOKEN=$(cat "$SECRETS_DIR/keycape/pi_admin_token")
|
||||
echo "INFO: Using privacyIDEA admin token from $SECRETS_DIR/keycape/pi_admin_token"
|
||||
fi
|
||||
|
||||
# ── RSA signing key ───────────────────────────────────────────────────────────
|
||||
if [[ ! -f "$KEY_FILE" ]]; then
|
||||
echo "Generating RSA-2048 signing key for KeyCape JWT tokens..."
|
||||
mkdir -p "$(dirname "$KEY_FILE")"
|
||||
openssl genrsa -out "$KEY_FILE" 2048 2>/dev/null
|
||||
chmod 600 "$KEY_FILE"
|
||||
echo " Generated: $KEY_FILE"
|
||||
echo " IMPORTANT: Store this key in KeePassXC → net-kingdom/KeyCape/jwt-signing-key"
|
||||
echo " as a binary attachment. It cannot be recovered if lost."
|
||||
else
|
||||
echo "INFO: Using existing key: $KEY_FILE"
|
||||
fi
|
||||
KEY_CONTENT=$(cat "$KEY_FILE")
|
||||
|
||||
# ── Build config.yaml ─────────────────────────────────────────────────────────
|
||||
# Edit the OIDC clients block below to register downstream applications.
|
||||
# Re-run this script after any change.
|
||||
CONFIG_YAML=$(cat <<EOF
|
||||
issuer: "https://kc.coulomb.social"
|
||||
port: 8080
|
||||
tokenLifetime: "15m"
|
||||
privateKeyPem: "/etc/keycape/key.pem"
|
||||
environment: "production"
|
||||
|
||||
lldap:
|
||||
url: "ldap://lldap.sso.svc.cluster.local:3890"
|
||||
bindDN: "uid=admin,ou=people,dc=netkingdom,dc=local"
|
||||
bindPW: "${LLDAP_BIND_PW}"
|
||||
baseDN: "dc=netkingdom,dc=local"
|
||||
userOU: "ou=people"
|
||||
groupOU: "ou=groups"
|
||||
|
||||
authelia:
|
||||
# Cluster-internal URL for server-side token exchange.
|
||||
baseURL: "http://authelia.sso.svc.cluster.local:9091"
|
||||
# Browser-facing URL. KeyCape redirects the user's browser here for the
|
||||
# upstream Authelia password step, so this must not be the cluster-internal
|
||||
# service URL.
|
||||
browserBaseURL: "https://auth.coulomb.social"
|
||||
tokenBaseURL: "http://authelia.sso.svc.cluster.local:9091"
|
||||
clientId: "keycape"
|
||||
clientSecret: "${AUTHELIA_CLIENT_SECRET}"
|
||||
redirectURI: "https://kc.coulomb.social/authorize/callback"
|
||||
|
||||
privacyidea:
|
||||
baseURL: "http://privacyidea.mfa.svc.cluster.local:8080"
|
||||
adminToken: "${PI_ADMIN_TOKEN}"
|
||||
realm: "coulomb"
|
||||
requireForAll: true
|
||||
|
||||
# ── OIDC client registrations ─────────────────────────────────────────────────
|
||||
# clientType: "public" for SPAs/native apps (PKCE, no client secret)
|
||||
# "confidential" for server-side apps (client secret required)
|
||||
clients:
|
||||
- clientId: "demo-app"
|
||||
displayName: "Demo Application"
|
||||
redirectUris:
|
||||
- "http://localhost:3000/callback"
|
||||
- "http://127.0.0.1:8876/oidc/callback"
|
||||
- "http://localhost:8876/oidc/callback"
|
||||
- "https://demo.coulomb.social/callback"
|
||||
allowedScopes: ["openid", "profile", "email", "groups"]
|
||||
grantTypes: ["authorization_code"]
|
||||
clientType: "public"
|
||||
- clientId: user-engine-portal
|
||||
displayName: User Engine Portal
|
||||
redirectUris:
|
||||
- https://users.92-205-62-239.nip.io/oidc/callback
|
||||
- https://users.coulomb.social/oidc/callback
|
||||
allowedScopes:
|
||||
- openid
|
||||
- profile
|
||||
- email
|
||||
- groups
|
||||
grantTypes:
|
||||
- authorization_code
|
||||
clientType: public
|
||||
- clientId: "netkingdom-bootstrap-console"
|
||||
displayName: "NetKingdom Bootstrap Console"
|
||||
redirectUris:
|
||||
- "http://127.0.0.1:8876/oidc/callback"
|
||||
- "http://localhost:8876/oidc/callback"
|
||||
allowedScopes: ["openid", "profile", "email", "groups"]
|
||||
grantTypes: ["authorization_code"]
|
||||
clientType: "public"
|
||||
- clientId: "openbao-admin"
|
||||
displayName: "Railiance OpenBao Admin"
|
||||
redirectUris:
|
||||
- "http://localhost:8250/oidc/callback"
|
||||
- "http://127.0.0.1:8250/oidc/callback"
|
||||
- "http://127.0.0.1:18200/ui/vault/auth/netkingdom/oidc/callback"
|
||||
allowedScopes: ["openid", "profile", "email", "groups"]
|
||||
grantTypes: ["authorization_code"]
|
||||
clientType: "public"
|
||||
- clientId: "rapp-qonto-client"
|
||||
displayName: "rapp-qonto workload"
|
||||
allowedScopes: ["qonto:read"]
|
||||
grantTypes: ["client_credentials"]
|
||||
clientType: "confidential"
|
||||
secretRef: "env:KEYCAPE_RAPP_QONTO_CLIENT_SECRET"
|
||||
serviceSubject: "rapp-qonto"
|
||||
tenant: "tenant:friendly:binky"
|
||||
roles: ["qonto-reader"]
|
||||
EOF
|
||||
)
|
||||
|
||||
echo "Creating K8s Secret: keycape-config (namespace: sso)"
|
||||
kubectl create secret generic keycape-config \
|
||||
--namespace=sso \
|
||||
--from-literal=config.yaml="$CONFIG_YAML" \
|
||||
--from-literal=key.pem="$KEY_CONTENT" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
echo ""
|
||||
echo "Done. Secret keycape-config created in namespace: sso"
|
||||
echo ""
|
||||
if [[ "$PI_ADMIN_TOKEN" == "PENDING_create-pi-token.sh" ]]; then
|
||||
echo "WARN: privacyIDEA admin token is a placeholder."
|
||||
echo " After T04 bootstrap is complete, run:"
|
||||
echo " ./create-pi-token.sh"
|
||||
echo " Then re-run this script to update keycape-config."
|
||||
echo ""
|
||||
fi
|
||||
echo "Next: apply deployment.yaml, middleware.yaml, ingress.yaml"
|
||||
umask 077
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
exec python3 "$SCRIPT_DIR/create-secrets.py" "$@"
|
||||
|
|
|
|||
|
|
@ -1,180 +1,6 @@
|
|||
#!/usr/bin/env bash
|
||||
# refresh-pi-token-live.sh — refresh KeyCape's privacyIDEA admin token.
|
||||
#
|
||||
# This is an attended repair for "mfa check error" when the privacyIDEA admin
|
||||
# JWT embedded in keycape-config has expired. It prompts for the pi-admin
|
||||
# password, fetches a fresh token inside the privacyIDEA pod, updates the
|
||||
# keycape-pi-token Secret, patches keycape-config without printing secrets, and
|
||||
# restarts KeyCape.
|
||||
#
|
||||
# Usage:
|
||||
# bash refresh-pi-token-live.sh [username]
|
||||
#
|
||||
# Optional environment:
|
||||
# KUBECTL=/path/to/kubectl
|
||||
# KEYCAPE_PI_REALM=coulomb|netkingdom # defaults to coulomb, the live privacyIDEA realm
|
||||
# KEYCAPE_PI_REQUIRE_FOR_ALL=true|false # defaults to true to avoid admin token-list checks
|
||||
|
||||
# Retired: KeyCape uses the scoped, renewable factor-reader lane.
|
||||
set +x
|
||||
set -euo pipefail
|
||||
|
||||
USERNAME="${1:-platform-root}"
|
||||
KUBECTL="${KUBECTL:-kubectl}"
|
||||
SSO_NAMESPACE="${SSO_NAMESPACE:-sso}"
|
||||
MFA_NAMESPACE="${MFA_NAMESPACE:-mfa}"
|
||||
KEYCAPE_DEPLOYMENT="${KEYCAPE_DEPLOYMENT:-keycape}"
|
||||
KEYCAPE_SECRET="${KEYCAPE_SECRET:-keycape-config}"
|
||||
KEYCAPE_TOKEN_SECRET="${KEYCAPE_TOKEN_SECRET:-keycape-pi-token}"
|
||||
|
||||
if [[ -r /dev/tty ]]; then
|
||||
printf "privacyIDEA pi-admin password: " > /dev/tty
|
||||
IFS= read -r -s PI_ADMIN_PASSWORD < /dev/tty
|
||||
printf "\n" > /dev/tty
|
||||
else
|
||||
printf "privacyIDEA pi-admin password: " >&2
|
||||
IFS= read -r -s PI_ADMIN_PASSWORD
|
||||
printf "\n" >&2
|
||||
fi
|
||||
if [[ -z "$PI_ADMIN_PASSWORD" ]]; then
|
||||
echo "[FAIL] Empty pi-admin password." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PI_POD="$(
|
||||
"$KUBECTL" get pod -n "$MFA_NAMESPACE" \
|
||||
-l app.kubernetes.io/name=privacyidea \
|
||||
--field-selector=status.phase=Running \
|
||||
-o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true
|
||||
)"
|
||||
if [[ -z "$PI_POD" ]]; then
|
||||
echo "[FAIL] No running privacyIDEA pod in namespace $MFA_NAMESPACE." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Fetching fresh privacyIDEA admin token inside pod $PI_POD ..."
|
||||
PI_TOKEN="$(
|
||||
"$KUBECTL" exec -n "$MFA_NAMESPACE" "$PI_POD" -- \
|
||||
env PI_ADMIN_PASSWORD="$PI_ADMIN_PASSWORD" \
|
||||
python3 -c '
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
payload = urllib.parse.urlencode({
|
||||
"username": "pi-admin",
|
||||
"password": os.environ["PI_ADMIN_PASSWORD"],
|
||||
}).encode()
|
||||
req = urllib.request.Request("http://localhost:8080/auth", data=payload)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as response:
|
||||
body = json.load(response)
|
||||
print(body["result"]["value"]["token"])
|
||||
except Exception as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
' 2>/dev/null
|
||||
)"
|
||||
unset PI_ADMIN_PASSWORD
|
||||
|
||||
if [[ -z "$PI_TOKEN" ]]; then
|
||||
echo "[FAIL] Could not fetch a privacyIDEA admin token." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
tmpdir="$(mktemp -d)"
|
||||
cleanup() {
|
||||
rm -rf "$tmpdir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
current_config="$(
|
||||
"$KUBECTL" get secret "$KEYCAPE_SECRET" -n "$SSO_NAMESPACE" \
|
||||
-o jsonpath='{.data.config\.yaml}' | base64 -d
|
||||
)"
|
||||
current_realm="$(
|
||||
CONFIG_YAML="$current_config" python3 -c '
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
print("PyYAML is required: install python3-yaml", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
config = yaml.safe_load(os.environ["CONFIG_YAML"]) or {}
|
||||
privacyidea = config.get("privacyidea") or {}
|
||||
if not isinstance(privacyidea, dict):
|
||||
print("")
|
||||
else:
|
||||
print(str(privacyidea.get("realm") or "").strip())
|
||||
'
|
||||
)"
|
||||
|
||||
selected_realm="${KEYCAPE_PI_REALM:-coulomb}"
|
||||
selected_require_for_all="${KEYCAPE_PI_REQUIRE_FOR_ALL:-true}"
|
||||
if [[ -z "${KEYCAPE_PI_REALM:-}" && -n "$current_realm" && "$current_realm" != "$selected_realm" ]]; then
|
||||
echo "[WARN] KeyCape currently points privacyIDEA at realm '$current_realm'; repairing to '$selected_realm'." >&2
|
||||
fi
|
||||
|
||||
if [[ "$selected_realm" != "coulomb" && "$selected_realm" != "netkingdom" ]]; then
|
||||
echo "[FAIL] Refusing unsupported privacyIDEA realm: $selected_realm" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$selected_require_for_all" != "true" && "$selected_require_for_all" != "false" ]]; then
|
||||
echo "[FAIL] KEYCAPE_PI_REQUIRE_FOR_ALL must be true or false." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Selected privacyIDEA realm for KeyCape: $selected_realm"
|
||||
echo "Selected privacyIDEA requireForAll for KeyCape: $selected_require_for_all"
|
||||
|
||||
"$KUBECTL" get secret "$KEYCAPE_SECRET" -n "$SSO_NAMESPACE" \
|
||||
-o jsonpath='{.data.key\.pem}' | base64 -d > "$tmpdir/key.pem"
|
||||
chmod 600 "$tmpdir/key.pem"
|
||||
|
||||
CONFIG_YAML="$current_config" PI_TOKEN="$PI_TOKEN" PI_REALM="$selected_realm" PI_REQUIRE_FOR_ALL="$selected_require_for_all" \
|
||||
python3 -c '
|
||||
import os
|
||||
import sys
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
print("PyYAML is required: install python3-yaml", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
config = yaml.safe_load(os.environ["CONFIG_YAML"]) or {}
|
||||
if not isinstance(config, dict):
|
||||
print("KeyCape config.yaml must decode to a YAML mapping.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
privacyidea = config.setdefault("privacyidea", {})
|
||||
if not isinstance(privacyidea, dict):
|
||||
print("KeyCape privacyidea config must decode to a YAML mapping.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
privacyidea["adminToken"] = os.environ["PI_TOKEN"]
|
||||
privacyidea["realm"] = os.environ["PI_REALM"]
|
||||
privacyidea["requireForAll"] = os.environ["PI_REQUIRE_FOR_ALL"] == "true"
|
||||
sys.stdout.write(yaml.safe_dump(config, sort_keys=False))
|
||||
' > "$tmpdir/config.yaml"
|
||||
|
||||
echo "Applying refreshed KeyCape config Secret ..."
|
||||
"$KUBECTL" create secret generic "$KEYCAPE_TOKEN_SECRET" \
|
||||
--namespace="$SSO_NAMESPACE" \
|
||||
--from-literal=token="$PI_TOKEN" \
|
||||
--dry-run=client -o yaml | "$KUBECTL" apply -f -
|
||||
|
||||
"$KUBECTL" create secret generic "$KEYCAPE_SECRET" \
|
||||
--namespace="$SSO_NAMESPACE" \
|
||||
--from-file=config.yaml="$tmpdir/config.yaml" \
|
||||
--from-file=key.pem="$tmpdir/key.pem" \
|
||||
--dry-run=client -o yaml | "$KUBECTL" apply -f -
|
||||
|
||||
unset PI_TOKEN
|
||||
|
||||
echo "Restarting KeyCape ..."
|
||||
"$KUBECTL" rollout restart "deployment/$KEYCAPE_DEPLOYMENT" -n "$SSO_NAMESPACE"
|
||||
"$KUBECTL" rollout status "deployment/$KEYCAPE_DEPLOYMENT" -n "$SSO_NAMESPACE" --timeout=90s
|
||||
|
||||
echo ""
|
||||
echo "[OK] KeyCape privacyIDEA token refreshed. Retry the OIDC login with a fresh URL."
|
||||
printf '%s\n' 'ERROR: personal privacyIDEA admin-token delivery is retired.' 'Use railiance-platform RPF-WP-0040 scoped factor custody, ESO delivery and renewal recovery.' 'This helper performs no login, credential read, Secret change or restart.' >&2
|
||||
exit 1
|
||||
|
|
|
|||
|
|
@ -62,9 +62,7 @@ class PortalClientTests(unittest.TestCase):
|
|||
rollout.replacement(self.fixture(clients)[0])
|
||||
|
||||
def test_bootstrap_retains_same_public_registration(self):
|
||||
source = Path(__file__).with_name('create-secrets.sh').read_text()
|
||||
raw = source.split('CONFIG_YAML=$(cat <<EOF\n', 1)[1].split('\nEOF', 1)[0]
|
||||
clients = yaml.safe_load(raw)['clients']
|
||||
clients = json.loads(Path(__file__).with_name('bootstrap-config.json').read_text())['clients']
|
||||
self.assertEqual([c for c in clients if c['clientId'] == rollout.portal.CLIENT_ID],
|
||||
[rollout.portal.CLIENT])
|
||||
|
||||
|
|
|
|||
142
tests/test_keycape_bootstrap.py
Normal file
142
tests/test_keycape_bootstrap.py
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
"""Synthetic custody tests; no real cluster or credential access."""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPTS = ROOT / 'sso-mfa/k8s/keycape'
|
||||
PASSWORD = 'SyntheticOnlyCredential12345678901234567890'
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup(tmp_path):
|
||||
root = tmp_path / 'private'; root.mkdir(mode=0o700)
|
||||
for component, key in [('lldap', 'LLDAP_LDAP_USER_PASS'), ('authelia', 'AUTHELIA_KEYCAPE_CLIENT_SECRET'), ('keycape', None)]:
|
||||
folder = root / component; folder.mkdir(mode=0o700)
|
||||
file = folder / 'secrets.env'; file.write_text(f'{key}={PASSWORD}\n' if key else '# placeholder\n'); file.chmod(0o600)
|
||||
binary = tmp_path / 'bin'; binary.mkdir()
|
||||
(binary / 'kubectl').write_text('''#!/usr/bin/python3
|
||||
import json,os,sys
|
||||
args=sys.argv[1:]
|
||||
raw=sys.stdin.read() if args[-1:]==['-'] else ''
|
||||
with open(os.environ['CALLS'],'a') as f:f.write(json.dumps({'args':args,'stdin':raw})+'\\n')
|
||||
if os.environ.get('FAIL')==args[0]:
|
||||
print(os.environ['SYNTHETIC']);print(os.environ['SYNTHETIC'],file=sys.stderr);sys.exit(1)
|
||||
if args[:3]==['get','secret','keycape-config']:
|
||||
print(os.environ.get('EXISTING',''),end='')
|
||||
elif args[:2]==['get','externalsecret']:
|
||||
print(os.environ.get('READY','True'),end='')
|
||||
elif args[:3]==['get','secret','keycape-factor-read']:
|
||||
print(os.environ.get('FACTOR','secret/keycape-factor-read'),end='')
|
||||
elif args[0]=='create':
|
||||
print('secret/keycape-config',end='')
|
||||
else: sys.exit(3)
|
||||
''')
|
||||
(binary / 'kubectl').chmod(0o700)
|
||||
for name in ('curl', 'sleep'):
|
||||
(binary / name).write_text('#!/bin/sh\nexit 97\n'); (binary / name).chmod(0o700)
|
||||
env={'PATH':str(binary)+':'+os.environ['PATH'],'HOME':str(tmp_path),'CALLS':str(tmp_path/'calls'),'SYNTHETIC':PASSWORD}
|
||||
return root, env
|
||||
|
||||
|
||||
def invoke(setup, script='create-secrets.sh', **extra):
|
||||
root, env=setup
|
||||
return subprocess.run(['bash','-x',str(SCRIPTS/script),str(root)],input=PASSWORD+'\n',
|
||||
env={**env,**extra},capture_output=True,text=True,timeout=15)
|
||||
|
||||
|
||||
def calls(setup):
|
||||
file=Path(setup[1]['CALLS'])
|
||||
return [json.loads(line) for line in file.read_text().splitlines()] if file.exists() else []
|
||||
|
||||
|
||||
@pytest.mark.parametrize('script',['create-pi-token.sh','refresh-pi-token-live.sh'])
|
||||
def test_retired_admin_token_paths_refuse_before_read_or_cluster_access(setup,script):
|
||||
result=invoke(setup,script)
|
||||
assert result.returncode!=0
|
||||
assert 'RPF-WP-0040' in result.stdout+result.stderr
|
||||
assert PASSWORD not in result.stdout+result.stderr
|
||||
assert not calls(setup)
|
||||
assert not (setup[0]/'keycape/pi_admin_token').exists()
|
||||
|
||||
|
||||
def test_new_config_uses_mounted_scoped_credential_without_secret_argv_or_output(setup):
|
||||
result=invoke(setup)
|
||||
assert result.returncode==0, result.stderr
|
||||
assert PASSWORD not in result.stdout+result.stderr
|
||||
observed=calls(setup)
|
||||
assert PASSWORD not in json.dumps([c['args'] for c in observed])
|
||||
writes=[c for c in observed if c['args'][0] not in ['get']]
|
||||
assert len(writes)==1 and writes[0]['args']==['create','-o','name','-f','-']
|
||||
secret=json.loads(writes[0]['stdin'])
|
||||
assert 'annotations' not in secret['metadata']
|
||||
config=yaml.safe_load(base64.b64decode(secret['data']['config.yaml']))
|
||||
assert config['privacyidea'].get('adminToken','')==''
|
||||
assert config['privacyidea']['adminTokenFile']=='/etc/keycape-factor/admin-token'
|
||||
assert config['privacyidea']['realm']=='coulomb'
|
||||
assert config['privacyidea']['requireForAll'] is True
|
||||
assert config['lldap']['bindPW']==PASSWORD
|
||||
assert config['authelia']['clientSecret']==PASSWORD
|
||||
key=setup[0]/'keycape/key.pem'
|
||||
assert key.stat().st_mode & 0o077==0
|
||||
assert base64.b64decode(secret['data']['key.pem'])==key.read_bytes()
|
||||
checked=subprocess.run(['openssl','rsa','-check','-noout'],input=key.read_bytes(),capture_output=True)
|
||||
assert checked.returncode==0
|
||||
|
||||
|
||||
@pytest.mark.parametrize('extra',[{'EXISTING':'secret/keycape-config'},{'READY':'False'},{'READY':''},{'FACTOR':''},{'FAIL':'get'}])
|
||||
def test_existing_config_or_missing_delivery_refuses_without_local_or_remote_writes(setup,extra):
|
||||
result=invoke(setup,**extra)
|
||||
assert result.returncode!=0
|
||||
assert not (setup[0]/'keycape/key.pem').exists()
|
||||
assert all(c['args'][0]=='get' for c in calls(setup))
|
||||
assert PASSWORD not in result.stdout+result.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize('defect',['public','public-parent','public-root','symlink','parent-symlink','hardlink','duplicate','shell','missing'])
|
||||
def test_bad_custody_refused_before_cluster_access(setup,defect):
|
||||
root,_=setup; file=root/'lldap/secrets.env'; marker=root/'executed'
|
||||
if defect=='public':file.chmod(0o644)
|
||||
elif defect=='public-parent':file.parent.chmod(0o755)
|
||||
elif defect=='public-root':root.chmod(0o755)
|
||||
elif defect=='symlink':
|
||||
target=file.with_name('other');file.rename(target);file.symlink_to(target)
|
||||
elif defect=='parent-symlink':
|
||||
target=root/'other';file.parent.rename(target);(root/'lldap').symlink_to(target)
|
||||
elif defect=='hardlink':os.link(file,file.with_name('alias'))
|
||||
elif defect=='duplicate':file.write_text(file.read_text()*2)
|
||||
elif defect=='shell':file.write_text(f'LLDAP_LDAP_USER_PASS=$(touch {marker})\n')
|
||||
else:file.unlink()
|
||||
result=invoke(setup)
|
||||
assert result.returncode!=0
|
||||
assert not marker.exists()
|
||||
assert not calls(setup)
|
||||
assert PASSWORD not in result.stdout+result.stderr
|
||||
|
||||
|
||||
def test_creation_failure_retains_private_key_for_retry_without_leaking(setup):
|
||||
result=invoke(setup,FAIL='create')
|
||||
assert result.returncode!=0
|
||||
assert PASSWORD not in result.stdout+result.stderr
|
||||
key=setup[0]/'keycape/key.pem'; before=key.read_bytes()
|
||||
assert key.stat().st_mode & 0o077==0
|
||||
assert invoke(setup).returncode==0
|
||||
assert key.read_bytes()==before
|
||||
|
||||
|
||||
@pytest.mark.parametrize('defect',['symlink','hardlink','public','invalid'])
|
||||
def test_existing_invalid_signing_key_is_not_replaced(setup,defect):
|
||||
key=setup[0]/'keycape/key.pem'; key.write_text('invalid');key.chmod(0o600)
|
||||
if defect=='symlink':
|
||||
target=key.with_name('other');key.rename(target);key.symlink_to(target)
|
||||
elif defect=='hardlink':os.link(key,key.with_name('alias'))
|
||||
elif defect=='public':key.chmod(0o644)
|
||||
result=invoke(setup)
|
||||
assert result.returncode!=0
|
||||
assert key.read_text()=='invalid'
|
||||
assert not any(c['args'][0]=='create' for c in calls(setup))
|
||||
|
|
@ -12,7 +12,7 @@ BOOT = ROOT / 'sso-mfa/bootstrap'
|
|||
CASES = {
|
||||
'PI_SECRET_KEY': 'privacyidea', 'PI_DB_PASSWORD': 'privacyidea',
|
||||
'LLDAP_JWT_SECRET': 'lldap', 'AUTHELIA_SESSION_SECRET': 'authelia',
|
||||
'AUTHELIA_KEYCAPE_CLIENT_SECRET': 'authelia', 'BREAKGLASS_PASSWORD': 'breakglass',
|
||||
'BREAKGLASS_PASSWORD': 'breakglass',
|
||||
}
|
||||
SYNTHETIC = 'A' * 64
|
||||
|
||||
|
|
@ -183,3 +183,19 @@ def test_declining_rotation_preserves_custody_file_and_cluster(rotation):
|
|||
assert file.read_bytes() == before
|
||||
assert not calls(rotation)
|
||||
assert bool(SYNTHETIC[:40] in result.stdout + result.stderr) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize('key',['AUTHELIA_KEYCAPE_CLIENT_SECRET','KEYCAPE_RSA_KEY'])
|
||||
@pytest.mark.parametrize('attended',[False,True])
|
||||
def test_retired_keycape_replacement_refuses_before_any_credential_change(rotation,key,attended):
|
||||
repo,boot,secrets,env=rotation
|
||||
signing=secrets/'keycape/key.pem';signing.write_text('synthetic-existing-private-key');signing.chmod(0o600)
|
||||
before={p:p.read_bytes() for p in secrets.rglob('*') if p.is_file()}
|
||||
args=['bash','-x',str(boot/'creds-rotate.sh')]
|
||||
if not attended:args.append('--non-interactive')
|
||||
result=subprocess.run([*args,str(secrets)],input='yes\n',env={**env,'SECRET':key},capture_output=True,text=True,timeout=10)
|
||||
assert result.returncode!=0
|
||||
assert not calls(rotation)
|
||||
assert {p:p.read_bytes() for p in secrets.rglob('*') if p.is_file()}==before
|
||||
assert 'last_rotated_' not in (boot/'creds-state.yaml').read_text()
|
||||
assert 'NK-WP-0049-T02' in result.stderr
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ status: active
|
|||
owner: codex
|
||||
topic_slug: netkingdom
|
||||
created: "2026-10-12"
|
||||
updated: "2026-10-12"
|
||||
updated: "2026-10-13"
|
||||
flavor: implementation
|
||||
depends_on: []
|
||||
state_hub_workstream_id: "17d315f0-829e-57c7-85cc-3a7914152804"
|
||||
|
|
@ -74,13 +74,15 @@ signal deferral and arbitrary-child copy retention are explicitly not solved by
|
|||
this helper. Evidence: Custodian `custody-entrypoints.json`.
|
||||
|
||||
Concrete uncovered callers remain (T02 stays progress):
|
||||
- The separate `keycape/create-pi-token.sh` still uses credential-bearing argv,
|
||||
ambient/public-URL fallback, raw child-output handling and client-side Secret
|
||||
apply. Its token lifecycle and current consumer authority need reconciliation.
|
||||
- The KeyCape personal-admin token mint/refresh paths are retired by the October
|
||||
13 repair below. Historical token copies still need owner inventory/revocation;
|
||||
the old `privacyidea/check-user-mfa-state.sh` also consumes that legacy Secret
|
||||
with insecure curl/argv handling and still requires reconciliation.
|
||||
- Legacy trigger account/Secret inventory and owner-approved revocation are still
|
||||
required; removing automatic creation does not revoke historical credentials.
|
||||
- RSA rotation rollback/custody and native multi-consumer acceptance remain open;
|
||||
the output repair below is not lifecycle closure.
|
||||
- RSA and Authelia-to-KeyCape client-secret rotation need configuration-preserving
|
||||
updates, retained rollback custody and native multi-consumer acceptance. The
|
||||
legacy replacement paths now refuse before mutation; this is not rotation closure.
|
||||
- Delegated create/apply helpers still need full tracing/error/Secret-annotation
|
||||
output review; transport capture and native effect evidence remain mandatory.
|
||||
|
||||
|
|
@ -125,6 +127,30 @@ handling. These do not prove the deployed image/PostgreSQL or effective MFA.
|
|||
Existing objects are not deleted or rotated. Current callers and the native
|
||||
acceptance gate remain open. Evidence: Custodian `admin-bootstrap.json`.
|
||||
|
||||
2026-10-13 KeyCape caller reconciliation: RPF-WP-0040 is finished and its
|
||||
owner contract/deployment use the scoped renewable factor-reader credential with
|
||||
ESO and `adminTokenFile`. Retired `create-pi-token.sh` and
|
||||
`refresh-pi-token-live.sh` before input/credential/cluster access; neither personal
|
||||
admin JWT delivery nor automatic realm/MFA-policy replacement is a current
|
||||
consumer requirement. Existing provider identities/Secrets were not revoked.
|
||||
|
||||
The first-bootstrap config generator now uses that mounted-file source, private
|
||||
literal input parsing, captured child diagnostics, stdin-only Secret data and
|
||||
atomic private signing-key publication without replacement. It requires Ready
|
||||
factor delivery and refuses existing config; API creation cannot overwrite a
|
||||
concurrent creator and adds no client-side apply annotation. A failed API call
|
||||
leaves its remote outcome unverified and retains the private key for recovery.
|
||||
Existing installations require a scoped owner update preserving clients/policy.
|
||||
|
||||
Bootstrap callers and recovery guidance no longer invoke retired token helpers.
|
||||
Legacy RSA/client-secret replacement rotations refuse before local or remote
|
||||
mutation: the former destroyed rollback custody before attempting publication,
|
||||
and both would overwrite newer live configuration. The remaining coordinated
|
||||
rotation work stays here, not silently completed by refusal. Native recovery,
|
||||
current deployed consumer acceptance and other create/apply/SOPS callers remain
|
||||
open. Source verification and its limits: Custodian
|
||||
`docs/evidence/2026-10-13-netkingdom-security/keycape-caller-reconciliation.json`.
|
||||
|
||||
## Verify owner-approved recovery and current native callers
|
||||
|
||||
```task
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue