docs: preserve netkingdom-target-2026-10-16 session evidence (batch 6)
This commit is contained in:
parent
a870092d09
commit
7a3632ffcc
187 changed files with 22808 additions and 0 deletions
|
|
@ -0,0 +1,5 @@
|
|||
#!/usr/bin/env bash
|
||||
# Managed verification/rollback belongs to rapp-user-engine, not this migration tree.
|
||||
set +x
|
||||
printf '%s\n' 'Retired: use rapp-user-engine make verify-live and its reviewed release/rollback contract. This legacy helper cannot reapply old manifests, exercise rollback or certify historical backup jobs.' >&2
|
||||
exit 2
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
#!/usr/bin/env bash
|
||||
# Verify that the active identity stack has no runtime dependency on the
|
||||
# retired/source host. Secret values are decoded only in process memory and
|
||||
# never printed; failures report resource and key locators only.
|
||||
set +x
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
KUBECTL="${KUBECTL:-kubectl}"
|
||||
SOURCE_HOST_MARKER="${SOURCE_HOST_MARKER:-coulombcore}"
|
||||
SOURCE_IP="${SOURCE_IP:-92.205.130.254}"
|
||||
NAMESPACES="${NAMESPACES:-sso mfa user-engine databases}"
|
||||
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
for namespace in $NAMESPACES; do
|
||||
for kind in configmap secret deployment statefulset; do
|
||||
"$KUBECTL" -n "$namespace" get "$kind" -o json \
|
||||
>"$tmp/${namespace}-${kind}.json"
|
||||
done
|
||||
done
|
||||
|
||||
python3 - "$tmp" "$SOURCE_HOST_MARKER" "$SOURCE_IP" <<'PY'
|
||||
import base64
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
root = pathlib.Path(sys.argv[1])
|
||||
patterns = {
|
||||
"source-host": sys.argv[2].lower(),
|
||||
"source-ip": sys.argv[3].lower(),
|
||||
"public-lldap": "lldap.coulomb.social",
|
||||
"public-privacyidea": "pink.coulomb.social",
|
||||
"public-privacyidea-account": "pink-account.coulomb.social",
|
||||
}
|
||||
failures = []
|
||||
|
||||
for path in sorted(root.glob("*.json")):
|
||||
namespace, kind = path.stem.rsplit("-", 1)
|
||||
for item in json.loads(path.read_text()).get("items", []):
|
||||
name = item["metadata"]["name"]
|
||||
if kind in {"configmap", "secret"}:
|
||||
for key, raw in (item.get("data") or {}).items():
|
||||
try:
|
||||
value = (
|
||||
base64.b64decode(raw).decode("utf-8", "replace")
|
||||
if kind == "secret"
|
||||
else raw
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
lowered = value.lower()
|
||||
hits = [label for label, needle in patterns.items() if needle in lowered]
|
||||
if hits:
|
||||
failures.append(
|
||||
f"{kind}/{namespace}/{name}:{key} matches={','.join(hits)}"
|
||||
)
|
||||
else:
|
||||
lowered = json.dumps(item.get("spec", {}), sort_keys=True).lower()
|
||||
hits = [label for label, needle in patterns.items() if needle in lowered]
|
||||
if hits:
|
||||
failures.append(
|
||||
f"{kind}/{namespace}/{name} matches={','.join(hits)}"
|
||||
)
|
||||
|
||||
if failures:
|
||||
print("stale_dependencies=found")
|
||||
for failure in failures:
|
||||
print(failure)
|
||||
raise SystemExit(1)
|
||||
print("stale_dependencies=absent")
|
||||
PY
|
||||
|
||||
keycape_config="$(
|
||||
"$KUBECTL" -n sso get secret keycape-config \
|
||||
-o jsonpath='{.data.config\.yaml}' | base64 -d
|
||||
)"
|
||||
|
||||
for expected in \
|
||||
lldap.sso.svc.cluster.local \
|
||||
authelia.sso.svc.cluster.local \
|
||||
privacyidea.mfa.svc.cluster.local \
|
||||
user-engine \
|
||||
rapp-qonto
|
||||
do
|
||||
if [[ "$keycape_config" != *"$expected"* ]]; then
|
||||
echo "keycape_expected_dependency_missing=$expected" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
unset keycape_config
|
||||
echo "keycape_dependencies=cluster-local-and-complete"
|
||||
|
||||
pg_pod="$(
|
||||
"$KUBECTL" -n databases get pod \
|
||||
-l 'cnpg.io/cluster=net-kingdom-pg,role=primary' \
|
||||
-o jsonpath='{.items[0].metadata.name}'
|
||||
)"
|
||||
resolver_class="$(
|
||||
"$KUBECTL" -n databases exec "$pg_pod" -- \
|
||||
psql -X -A -t -U postgres -d privacyidea_db -c \
|
||||
"select case
|
||||
when lower(\"Value\") like '%lldap.sso.svc.cluster.local%'
|
||||
then 'cluster-local'
|
||||
else 'other'
|
||||
end
|
||||
from resolverconfig
|
||||
where \"Key\"='LDAPURI';" 2>/dev/null
|
||||
)"
|
||||
if [[ "$resolver_class" != "cluster-local" ]]; then
|
||||
echo "privacyidea_lldap_resolver=$resolver_class" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "privacyidea_lldap_resolver=cluster-local"
|
||||
|
||||
for target in \
|
||||
sso/lldap \
|
||||
sso/authelia \
|
||||
sso/keycape \
|
||||
mfa/privacyidea \
|
||||
user-engine/user-engine
|
||||
do
|
||||
namespace="${target%/*}"
|
||||
name="${target#*/}"
|
||||
ready="$(
|
||||
"$KUBECTL" -n "$namespace" get deployment "$name" \
|
||||
-o jsonpath='{.status.readyReplicas}'
|
||||
)"
|
||||
if [[ "${ready:-0}" -lt 1 ]]; then
|
||||
echo "deployment_not_ready=$target" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "identity_deployments=ready"
|
||||
echo "identity_cutover_dependencies=PASS"
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
#!/usr/bin/env bash
|
||||
# verify-t02.sh — verify T02 (K8s foundations) completion criteria
|
||||
#
|
||||
# Run after applying all T02 manifests. Exits 0 if all checks pass.
|
||||
#
|
||||
# Usage: ./verify-t02.sh [--kubeconfig PATH]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
KUBECTL="kubectl"
|
||||
if [[ "${1:-}" == "--kubeconfig" ]]; then
|
||||
KUBECTL="kubectl --kubeconfig $2"
|
||||
fi
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
check() {
|
||||
local desc="$1"
|
||||
shift
|
||||
if "$@" &>/dev/null; then
|
||||
echo " PASS $desc"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " FAIL $desc"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
check_output() {
|
||||
local desc="$1"
|
||||
local expected="$2"
|
||||
shift 2
|
||||
local actual
|
||||
actual="$("$@" 2>/dev/null || true)"
|
||||
if echo "$actual" | grep -q "$expected"; then
|
||||
echo " PASS $desc"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " FAIL $desc (got: $actual)"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
echo "=== T02 Verification — K8s Foundations ==="
|
||||
echo ""
|
||||
|
||||
# ── Namespaces ────────────────────────────────────────────────────────────────
|
||||
echo "[ Namespaces ]"
|
||||
check "namespace sso exists" $KUBECTL get namespace sso
|
||||
check "namespace mfa exists" $KUBECTL get namespace mfa
|
||||
check "namespace databases exists" $KUBECTL get namespace databases
|
||||
check "sso has net-kingdom/component label" \
|
||||
$KUBECTL get namespace sso -o jsonpath='{.metadata.labels.net-kingdom/component}'
|
||||
echo ""
|
||||
|
||||
# ── NetworkPolicies ───────────────────────────────────────────────────────────
|
||||
echo "[ NetworkPolicies ]"
|
||||
for ns in sso mfa databases; do
|
||||
check "default-deny-all in $ns" \
|
||||
$KUBECTL get networkpolicy default-deny-all -n "$ns"
|
||||
check "allow-egress-dns in $ns" \
|
||||
$KUBECTL get networkpolicy allow-egress-dns -n "$ns"
|
||||
done
|
||||
check "allow-traefik-to-keycape in sso" $KUBECTL get networkpolicy allow-traefik-to-keycape -n sso
|
||||
check "allow-user-engine-to-keycape in sso" $KUBECTL get networkpolicy allow-user-engine-to-keycape -n sso
|
||||
check "allow-keycape-egress-to-privacyidea in sso" $KUBECTL get networkpolicy allow-keycape-egress-to-privacyidea -n sso
|
||||
check "allow-ingress-from-traefik in mfa" $KUBECTL get networkpolicy allow-ingress-from-traefik -n mfa
|
||||
check "allow-ingress-from-keycape in mfa" $KUBECTL get networkpolicy allow-ingress-from-keycape -n mfa
|
||||
check "allow-egress-to-postgres in mfa" $KUBECTL get networkpolicy allow-egress-to-postgres -n mfa
|
||||
check "allow-ingress-from-keycloak in databases" $KUBECTL get networkpolicy allow-ingress-from-keycloak -n databases
|
||||
check "allow-ingress-from-privacyidea in databases" $KUBECTL get networkpolicy allow-ingress-from-privacyidea -n databases
|
||||
echo ""
|
||||
|
||||
# ── cert-manager ─────────────────────────────────────────────────────────────
|
||||
echo "[ cert-manager ]"
|
||||
check "cert-manager namespace exists" $KUBECTL get namespace cert-manager
|
||||
check "selfsigned-issuer exists" $KUBECTL get clusterissuer selfsigned-issuer
|
||||
check "letsencrypt-prod issuer exists" $KUBECTL get clusterissuer letsencrypt-prod
|
||||
check_output "selfsigned-issuer is Ready" "True" \
|
||||
$KUBECTL get clusterissuer selfsigned-issuer -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}'
|
||||
|
||||
# Test certificate (applied separately — only check if present)
|
||||
if $KUBECTL get namespace cert-manager-test &>/dev/null; then
|
||||
check_output "test certificate is Ready" "True" \
|
||||
$KUBECTL get certificate selfsigned-test -n cert-manager-test \
|
||||
-o jsonpath='{.status.conditions[?(@.type=="Ready")].status}'
|
||||
else
|
||||
echo " SKIP test certificate (namespace cert-manager-test not found — apply test-certificate.yaml)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ── StorageClass ──────────────────────────────────────────────────────────────
|
||||
echo "[ StorageClass ]"
|
||||
check "a default StorageClass exists" \
|
||||
$KUBECTL get storageclass -o jsonpath='{.items[?(@.metadata.annotations.storageclass\.kubernetes\.io/is-default-class=="true")].metadata.name}'
|
||||
|
||||
if $KUBECTL get namespace storage-test &>/dev/null; then
|
||||
check_output "storage-test pod Completed" "Completed\|Succeeded" \
|
||||
$KUBECTL get pod storage-test -n storage-test -o jsonpath='{.status.phase}'
|
||||
else
|
||||
echo " SKIP StorageClass PVC test (apply storage/verify-pvc.yaml first)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────────────────
|
||||
TOTAL=$((PASS + FAIL))
|
||||
echo "=== Results: $PASS/$TOTAL passed ==="
|
||||
if [[ $FAIL -gt 0 ]]; then
|
||||
echo " $FAIL check(s) failed."
|
||||
exit 1
|
||||
else
|
||||
echo " T02 COMPLETE — all checks passed."
|
||||
fi
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
#!/usr/bin/env bash
|
||||
# verify-t03.sh — verify NK-WP-0001-T03 done-criteria
|
||||
#
|
||||
# Checks:
|
||||
# 1. CloudNativePG operator is installed and running
|
||||
# 2. Cluster net-kingdom-pg is Ready
|
||||
# 3. privacyidea_db database exists
|
||||
# 4. privacyidea role exists
|
||||
# 5. K8s Secrets are present in the databases namespace
|
||||
# 6. (Optional) Scheduled backup CR is present when backup is configured
|
||||
#
|
||||
# Note: keycloak_db removed — Keycloak replaced by Authelia+LLDAP+KeyCape (T05).
|
||||
#
|
||||
# Usage:
|
||||
# chmod +x verify-t03.sh
|
||||
# ./verify-t03.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
WARN=0
|
||||
|
||||
pass() { echo " [PASS] $1"; PASS=$((PASS + 1)); }
|
||||
fail() { echo " [FAIL] $1"; FAIL=$((FAIL + 1)); }
|
||||
warn() { echo " [WARN] $1"; WARN=$((WARN + 1)); }
|
||||
|
||||
section() { echo ""; echo "── $1 ──────────────────────────────────────"; }
|
||||
|
||||
# ── 1. CloudNativePG operator ─────────────────────────────────────────────────
|
||||
section "1. CloudNativePG operator"
|
||||
|
||||
if kubectl get ns cnpg-system &>/dev/null; then
|
||||
pass "cnpg-system namespace exists"
|
||||
else
|
||||
fail "cnpg-system namespace not found — install operator first (see postgresql/README.md)"
|
||||
fi
|
||||
|
||||
if kubectl get crd clusters.postgresql.cnpg.io &>/dev/null; then
|
||||
pass "clusters.postgresql.cnpg.io CRD registered"
|
||||
else
|
||||
fail "CloudNativePG CRD not found — operator not installed"
|
||||
fi
|
||||
|
||||
CNPG_READY=$(kubectl get pods -n cnpg-system -l app.kubernetes.io/name=cloudnative-pg \
|
||||
--field-selector=status.phase=Running --no-headers 2>/dev/null | wc -l || echo 0)
|
||||
if [[ "$CNPG_READY" -ge 1 ]]; then
|
||||
pass "CloudNativePG operator pod running ($CNPG_READY pod(s))"
|
||||
else
|
||||
fail "No running CloudNativePG operator pods in cnpg-system"
|
||||
fi
|
||||
|
||||
# ── 2. Cluster readiness ──────────────────────────────────────────────────────
|
||||
section "2. Cluster net-kingdom-pg"
|
||||
|
||||
CLUSTER_READY=$(kubectl get cluster net-kingdom-pg -n databases \
|
||||
-o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || echo "")
|
||||
if [[ "$CLUSTER_READY" == "True" ]]; then
|
||||
pass "Cluster net-kingdom-pg status: Ready"
|
||||
else
|
||||
CLUSTER_PHASE=$(kubectl get cluster net-kingdom-pg -n databases \
|
||||
-o jsonpath='{.status.phase}' 2>/dev/null || echo "not found")
|
||||
fail "Cluster net-kingdom-pg not Ready (phase: $CLUSTER_PHASE)"
|
||||
fi
|
||||
|
||||
PRIMARY_POD=$(kubectl get pod -n databases \
|
||||
-l "cnpg.io/cluster=net-kingdom-pg,role=primary" \
|
||||
--field-selector=status.phase=Running \
|
||||
-o name 2>/dev/null | head -1 || echo "")
|
||||
if [[ -n "$PRIMARY_POD" ]]; then
|
||||
pass "Primary pod running: $PRIMARY_POD"
|
||||
else
|
||||
fail "No running primary pod found for net-kingdom-pg"
|
||||
fi
|
||||
|
||||
# ── 3. Databases ──────────────────────────────────────────────────────────────
|
||||
section "3. Databases"
|
||||
|
||||
if [[ -n "$PRIMARY_POD" ]]; then
|
||||
DB_LIST=$(kubectl exec -n databases "$PRIMARY_POD" -- \
|
||||
psql -U postgres -tAc "SELECT datname FROM pg_database WHERE datname = 'privacyidea_db';" \
|
||||
2>/dev/null || echo "")
|
||||
|
||||
if echo "$DB_LIST" | grep -q "privacyidea_db"; then
|
||||
pass "privacyidea_db exists"
|
||||
else
|
||||
fail "privacyidea_db not found"
|
||||
fi
|
||||
else
|
||||
warn "Skipping database checks — no primary pod available"
|
||||
fi
|
||||
|
||||
# ── 4. Roles ──────────────────────────────────────────────────────────────────
|
||||
section "4. Database roles"
|
||||
|
||||
if [[ -n "$PRIMARY_POD" ]]; then
|
||||
ROLE_LIST=$(kubectl exec -n databases "$PRIMARY_POD" -- \
|
||||
psql -U postgres -tAc "SELECT rolname FROM pg_roles WHERE rolname = 'privacyidea';" \
|
||||
2>/dev/null || echo "")
|
||||
|
||||
if echo "$ROLE_LIST" | grep -q "privacyidea"; then
|
||||
pass "role privacyidea exists"
|
||||
else
|
||||
fail "role privacyidea not found"
|
||||
fi
|
||||
else
|
||||
warn "Skipping role checks — no primary pod available"
|
||||
fi
|
||||
|
||||
# ── 5. K8s Secrets ────────────────────────────────────────────────────────────
|
||||
section "5. K8s Secrets (databases namespace)"
|
||||
|
||||
for secret in net-kingdom-pg-privacyidea-app; do
|
||||
if kubectl get secret "$secret" -n databases &>/dev/null; then
|
||||
pass "Secret $secret exists"
|
||||
else
|
||||
fail "Secret $secret not found — run create-secrets.sh"
|
||||
fi
|
||||
done
|
||||
|
||||
# CNPG auto-creates these
|
||||
for secret in net-kingdom-pg-superuser; do
|
||||
if kubectl get secret "$secret" -n databases &>/dev/null; then
|
||||
pass "Secret $secret exists (CNPG-managed)"
|
||||
else
|
||||
warn "Secret $secret not found (CNPG creates this; may appear after cluster init)"
|
||||
fi
|
||||
done
|
||||
|
||||
# ── 6. Backup configuration ───────────────────────────────────────────────────
|
||||
section "6. Backup (optional until object storage is provisioned)"
|
||||
|
||||
if kubectl get scheduledbackup net-kingdom-pg-daily -n databases &>/dev/null; then
|
||||
BACKUP_SUSPENDED=$(kubectl get scheduledbackup net-kingdom-pg-daily -n databases \
|
||||
-o jsonpath='{.spec.suspend}' 2>/dev/null || echo "false")
|
||||
if [[ "$BACKUP_SUSPENDED" == "true" ]]; then
|
||||
warn "ScheduledBackup net-kingdom-pg-daily is suspended"
|
||||
else
|
||||
pass "ScheduledBackup net-kingdom-pg-daily present and active"
|
||||
fi
|
||||
|
||||
LAST_BACKUP=$(kubectl get backup -n databases \
|
||||
-l "cnpg.io/cluster=net-kingdom-pg" \
|
||||
--sort-by='.metadata.creationTimestamp' \
|
||||
-o name 2>/dev/null | tail -1 || echo "")
|
||||
if [[ -n "$LAST_BACKUP" ]]; then
|
||||
pass "At least one backup found: $LAST_BACKUP"
|
||||
else
|
||||
warn "No backups yet — trigger a manual backup or wait for schedule"
|
||||
fi
|
||||
else
|
||||
warn "ScheduledBackup not deployed — configure object storage, then apply scheduled-backup.yaml"
|
||||
fi
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════"
|
||||
echo " T03 verification: PASS=$PASS WARN=$WARN FAIL=$FAIL"
|
||||
echo "════════════════════════════════════════════════"
|
||||
|
||||
if [[ "$FAIL" -gt 0 ]]; then
|
||||
echo " Result: INCOMPLETE — resolve FAIL items before proceeding to T04"
|
||||
exit 1
|
||||
elif [[ "$WARN" -gt 0 ]]; then
|
||||
echo " Result: PARTIAL — T03 core done; WARN items should be addressed before production"
|
||||
exit 0
|
||||
else
|
||||
echo " Result: COMPLETE — T03 done-criteria met"
|
||||
exit 0
|
||||
fi
|
||||
|
|
@ -0,0 +1,213 @@
|
|||
#!/usr/bin/env bash
|
||||
# verify-t04.sh — verify NK-WP-0001-T04 done-criteria
|
||||
#
|
||||
# Checks:
|
||||
# 1. privacyIDEA pod is Running in the mfa namespace
|
||||
# 2. privacyidea Service exists
|
||||
# 3. Traefik Middlewares exist
|
||||
# 4. Ingress resources exist with correct hostnames
|
||||
# 5. TLS certificates issued by cert-manager
|
||||
# 6. Required K8s Secrets are present
|
||||
# 7. PVCs are Bound
|
||||
# 8. Enckey and auditkey Secrets present (from enckey-bootstrap.sh)
|
||||
#
|
||||
# Usage:
|
||||
# chmod +x verify-t04.sh
|
||||
# ./verify-t04.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
NAMESPACE="mfa"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
WARN=0
|
||||
|
||||
pass() { echo " [PASS] $1"; ((PASS++)); }
|
||||
fail() { echo " [FAIL] $1"; ((FAIL++)); }
|
||||
warn() { echo " [WARN] $1"; ((WARN++)); }
|
||||
|
||||
section() { echo ""; echo "── $1 ──────────────────────────────────────"; }
|
||||
|
||||
# ── 1. privacyIDEA pod ────────────────────────────────────────────────────────
|
||||
section "1. privacyIDEA pod (namespace: $NAMESPACE)"
|
||||
|
||||
PI_POD=$(kubectl get pod -n "$NAMESPACE" \
|
||||
-l app.kubernetes.io/name=privacyidea \
|
||||
--field-selector=status.phase=Running \
|
||||
-o jsonpath='{.items[0].metadata.name}' 2>/dev/null || echo "")
|
||||
|
||||
if [[ -n "$PI_POD" ]]; then
|
||||
pass "Pod Running: $PI_POD"
|
||||
|
||||
READY=$(kubectl get pod -n "$NAMESPACE" "$PI_POD" \
|
||||
-o jsonpath='{.status.containerStatuses[0].ready}' 2>/dev/null || echo "false")
|
||||
if [[ "$READY" == "true" ]]; then
|
||||
pass "Pod readiness probe passing"
|
||||
else
|
||||
fail "Pod is Running but not Ready (probe failing — check logs)"
|
||||
fi
|
||||
else
|
||||
PENDING=$(kubectl get pod -n "$NAMESPACE" \
|
||||
-l app.kubernetes.io/name=privacyidea \
|
||||
-o name 2>/dev/null | wc -l || echo 0)
|
||||
if [[ "$PENDING" -gt 0 ]]; then
|
||||
fail "privacyIDEA pod(s) exist but none are Running (check kubectl describe pod -n $NAMESPACE)"
|
||||
else
|
||||
fail "No privacyIDEA pods found in namespace $NAMESPACE — apply deployment.yaml"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 2. Service ────────────────────────────────────────────────────────────────
|
||||
section "2. Service"
|
||||
|
||||
if kubectl get service privacyidea -n "$NAMESPACE" &>/dev/null; then
|
||||
pass "Service privacyidea exists"
|
||||
PORT=$(kubectl get service privacyidea -n "$NAMESPACE" \
|
||||
-o jsonpath='{.spec.ports[0].port}' 2>/dev/null || echo "")
|
||||
if [[ "$PORT" == "8080" ]]; then
|
||||
pass "Service port: 8080"
|
||||
else
|
||||
warn "Service port is $PORT (expected 8080 — check container port and netpol)"
|
||||
fi
|
||||
else
|
||||
fail "Service privacyidea not found in namespace $NAMESPACE"
|
||||
fi
|
||||
|
||||
# ── 3. Traefik Middlewares ────────────────────────────────────────────────────
|
||||
section "3. Traefik Middlewares"
|
||||
|
||||
for mw in privacyidea-rate-limit privacyidea-admin-allowlist; do
|
||||
if kubectl get middleware "$mw" -n "$NAMESPACE" &>/dev/null; then
|
||||
pass "Middleware $mw exists"
|
||||
else
|
||||
fail "Middleware $mw not found — apply middleware.yaml"
|
||||
fi
|
||||
done
|
||||
|
||||
# ── 4. Ingress resources ──────────────────────────────────────────────────────
|
||||
section "4. Ingress resources"
|
||||
|
||||
for ing in privacyidea privacyidea-admin privacyidea-account; do
|
||||
if kubectl get ingress "$ing" -n "$NAMESPACE" &>/dev/null; then
|
||||
pass "Ingress $ing exists"
|
||||
else
|
||||
fail "Ingress $ing not found — apply ingress.yaml"
|
||||
fi
|
||||
done
|
||||
|
||||
# Verify hostnames in the main Ingress
|
||||
PI_HOST=$(kubectl get ingress privacyidea -n "$NAMESPACE" \
|
||||
-o jsonpath='{.spec.rules[0].host}' 2>/dev/null || echo "")
|
||||
if [[ "$PI_HOST" == "pink.coulomb.social" ]]; then
|
||||
pass "Ingress host: pink.coulomb.social"
|
||||
else
|
||||
fail "Ingress host is '$PI_HOST' (expected pink.coulomb.social)"
|
||||
fi
|
||||
|
||||
ACCOUNT_HOST=$(kubectl get ingress privacyidea-account -n "$NAMESPACE" \
|
||||
-o jsonpath='{.spec.rules[0].host}' 2>/dev/null || echo "")
|
||||
if [[ "$ACCOUNT_HOST" == "pink-account.coulomb.social" ]]; then
|
||||
pass "Self-service host: pink-account.coulomb.social"
|
||||
else
|
||||
fail "Self-service host is '$ACCOUNT_HOST' (expected pink-account.coulomb.social)"
|
||||
fi
|
||||
|
||||
# ── 5. TLS certificates ───────────────────────────────────────────────────────
|
||||
section "5. TLS certificates"
|
||||
|
||||
for cert_secret in pink-tls pink-account-tls; do
|
||||
if kubectl get secret "$cert_secret" -n "$NAMESPACE" &>/dev/null; then
|
||||
# Check cert-manager Ready condition
|
||||
CERT_NAME="${cert_secret%-tls}"
|
||||
CERT_READY=$(kubectl get certificate "$CERT_NAME" -n "$NAMESPACE" \
|
||||
-o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || echo "")
|
||||
if [[ "$CERT_READY" == "True" ]]; then
|
||||
pass "Certificate $cert_name is Ready (TLS secret $cert_secret exists)"
|
||||
else
|
||||
warn "TLS secret $cert_secret exists but certificate status is not Ready (DNS propagation pending?)"
|
||||
fi
|
||||
else
|
||||
warn "TLS secret $cert_secret not yet issued (cert-manager pending — check DNS and ACME)"
|
||||
fi
|
||||
done
|
||||
|
||||
# ── 6. K8s Secrets ────────────────────────────────────────────────────────────
|
||||
section "6. K8s Secrets (namespace: $NAMESPACE)"
|
||||
|
||||
for secret in privacyidea-config; do
|
||||
if kubectl get secret "$secret" -n "$NAMESPACE" &>/dev/null; then
|
||||
pass "Secret $secret exists"
|
||||
else
|
||||
fail "Secret $secret not found — run create-secrets.sh"
|
||||
fi
|
||||
done
|
||||
|
||||
# DR secrets — created by enckey-bootstrap.sh (may not exist yet on first run)
|
||||
for secret in privacyidea-enckey privacyidea-auditkeys; do
|
||||
if kubectl get secret "$secret" -n "$NAMESPACE" &>/dev/null; then
|
||||
pass "Secret $secret exists"
|
||||
else
|
||||
warn "Secret $secret not found — use privacyidea/KEY-RECOVERY.md for private export"
|
||||
fi
|
||||
done
|
||||
|
||||
# ── 7. PVCs ───────────────────────────────────────────────────────────────────
|
||||
section "7. PersistentVolumeClaims"
|
||||
|
||||
for pvc in privacyidea-data privacyidea-logs; do
|
||||
STATUS=$(kubectl get pvc "$pvc" -n "$NAMESPACE" \
|
||||
-o jsonpath='{.status.phase}' 2>/dev/null || echo "not found")
|
||||
if [[ "$STATUS" == "Bound" ]]; then
|
||||
pass "PVC $pvc: Bound"
|
||||
elif [[ "$STATUS" == "not found" ]]; then
|
||||
fail "PVC $pvc not found — apply pvc.yaml"
|
||||
else
|
||||
fail "PVC $pvc status: $STATUS (expected Bound)"
|
||||
fi
|
||||
done
|
||||
|
||||
# ── 8. Key material check ─────────────────────────────────────────────────────
|
||||
section "8. Key material (inside pod)"
|
||||
|
||||
if [[ -n "$PI_POD" ]]; then
|
||||
if kubectl exec -n "$NAMESPACE" "$PI_POD" -- test -f /etc/privacyidea/enckey 2>/dev/null; then
|
||||
pass "enckey present in pod"
|
||||
else
|
||||
warn "enckey not found in pod — stop and follow privacyidea/KEY-RECOVERY.md; do not generate a replacement recovery key"
|
||||
fi
|
||||
|
||||
if kubectl exec -n "$NAMESPACE" "$PI_POD" -- test -f /etc/privacyidea/private.pem 2>/dev/null; then
|
||||
pass "audit private.pem present in pod"
|
||||
else
|
||||
warn "audit private.pem not found — use privacyidea/KEY-RECOVERY.md for private export"
|
||||
fi
|
||||
|
||||
if kubectl exec -n "$NAMESPACE" "$PI_POD" -- \
|
||||
pi-manage admin list 2>/dev/null | grep -q "pi-admin"; then
|
||||
pass "Admin user pi-admin exists"
|
||||
else
|
||||
warn "pi-admin not found — run bootstrap-admin.sh"
|
||||
fi
|
||||
|
||||
# Legacy trigger-account presence is not a current KeyCape readiness gate.
|
||||
|
||||
else
|
||||
warn "Skipping key material checks — no running pod"
|
||||
fi
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
echo " T04 verification: PASS=$PASS WARN=$WARN FAIL=$FAIL"
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
|
||||
if [[ "$FAIL" -gt 0 ]]; then
|
||||
echo " Result: INCOMPLETE — resolve FAIL items before proceeding to T05"
|
||||
exit 1
|
||||
elif [[ "$WARN" -gt 0 ]]; then
|
||||
echo " Result: PARTIAL — T04 core is up; WARN items should be resolved before T05"
|
||||
exit 0
|
||||
else
|
||||
echo " Result: COMPLETE — T04 done-criteria met; proceed to T05 (SSO core: LLDAP+Authelia+KeyCape)"
|
||||
exit 0
|
||||
fi
|
||||
|
|
@ -0,0 +1,313 @@
|
|||
#!/usr/bin/env bash
|
||||
# verify-t05.sh — verify NK-WP-0001-T05 done-criteria
|
||||
#
|
||||
# Checks all three components of the new SSO stack (LLDAP, Authelia, KeyCape).
|
||||
#
|
||||
# Sections:
|
||||
# 1. LLDAP pod Running+Ready
|
||||
# 2. LLDAP services (ldap :3890, web-ui :17170)
|
||||
# 3. LLDAP Middleware (lldap-admin-allowlist)
|
||||
# 4. LLDAP Ingress + hostname (lldap.coulomb.social)
|
||||
# 5. LLDAP TLS certificate
|
||||
# 6. LLDAP Secret (lldap-secrets)
|
||||
# 7. LLDAP PVC Bound
|
||||
# 8. LLDAP health endpoint
|
||||
# 9. Authelia pod Running+Ready
|
||||
# 10. Authelia service
|
||||
# 11. Authelia Ingress + hostname (auth.coulomb.social)
|
||||
# 12. Authelia TLS certificate
|
||||
# 13. Authelia Secrets (authelia-secrets)
|
||||
# 14. Authelia PVC Bound
|
||||
# 15. Authelia health endpoint
|
||||
# 16. KeyCape pod Running+Ready
|
||||
# 17. KeyCape service
|
||||
# 18. KeyCape Middlewares (keycape-rate-limit, keycape-hsts)
|
||||
# 19. KeyCape Ingress + hostname (kc.coulomb.social)
|
||||
# 20. KeyCape TLS certificate
|
||||
# 21. KeyCape Secret (keycape-config)
|
||||
# 22. KeyCape health endpoint (/healthz)
|
||||
# 23. KeyCape OIDC discovery (/.well-known/openid-configuration)
|
||||
#
|
||||
# Usage:
|
||||
# chmod +x verify-t05.sh
|
||||
# ./verify-t05.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
NAMESPACE="sso"
|
||||
LLDAP_HOST="lldap.coulomb.social"
|
||||
AUTH_HOST="auth.coulomb.social"
|
||||
KC_HOST="kc.coulomb.social"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
WARN=0
|
||||
|
||||
pass() { echo " [PASS] $1"; ((PASS++)); }
|
||||
fail() { echo " [FAIL] $1"; ((FAIL++)); }
|
||||
warn() { echo " [WARN] $1"; ((WARN++)); }
|
||||
|
||||
section() { echo ""; echo "── $1 ──────────────────────────────────────"; }
|
||||
|
||||
check_pod() {
|
||||
local name="$1"
|
||||
local label="$2"
|
||||
local pod=""
|
||||
pod=$(kubectl get pod -n "$NAMESPACE" \
|
||||
-l "app.kubernetes.io/name=${label}" \
|
||||
--field-selector=status.phase=Running \
|
||||
-o jsonpath='{.items[0].metadata.name}' 2>/dev/null || echo "")
|
||||
if [[ -n "$pod" ]]; then
|
||||
pass "Pod Running: $pod"
|
||||
local ready
|
||||
ready=$(kubectl get pod -n "$NAMESPACE" "$pod" \
|
||||
-o jsonpath='{.status.containerStatuses[0].ready}' 2>/dev/null || echo "false")
|
||||
if [[ "$ready" == "true" ]]; then
|
||||
pass "Pod readiness probe passing"
|
||||
else
|
||||
fail "Pod is Running but not Ready (probe failing — check logs)"
|
||||
fi
|
||||
echo "$pod"
|
||||
else
|
||||
local count
|
||||
count=$(kubectl get pod -n "$NAMESPACE" \
|
||||
-l "app.kubernetes.io/name=${label}" -o name 2>/dev/null | wc -l || echo 0)
|
||||
if [[ "$count" -gt 0 ]]; then
|
||||
fail "${name} pod(s) exist but none Running (kubectl describe pod -n $NAMESPACE -l app.kubernetes.io/name=${label})"
|
||||
else
|
||||
fail "No ${name} pods found — apply deployment.yaml"
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
check_service() {
|
||||
local svc="$1"; shift
|
||||
local ports=("$@")
|
||||
if kubectl get service "$svc" -n "$NAMESPACE" &>/dev/null; then
|
||||
pass "Service $svc exists"
|
||||
for port in "${ports[@]}"; do
|
||||
if kubectl get service "$svc" -n "$NAMESPACE" \
|
||||
-o jsonpath="{.spec.ports[*].port}" 2>/dev/null | grep -qw "$port"; then
|
||||
pass "Service $svc has port $port"
|
||||
else
|
||||
fail "Service $svc missing port $port"
|
||||
fi
|
||||
done
|
||||
else
|
||||
fail "Service $svc not found"
|
||||
fi
|
||||
}
|
||||
|
||||
check_ingress() {
|
||||
local ing="$1"; local expected_host="$2"
|
||||
if kubectl get ingress "$ing" -n "$NAMESPACE" &>/dev/null; then
|
||||
pass "Ingress $ing exists"
|
||||
local host
|
||||
host=$(kubectl get ingress "$ing" -n "$NAMESPACE" \
|
||||
-o jsonpath='{.spec.rules[0].host}' 2>/dev/null || echo "")
|
||||
if [[ "$host" == "$expected_host" ]]; then
|
||||
pass "Ingress $ing host: $expected_host"
|
||||
else
|
||||
fail "Ingress $ing host is '$host' (expected $expected_host)"
|
||||
fi
|
||||
else
|
||||
fail "Ingress $ing not found — apply ingress.yaml"
|
||||
fi
|
||||
}
|
||||
|
||||
check_tls() {
|
||||
local secret="$1"; local host="$2"
|
||||
if kubectl get secret "$secret" -n "$NAMESPACE" &>/dev/null; then
|
||||
local cert_name="${secret%-tls}"
|
||||
local cert_ready
|
||||
cert_ready=$(kubectl get certificate "$cert_name" -n "$NAMESPACE" \
|
||||
-o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || echo "")
|
||||
if [[ "$cert_ready" == "True" ]]; then
|
||||
pass "Certificate $cert_name Ready (TLS secret $secret issued)"
|
||||
else
|
||||
warn "TLS secret $secret exists but cert status not Ready (DNS/ACME pending?)"
|
||||
fi
|
||||
else
|
||||
warn "TLS secret $secret not yet issued (cert-manager pending — check DNS and ACME)"
|
||||
fi
|
||||
}
|
||||
|
||||
check_secret() {
|
||||
local secret="$1"
|
||||
if kubectl get secret "$secret" -n "$NAMESPACE" &>/dev/null; then
|
||||
pass "Secret $secret exists"
|
||||
else
|
||||
fail "Secret $secret not found — run create-secrets.sh"
|
||||
fi
|
||||
}
|
||||
|
||||
check_pvc() {
|
||||
local pvc="$1"
|
||||
local status
|
||||
status=$(kubectl get pvc "$pvc" -n "$NAMESPACE" \
|
||||
-o jsonpath='{.status.phase}' 2>/dev/null || echo "not found")
|
||||
if [[ "$status" == "Bound" ]]; then
|
||||
pass "PVC $pvc: Bound"
|
||||
elif [[ "$status" == "not found" ]]; then
|
||||
fail "PVC $pvc not found — apply pvc.yaml"
|
||||
else
|
||||
fail "PVC $pvc status: $status (expected Bound)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# LLDAP
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
section "1. LLDAP pod (namespace: $NAMESPACE)"
|
||||
LLDAP_POD=$(check_pod "LLDAP" "lldap")
|
||||
|
||||
section "2. LLDAP services"
|
||||
check_service "lldap" 3890 17170
|
||||
|
||||
section "3. LLDAP Middleware"
|
||||
if kubectl get middleware lldap-admin-allowlist -n "$NAMESPACE" &>/dev/null; then
|
||||
pass "Middleware lldap-admin-allowlist exists"
|
||||
else
|
||||
fail "Middleware lldap-admin-allowlist not found — apply middleware.yaml"
|
||||
fi
|
||||
|
||||
section "4. LLDAP Ingress"
|
||||
check_ingress "lldap" "$LLDAP_HOST"
|
||||
|
||||
section "5. LLDAP TLS certificate"
|
||||
check_tls "lldap-tls" "$LLDAP_HOST"
|
||||
|
||||
section "6. LLDAP Secret"
|
||||
check_secret "lldap-secrets"
|
||||
|
||||
section "7. LLDAP PVC"
|
||||
check_pvc "lldap-data"
|
||||
|
||||
section "8. LLDAP health endpoint"
|
||||
if [[ -n "$LLDAP_POD" ]]; then
|
||||
HTTP=$(kubectl exec -n "$NAMESPACE" "$LLDAP_POD" -- \
|
||||
wget -qO- http://localhost:17170/health 2>/dev/null || echo "")
|
||||
if echo "$HTTP" | grep -qi "ok\|healthy\|true"; then
|
||||
pass "LLDAP health endpoint responds OK"
|
||||
else
|
||||
warn "LLDAP health endpoint response unclear: '$HTTP'"
|
||||
fi
|
||||
else
|
||||
warn "Skipping LLDAP health check — no running pod"
|
||||
fi
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Authelia
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
section "9. Authelia pod (namespace: $NAMESPACE)"
|
||||
AUTHELIA_POD=$(check_pod "Authelia" "authelia")
|
||||
|
||||
section "10. Authelia service"
|
||||
check_service "authelia" 9091
|
||||
|
||||
section "11. Authelia Ingress"
|
||||
check_ingress "authelia" "$AUTH_HOST"
|
||||
|
||||
section "12. Authelia TLS certificate"
|
||||
check_tls "auth-tls" "$AUTH_HOST"
|
||||
|
||||
section "13. Authelia Secrets"
|
||||
check_secret "authelia-secrets"
|
||||
|
||||
section "14. Authelia PVC"
|
||||
check_pvc "authelia-data"
|
||||
|
||||
section "15. Authelia health endpoint"
|
||||
if [[ -n "$AUTHELIA_POD" ]]; then
|
||||
STATUS=$(kubectl exec -n "$NAMESPACE" "$AUTHELIA_POD" -- \
|
||||
wget -qO- http://localhost:9091/api/health 2>/dev/null || echo "")
|
||||
if echo "$STATUS" | grep -qi "ok\|healthy"; then
|
||||
pass "Authelia /api/health responds OK"
|
||||
else
|
||||
warn "Authelia /api/health response unclear: '$STATUS'"
|
||||
fi
|
||||
else
|
||||
warn "Skipping Authelia health check — no running pod"
|
||||
fi
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# KeyCape
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
section "16. KeyCape pod (namespace: $NAMESPACE)"
|
||||
KC_POD=$(check_pod "KeyCape" "keycape")
|
||||
|
||||
section "17. KeyCape service"
|
||||
check_service "keycape" 8080
|
||||
|
||||
section "18. KeyCape Middlewares"
|
||||
for mw in keycape-rate-limit keycape-hsts; do
|
||||
if kubectl get middleware "$mw" -n "$NAMESPACE" &>/dev/null; then
|
||||
pass "Middleware $mw exists"
|
||||
else
|
||||
fail "Middleware $mw not found — apply middleware.yaml"
|
||||
fi
|
||||
done
|
||||
|
||||
section "19. KeyCape Ingress"
|
||||
check_ingress "keycape" "$KC_HOST"
|
||||
|
||||
section "20. KeyCape TLS certificate"
|
||||
check_tls "kc-tls" "$KC_HOST"
|
||||
|
||||
section "21. KeyCape Secrets"
|
||||
check_secret "keycape-config"
|
||||
|
||||
section "22. KeyCape health endpoint"
|
||||
if [[ -n "$KC_POD" ]]; then
|
||||
STATUS=$(kubectl exec -n "$NAMESPACE" "$KC_POD" -- \
|
||||
wget -qO- http://localhost:8080/healthz 2>/dev/null || echo "")
|
||||
if echo "$STATUS" | grep -qi "ok\|healthy"; then
|
||||
pass "KeyCape /healthz responds OK"
|
||||
else
|
||||
warn "KeyCape /healthz response unclear: '$STATUS'"
|
||||
fi
|
||||
else
|
||||
warn "Skipping KeyCape health check — no running pod"
|
||||
fi
|
||||
|
||||
section "23. KeyCape OIDC discovery"
|
||||
if [[ -n "$KC_POD" ]]; then
|
||||
DISCOVERY=$(kubectl exec -n "$NAMESPACE" "$KC_POD" -- \
|
||||
wget -qO- "http://localhost:8080/.well-known/openid-configuration" 2>/dev/null || echo "")
|
||||
if echo "$DISCOVERY" | grep -q '"issuer"'; then
|
||||
pass "OIDC discovery endpoint returns issuer"
|
||||
ISSUER=$(echo "$DISCOVERY" | python3 -c \
|
||||
"import sys,json; d=json.load(sys.stdin); print(d.get('issuer',''))" 2>/dev/null || echo "")
|
||||
if [[ "$ISSUER" == "https://$KC_HOST" ]]; then
|
||||
pass "OIDC issuer matches CP-NK-004: $ISSUER"
|
||||
else
|
||||
warn "OIDC issuer is '$ISSUER' (expected https://$KC_HOST)"
|
||||
fi
|
||||
else
|
||||
fail "OIDC discovery endpoint did not return expected JSON"
|
||||
fi
|
||||
else
|
||||
warn "Skipping OIDC discovery check — no running pod"
|
||||
fi
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Summary
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
echo " T05 verification: PASS=$PASS WARN=$WARN FAIL=$FAIL"
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
|
||||
if [[ "$FAIL" -gt 0 ]]; then
|
||||
echo " Result: INCOMPLETE — resolve FAIL items before proceeding to T06"
|
||||
exit 1
|
||||
elif [[ "$WARN" -gt 0 ]]; then
|
||||
echo " Result: PARTIAL — T05 core is up; WARN items should be resolved before T06"
|
||||
exit 0
|
||||
else
|
||||
echo " Result: COMPLETE — T05 done-criteria met; proceed to T06 (MFA flow integration)"
|
||||
exit 0
|
||||
fi
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
#!/usr/bin/env bash
|
||||
# Functional realm/resolver and token-backed MFA proof, NK-WP-0034.
|
||||
# Usage: ./verify-t06.sh --user platform-root
|
||||
# Runbook: docs/verify-t06.md (credentials are prompted, not read from old bundles).
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec python3 "$SCRIPT_DIR/privacyidea/verify_mfa.py" "$@"
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
#!/usr/bin/env bash
|
||||
# Explicit private input and preview/readback adapter; see lldap/GROUP-MAINTENANCE.md.
|
||||
set +x
|
||||
set -euo pipefail
|
||||
exec python3 "$(dirname -- "$0")/lldap/lldap_admin.py" verify "$@"
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
#!/usr/bin/env bash
|
||||
# verify-t08.sh — verify NK-WP-0001-T08 done-criteria
|
||||
#
|
||||
# Checks backups, DR readiness, and break-glass account.
|
||||
#
|
||||
# Sections:
|
||||
# 1. Backup CronJobs exist (lldap-backup, authelia-backup, privacyidea-backup)
|
||||
# 2. backup-sa ServiceAccount and RBAC exist
|
||||
# 3. lldap-backup has run successfully at least once
|
||||
# 4. authelia-backup has run successfully at least once
|
||||
# 5. privacyidea-backup has run successfully at least once
|
||||
# 6. privacyIDEA enckey backup exists on PVC
|
||||
# 7. LLDAP SQLite backup exists on PVC
|
||||
# 8. DR-RUNBOOK.md present in repo
|
||||
# 9. KeePassXC ops bundle (pack-bundle.sh) — manual confirmation required
|
||||
#
|
||||
# Usage:
|
||||
# chmod +x verify-t08.sh
|
||||
# ./verify-t08.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SSO_NAMESPACE="sso"
|
||||
MFA_NAMESPACE="mfa"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
WARN=0
|
||||
|
||||
pass() { echo " [PASS] $1"; PASS=$((PASS + 1)); }
|
||||
fail() { echo " [FAIL] $1"; FAIL=$((FAIL + 1)); }
|
||||
warn() { echo " [WARN] $1"; WARN=$((WARN + 1)); }
|
||||
|
||||
section() { echo ""; echo "── $1 ──────────────────────────────────────"; }
|
||||
|
||||
check_cronjob() {
|
||||
local name="$1"; local ns="$2"
|
||||
if kubectl get cronjob "$name" -n "$ns" &>/dev/null; then
|
||||
pass "CronJob $name exists (namespace: $ns)"
|
||||
local schedule
|
||||
schedule=$(kubectl get cronjob "$name" -n "$ns" \
|
||||
-o jsonpath='{.spec.schedule}' 2>/dev/null || echo "?")
|
||||
pass " Schedule: $schedule"
|
||||
else
|
||||
fail "CronJob $name not found in namespace $ns — apply backup/cronjob-sqlite-backups.yaml"
|
||||
fi
|
||||
}
|
||||
|
||||
check_last_job() {
|
||||
local cronjob="$1"; local ns="$2"
|
||||
# Find the most recent Job spawned by this CronJob
|
||||
LAST_JOB=$(kubectl get job -n "$ns" \
|
||||
-l "batch.kubernetes.io/controller-uid" \
|
||||
--sort-by=.metadata.creationTimestamp \
|
||||
-o jsonpath='{.items[-1].metadata.name}' 2>/dev/null || echo "")
|
||||
# Simpler: look for any completed job with the cronjob name prefix
|
||||
SUCCEEDED=$(kubectl get job -n "$ns" \
|
||||
-o jsonpath="{.items[?(@.metadata.ownerReferences[0].name==\"$cronjob\")].status.succeeded}" \
|
||||
2>/dev/null || echo "")
|
||||
if [[ "$SUCCEEDED" == *"1"* ]]; then
|
||||
pass "CronJob $cronjob has at least one successful run"
|
||||
else
|
||||
warn "CronJob $cronjob has no successful runs yet — trigger manually to test:"
|
||||
warn " kubectl create job -n $ns --from=cronjob/$cronjob ${cronjob}-manual-test"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── 1. Backup CronJobs ────────────────────────────────────────────────────────
|
||||
section "1. Backup CronJobs"
|
||||
check_cronjob "lldap-backup" "$SSO_NAMESPACE"
|
||||
check_cronjob "authelia-backup" "$SSO_NAMESPACE"
|
||||
check_cronjob "privacyidea-backup" "$MFA_NAMESPACE"
|
||||
|
||||
# ── 2. RBAC ───────────────────────────────────────────────────────────────────
|
||||
section "2. Backup ServiceAccount and RBAC (namespace: $SSO_NAMESPACE)"
|
||||
if kubectl get serviceaccount backup-sa -n "$SSO_NAMESPACE" &>/dev/null; then
|
||||
pass "ServiceAccount backup-sa exists"
|
||||
else
|
||||
fail "ServiceAccount backup-sa not found — apply backup/cronjob-sqlite-backups.yaml"
|
||||
fi
|
||||
if kubectl get role backup-scaler -n "$SSO_NAMESPACE" &>/dev/null; then
|
||||
pass "Role backup-scaler exists"
|
||||
else
|
||||
fail "Role backup-scaler not found"
|
||||
fi
|
||||
if kubectl get rolebinding backup-sa-scaler -n "$SSO_NAMESPACE" &>/dev/null; then
|
||||
pass "RoleBinding backup-sa-scaler exists"
|
||||
else
|
||||
fail "RoleBinding backup-sa-scaler not found"
|
||||
fi
|
||||
|
||||
# ── 3–5. CronJob run history ──────────────────────────────────────────────────
|
||||
section "3. lldap-backup run history"
|
||||
check_last_job "lldap-backup" "$SSO_NAMESPACE"
|
||||
|
||||
section "4. authelia-backup run history"
|
||||
check_last_job "authelia-backup" "$SSO_NAMESPACE"
|
||||
|
||||
section "5. privacyidea-backup run history"
|
||||
check_last_job "privacyidea-backup" "$MFA_NAMESPACE"
|
||||
|
||||
# ── 6. privacyIDEA enckey backup on PVC ──────────────────────────────────────
|
||||
section "6. privacyIDEA enckey backup on PVC"
|
||||
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 || echo "")
|
||||
if [[ -n "$PI_POD" ]]; then
|
||||
# PI PVC is mounted at /etc/privacyidea (not /data) in the privacyIDEA container
|
||||
BACKUP_COUNT=$(kubectl exec -n "$MFA_NAMESPACE" "$PI_POD" -- \
|
||||
sh -c 'ls /etc/privacyidea/backups/enckey.backup.* 2>/dev/null | wc -l' 2>/dev/null || echo "0")
|
||||
BACKUP_COUNT="${BACKUP_COUNT// /}"
|
||||
if [[ "$BACKUP_COUNT" -gt 0 ]]; then
|
||||
pass "privacyIDEA enckey backups found on PVC ($BACKUP_COUNT file(s))"
|
||||
else
|
||||
warn "No enckey backup files on PVC yet — trigger privacyidea-backup CronJob to create one"
|
||||
warn " kubectl create job -n $MFA_NAMESPACE --from=cronjob/privacyidea-backup pi-backup-test"
|
||||
fi
|
||||
else
|
||||
warn "Skipping enckey backup check — no running privacyIDEA pod"
|
||||
fi
|
||||
|
||||
# ── 7. LLDAP SQLite backup on PVC ────────────────────────────────────────────
|
||||
section "7. LLDAP SQLite backup on PVC"
|
||||
LLDAP_POD=$(kubectl get pod -n "$SSO_NAMESPACE" \
|
||||
-l app.kubernetes.io/name=lldap \
|
||||
--field-selector=status.phase=Running \
|
||||
-o jsonpath='{.items[0].metadata.name}' 2>/dev/null || echo "")
|
||||
if [[ -n "$LLDAP_POD" ]]; then
|
||||
BACKUP_COUNT=$(kubectl exec -n "$SSO_NAMESPACE" "$LLDAP_POD" -- \
|
||||
sh -c 'ls /data/backups/users.backup.* 2>/dev/null | wc -l' 2>/dev/null || echo "0")
|
||||
BACKUP_COUNT="${BACKUP_COUNT// /}"
|
||||
if [[ "$BACKUP_COUNT" -gt 0 ]]; then
|
||||
pass "LLDAP SQLite backups found on PVC ($BACKUP_COUNT file(s))"
|
||||
else
|
||||
warn "No LLDAP backup files on PVC yet — trigger lldap-backup CronJob to create one"
|
||||
warn " kubectl create job -n $SSO_NAMESPACE --from=cronjob/lldap-backup lldap-backup-test"
|
||||
fi
|
||||
else
|
||||
warn "Skipping LLDAP backup check — no running LLDAP pod"
|
||||
fi
|
||||
|
||||
# ── 8. DR runbook present ─────────────────────────────────────────────────────
|
||||
section "8. DR runbook"
|
||||
RUNBOOK="$SCRIPT_DIR/backup/DR-RUNBOOK.md"
|
||||
if [[ -f "$RUNBOOK" ]]; then
|
||||
pass "DR-RUNBOOK.md present at $RUNBOOK"
|
||||
else
|
||||
fail "DR-RUNBOOK.md not found — it should be at sso-mfa/k8s/backup/DR-RUNBOOK.md"
|
||||
fi
|
||||
|
||||
# ── 9. Offsite backup (manual confirmation) ───────────────────────────────────
|
||||
section "9. Offsite backup (manual)"
|
||||
warn "Cannot verify offsite backup automatically — confirm manually:"
|
||||
warn " - pack-bundle.sh has been run with current secrets"
|
||||
warn " - ops-bundle.tar.age stored in a separate physical location"
|
||||
warn " - age decryption key stored separately (NOT in the same location as the bundle)"
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
echo " T08 verification: PASS=$PASS WARN=$WARN FAIL=$FAIL"
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
|
||||
if [[ "$FAIL" -gt 0 ]]; then
|
||||
echo " Result: INCOMPLETE — resolve FAIL items before marking T08 done"
|
||||
exit 1
|
||||
elif [[ "$WARN" -gt 0 ]]; then
|
||||
echo " Result: PARTIAL — structure is in place; resolve WARN items (trigger CronJobs)"
|
||||
exit 0
|
||||
else
|
||||
echo " Result: COMPLETE — T08 done-criteria met; SSO/MFA platform workplan complete!"
|
||||
exit 0
|
||||
fi
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
{
|
||||
"schema": "nk49.keycape-native-fixture-proposal.v1",
|
||||
"status": "prepared_not_executed_not_approved",
|
||||
"source_sha256": {
|
||||
"sso-mfa/bootstrap/sops-private.py": "sha256:acac68e2936ed38b7d7c9e35eb4619647c99c117a3928a1c5ce704b570906780",
|
||||
"sso-mfa/k8s/privacyidea/key-custody-remote.py": "sha256:27b2b885c81923d85054e3999c5613e104fe627779b2b50398ed9bed5088f47e",
|
||||
"tests/run_isolated_keycape_native_fixture.py": "sha256:ecbadecc475622eaaf20a726110aebef5f4fe7af141110a98aca1640179b4a03",
|
||||
"tests/test_identity_bundle.py": "sha256:be5ce74097594cc7f7e77dbccd6cb53aa72f263cd1bb442ce5464038aed1116c",
|
||||
"tests/test_isolated_keycape_fixture_plan.py": "sha256:5945619f4c5957187763276cc900870a9f08f9475b63c240694688e0c2eca7dd",
|
||||
"tests/test_isolated_keycape_recovery.py": "sha256:247e9a87f5be2ee9f20f7edbb4a9f612ab7cb0cd940392c3caeca8b046605f55",
|
||||
"tests/test_isolated_keycape_session.py": "sha256:cbdf51efde26eff337e9303a2c431f80b333b8846d763eb62853f81eb3f13e2c",
|
||||
"tools/bootstrap-recovery/capture-identity.py": "sha256:47dca7c4dc96fa7eb9fb02c81fca9ca8dc5281aac918979eb7367148168ba705",
|
||||
"tools/bootstrap-recovery/check-privacyidea-database.py": "sha256:90f454c87998d7f5d625e3c6656c3418f8f56275dc1879a9d19ed605431b94a4",
|
||||
"tools/bootstrap-recovery/fetch-privacyidea-backup.py": "sha256:233160e1206643a5cf7ad05141d733f0a75aff651190e27f77515a124ff0a59f",
|
||||
"tools/bootstrap-recovery/identity-backup-profile.json": "sha256:40f9e5b5bb957e5b23e03b31701702b850502d16a92a0fa0ebf92618cc3f18a1",
|
||||
"tools/bootstrap-recovery/identity-bundle.py": "sha256:524a03c065db4307fcde83d33af39503f3de19cb6ef3a8ca72c991d4a8c08ee6",
|
||||
"tools/bootstrap-recovery/identity-credential-setup.py": "sha256:ddd6127fa331c0185523ec3bb22d743a791edd56a2e6af7ad8d7949b7c051141",
|
||||
"tools/bootstrap-recovery/identity-custody.json": "sha256:f8aee8487ed01d7940b82676bb4a5c8d61f51b6c598521b8b8a73588aaea4d4f",
|
||||
"tools/bootstrap-recovery/identity-files.py": "sha256:d0063a9eedd3610e3d45bd2d834aae83cff74e4b3504d138b3548f427e89331d",
|
||||
"tools/bootstrap-recovery/identity-isolation.py": "sha256:88d1e13112a44c79571825af89d3fe5b01fa490cff2653badef4c62a45c844f1",
|
||||
"tools/bootstrap-recovery/identity-offsite.py": "sha256:e7c31fa9ee081961af873348ee3b4829a645d40c7dc7f1e22c564aa9ed01bc49",
|
||||
"tools/bootstrap-recovery/identity-retention.py": "sha256:a41ea4759f69c35548149fc8d0d5ace547f16cf9fe4c9dac10b119a4d0becdec",
|
||||
"tools/bootstrap-recovery/identity-runtime.py": "sha256:a2c0acf567c204705b90be64d561d0fda2285e0fc71a962d6e1b149f571e1940",
|
||||
"tools/bootstrap-recovery/install-identity-backup.py": "sha256:5c612a2a51c9b59aad8cc01d418c2f563cbd92c62dc1fa8be994e5fb59df90f1",
|
||||
"tools/bootstrap-recovery/inventory-scaleway.py": "sha256:a219df205708fa4ed44b763a418585d63c820c25434bc0fd14e805eb5c2f79d3",
|
||||
"tools/bootstrap-recovery/inventory-storage-share.py": "sha256:673f9b17fb2f3fef6fcdb5c50a2bcca69e3df3157a9e5582a2d995c6b87640b5",
|
||||
"tools/bootstrap-recovery/isolated-keycape-session.py": "sha256:8fa38efe28167b59a6307f342b2ac8fcda78ecda60e6228f22017424d2343c22",
|
||||
"tools/bootstrap-recovery/isolated-keycape.py": "sha256:f67627b7d1647d75187dd899874efe22383bc91de5d97dabb9801076668ab3c2",
|
||||
"tools/bootstrap-recovery/isolated-lldap.py": "sha256:35bfa8411186ad936a199bd450c7e18c2ad51059301a6db9650e6bd055ab1b81",
|
||||
"tools/bootstrap-recovery/isolated-privacyidea.py": "sha256:4df7196678aac1ad594f32abf45c68dc21a9d2760d9e8a536a215a4a3c5d277f",
|
||||
"tools/bootstrap-recovery/prepare-identity-coverage.py": "sha256:cd5f3067d28b4c7cc93ed08b8cf3869218bdecf7c54b5dffed22d697de71dbb1",
|
||||
"tools/bootstrap-recovery/recovery-kit.py": "sha256:6fa0f1494dccaf003b6c6396c5de8b36faa2b276eeb16badd04383f5efb71e66",
|
||||
"tools/bootstrap-recovery/retrieve-identity-offline.py": "sha256:3421dbb3f3280a001d33ea2deb12ab609404a0b4254cd43c21518d77d166f972",
|
||||
"tools/bootstrap-recovery/retrieve-identity.py": "sha256:ec123d47a07305253570dacab7e103f8c193a23f20c9fc87466ea4a95ae0ec13",
|
||||
"tools/bootstrap-recovery/run-isolated-keycape.sh": "sha256:0d994c9c14ebdffdbf7879bd7562042531083d0e8955424d0ae099898a141668",
|
||||
"tools/bootstrap-recovery/verify-bundle.py": "sha256:885b05a2011e711228e42e316bda70bbe0d97d481383d61aa8469d0f10452370",
|
||||
"tools/bootstrap-recovery/verify-identity-delivery.py": "sha256:63658d1976806f8d0ce80ba8f750bbcfdca59da936b6eb6a78a250f0e53710f1",
|
||||
"tools/bootstrap-recovery/verify-identity.py": "sha256:039864d0141b552887752bf17cfdcf1bef4bdc9126f946a9c0533e4fd54ebf4f",
|
||||
"sso-mfa/bootstrap/sops-custody-unlock.sh": "sha256:86c527a8ae0d0cca66a2345c566b25fbff5bc52c1e22c725548c0fbb2f86a58e",
|
||||
"sso-mfa/bootstrap/sops-custody-fd.py": "sha256:df29eaba83ec6b5fc311664dcf4b22d8f3c8ed7274ed8e6c419ada0dcd169c0f",
|
||||
"tools/bootstrap-recovery/prepare-isolated-keycape.py": "sha256:5098bdfa4e0ad30627c1716e5e355a2c78dca6ccc607bd2148a2e06126632288",
|
||||
"tests/test_prepare_isolated_keycape.py": "sha256:4b0871f9a11a3940983464f1b40eca1613110b1cb3cb9694f4891d58e14c700a",
|
||||
"tests/test_sealed_custody.py": "sha256:f0fa07c89e2419464dbee0489462864a4533770417a554b1ab1d1ed5d7b6c49f",
|
||||
"tests/test_private_manifest_binding.py": "sha256:2e7ee16af65390343435b7210ef191aa2a10e9dcbb8759ed95fa8bf6fb81893b"
|
||||
},
|
||||
"fixed_endpoint": "unix:///var/run/docker.sock",
|
||||
"engine_id": "74f93783-6cef-43a8-a3c4-f62586cd3bc1",
|
||||
"engine_name": "bnt-lap001",
|
||||
"images": [
|
||||
"forgejo.coulomb.social/coulomb/key-cape@sha256:cdec9dd7c515f7e0a6169fc3fa560ee2cc47bf40d899755dec808c362f5ce010",
|
||||
"ghcr.io/gpappsoft/privacyidea-docker@sha256:af7841adad262f129e0c1d4f553af13f21cb2f4dc713533f316cfe43ed0b4473"
|
||||
],
|
||||
"pull_policy": "never",
|
||||
"synthetic_issuer": "https://nk49-fixture.invalid",
|
||||
"cases": [
|
||||
"positive",
|
||||
"hard-parent-loss",
|
||||
"deadline",
|
||||
"nonzero-after-accept"
|
||||
],
|
||||
"limits": {
|
||||
"sequential_cases": true,
|
||||
"max_live_service_containers": 2,
|
||||
"max_tmpfs_volumes": 4,
|
||||
"max_public_probe_containers": 1,
|
||||
"service_memory_bytes_each": 536870912,
|
||||
"service_cpus_each": 1,
|
||||
"tmpfs_volume_bytes_each": 33554432,
|
||||
"host_tmpfs_fixture_limit_expected_bytes": 8388608,
|
||||
"execution_deadline_seconds": {
|
||||
"deadline_case": 20,
|
||||
"other_cases": 120
|
||||
},
|
||||
"driver_wait_seconds_per_case": 150
|
||||
},
|
||||
"resources": {
|
||||
"container_names": "nk49-kc-<fresh-run-id>-{transport,issuer,image-probe}",
|
||||
"volume_names": "nk49-kc-<fresh-run-id>-v{0,1,2,3}",
|
||||
"label": "net-kingdom.recovery-run=<fresh-run-id>",
|
||||
"host_plaintext": "exact private /dev/shm/nk49-keycape-<run-id>-<suffix> registered before writes",
|
||||
"synthetic_source_root": "private /dev/shm/nk49-keycape-fixture-<suffix>",
|
||||
"safe_result": "private /tmp/nk49-keycape-native-fixture-review-<suffix>/result.json"
|
||||
},
|
||||
"scope": "candidate itself on complete synthetic format/integrity archive fixture; no native PI/LLDAP/Authelia proof, real custody, real factor or real client credential; successor binds sealed-descriptor custody preparation sources; synthetic fixture itself overrides custody with generated fixture key as before",
|
||||
"mutations": [
|
||||
"conditional creation of exact fresh local containers and local-driver tmpfs volumes",
|
||||
"private synthetic file import to those volumes",
|
||||
"original-image KeyCape service/client exchange within same loopback-only namespace",
|
||||
"exact owned quiescent removal and bounded safe receipts"
|
||||
],
|
||||
"forbidden": [
|
||||
"Docker execution before independent approval",
|
||||
"image pulls/builds/pushes",
|
||||
"production routes/ports/host networking/host directory mounts",
|
||||
"OpenBao/Kubernetes/provider APIs",
|
||||
"actual identity archive, age key, TLS key, user password, factor or client secret"
|
||||
],
|
||||
"cleanup": "candidate exact ownership/volume-driver/inode checks; quiesce fresh owner SID/PGID via pidfds first; retained inflight uncertainty never accepted even on absent readback",
|
||||
"oracles": [
|
||||
"positive issuer-native verify-client must establish original-hostname HTTPS trust; helper alone never suffices",
|
||||
"same original single RS256 key-1 JWKS and exact config/registry/policy/history/lock/deliveries",
|
||||
"correct service exchange and explicit wrong-secret/excess-scope denial through existing native verifier",
|
||||
"hard-parent-loss and tightened deadline both quiesce before exact resource/plaintext cleanup",
|
||||
"after real Docker run accepts a synthetic container, inject returncode1; require failed recovery and sticky daemon uncertainty after cleanup"
|
||||
],
|
||||
"proposed_command": [
|
||||
"python3",
|
||||
"tests/run_isolated_keycape_native_fixture.py",
|
||||
"--execute-reviewed-fixture",
|
||||
"--expected-manifest-sha256",
|
||||
"<SHA256-of-this-file>",
|
||||
"--case",
|
||||
"positive"
|
||||
],
|
||||
"extended_command": [
|
||||
"python3",
|
||||
"tests/run_isolated_keycape_native_fixture.py",
|
||||
"--execute-reviewed-fixture",
|
||||
"--expected-manifest-sha256",
|
||||
"<SHA256-of-this-file>",
|
||||
"--case",
|
||||
"all"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,221 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Prepare/run the exact reviewed T18 candidate on disposable synthetic material.
|
||||
|
||||
Default --plan performs no Docker calls and generates no key/material. Execution
|
||||
requires the reviewed fixture manifest hash and source closure. This runner calls
|
||||
the candidate itself; hooks only pause or inject a post-response transport fault.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
from datetime import datetime,timedelta,timezone
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
MANIFEST=ROOT/'tests/isolated-keycape-native-fixture-manifest.json'
|
||||
ISSUER='https://nk49-fixture.invalid'
|
||||
CASES=('positive','hard-parent-loss','deadline','nonzero-after-accept')
|
||||
|
||||
|
||||
def load(name,path):
|
||||
spec=importlib.util.spec_from_file_location(name,path)
|
||||
module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module);return module
|
||||
|
||||
s=load('session',ROOT/'tools/bootstrap-recovery/isolated-keycape-session.py')
|
||||
|
||||
|
||||
def require(condition):
|
||||
if not condition:raise RuntimeError('synthetic_fixture_refused')
|
||||
|
||||
|
||||
def reviewed_manifest(expected):
|
||||
raw=MANIFEST.read_bytes();require(hashlib.sha256(raw).hexdigest()==expected)
|
||||
manifest=json.loads(raw)
|
||||
for name,digest in manifest['source_sha256'].items():
|
||||
require(isinstance(digest,str) and re.fullmatch('sha256:[a-f0-9]{64}',digest))
|
||||
require('sha256:'+hashlib.sha256((ROOT/name).read_bytes()).hexdigest()==digest)
|
||||
return manifest
|
||||
|
||||
|
||||
def private_write(path,value):
|
||||
path.parent.mkdir(mode=0o700,parents=True,exist_ok=True)
|
||||
fd=os.open(path,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
with os.fdopen(fd,'wb') as out:out.write(value)
|
||||
|
||||
|
||||
def materials(root):
|
||||
"""Reuse the existing full archive fixture factory, no real archive accepted."""
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes,serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
import yaml
|
||||
existing=load('existing_bundle_fixture',ROOT/'tests/test_identity_bundle.py')
|
||||
payload=existing.stage.__wrapped__(root)
|
||||
config={'issuer':ISSUER,'port':8080,'privateKeyPem':'/etc/keycape/key.pem','environment':'dev','tokenLifetime':'5m',
|
||||
'lldap':{'url':'ldap://127.0.0.1:1','bindDN':'cn=fixture','bindPW':'synthetic-only','baseDN':'dc=fixture'},
|
||||
'authelia':{'baseURL':'http://127.0.0.1:1','clientId':'synthetic','clientSecret':'synthetic','redirectURI':ISSUER+'/authorize/callback'},
|
||||
'privacyidea':{'baseURL':'http://127.0.0.1:1'},
|
||||
'clients':[{'clientId':'nk49-fixture-consumer','clientType':'confidential','audience':'nk49-fixture',
|
||||
'grantTypes':['client_credentials'],'allowedScopes':['fixture:read'],'secretRef':'env:KEYCAPE_FIXTURE_CLIENT_SECRET',
|
||||
'serviceSubject':'service:nk49-fixture','tenant':'tenant:fixture','roles':['fixture-reader']}]+
|
||||
[{'clientId':name,'displayName':name,'clientType':'public','grantTypes':['authorization_code'],
|
||||
'redirectUris':['https://nk49-fixture.invalid/callback'],'allowedScopes':['openid'],'mfaRequired':True}
|
||||
for name in ('vergabe-demo-company','user-engine-portal')]}
|
||||
secret_path=payload/'resources/sso/secret/keycape-config.json';secret=json.loads(secret_path.read_text())
|
||||
secret['data']['config.yaml']=base64.b64encode(yaml.safe_dump(config).encode()).decode();secret_path.write_text(json.dumps(secret))
|
||||
client_path=payload/'resources/sso/secret/keycape-rapp-qonto-client.json';client=json.loads(client_path.read_text())
|
||||
client['data']={'client-secret':base64.b64encode(b'synthetic-fixture-client-secret').decode()};client_path.write_text(json.dumps(client))
|
||||
factor_path=payload/'resources/sso/secret/keycape-factor-read.json';factor=json.loads(factor_path.read_text())
|
||||
factor['data']={'admin-token':base64.b64encode(b'synthetic-unused-factor-reader').decode()};factor_path.write_text(json.dumps(factor))
|
||||
dep_path=payload/'resources/sso/deployment/keycape.json';dep=json.loads(dep_path.read_text())
|
||||
dep['spec']={'template':{'spec':{'containers':[{'name':'keycape','image':s.adapter.IMAGE,'env':[
|
||||
{'name':'KEYCAPE_CONFIG','value':'/etc/keycape/config.yaml'},
|
||||
{'name':'KEYCAPE_POLICY_PATH','value':'/var/lib/keycape-policy/policy.json'},
|
||||
{'name':'KEYCAPE_FIXTURE_CLIENT_SECRET','valueFrom':{'secretKeyRef':{'name':'keycape-rapp-qonto-client','key':'client-secret'}}}]}]}}};dep_path.write_text(json.dumps(dep))
|
||||
policy={'Revision':1,'Modes':{k:'mandatory' for k in ('vergabe-demo-company','user-engine-portal')},'History':[
|
||||
{'reference':'synthetic-retained-receipt','actor':'service:fixture','client':'user-engine-portal','before':'mandatory','after':'mandatory','revision':1,'at':1}]}
|
||||
# JSON tags in the owner store are lowercase; retain an existing lock inode
|
||||
# representation as part of the archived tree, never synthesize it after import.
|
||||
policy={k.lower():v for k,v in policy.items()};(payload/'data/keycape/policy.json').write_text(json.dumps(policy))
|
||||
private_write(payload/'data/keycape/policy.json.lock',b'')
|
||||
key=rsa.generate_private_key(public_exponent=65537,key_size=2048)
|
||||
subject=x509.Name([x509.NameAttribute(NameOID.COMMON_NAME,'nk49-fixture.invalid')]);now=datetime.now(timezone.utc)
|
||||
cert=(x509.CertificateBuilder().subject_name(subject).issuer_name(subject).public_key(key.public_key()).serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now-timedelta(minutes=5)).not_valid_after(now+timedelta(days=1))
|
||||
.add_extension(x509.SubjectAlternativeName([x509.DNSName('nk49-fixture.invalid')]),critical=False)
|
||||
.add_extension(x509.BasicConstraints(ca=True,path_length=None),critical=True).sign(key,hashes.SHA256()))
|
||||
tls={'/recovery-tls/server.pem':cert.public_bytes(serialization.Encoding.PEM),
|
||||
'/recovery-tls/server-key.pem':key.private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.PKCS8,serialization.NoEncryption()),
|
||||
'/recovery-tls/trust.pem':cert.public_bytes(serialization.Encoding.PEM)}
|
||||
for path,value in tls.items():private_write(root/Path(path).name,value)
|
||||
age=root/'synthetic-age.key';s.run(['age-keygen','-o',str(age)])
|
||||
recipient=s.run(['age-keygen','-y',str(age)]).decode().strip();profile=s.bundle.profile();profile['public_recipient']=recipient
|
||||
s.bundle.profile=lambda:profile
|
||||
artifact=root/'net-kingdom-identity-20261016T063159Z-0123456789ab.tar.age';s.bundle.seal(payload,artifact,{'synthetic_native_keycape_fixture':True})
|
||||
raw=s.run(['age','-d','-i',str(age),str(artifact)],max_output=s.bundle.LIMIT)
|
||||
with tarfile.open(fileobj=io.BytesIO(raw)) as archive:manifest=json.load(archive.extractfile('manifest.json'))
|
||||
transfer={'schema':'netkingdom.identity-offsite.v1','status':'readback_verified','provider':'Storage Share',
|
||||
'folder':'Backup/railiance-backups/net-kingdom-identity','artifact':artifact.name,'ciphertext_bytes':artifact.stat().st_size,
|
||||
'ciphertext_sha256':hashlib.sha256(artifact.read_bytes()).hexdigest(),'etag':'"synthetic"','fetched_path':str(artifact),
|
||||
'independent_owner_retrieval_verified':True}
|
||||
# Derive public executable identity only from the exact cached admitted image.
|
||||
probe=s.plan_session();name=probe['public_image_probe']
|
||||
try:
|
||||
s.run(['docker','create','--pull=never','--name',name,'--network','none','--read-only','--label',s.adapter.LABEL+'='+probe['run_id'],s.adapter.IMAGE])
|
||||
binary=s.adapter.container_file(name,'/keycape')
|
||||
finally:s.adapter.cleanup({**probe,'containers':[name]}, {})
|
||||
admission={'issuer':ISSUER,'ciphertext_sha256':transfer['ciphertext_sha256'],
|
||||
'manifest_sha256':hashlib.sha256(json.dumps(manifest,sort_keys=True,separators=(',',':')).encode()).hexdigest(),
|
||||
'verifier_sha256':hashlib.sha256(binary).hexdigest(),'trust_sha256':hashlib.sha256(tls['/recovery-tls/trust.pem']).hexdigest(),
|
||||
'tls_cert_sha256':hashlib.sha256(tls['/recovery-tls/server.pem']).hexdigest(),
|
||||
'consumer':{'client_id':'nk49-fixture-consumer','secret_env':'KEYCAPE_FIXTURE_CLIENT_SECRET','audience':'nk49-fixture','scope':'fixture:read',
|
||||
'subject':'service:nk49-fixture','tenant':'tenant:fixture','roles':'fixture-reader','deny_scope':'fixture:write'}}
|
||||
private_write(root/'fixture.json',json.dumps({'schema':'nk49.generated-keycape-fixture.v1','transfer':transfer,'admission':admission,'recipient':recipient}).encode())
|
||||
|
||||
|
||||
def worker(root,case):
|
||||
execution=s.isolate_execution();execution['deadline']=time.monotonic()+(20 if case=='deadline' else 120)
|
||||
data=json.loads((root/'fixture.json').read_text());require(data['schema']=='nk49.generated-keycape-fixture.v1')
|
||||
profile=s.bundle.profile();profile['public_recipient']=data['recipient'];s.bundle.profile=lambda:profile
|
||||
s.local.verify.custody.private_key_fd=lambda:os.open(root/'synthetic-age.key',os.O_RDONLY|os.O_NOFOLLOW)
|
||||
tls={'/recovery-tls/'+name:(root/name).read_bytes() for name in ('server.pem','server-key.pem','trust.pem')}
|
||||
receipt={};output=root/(case+'.json');checkpoint=lambda r:output.write_text(json.dumps(r))
|
||||
provision=s.provision
|
||||
def paused(*args):
|
||||
provision(*args)
|
||||
if case in ('hard-parent-loss','deadline'):
|
||||
private_write(root/(case+'.barrier'),b'private-import-and-default-launch-complete')
|
||||
time.sleep(150)
|
||||
s.provision=paused
|
||||
if case=='nonzero-after-accept':
|
||||
original=s.adapter.subprocess.run;injected=False
|
||||
def fault(argv,**kwargs):
|
||||
nonlocal injected
|
||||
result=original(argv,**kwargs)
|
||||
if argv[:3]==['docker','--host',s.adapter.ENDPOINT] and 'run' in argv and not injected and result.returncode==0:
|
||||
injected=True
|
||||
return subprocess.CompletedProcess(argv,1,b'',b'')
|
||||
return result
|
||||
s.adapter.subprocess.run=fault
|
||||
try:s.execute(data['transfer'],data['admission'],tls,receipt,execution,checkpoint=checkpoint)
|
||||
except BaseException:receipt['status']='failed'
|
||||
finally:output.write_text(json.dumps(receipt))
|
||||
return int(receipt.get('status')!='isolated_keycape_native_registration_checked')
|
||||
|
||||
|
||||
def run_cases(root,summary,cases):
|
||||
for case in cases:
|
||||
child=subprocess.Popen([sys.executable,str(Path(__file__).resolve()),'--worker-root',str(root),'--worker-case',case,
|
||||
'--execute-reviewed-fixture','--expected-manifest-sha256',summary['manifest_sha256']],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
|
||||
try:
|
||||
end=time.monotonic()+150
|
||||
if case=='hard-parent-loss':
|
||||
while not (root/(case+'.barrier')).exists() and child.poll() is None and time.monotonic()<end:time.sleep(.05)
|
||||
require((root/(case+'.barrier')).exists())
|
||||
execution=json.loads((root/(case+'.json')).read_text())['execution_binding'];require(execution['pid']==child.pid)
|
||||
fd=os.pidfd_open(child.pid)
|
||||
try:signal.pidfd_send_signal(fd,signal.SIGKILL)
|
||||
finally:os.close(fd)
|
||||
child.wait(timeout=max(1,end-time.monotonic()))
|
||||
receipt=json.loads((root/(case+'.json')).read_text())
|
||||
summary['receipts'][case]=receipt
|
||||
if case=='deadline':require((root/(case+'.barrier')).exists())
|
||||
if case=='positive':
|
||||
require(receipt.get('status')=='isolated_keycape_native_registration_checked' and receipt.get('container_cleanup_verified') and receipt.get('temporary_plaintext_cleanup_verified'))
|
||||
require(receipt.get('native_service_registration_verified') and receipt.get('original_public_signing_key_in_live_jwks'))
|
||||
summary['actual_issuer_original_hostname_tls_verified']=True
|
||||
else:
|
||||
require(receipt.get('status')!='isolated_keycape_native_registration_checked')
|
||||
outcome=Path(receipt['reaper_outcome_path'])
|
||||
while not outcome.exists() and time.monotonic()<end:time.sleep(.05)
|
||||
require(outcome.exists());cleanup=json.loads(outcome.read_text())
|
||||
require(cleanup.get('execution_quiescence_verified') and cleanup.get('temporary_plaintext_cleanup_verified'))
|
||||
if case=='nonzero-after-accept':require(cleanup.get('status')=='bounded_cleanup_uncertain' and cleanup.get('in_flight_daemon_request_uncertain'))
|
||||
else:require(cleanup.get('status')=='bounded_cleanup_verified')
|
||||
summary['cases'][case]='passed'
|
||||
finally:
|
||||
if child.poll() is None:
|
||||
fd=os.pidfd_open(child.pid)
|
||||
try:signal.pidfd_send_signal(fd,signal.SIGKILL)
|
||||
finally:os.close(fd)
|
||||
child.wait(timeout=5)
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--plan',action='store_true')
|
||||
p.add_argument('--execute-reviewed-fixture',action='store_true');p.add_argument('--expected-manifest-sha256')
|
||||
p.add_argument('--worker-root');p.add_argument('--worker-case',choices=CASES);p.add_argument('--case',choices=(*CASES,'all'),default='positive');args=p.parse_args()
|
||||
if not args.execute_reviewed_fixture:
|
||||
print(MANIFEST.read_text());return 0
|
||||
require(not args.plan);manifest=reviewed_manifest(args.expected_manifest_sha256);os.umask(0o077)
|
||||
if args.worker_root:
|
||||
root=Path(args.worker_root);require(root.parent==Path('/dev/shm') and root.name.startswith('nk49-keycape-fixture-'))
|
||||
s.bundle.private(root,True);return worker(root,args.worker_case)
|
||||
require(not args.worker_case)
|
||||
s.local.preflight();s.run(['docker','image','inspect',s.adapter.IMAGE,'--format','{{.Id}}'])
|
||||
safe=Path(tempfile.mkdtemp(prefix='nk49-keycape-native-fixture-review-',dir='/tmp'))
|
||||
summary={'schema':'nk49.synthetic-keycape-native-fixture.v1','status':'failed','manifest_sha256':args.expected_manifest_sha256,
|
||||
'cases':{},'receipts':{},'actual_credentials_used':False,'actual_recovery_proven':False,'docker_executed':True}
|
||||
print('Safe synthetic fixture result: '+str(safe/'result.json'),flush=True)
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix='nk49-keycape-fixture-',dir='/dev/shm') as temporary:
|
||||
root=Path(temporary);materials(root);run_cases(root,summary,CASES if args.case=='all' else (args.case,))
|
||||
summary['status']='synthetic_native_fixture_passed'
|
||||
finally:(safe/'result.json').write_text(json.dumps(summary,indent=2)+'\n')
|
||||
print('Safe synthetic fixture result: '+str(safe/'result.json'))
|
||||
return int(summary['status']!='synthetic_native_fixture_passed')
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
"""Disposable age keys only; subprocess logs are never asserted with raw values."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BOOT = ROOT / 'sso-mfa/bootstrap'
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def custody(tmp_path):
|
||||
home = tmp_path / 'home'; home.mkdir()
|
||||
key = tmp_path / 'disposable.key'
|
||||
subprocess.run(['age-keygen', '-o', str(key)], capture_output=True, check=True)
|
||||
public = subprocess.check_output(['age-keygen', '-y', str(key)], text=True).strip()
|
||||
env = {'PATH': os.environ['PATH'], 'HOME': str(home), 'TMPDIR': str(tmp_path)}
|
||||
return key, public, env
|
||||
|
||||
|
||||
def unlock(custody, *args):
|
||||
_, public, env = custody
|
||||
return subprocess.run(['bash', str(BOOT / 'sops-custody-unlock.sh'), '--expected-recipient', public, *args],
|
||||
env=env, capture_output=True, text=True, timeout=10)
|
||||
|
||||
|
||||
def test_custody_unknown_arguments_are_not_echoed(custody):
|
||||
result = unlock(custody, 'synthetic-secret-argument')
|
||||
assert result.returncode != 0
|
||||
assert 'synthetic-secret-argument' not in result.stdout + result.stderr
|
||||
|
||||
|
||||
def test_signal_preserves_failure_status_and_removes_temporary_key(custody):
|
||||
key, _, env = custody
|
||||
result = unlock(custody, '--from-file', str(key), '--', 'sh', '-c', 'kill -TERM "$PPID"; exit 0')
|
||||
assert result.returncode == 143
|
||||
assert not list(Path(env['TMPDIR']).glob('netkingdom-sops-age.*'))
|
||||
|
||||
|
||||
def test_ambiguous_custody_sources_are_refused_before_child(custody, tmp_path):
|
||||
key, _, _ = custody
|
||||
marker = tmp_path / 'child-called'
|
||||
result = unlock(custody, '--from-file', str(key), '--from-file', str(key), '--', 'touch', str(marker))
|
||||
assert result.returncode != 0
|
||||
assert not marker.exists()
|
||||
|
||||
|
||||
def test_explicit_custody_check_cleans_key(custody):
|
||||
key, _, env = custody
|
||||
result = unlock(custody, '--from-file', str(key), '--check-only')
|
||||
assert result.returncode == 0
|
||||
assert not list(Path(env['TMPDIR']).glob('netkingdom-sops-age.*'))
|
||||
|
||||
|
||||
def test_prompt_does_not_leak_when_shell_tracing_is_requested(custody):
|
||||
import pty, select, time
|
||||
key, public, env = custody
|
||||
private_line = next(line for line in key.read_bytes().splitlines() if line.startswith(b'AGE-SECRET-KEY-'))
|
||||
master, slave = pty.openpty()
|
||||
child = subprocess.Popen(['bash', '-x', str(BOOT / 'sops-custody-unlock.sh'),
|
||||
'--expected-recipient', public, '--prompt', '--check-only'],
|
||||
stdin=slave, stdout=slave, stderr=slave, env=env)
|
||||
os.close(slave)
|
||||
captured = bytearray(); sent = False
|
||||
deadline = time.monotonic() + 10
|
||||
try:
|
||||
while time.monotonic() < deadline:
|
||||
if select.select([master], [], [], 0.05)[0]:
|
||||
try: chunk = os.read(master, 65536)
|
||||
except OSError: break
|
||||
if not chunk: break
|
||||
captured.extend(chunk)
|
||||
if b'input hidden): ' in captured and not sent:
|
||||
os.write(master, private_line + b'\n'); sent = True
|
||||
assert child.wait(timeout=2) == 0
|
||||
assert bool(private_line in captured) is False
|
||||
assert not list(Path(env['TMPDIR']).glob('netkingdom-sops-age.*'))
|
||||
finally:
|
||||
if child.poll() is None: child.kill(); child.wait()
|
||||
os.close(master)
|
||||
|
||||
|
||||
def encrypt_fixture(tmp_path, public):
|
||||
secrets = tmp_path / 'secrets'; (secrets / 'service').mkdir(parents=True)
|
||||
(secrets / 'service/one.env').write_text('TEST_VALUE=synthetic-private-payload\n')
|
||||
out = tmp_path / 'secrets.enc/service'; out.mkdir(parents=True)
|
||||
subprocess.run(['age', '-r', public, '-o', str(out / 'one.env.age'), str(secrets / 'service/one.env')],
|
||||
capture_output=True, check=True)
|
||||
return secrets, out
|
||||
|
||||
|
||||
def test_encryption_refuses_private_key_as_recipient(custody, tmp_path):
|
||||
key, public, env = custody
|
||||
secrets, _ = encrypt_fixture(tmp_path, public)
|
||||
result = subprocess.run(['bash', str(BOOT / 'encrypt-secrets.sh'), str(secrets), str(key), '--no-shred'],
|
||||
env=env, capture_output=True, text=True, timeout=10)
|
||||
assert result.returncode != 0
|
||||
assert 'public recipient' in result.stderr
|
||||
assert (secrets / 'service/one.env').exists()
|
||||
|
||||
|
||||
def test_decryption_does_not_fall_back_to_home_key(custody, tmp_path):
|
||||
key, public, env = custody
|
||||
encrypt_fixture(tmp_path, public)
|
||||
home_key = Path(env['HOME']) / '.config/net-kingdom/age.key'
|
||||
home_key.parent.mkdir(parents=True); home_key.write_bytes(key.read_bytes())
|
||||
output = tmp_path / 'restored'
|
||||
result = subprocess.run(['bash', str(BOOT / 'decrypt-secrets.sh'), str(output)],
|
||||
env=env, capture_output=True, text=True, timeout=10)
|
||||
assert result.returncode != 0
|
||||
assert not output.exists()
|
||||
|
||||
|
||||
def test_decryption_failure_removes_partial_plaintext(custody, tmp_path):
|
||||
key, public, env = custody
|
||||
_, encrypted = encrypt_fixture(tmp_path, public)
|
||||
(encrypted / 'two.env.age').write_bytes(b'not-a-valid-ciphertext')
|
||||
output = tmp_path / 'restored'
|
||||
result = subprocess.run(['bash', str(BOOT / 'decrypt-secrets.sh'), str(output), str(key)],
|
||||
env=env, capture_output=True, text=True, timeout=10)
|
||||
assert result.returncode != 0
|
||||
assert not output.exists()
|
||||
assert not list(tmp_path.glob('.netkingdom-decrypt.*'))
|
||||
|
||||
|
||||
def test_explicit_decryption_has_private_modes_and_no_output(custody, tmp_path):
|
||||
key, public, env = custody
|
||||
original, _ = encrypt_fixture(tmp_path, public)
|
||||
output = tmp_path / 'restored'
|
||||
result = subprocess.run(['bash', str(BOOT / 'decrypt-secrets.sh'), str(output), str(key)],
|
||||
env=env, capture_output=True, text=True, timeout=10)
|
||||
assert result.returncode == 0
|
||||
restored = output / 'service/one.env'
|
||||
assert restored.read_bytes() == (original / 'service/one.env').read_bytes()
|
||||
assert output.stat().st_mode & 0o077 == 0
|
||||
assert restored.stat().st_mode & 0o077 == 0
|
||||
assert 'synthetic-private-payload' not in result.stdout + result.stderr
|
||||
|
||||
|
||||
def test_default_encryption_uses_registered_public_file(custody, tmp_path):
|
||||
import shutil
|
||||
_, public, env = custody
|
||||
checkout = tmp_path / 'fixture-repo'
|
||||
script_dir = checkout / 'sso-mfa/bootstrap'; script_dir.mkdir(parents=True)
|
||||
shutil.copyfile(BOOT / 'encrypt-secrets.sh', script_dir / 'encrypt-secrets.sh')
|
||||
(checkout / 'keys').mkdir(); (checkout / 'keys/age.pub').write_text(public + '\n')
|
||||
secrets = tmp_path / 'plaintext'; (secrets / 'service').mkdir(parents=True)
|
||||
(secrets / 'service/test.env').write_text('TEST=synthetic-private-payload\n')
|
||||
result = subprocess.run(['bash', str(script_dir / 'encrypt-secrets.sh'), str(secrets)],
|
||||
env=env, capture_output=True, text=True, timeout=10)
|
||||
assert result.returncode == 0
|
||||
assert not secrets.exists()
|
||||
assert (tmp_path / 'secrets.enc/service/test.env.age').is_file()
|
||||
assert not (Path(env['HOME']) / '.config').exists()
|
||||
|
||||
|
||||
def test_custody_child_does_not_inherit_competing_inline_key(custody):
|
||||
key, public, env = custody
|
||||
env = {**env, 'SOPS_AGE_KEY': 'synthetic-competing-key', 'SOPS_AGE_KEY_CMD': 'false'}
|
||||
result = unlock((key, public, env), '--from-file', str(key), '--', 'sh', '-c',
|
||||
'test -z "${SOPS_AGE_KEY+x}" && test -z "${SOPS_AGE_KEY_CMD+x}" && test -f "$SOPS_AGE_KEY_FILE"')
|
||||
assert result.returncode == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize('kind', ['file', 'directory', 'dangling-symlink'])
|
||||
def test_decryption_never_overwrites_existing_output(custody, tmp_path, kind):
|
||||
key, public, env = custody
|
||||
encrypt_fixture(tmp_path, public)
|
||||
output = tmp_path / 'restored'
|
||||
if kind == 'file': output.write_bytes(b'existing-output')
|
||||
elif kind == 'directory': output.mkdir(); (output / 'existing').write_bytes(b'existing-output')
|
||||
else: output.symlink_to(tmp_path / 'uncreated')
|
||||
result = subprocess.run(['bash', str(BOOT / 'decrypt-secrets.sh'), str(output), str(key)],
|
||||
env=env, capture_output=True, text=True, timeout=10)
|
||||
assert result.returncode != 0
|
||||
if kind == 'file': assert output.read_bytes() == b'existing-output'
|
||||
elif kind == 'directory': assert (output / 'existing').read_bytes() == b'existing-output'
|
||||
else: assert output.is_symlink(); assert not (tmp_path / 'uncreated').exists()
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
"""Actual SQLite/age/RSA fixtures and hostile archive/custody boundaries."""
|
||||
import base64
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import tarfile
|
||||
import time
|
||||
import uuid
|
||||
from copy import deepcopy
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def load(name):
|
||||
spec=importlib.util.spec_from_file_location(name,ROOT/f'tools/bootstrap-recovery/{name}.py')
|
||||
m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m);return m
|
||||
|
||||
|
||||
b=load('identity-bundle');files=load('identity-files');capture=load('capture-identity')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stage(tmp_path):
|
||||
os.umask(0o077);root=tmp_path/'payload';root.mkdir(mode=0o700)
|
||||
key=rsa.generate_private_key(public_exponent=65537,key_size=2048)
|
||||
private=key.private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.TraditionalOpenSSL,serialization.NoEncryption())
|
||||
public=key.public_key().public_bytes(serialization.Encoding.PEM,serialization.PublicFormat.SubjectPublicKeyInfo)
|
||||
for c in b.profile()['components']:
|
||||
ns=c['namespace'];name=c['deployment']
|
||||
d={'kind':'Deployment','metadata':{'name':name,'namespace':ns,'uid':c['deployment_uid']},'spec':{}}
|
||||
b.save(root,f'resources/{ns}/deployment/{name}.json',json.dumps(d).encode())
|
||||
for name in c['secret_refs']:
|
||||
data={'fixture':base64.b64encode(b'synthetic-private-value-canary').decode()}
|
||||
if name=='keycape-config':data={'key.pem':base64.b64encode(private).decode(),'config.yaml':base64.b64encode(b'issuer: https://isolated.invalid').decode()}
|
||||
b.save(root,f'resources/{ns}/secret/{name}.json',json.dumps({'kind':'Secret','metadata':{'name':name,'namespace':ns},'data':data}).encode())
|
||||
for name in c['configmap_refs']:
|
||||
b.save(root,f'resources/{ns}/configmap/{name}.json',json.dumps({'kind':'ConfigMap','metadata':{'name':name,'namespace':ns},'data':{'fixture':'synthetic'}}).encode())
|
||||
for component,name in [('lldap','users.db'),('authelia','db.sqlite3')]:
|
||||
path=root/f'data/{component}/{name}';path.parent.mkdir(mode=0o700,parents=True)
|
||||
with sqlite3.connect(path) as db:db.execute('CREATE TABLE retained(value TEXT)');db.execute("INSERT INTO retained VALUES ('fixture-preserved')")
|
||||
path.chmod(0o600)
|
||||
b.save(root,'data/keycape/policy.json',b'{"version":1}')
|
||||
for name,value in [('enckey',os.urandom(96)),('private.pem',private),('public.pem',public)]:
|
||||
b.save(root,'data/privacyidea/'+name,value)
|
||||
b.save(root,'databases/privacyidea.dump',b'PGDMP-synthetic-format-only')
|
||||
return root
|
||||
|
||||
|
||||
def test_complete_bundle_has_native_integrity_checks(stage):
|
||||
r=b.checks(stage)
|
||||
assert r['sqlite_integrity_passed'] and r['privacyidea_key_format_and_pair_passed']
|
||||
assert r['native_identity_recovery_proven'] is False
|
||||
|
||||
|
||||
def test_real_age_seal_manifest_is_encrypted_and_plaintext_never_prints(stage,tmp_path,monkeypatch,capsys):
|
||||
key=tmp_path/'fixture-age.key'
|
||||
subprocess.run(['age-keygen','-o',str(key)],check=True,capture_output=True)
|
||||
recipient=subprocess.run(['age-keygen','-y',str(key)],check=True,capture_output=True).stdout.decode().strip()
|
||||
p=b.profile();p['public_recipient']=recipient;monkeypatch.setattr(b,'profile',lambda:p)
|
||||
artifact=tmp_path/'identity.tar.age';r=b.seal(stage,artifact,{'test_only':True})
|
||||
assert artifact.stat().st_mode&0o077==0
|
||||
assert b'synthetic-private-value-canary' not in artifact.read_bytes()
|
||||
result=subprocess.run(['age','-d','-i',str(key),str(artifact)],check=True,capture_output=True)
|
||||
with tarfile.open(fileobj=io.BytesIO(result.stdout)) as archive:
|
||||
manifest=json.load(archive.extractfile('manifest.json'))
|
||||
assert len(manifest['files'])==r['file_count']
|
||||
assert 'data/privacyidea/enckey' in archive.getnames()
|
||||
assert 'data/lldap/users.db' in archive.getnames()
|
||||
plain=tmp_path/'decrypted.tar';plain.write_bytes(result.stdout)
|
||||
recovered=tmp_path/'recovered';recovered.mkdir(mode=0o700)
|
||||
restored,checks=b.restore(plain,recovered)
|
||||
assert checks['privacyidea_key_format_and_pair_passed']
|
||||
assert (recovered/'data/privacyidea/enckey').read_bytes()==(stage/'data/privacyidea/enckey').read_bytes()
|
||||
assert 'synthetic-private-value-canary' not in json.dumps(r)+capsys.readouterr().out
|
||||
with pytest.raises(b.Refused):b.seal(stage,artifact,{})
|
||||
|
||||
|
||||
@pytest.mark.parametrize('defect',['missing-key','bad-sqlite','bad-audit-pair','wrong-deployment','private-age-key','symlink','hardlink','unknown-payload'])
|
||||
def test_incomplete_unsafe_and_mismatched_inputs_refuse(stage,tmp_path,defect):
|
||||
if defect=='missing-key':(stage/'data/privacyidea/enckey').unlink()
|
||||
if defect=='bad-sqlite':(stage/'data/lldap/users.db').write_bytes(b'broken')
|
||||
if defect=='bad-audit-pair':
|
||||
key=rsa.generate_private_key(public_exponent=65537,key_size=2048)
|
||||
(stage/'data/privacyidea/public.pem').write_bytes(key.public_key().public_bytes(serialization.Encoding.PEM,serialization.PublicFormat.SubjectPublicKeyInfo))
|
||||
if defect=='wrong-deployment':
|
||||
p=stage/'resources/sso/deployment/keycape.json';r=json.loads(p.read_text());r['metadata']['uid']='replacement';p.write_text(json.dumps(r))
|
||||
if defect=='private-age-key':(stage/'data/keycape/poison').write_bytes(b'AGE-SECRET-KEY-' + b'fixture')
|
||||
if defect=='symlink':(stage/'data/keycape/linked').symlink_to(stage/'data/privacyidea/enckey')
|
||||
if defect=='hardlink':os.link(stage/'data/privacyidea/enckey',stage/'data/keycape/linked')
|
||||
if defect=='unknown-payload':(stage/'unreviewed').write_bytes(b'not-allowed')
|
||||
with pytest.raises(Exception):
|
||||
b.checks(stage);b.inventory(stage)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name,kind',[('../escape','file'),('/absolute','file'),('data/keycape/valid','symlink')])
|
||||
def test_tar_import_refuses_traversal_and_links(tmp_path,name,kind):
|
||||
raw=io.BytesIO()
|
||||
with tarfile.open(fileobj=raw,mode='w') as archive:
|
||||
member=tarfile.TarInfo(name);member.size=1
|
||||
if kind=='symlink':member.type=tarfile.SYMTYPE;member.linkname='/etc/passwd'
|
||||
archive.addfile(member,io.BytesIO(b'x'))
|
||||
with pytest.raises(b.Refused):b.import_files(raw.getvalue(),tmp_path,'sso')
|
||||
|
||||
|
||||
def test_native_sqlite_snapshot_keeps_committed_wal_rows_and_source_unchanged(tmp_path):
|
||||
root=tmp_path/'sources';root.mkdir();scratch=tmp_path/'scratch';scratch.mkdir()
|
||||
for component in ['lldap','authelia','keycape']:(root/component).mkdir()
|
||||
for component,name in [('lldap','users.db'),('authelia','db.sqlite3')]:
|
||||
with sqlite3.connect(root/component/name) as con:
|
||||
con.execute('CREATE TABLE retained(value TEXT)');con.execute("INSERT INTO retained VALUES ('native-snapshot')")
|
||||
(root/'keycape/policy.json').write_text('{}')
|
||||
live=sqlite3.connect(root/'lldap/users.db');live.execute('PRAGMA journal_mode=WAL')
|
||||
live.execute("INSERT INTO retained VALUES ('committed-wal')");live.commit()
|
||||
try:
|
||||
result=files.capture('sso',root,scratch)
|
||||
restored=tmp_path/'restored.sqlite';restored.write_bytes(result['data/lldap/users.db'])
|
||||
with sqlite3.connect(restored) as con:assert con.execute('SELECT count(*) FROM retained').fetchone()==(2,)
|
||||
assert list(scratch.iterdir())==[]
|
||||
assert not any(name.endswith(('-wal','-shm')) for name in result)
|
||||
finally:live.close()
|
||||
|
||||
|
||||
def test_helper_manifest_has_no_writable_service_mount_or_credentials():
|
||||
objects=capture.manifests('fixture')
|
||||
for obj in objects:
|
||||
if obj['kind']=='NetworkPolicy':
|
||||
assert obj['spec']['egress']==[] and obj['spec']['ingress']==[]
|
||||
else:
|
||||
s=obj['spec'];assert s['automountServiceAccountToken'] is False
|
||||
assert 'fsGroup' not in s['securityContext']
|
||||
assert s['activeDeadlineSeconds']==900
|
||||
assert all(v['persistentVolumeClaim']['readOnly'] for v in s['volumes'] if 'persistentVolumeClaim' in v)
|
||||
assert not any('secret' in v for v in s['volumes'])
|
||||
|
||||
|
||||
def test_cleanup_delete_carries_exact_uid_precondition(monkeypatch):
|
||||
calls=[];monkeypatch.setattr(capture,'command',lambda args,payload=None,**kw:calls.append((args,payload)) or b'')
|
||||
capture.delete_created({'kind':'Pod','metadata':{'namespace':'sso','name':'nk49-capture-fixture','uid':'exact-created-uid'}})
|
||||
assert json.loads(calls[0][1])['preconditions']=={'uid':'exact-created-uid'}
|
||||
|
||||
|
||||
@pytest.mark.parametrize('defect',['writable-mount','extra-container','service-token','different-image','host-network'])
|
||||
def test_mutated_helper_is_refused_before_private_exec(defect):
|
||||
wanted=next(o for o in capture.manifests('fixture') if o['kind']=='Pod')
|
||||
actual=deepcopy(wanted);spec=actual['spec']
|
||||
if defect=='writable-mount':spec['containers'][0]['volumeMounts'][0]['readOnly']=False
|
||||
if defect=='extra-container':spec['containers'].append(deepcopy(spec['containers'][0]))
|
||||
if defect=='service-token':spec['automountServiceAccountToken']=True
|
||||
if defect=='different-image':spec['containers'][0]['image']='unreviewed:latest'
|
||||
if defect=='host-network':spec['hostNetwork']=True
|
||||
with pytest.raises(capture.Refused):capture.validate_helper(actual,wanted)
|
||||
|
||||
|
||||
def test_namespace_dns_allow_is_excluded_only_for_capture_helpers():
|
||||
selector={'matchExpressions':[{'key':'net-kingdom/capture','operator':'DoesNotExist'}]}
|
||||
assert capture.selects(selector,{'app.kubernetes.io/name':'authelia'})
|
||||
assert not capture.selects(selector,{'net-kingdom/capture':'fixture'})
|
||||
|
||||
|
||||
def test_unknown_create_outcome_is_checkpointed_and_never_claimed_clean(tmp_path,monkeypatch):
|
||||
monkeypatch.setattr(capture.bundle,'profile',lambda:{'components':[]})
|
||||
monkeypatch.setattr(capture,'command',lambda *args,**kwargs:(_ for _ in ()).throw(TimeoutError()))
|
||||
r={'started_at':'fixture'};checkpoints=[]
|
||||
p={'run_id':'fixture','create_resources':[capture.manifests('fixture')[0]]}
|
||||
with pytest.raises(capture.Refused):capture.capture(p,tmp_path/'unused.age',r,'fixture',lambda:checkpoints.append(deepcopy(r)))
|
||||
assert r['helper_cleanup_verified'] is False and r['unverified_create_outcomes']
|
||||
assert any(c.get('unverified_create_outcomes') for c in checkpoints)
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.environ.get('NK49_RUN_DISPOSABLE_POSTGRES_TEST')!='1',reason='explicit local Docker fixture opt-in')
|
||||
def test_full_new_archive_decrypt_extract_native_database_restore_and_cleanup(stage,tmp_path,monkeypatch):
|
||||
verify=load('verify-identity');fixture='nk49-bundle-fixture-'+uuid.uuid4().hex
|
||||
def command(args,**kwargs):
|
||||
return subprocess.run(args,check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=120,**kwargs).stdout
|
||||
try:
|
||||
command(['docker','run','--detach','--pull=never','--name',fixture,'--network','none',
|
||||
'--tmpfs','/var/lib/postgresql/data:rw,size=268435456','--env','POSTGRES_HOST_AUTH_METHOD=trust',
|
||||
verify.pg.IMAGE,'postgres','-c','listen_addresses='])
|
||||
for _ in range(60):
|
||||
probe=subprocess.run(['docker','exec',fixture,'pg_isready','-U','postgres'],capture_output=True)
|
||||
if probe.returncode==0:break
|
||||
time.sleep(1)
|
||||
command(['docker','exec','-i',fixture,'psql','-X','-U','postgres','-v','ON_ERROR_STOP=1'],
|
||||
input=b'CREATE TABLE token(id integer); INSERT INTO token VALUES (1),(2); CREATE TABLE resolver(id integer); CREATE TABLE realm(id integer);')
|
||||
(stage/'databases/privacyidea.dump').write_bytes(command(['docker','exec',fixture,'pg_dump','-U','postgres','-Fc','postgres']))
|
||||
key=tmp_path/'disposable-age.key';command(['age-keygen','-o',str(key)])
|
||||
recipient=command(['age-keygen','-y',str(key)]).decode().strip()
|
||||
p=b.profile();p['public_recipient']=recipient
|
||||
monkeypatch.setattr(b,'profile',lambda:p);monkeypatch.setattr(verify.bundle,'profile',lambda:p)
|
||||
artifact=tmp_path/'net-kingdom-identity-20261016T000000Z-0123456789ab.tar.age'
|
||||
c=b.seal(stage,artifact,{'test_only':True})
|
||||
transfer={'schema':'netkingdom.identity-offsite.v1','status':'readback_verified',
|
||||
'independent_owner_retrieval_verified':True,'fetched_path':str(artifact),'artifact':artifact.name,
|
||||
'ciphertext_sha256':c['ciphertext_sha256'],'ciphertext_bytes':c['ciphertext_bytes']}
|
||||
r={};fd=os.open(key,os.O_RDONLY)
|
||||
try:verify.verify(transfer,fd,r)
|
||||
finally:os.close(fd)
|
||||
assert r['status']=='identity_archive_verified'
|
||||
assert r['database_restore']['aggregate_row_counts']['token']==2
|
||||
assert r['all_manifest_hashes_verified'] and r['wrong_key_refused']
|
||||
assert r['container_cleanup_verified'] and r['temporary_plaintext_cleanup_verified']
|
||||
assert not r['full_service_authentication_proven']
|
||||
finally:subprocess.run(['docker','rm','--force','--volumes',fixture],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=30)
|
||||
|
||||
|
||||
def test_runtime_host_requires_owner_and_exact_machine_binding(monkeypatch):
|
||||
import hashlib,socket
|
||||
module=load('identity-bundle')
|
||||
current=module.profile();current['runtime_host']={'hostname':'verified-node','machine_id_sha256':hashlib.sha256(b'fixture-machine').hexdigest()}
|
||||
monkeypatch.setattr(module,'profile',lambda:current)
|
||||
monkeypatch.setattr(module.os,'geteuid',lambda:0);monkeypatch.setattr(socket,'gethostname',lambda:'verified-node')
|
||||
original=Path.read_bytes
|
||||
monkeypatch.setattr(Path,'read_bytes',lambda p:b'fixture-machine' if str(p)=='/etc/machine-id' else original(p))
|
||||
module.require_runtime_host()
|
||||
monkeypatch.setattr(socket,'gethostname',lambda:'foreign-node')
|
||||
with pytest.raises(module.Refused):module.require_runtime_host()
|
||||
monkeypatch.setattr(socket,'gethostname',lambda:'verified-node');monkeypatch.setattr(module.os,'geteuid',lambda:1000)
|
||||
with pytest.raises(module.Refused):module.require_runtime_host()
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import contextlib
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
spec=importlib.util.spec_from_file_location('fixture_runner',ROOT/'tests/run_isolated_keycape_native_fixture.py')
|
||||
f=importlib.util.module_from_spec(spec);spec.loader.exec_module(f)
|
||||
|
||||
class FixturePreparationTests(unittest.TestCase):
|
||||
def test_default_plan_neither_runs_docker_nor_generates_materials(self):
|
||||
output=io.StringIO()
|
||||
with patch.object(sys,'argv',['fixture','--plan']),patch.object(f.s,'run') as run,patch.object(f,'materials') as materials,patch.object(f.s.local,'preflight') as preflight,contextlib.redirect_stdout(output):
|
||||
self.assertEqual(f.main(),0)
|
||||
run.assert_not_called();materials.assert_not_called();preflight.assert_not_called()
|
||||
manifest=json.loads(output.getvalue())
|
||||
self.assertEqual(manifest['status'],'prepared_not_executed_not_approved')
|
||||
self.assertEqual(manifest['fixed_endpoint'],'unix:///var/run/docker.sock')
|
||||
def test_wrong_reviewed_manifest_hash_stops_before_preflight_or_generation(self):
|
||||
with patch.object(sys,'argv',['fixture','--execute-reviewed-fixture','--expected-manifest-sha256','0'*64]),patch.object(f.s,'run') as run,patch.object(f,'materials') as materials,patch.object(f.s.local,'preflight') as preflight:
|
||||
with self.assertRaises(RuntimeError):f.main()
|
||||
run.assert_not_called();materials.assert_not_called();preflight.assert_not_called()
|
||||
def test_untyped_or_malformed_public_digest_refused(self):
|
||||
for invalid in ('0'*64, 'sha256:'+'A'*64, 'sha256:'+'0'*63, None):
|
||||
with self.subTest(invalid_type=type(invalid).__name__), tempfile.TemporaryDirectory() as temporary:
|
||||
manifest=json.loads(f.MANIFEST.read_text())
|
||||
first=next(iter(manifest['source_sha256']))
|
||||
manifest['source_sha256'][first]=invalid
|
||||
path=Path(temporary)/'manifest.json'
|
||||
path.write_text(json.dumps(manifest))
|
||||
with patch.object(f,'MANIFEST',path), patch.object(f.s,'run') as run:
|
||||
with self.assertRaises(RuntimeError):
|
||||
f.reviewed_manifest(hashlib.sha256(path.read_bytes()).hexdigest())
|
||||
run.assert_not_called()
|
||||
|
||||
def test_manifest_binds_current_candidate_and_existing_fixture_factory(self):
|
||||
digest=hashlib.sha256(f.MANIFEST.read_bytes()).hexdigest()
|
||||
manifest=f.reviewed_manifest(digest)
|
||||
self.assertIn('tests/test_identity_bundle.py',manifest['source_sha256'])
|
||||
self.assertIn('tools/bootstrap-recovery/isolated-keycape-session.py',manifest['source_sha256'])
|
||||
self.assertIn('sso-mfa/k8s/privacyidea/key-custody-remote.py',manifest['source_sha256'])
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
|
|
@ -0,0 +1,234 @@
|
|||
import base64
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
SOURCE=Path(__file__).parents[1]/'tools/bootstrap-recovery/isolated-keycape.py'
|
||||
spec=importlib.util.spec_from_file_location('adapter',SOURCE)
|
||||
a=importlib.util.module_from_spec(spec);spec.loader.exec_module(a)
|
||||
|
||||
class ArchiveTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp=tempfile.TemporaryDirectory();self.addCleanup(self.tmp.cleanup)
|
||||
self.root=Path(self.tmp.name)
|
||||
config={'data':{k:base64.b64encode(v).decode() for k,v in {'config.yaml':b'issuer: https://issuer.example','key.pem':b'fixture-key'}.items()}}
|
||||
self.values={'resources/sso/secret/keycape-config.json':json.dumps(config).encode(),
|
||||
'resources/sso/deployment/keycape.json':json.dumps({'spec':{'template':{'spec':{'containers':[{'name':'keycape','image':a.IMAGE,'env':[
|
||||
{'name':'KEYCAPE_CONFIG','value':'/etc/keycape/config.yaml'},
|
||||
{'name':'KEYCAPE_POLICY_PATH','value':'/var/lib/keycape-policy/policy.json'},
|
||||
{'name':'KEYCAPE_ORIGINAL_SECRET','valueFrom':{'secretKeyRef':{'name':'client','key':'secret'}}}]}]}}}}).encode(),
|
||||
'resources/sso/secret/client.json':json.dumps({'data':{'secret':base64.b64encode(b'fixture-private').decode()}}).encode(),
|
||||
'data/keycape/policy.json':b'{}','data/keycape/receipts/one.json':b'{"original":true}'}
|
||||
for name,value in self.values.items():
|
||||
p=self.root/name;p.parent.mkdir(parents=True,exist_ok=True);p.write_bytes(value);p.chmod(0o600)
|
||||
self.manifest={'profile':{'components':[{'namespace':'sso','deployment':'keycape','secret_refs':['keycape-config','client'],'configmap_refs':[]}]},
|
||||
'files':[{'path':k,'bytes':len(v),'sha256':hashlib.sha256(v).hexdigest()} for k,v in self.values.items()]}
|
||||
def payload(self):
|
||||
digest=hashlib.sha256(json.dumps(self.manifest,sort_keys=True,separators=(',',':')).encode()).hexdigest()
|
||||
return a.private_payload(self.root,self.manifest,digest)
|
||||
def test_original_state_and_secret_delivery_retained(self):
|
||||
p=self.payload();self.assertEqual(p['env']['KEYCAPE_ORIGINAL_SECRET'],'fixture-private')
|
||||
self.assertEqual(base64.b64decode(p['files']['/var/lib/keycape-policy/receipts/one.json']),b'{"original":true}')
|
||||
def test_archive_digest_refused(self):
|
||||
with self.assertRaises(a.Refused):a.private_payload(self.root,self.manifest,'0'*64)
|
||||
def test_changed_file_refused(self):
|
||||
(self.root/'data/keycape/policy.json').write_text('{"changed":true}')
|
||||
with self.assertRaises(a.Refused):self.payload()
|
||||
def test_symlink_refused(self):
|
||||
p=self.root/'data/keycape/policy.json';p.unlink();p.symlink_to('/etc/hostname')
|
||||
with self.assertRaises(Exception):self.payload()
|
||||
def test_duplicate_manifest_refused(self):
|
||||
self.manifest['files'].append(self.manifest['files'][0])
|
||||
with self.assertRaises(a.Refused):self.payload()
|
||||
def test_image_mismatch_refused(self):
|
||||
d=json.loads(self.values['resources/sso/deployment/keycape.json']);d['spec']['template']['spec']['containers'][0]['image']='keycape:latest'
|
||||
raw=json.dumps(d).encode();p=self.root/'resources/sso/deployment/keycape.json';p.write_bytes(raw)
|
||||
for row in self.manifest['files']:
|
||||
if row['path'].endswith('/deployment/keycape.json'):row.update(bytes=len(raw),sha256=hashlib.sha256(raw).hexdigest())
|
||||
with self.assertRaises(a.Refused):self.payload()
|
||||
|
||||
class NativeTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
self.key=rsa.generate_private_key(public_exponent=65537,key_size=2048).private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.PKCS8,serialization.NoEncryption())
|
||||
self.cfg=b'issuer: https://issuer.example\nclients:\n- clientId: existing\n clientType: confidential\n secretRef: env:KEYCAPE_ORIGINAL_SECRET\n grantTypes: [client_credentials]\n'
|
||||
self.files={'/etc/keycape/config.yaml':self.cfg,'/etc/keycape/key.pem':self.key,'/var/lib/keycape-policy/policy.json':b'{}'}
|
||||
self.payload={'env':{'KEYCAPE_ORIGINAL_SECRET':'fixture-private'},'files':{k:base64.b64encode(v).decode() for k,v in self.files.items()}}
|
||||
self.admission={'issuer':'https://issuer.example','verifier_sha256':hashlib.sha256(b'binary').hexdigest(),
|
||||
'trust_path':'/trust.pem','trust_sha256':hashlib.sha256(b'original-trust').hexdigest(),
|
||||
'consumer':{'client_id':'existing','secret_env':'KEYCAPE_ORIGINAL_SECRET','scope':'read','audience':'consumer',
|
||||
'subject':'service:existing','tenant':'tenant:platform','roles':'reader','deny_scope':'write'}}
|
||||
self.session={'issuer':'isolated','volumes':{x:{'target':x} for x in ('/etc/keycape','/var/lib/keycape-policy','/etc/keycape-factor','/recovery-tls')}}
|
||||
self.output=''.join('PASS '+x+'\n' for x in ['discovery and endpoint origin','exchange and JWKS signature for granted scopes','access-token claim bindings','principal_type is service','sub matches the registration','tenant matches the registration','roles present','granted scope carries no excess grant','predecessor secret refused','excess scope refused: write'])
|
||||
def file(self,container,path):
|
||||
return {**self.files,'/etc/hosts':b'127.0.0.1 issuer.example\n','/keycape':b'binary','/trust.pem':b'original-trust'}[path]
|
||||
def command(self,argv,**kwargs):
|
||||
if argv[1:3]==['container','inspect']:return json.dumps(['KEYCAPE_ORIGINAL_SECRET=fixture-private','SSL_CERT_FILE=/trust.pem']).encode()
|
||||
output=io.BytesIO()
|
||||
with tarfile.open(fileobj=output,mode='w') as t:
|
||||
m=tarfile.TarInfo('policy.json');m.size=2;t.addfile(m,io.BytesIO(b'{}'))
|
||||
return output.getvalue()
|
||||
def verify(self,code=0,output=None):
|
||||
with patch.object(a,'check_jwks'),patch.object(a,'runtime_file',return_value=b'original-trust'),patch.object(a,'runtime_files',side_effect=lambda session,paths=(),tree=False:({k:v for k,v in self.files.items() if k.startswith('/var/lib/keycape-policy/')} if tree else dict(self.files))),patch.object(a,'container_file',side_effect=self.file),patch.object(a,'command',side_effect=self.command),patch.object(a.subprocess,'run',return_value=subprocess.CompletedProcess([],code,b'',(self.output if output is None else output).encode())) as run:
|
||||
result=a.run_verifier(self.session,self.admission,self.payload)
|
||||
argv=run.call_args.args[0]
|
||||
self.assertNotIn('fixture-private',' '.join(argv))
|
||||
self.assertIn('-previous-secret-env',argv)
|
||||
return result
|
||||
def test_original_registration_and_unrelated_secret(self):
|
||||
r=self.verify();self.assertTrue(r['wrong_unrelated_secret_explicitly_refused']);self.assertNotIn('fixture-private',json.dumps(r))
|
||||
def test_transport_failure_never_counts_as_wrong_secret(self):
|
||||
with self.assertRaises(a.Refused):self.verify(1,'FAIL discovery and endpoint origin: TLS failure\n')
|
||||
def test_missing_explicit_rejection_never_counts(self):
|
||||
with self.assertRaises(a.Refused):self.verify(0,self.output.replace('PASS predecessor secret refused\n',''))
|
||||
def test_fail_line_refused_even_zero_exit(self):
|
||||
with self.assertRaises(a.Refused):self.verify(0,self.output+'FAIL transport\n')
|
||||
def test_security_environment_override_refused(self):
|
||||
original=self.command
|
||||
def altered(argv,**kwargs):
|
||||
if argv[1:3]==['container','inspect']:return json.dumps(['KEYCAPE_ORIGINAL_SECRET=fixture-private','SSL_CERT_FILE=/trust.pem','SSL_CERT_DIR=/unreviewed']).encode()
|
||||
return original(argv,**kwargs)
|
||||
with patch.object(self,'command',side_effect=altered):
|
||||
with self.assertRaises(a.Refused):self.verify()
|
||||
def test_http_issuer_refused(self):
|
||||
self.admission['issuer']='http://issuer.example'
|
||||
with self.assertRaises(a.Refused):self.verify()
|
||||
def test_changed_trust_refused(self):
|
||||
self.admission['trust_sha256']='0'*64
|
||||
with self.assertRaises(a.Refused):self.verify()
|
||||
def test_config_drift_refused(self):
|
||||
self.payload['files']['/etc/keycape/config.yaml']=base64.b64encode(b'changed').decode()
|
||||
with self.assertRaises(a.Refused):self.verify()
|
||||
|
||||
class PublicReadinessTransportTests(unittest.TestCase):
|
||||
def test_provider_probe_failure_is_closed_false_data_not_failed_docker_exec(self):
|
||||
output=io.StringIO()
|
||||
with patch('sys.stdin',io.StringIO('{}')),patch('sys.stdout',output):
|
||||
exec(compile(a.JWKS_TRANSPORT,'<public-readiness>','exec',optimize=2),{})
|
||||
self.assertEqual(json.loads(output.getvalue()),{'original_public_signing_key_in_live_jwks':False})
|
||||
|
||||
class BoundaryTests(unittest.TestCase):
|
||||
def test_remote_docker_refused_before_info(self):
|
||||
session={'run_id':'a'*32,'containers':['nk49-kc-'+('a'*32)+'-worker'],'worker':'nk49-kc-'+('a'*32)+'-worker','issuer':'nk49-kc-'+('a'*32)+'-worker'}
|
||||
with patch.object(a,'command',return_value=b'[{"Endpoints":{"docker":{"Host":"tcp://remote:2375"}}}]') as command:
|
||||
with self.assertRaises(a.Refused):a.inspect_session(session)
|
||||
self.assertEqual(command.call_count,1)
|
||||
def test_failed_verifier_cleanup_is_attempted(self):
|
||||
receipt={};admission={'session':{},'ciphertext_sha256':'a'*64,'manifest_sha256':'b'*64,'verifier_sha256':'c'*64,'trust_sha256':'d'*64,'consumer':{x:'yes' for x in ('client_id','secret_env','audience','scope','subject','tenant','roles','deny_scope')}}
|
||||
with patch.object(a,'inspect_session'),patch.object(a,'private_payload',return_value={}),patch.object(a,'run_verifier',side_effect=a.Refused),patch.object(a,'cleanup') as cleanup:
|
||||
with self.assertRaises(a.Refused):a.native_checks(Path('/unused'),{},admission,receipt)
|
||||
cleanup.assert_called_once();self.assertTrue(receipt['container_cleanup_verified']);self.assertEqual(receipt['status'],'failed')
|
||||
|
||||
def test_cleanup_failure_overrides_success(self):
|
||||
admission={'session':{},'ciphertext_sha256':'a'*64,'manifest_sha256':'b'*64,'verifier_sha256':'c'*64,'trust_sha256':'d'*64,'consumer':{x:'yes' for x in ('client_id','secret_env','audience','scope','subject','tenant','roles','deny_scope')}}
|
||||
result={x:True for x in ('native_service_registration_verified','wrong_unrelated_secret_explicitly_refused','denied_scopes_refused','original_config_registry_policy_receipts_equal','original_https_issuer_and_trust_preserved','original_public_signing_key_in_live_jwks')}
|
||||
result['public_signing_spki_sha256']='e'*64;receipt={}
|
||||
with patch.object(a,'inspect_session'),patch.object(a,'private_payload',return_value={}),patch.object(a,'run_verifier',return_value=result),patch.object(a,'cleanup',side_effect=a.Refused):
|
||||
with self.assertRaises(a.Refused):a.native_checks(Path('/unused'),{},admission,receipt)
|
||||
self.assertEqual(receipt['status'],'failed');self.assertFalse(receipt['container_cleanup_verified'])
|
||||
|
||||
class ContainmentTests(unittest.TestCase):
|
||||
def inspect(self,overrides=None,launch=None,mounts=None):
|
||||
name='nk49-kc-'+('a'*32)+'-issuer'
|
||||
session={'run_id':'a'*32,'containers':[name],'issuer':name,'worker':name,'images':{name:a.IMAGE}}
|
||||
host={'NetworkMode':'none','ReadonlyRootfs':True,'Privileged':False,'CapDrop':['ALL'],'SecurityOpt':['no-new-privileges']}
|
||||
host.update(overrides or {})
|
||||
def command(argv,**kwargs):
|
||||
if argv[1:3]==['context','inspect']:return b'[{"Endpoints":{"docker":{"Host":"unix:///var/run/docker.sock"}}}]'
|
||||
if argv[1]=='info':return b'{"ID":"74f93783-6cef-43a8-a3c4-f62586cd3bc1","Name":"bnt-lap001","OSType":"linux"}'
|
||||
if argv[-1]=='{{.Id}}':return b'anchor-id'
|
||||
if '"path"' in argv[-1]:return json.dumps(launch or {'entrypoint':['/keycape'],'cmd':None,'path':'/keycape','args':[]}).encode()
|
||||
return json.dumps({'host':host,'mounts':mounts or [],'labels':{a.LABEL:'a'*32},'image':a.IMAGE,'user':'1000:1000'}).encode()
|
||||
with patch.object(a,'command',side_effect=command),patch.dict('os.environ',{'DOCKER_HOST':'unix:///var/run/docker.sock'}):a.inspect_session(session)
|
||||
def test_minimal_network_none_admitted(self):self.inspect()
|
||||
def test_image_declared_anonymous_disk_volume_remains_refused(self):
|
||||
with self.assertRaises(a.Refused):
|
||||
self.inspect(mounts=[{'Type':'volume','Name':'anonymous-fixture','Destination':'/privacyidea/etc/persistent','RW':True}])
|
||||
|
||||
def test_alternate_same_image_command_refused(self):
|
||||
with self.assertRaises(a.Refused):self.inspect(launch={'entrypoint':['/keycape'],'cmd':['check-config'],'path':'/keycape','args':['check-config']})
|
||||
def test_cap_add_refused(self):
|
||||
with self.assertRaises(a.Refused):self.inspect({'CapAdd':['NET_ADMIN']})
|
||||
def test_devices_refused(self):
|
||||
with self.assertRaises(a.Refused):self.inspect({'Devices':[{'PathOnHost':'/dev/mem'}]})
|
||||
def test_device_requests_refused(self):
|
||||
with self.assertRaises(a.Refused):self.inspect({'DeviceRequests':[{'Driver':'nvidia'}]})
|
||||
def test_host_namespace_refused(self):
|
||||
for field in ('PidMode','IpcMode','UTSMode','UsernsMode','CgroupnsMode'):
|
||||
with self.subTest(field=field),self.assertRaises(a.Refused):self.inspect({field:'host'})
|
||||
def test_security_override_refused(self):
|
||||
with self.assertRaises(a.Refused):self.inspect({'SecurityOpt':['no-new-privileges','seccomp=unconfined']})
|
||||
|
||||
class CleanupTests(unittest.TestCase):
|
||||
def test_partial_cleanup_attempts_every_owned_container(self):
|
||||
session={'run_id':'a'*32,'containers':['first','second','third']};receipt={}
|
||||
removed=[]
|
||||
def command(argv,**kwargs):
|
||||
if argv[1:3]==['container','inspect']:return ('a'*32).encode()
|
||||
if argv[1]=='rm':
|
||||
removed.append(argv[-1])
|
||||
if argv[-1]=='third':raise a.Refused()
|
||||
return b''
|
||||
if argv[1:3]==['container','ls']:
|
||||
name=next(x for x in argv if x.startswith('name=^/')).removeprefix('name=^/').removesuffix('$')
|
||||
return (name+'\n').encode() if name=='third' or name not in removed else b''
|
||||
return b''
|
||||
with patch.object(a,'command',side_effect=command):
|
||||
with self.assertRaises(a.Refused):a.cleanup(session,receipt)
|
||||
self.assertEqual(removed,['third','second','first'])
|
||||
self.assertEqual([r['status'] for r in receipt['container_cleanup_obligations']],['still_present','absence_verified','absence_verified'])
|
||||
def test_ownership_mismatch_does_not_remove_and_continues(self):
|
||||
session={'run_id':'a'*32,'containers':['first','second']};receipt={};removed=[]
|
||||
def command(argv,**kwargs):
|
||||
if argv[1:3]==['container','inspect']:return (('b' if argv[3]=='second' else 'a')*32).encode()
|
||||
if argv[1]=='rm':removed.append(argv[-1])
|
||||
if argv[1:3]==['container','ls']:
|
||||
name=next(x for x in argv if x.startswith('name=^/')).removeprefix('name=^/').removesuffix('$')
|
||||
return (name+'\n').encode() if name not in removed else b''
|
||||
return b''
|
||||
with patch.object(a,'command',side_effect=command):
|
||||
with self.assertRaises(a.Refused):a.cleanup(session,receipt)
|
||||
self.assertEqual(removed,['first'])
|
||||
self.assertEqual(receipt['container_cleanup_obligations'][0]['status'],'ownership_mismatch_not_removed')
|
||||
def test_inspect_failure_retains_uncertain_obligation(self):
|
||||
receipt={}
|
||||
with patch.object(a,'command',side_effect=a.Refused):
|
||||
with self.assertRaises(a.Refused):a.cleanup({'run_id':'a'*32,'containers':['exact-owned-name']},receipt)
|
||||
self.assertEqual(receipt['container_cleanup_obligations'],[{'container':'exact-owned-name','status':'unverified'}])
|
||||
|
||||
class JWKSTests(unittest.TestCase):
|
||||
def execute(self,n='123',endpoint='https://issuer.example/jwks',resolved='127.0.0.1',extra=False,kid='key-1'):
|
||||
request={'issuer':'https://issuer.example','trust':'public-certificate','n':'123','e':'65537'}
|
||||
def enc(i):return base64.urlsafe_b64encode(int(i).to_bytes((int(i).bit_length()+7)//8,'big')).decode().rstrip('=')
|
||||
responses=[{'issuer':'https://issuer.example','jwks_uri':endpoint},{'keys':[{'kty':'RSA','kid':kid,'use':'sig','alg':'RS256','n':enc(n),'e':enc(65537)}]}]
|
||||
if extra:responses[1]['keys'].append(dict(responses[1]['keys'][0],kid='key-2'))
|
||||
class Response:
|
||||
def __init__(self,value):self.value=value
|
||||
def __enter__(self):return self
|
||||
def __exit__(self,*args):pass
|
||||
def read(self,*args):return json.dumps(self.value).encode()
|
||||
class Opener:
|
||||
def open(self,*args,**kwargs):return Response(responses.pop(0))
|
||||
with patch('sys.stdin',io.StringIO(json.dumps(request))),patch('sys.stdout',io.StringIO()),patch('socket.getaddrinfo',return_value=[(2,1,6,'',(resolved,443))]),patch('ssl.create_default_context'),patch('urllib.request.build_opener',return_value=Opener()):
|
||||
exec(compile(a.JWKS_PROBE,'<jwks-probe>','exec',optimize=2),{})
|
||||
def test_exact_original_public_key(self):self.execute()
|
||||
def test_extra_signing_key_refused_under_optimization(self):
|
||||
with self.assertRaises(ValueError):self.execute(extra=True)
|
||||
def test_wrong_kid_refused_under_optimization(self):
|
||||
with self.assertRaises(ValueError):self.execute(kid='replacement')
|
||||
def test_wrong_live_signing_key_refused(self):
|
||||
with self.assertRaises(ValueError):self.execute(n='124')
|
||||
def test_cross_origin_jwks_refused(self):
|
||||
with self.assertRaises(ValueError):self.execute(endpoint='https://production.example/jwks')
|
||||
def test_production_dns_refused_before_fetch(self):
|
||||
with self.assertRaises(ValueError):self.execute(resolved='192.0.2.1')
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
|
|
@ -0,0 +1,292 @@
|
|||
import base64
|
||||
import importlib.util
|
||||
import json
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
SOURCE=Path(__file__).parents[1]/'tools/bootstrap-recovery/isolated-keycape-session.py'
|
||||
spec=importlib.util.spec_from_file_location('session',SOURCE)
|
||||
s=importlib.util.module_from_spec(spec);spec.loader.exec_module(s)
|
||||
|
||||
class SessionTests(unittest.TestCase):
|
||||
def test_tmpfs_plan_has_four_exact_original_mounts(self):
|
||||
session=s.plan_session()
|
||||
self.assertEqual({v['target'] for v in session['volumes'].values()},set(s.RUNROOTS))
|
||||
self.assertTrue(all(v['options']==s.VOLUME_OPTIONS for v in session['volumes'].values()))
|
||||
self.assertEqual(session['images'][session['issuer']],s.adapter.IMAGE)
|
||||
def test_non_tmpfs_driver_options_refused(self):
|
||||
session=s.plan_session();name,spec=next(iter(session['volumes'].items()))
|
||||
wrong=[{'Name':name,'Driver':'local','Scope':'local','Options':{'type':'none','device':'/host/data'},'Labels':{s.adapter.LABEL:session['run_id']}}]
|
||||
with patch.object(s,'run',return_value=json.dumps(wrong).encode()):
|
||||
with self.assertRaises(s.adapter.Refused):s.verify_volume(name,spec,session['run_id'])
|
||||
def test_owner_secret_is_not_docker_argv_and_import_is_after_inspection(self):
|
||||
session=s.plan_session();events=[]
|
||||
payload={'files':{'/etc/keycape/config.yaml':base64.b64encode(b'original').decode()},'env':{'KEYCAPE_TEST_SECRET':'fixture-sensitive'}}
|
||||
def run(argv,**kwargs):
|
||||
events.append(('run',argv,kwargs))
|
||||
if argv[-1]=='{{.Id}}':return b'anchor'
|
||||
return b''
|
||||
with patch.object(s,'run',side_effect=run),patch.object(s,'verify_volume'),patch.object(s.adapter,'inspect_session',side_effect=lambda *args,**kwargs:events.append(('inspect',args,kwargs))):
|
||||
s.provision(session,payload,{'/recovery-tls/trust.pem':b'original-trust'},'https://issuer.example')
|
||||
joined=' '.join(' '.join(e[1]) for e in events if e[0]=='run')
|
||||
self.assertNotIn('fixture-sensitive',joined)
|
||||
private_index=next(i for i,e in enumerate(events) if e[0]=='run' and s.IMPORT in e[1])
|
||||
inspect_index=next(i for i,e in enumerate(events) if e[0]=='inspect')
|
||||
self.assertLess(inspect_index,private_index)
|
||||
issuer_call=next(e for e in events if e[0]=='run' and e[1][-1]==s.adapter.IMAGE)
|
||||
self.assertEqual(issuer_call[2]['env']['KEYCAPE_TEST_SECRET'],'fixture-sensitive')
|
||||
self.assertIn('KEYCAPE_TEST_SECRET',issuer_call[1])
|
||||
self.assertNotIn('--entrypoint',issuer_call[1])
|
||||
def test_pinned_helper_image_volume_is_explicitly_masked_with_tmpfs(self):
|
||||
calls=[]
|
||||
def run(argv,**kwargs):calls.append(argv);return b'anchor' if argv[-1]=='{{.Id}}' else b''
|
||||
with patch.object(s,'run',side_effect=run),patch.object(s,'verify_volume'),patch.object(s.adapter,'inspect_session'):
|
||||
s.provision(s.plan_session(),{'files':{},'env':{}},{},'https://issuer.example')
|
||||
worker=next(argv for argv in calls if s.WORKER in argv)
|
||||
mask='/privacyidea/etc/persistent:rw,nosuid,nodev,noexec,size=16777216,uid=1000,gid=1000,mode=0700'
|
||||
self.assertIn(mask,worker)
|
||||
self.assertEqual(worker[worker.index(mask)-1],'--tmpfs')
|
||||
issuer=next(argv for argv in calls if argv[-1]==s.adapter.IMAGE)
|
||||
self.assertNotIn(mask,issuer)
|
||||
def test_public_phase_sequence_reports_containment_before_private_import(self):
|
||||
session=s.plan_session();phases=[]
|
||||
def run(argv,**kwargs):
|
||||
return b'anchor' if argv[-1]=='{{.Id}}' else b''
|
||||
with patch.object(s,'run',side_effect=run),patch.object(s,'verify_volume'),patch.object(s.adapter,'inspect_session'):
|
||||
s.provision(session,{'files':{},'env':{}},{},'https://issuer.example',phases.append)
|
||||
self.assertEqual(phases,list(s.PROVISION_PHASES))
|
||||
def test_worker_containment_refusal_keeps_exact_phase_and_skips_import(self):
|
||||
phases=[];calls=[]
|
||||
def run(argv,**kwargs):calls.append(argv);return b''
|
||||
with patch.object(s,'run',side_effect=run),patch.object(s,'verify_volume'),patch.object(s.adapter,'inspect_session',side_effect=s.adapter.Refused):
|
||||
with self.assertRaises(s.adapter.Refused):
|
||||
s.provision(s.plan_session(),{'files':{},'env':{}},{},'https://issuer.example',phases.append)
|
||||
self.assertEqual(phases[-1],'worker_containment')
|
||||
self.assertFalse(any(s.IMPORT in argv for argv in calls))
|
||||
def test_issuer_containment_refusal_does_not_claim_provision_complete(self):
|
||||
phases=[]
|
||||
def run(argv,**kwargs):return b'anchor' if argv[-1]=='{{.Id}}' else b''
|
||||
with patch.object(s,'run',side_effect=run),patch.object(s,'verify_volume'),patch.object(s.adapter,'inspect_session',side_effect=[None,s.adapter.Refused()]):
|
||||
with self.assertRaises(s.adapter.Refused):
|
||||
s.provision(s.plan_session(),{'files':{},'env':{}},{},'https://issuer.example',phases.append)
|
||||
self.assertEqual(phases[-1],'issuer_containment')
|
||||
self.assertNotIn('complete',phases)
|
||||
def test_non_original_https_port_refused_before_any_mutation(self):
|
||||
with patch.object(s,'run') as run:
|
||||
with self.assertRaises(s.adapter.Refused):s.provision(s.plan_session(),{'files':{},'env':{}},{},'http://issuer.example')
|
||||
run.assert_not_called()
|
||||
def test_container_failure_still_attempts_each_volume_cleanup(self):
|
||||
session=s.plan_session();receipt={};removed=[]
|
||||
def run(argv,**kwargs):
|
||||
if argv[1:3]==['volume','rm']:removed.append(argv[-1])
|
||||
if argv[1:3]==['volume','ls']:
|
||||
name=next(x for x in argv if x.startswith('name=^')).removeprefix('name=^').removesuffix('$')
|
||||
return (name+'\n').encode() if name not in removed else b''
|
||||
return b''
|
||||
with patch.object(s.adapter,'cleanup',side_effect=s.adapter.Refused),patch.object(s,'verify_volume'),patch.object(s,'run',side_effect=run):
|
||||
with self.assertRaises(s.adapter.Refused):s.cleanup_all(session,receipt)
|
||||
self.assertEqual(set(removed),set(session['volumes']))
|
||||
self.assertFalse(receipt['container_cleanup_verified'])
|
||||
self.assertEqual(len(receipt['tmpfs_volume_cleanup_obligations']),4)
|
||||
def test_reaper_inherits_no_custody_or_service_environment(self):
|
||||
session=s.plan_session();staging={'path':'/dev/shm/registered','run_id':session['run_id']}
|
||||
with patch.object(s.subprocess,'Popen') as spawn,patch.object(s,'verify_execution'),patch.object(s.os,'pidfd_open',return_value=99),patch.object(s.os,'close'),patch.object(s,'register_workdir',return_value=staging),patch.object(s.select,'select',side_effect=lambda *args:([spawn.return_value.stdout],[],[])):
|
||||
spawn.return_value.stdout.readline.return_value=b'registered\n'
|
||||
s.start_reaper(session,staging,{'pid':123})
|
||||
kwargs=spawn.call_args.kwargs
|
||||
self.assertEqual(set(kwargs['env']),{'PATH','DOCKER_HOST'})
|
||||
self.assertTrue(kwargs['close_fds']);self.assertTrue(kwargs['start_new_session']);self.assertEqual(kwargs['pass_fds'],(99,))
|
||||
|
||||
class TargetBindingTests(unittest.TestCase):
|
||||
def test_every_direct_docker_command_is_explicitly_bound(self):
|
||||
with patch.dict('os.environ',{},clear=True),patch.object(s.adapter.subprocess,'run',return_value=__import__('subprocess').CompletedProcess([],0,b'ok')) as run:
|
||||
self.assertEqual(s.run(['docker','info']),b'ok')
|
||||
argv=run.call_args.args[0]
|
||||
self.assertEqual(argv[:3],['docker','--host',s.adapter.ENDPOINT])
|
||||
self.assertEqual(run.call_args.kwargs['env']['DOCKER_HOST'],s.adapter.ENDPOINT)
|
||||
def test_nondefault_unix_socket_is_refused_before_any_docker_call(self):
|
||||
with patch.dict('os.environ',{'DOCKER_HOST':'unix:///tmp/other-daemon.sock'},clear=True),patch.object(s.adapter.subprocess,'run') as run:
|
||||
with self.assertRaises(s.adapter.Refused):s.run(['docker','info'])
|
||||
run.assert_not_called()
|
||||
def test_conflicting_context_is_refused(self):
|
||||
with patch.dict('os.environ',{'DOCKER_CONTEXT':'production'},clear=True),patch.object(s.adapter.subprocess,'run') as run:
|
||||
with self.assertRaises(s.adapter.Refused):s.run(['docker','info'])
|
||||
run.assert_not_called()
|
||||
def test_conflicting_per_call_host_is_refused(self):
|
||||
with patch.dict('os.environ',{},clear=True),patch.object(s.adapter.subprocess,'run') as run:
|
||||
with self.assertRaises(s.adapter.Refused):s.run(['docker','info'],env={'DOCKER_HOST':'unix:///tmp/other.sock'})
|
||||
run.assert_not_called()
|
||||
def test_reused_preflight_uses_same_bound_command(self):
|
||||
self.assertIs(s.local.command,s.adapter.command)
|
||||
def test_probe_is_registered_before_session_creation(self):
|
||||
session=s.plan_session()
|
||||
self.assertEqual(session['public_image_probe'],'nk49-kc-'+session['run_id']+'-image-probe')
|
||||
self.assertEqual(session['docker_endpoint'],s.adapter.ENDPOINT)
|
||||
|
||||
class PlaintextLifecycleTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp=tempfile.TemporaryDirectory();self.addCleanup(self.tmp.cleanup)
|
||||
self.root=Path(self.tmp.name)
|
||||
self.override=patch.object(s,'STAGING_ROOT',self.root);self.override.start();self.addCleanup(self.override.stop)
|
||||
self.run_id='a'*32
|
||||
self.work=self.root/('nk49-keycape-'+self.run_id+'-abcdefgh');self.work.mkdir(mode=0o700)
|
||||
self.staging=s.register_workdir(self.work,self.run_id)
|
||||
def test_exact_plaintext_directory_removed_and_recorded(self):
|
||||
(self.work/'archive.tar').write_bytes(b'synthetic-private-archive')
|
||||
restored=self.work/'restored';restored.mkdir(mode=0o700);(restored/'fixture').write_bytes(b'synthetic')
|
||||
receipt={};s.cleanup_workdir(self.staging,receipt)
|
||||
self.assertFalse(self.work.exists());self.assertTrue(receipt['temporary_plaintext_cleanup_verified'])
|
||||
self.assertEqual(receipt['plaintext_cleanup_obligation']['status'],'absence_verified')
|
||||
def test_docker_failure_does_not_skip_plaintext_cleanup(self):
|
||||
(self.work/'archive.tar').write_bytes(b'synthetic')
|
||||
receipt={}
|
||||
with patch.object(s,'cleanup_all',side_effect=s.adapter.Refused):
|
||||
with self.assertRaises(s.adapter.Refused):s.recover_obligations({},self.staging,receipt)
|
||||
self.assertFalse(self.work.exists());self.assertTrue(receipt['temporary_plaintext_cleanup_verified'])
|
||||
def test_replacement_inode_is_not_removed(self):
|
||||
# Preserve original inode while replacing its registered pathname.
|
||||
old=self.root/'retained-original';self.work.rename(old);self.work.mkdir(mode=0o700)
|
||||
(self.work/'replacement').write_bytes(b'not-owned-by-this-obligation');receipt={}
|
||||
with self.assertRaises(s.adapter.Refused):s.cleanup_workdir(self.staging,receipt)
|
||||
self.assertTrue((self.work/'replacement').exists());self.assertTrue(old.exists())
|
||||
self.assertEqual(receipt['plaintext_cleanup_obligation']['status'],'identity_mismatch_not_removed')
|
||||
def test_symlink_root_is_not_followed(self):
|
||||
self.work.rmdir();outside=self.root/'outside';outside.mkdir();(outside/'kept').write_bytes(b'keep')
|
||||
self.work.symlink_to(outside,target_is_directory=True)
|
||||
with self.assertRaises(s.adapter.Refused):s.cleanup_workdir(self.staging,{})
|
||||
self.assertTrue((outside/'kept').exists())
|
||||
def test_descendant_symlink_is_not_followed(self):
|
||||
outside=self.root/'kept';outside.write_bytes(b'keep');(self.work/'link').symlink_to(outside)
|
||||
with self.assertRaises(s.adapter.Refused):s.cleanup_workdir(self.staging,{})
|
||||
self.assertEqual(outside.read_bytes(),b'keep')
|
||||
def test_wrong_owner_identity_is_not_removed(self):
|
||||
changed={**self.staging,'owner_uid':os.getuid()+1}
|
||||
with self.assertRaises(s.adapter.Refused):s.cleanup_workdir(changed,{})
|
||||
self.assertTrue(self.work.exists())
|
||||
def test_unregistered_path_refused(self):
|
||||
with self.assertRaises(s.adapter.Refused):s.cleanup_workdir({**self.staging,'path':str(self.root)}, {})
|
||||
|
||||
@unittest.skipUnless(hasattr(os,'pidfd_open') and hasattr(signal,'pidfd_send_signal'),'Linux pidfd required')
|
||||
class ExecutionDeadlineTests(unittest.TestCase):
|
||||
def start_synthetic_owner(self,root):
|
||||
late=str(root/'late-creation')
|
||||
code="""import importlib.util,json,subprocess,sys,time
|
||||
spec=importlib.util.spec_from_file_location('session',sys.argv[1]);s=importlib.util.module_from_spec(spec);spec.loader.exec_module(s)
|
||||
execution=s.isolate_execution()
|
||||
child=subprocess.Popen([sys.executable,'-c',"import pathlib,sys,time;time.sleep(2);pathlib.Path(sys.argv[1]).write_text('late')",sys.argv[2]])
|
||||
print(json.dumps({'execution':execution,'child_pid':child.pid}),flush=True)
|
||||
time.sleep(20)
|
||||
"""
|
||||
parent=subprocess.Popen([sys.executable,'-c',code,str(SOURCE),late],stdout=subprocess.PIPE,stderr=subprocess.DEVNULL)
|
||||
message=json.loads(parent.stdout.readline());parent.stdout.close()
|
||||
def finish():
|
||||
if parent.poll() is None:parent.kill()
|
||||
parent.wait(timeout=5)
|
||||
try:
|
||||
before=s.process_facts(message['child_pid'])
|
||||
if before is None or before['sid']!=message['execution']['sid'] or before['pgid']!=message['execution']['pgid']:return
|
||||
fd=os.pidfd_open(message['child_pid'])
|
||||
try:
|
||||
after=s.process_facts(message['child_pid'])
|
||||
if after is not None and all(after[k]==before[k] for k in ('pid','sid','pgid','start_ticks','uid')):
|
||||
signal.pidfd_send_signal(fd,signal.SIGKILL)
|
||||
except ProcessLookupError:pass
|
||||
finally:os.close(fd)
|
||||
except ProcessLookupError:pass
|
||||
self.addCleanup(finish)
|
||||
return parent,message['execution'],Path(late)
|
||||
def test_deadline_quiesces_only_fresh_owner_group_before_cleanup(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
parent,execution,late=self.start_synthetic_owner(Path(directory));fd=os.pidfd_open(parent.pid)
|
||||
try:
|
||||
receipt={};s.quiesce_execution(execution,fd,receipt)
|
||||
self.assertTrue(receipt['execution_quiescence_verified'])
|
||||
self.assertFalse(s.group_members(execution));self.assertFalse(late.exists())
|
||||
finally:os.close(fd)
|
||||
def test_hard_parent_loss_stops_surviving_marker_creator(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
parent,execution,late=self.start_synthetic_owner(Path(directory));fd=os.pidfd_open(parent.pid)
|
||||
try:
|
||||
signal.pidfd_send_signal(fd,signal.SIGKILL)
|
||||
poll=__import__('select').poll();poll.register(fd,__import__('select').POLLIN);self.assertTrue(poll.poll(2000))
|
||||
receipt={};s.quiesce_execution(execution,fd,receipt)
|
||||
self.assertTrue(receipt['execution_quiescence_verified']);self.assertFalse(late.exists())
|
||||
self.assertFalse(s.group_members(execution))
|
||||
finally:os.close(fd)
|
||||
def test_expired_execution_cannot_start_any_docker_command(self):
|
||||
binding={'pid':os.getpid(),'sid':os.getsid(0),'pgid':os.getpgrp(),'deadline':time.monotonic()-1,'staging':'/tmp/not-created'}
|
||||
with patch.object(s.adapter,'EXECUTION',binding),patch.object(s.adapter.subprocess,'run') as run:
|
||||
with self.assertRaises(s.adapter.Refused):s.run(['docker','create','fixed-image'])
|
||||
run.assert_not_called()
|
||||
def test_reused_or_foreign_session_refuses_signals(self):
|
||||
binding={'pid':123,'sid':123,'pgid':123,'start_ticks':10,'uid':os.getuid()}
|
||||
wrong={**binding,'sid':456,'state':'S'}
|
||||
with patch.object(s,'process_facts',return_value=wrong),patch.object(s.signal,'pidfd_send_signal') as send:
|
||||
with self.assertRaises(s.adapter.Refused):s.quiesce_execution(binding,99,{})
|
||||
send.assert_not_called()
|
||||
def test_inflight_uncertainty_survives_plaintext_directory_removal(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path=Path(directory)/'owned-stage';path.mkdir();marker=Path(str(path)+'-docker-inflight');marker.touch()
|
||||
staging={'path':str(path)};self.assertTrue(s.daemon_uncertain(staging));path.rmdir()
|
||||
self.assertTrue(s.daemon_uncertain(staging))
|
||||
|
||||
class ReaperOrderingTests(unittest.TestCase):
|
||||
def exercise(self,quiescence_fails=False):
|
||||
events=[];records=[]
|
||||
def quiet(*args):
|
||||
events.append('quiesce')
|
||||
if quiescence_fails:raise s.adapter.Refused()
|
||||
args[-1]['execution_quiescence_verified']=True
|
||||
def recover(*args):events.append('cleanup')
|
||||
with patch.object(s,'verify_execution'),patch.object(s,'pidfd_dead',return_value=False),patch.object(s.select,'poll') as poll,patch.object(s,'quiesce_execution',side_effect=quiet),patch.object(s,'daemon_uncertain',return_value=True),patch.object(s,'recover_obligations',side_effect=recover),patch.object(s.os,'close'),patch.object(s.os,'open',return_value=99),patch.object(s.os,'fdopen'),patch.object(s.json,'dump',side_effect=lambda r,*args:records.append(dict(r))),patch('sys.stdout',io.StringIO()):
|
||||
poll.return_value.poll.return_value=[]
|
||||
s.reaper_main({}, {'path':'/dev/shm/exact-staging'}, {'deadline':time.monotonic()-1},77)
|
||||
return events,records[0]
|
||||
def test_daemon_uncertainty_survives_verified_resource_readback(self):
|
||||
events,receipt=self.exercise();self.assertEqual(events,['quiesce','cleanup'])
|
||||
self.assertTrue(receipt['execution_quiescence_verified']);self.assertTrue(receipt['in_flight_daemon_request_uncertain'])
|
||||
self.assertEqual(receipt['status'],'bounded_cleanup_uncertain')
|
||||
def test_unverified_quiescence_prevents_any_cleanup(self):
|
||||
events,receipt=self.exercise(True);self.assertEqual(events,['quiesce'])
|
||||
self.assertFalse(receipt['execution_quiescence_verified']);self.assertEqual(receipt['status'],'bounded_cleanup_uncertain')
|
||||
def test_checkpoint_rewrites_one_valid_json_document(self):
|
||||
with tempfile.TemporaryFile() as stream:
|
||||
s.persist_receipt(stream.fileno(),{'status':'registered','long':'x'*100})
|
||||
s.persist_receipt(stream.fileno(),{'status':'failed'})
|
||||
stream.seek(0);self.assertEqual(json.load(stream),{'status':'failed'})
|
||||
def test_nonzero_docker_result_retains_uncertainty_before_inflight_clear(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
staging=str(Path(directory)/'stage');Path(staging).mkdir()
|
||||
binding={'pid':os.getpid(),'sid':os.getsid(0),'pgid':os.getpgrp(),'deadline':time.monotonic()+10,'staging':staging}
|
||||
def returned(*args,**kwargs):
|
||||
self.assertTrue(Path(staging+'-docker-inflight').exists())
|
||||
return subprocess.CompletedProcess(args[0],1,b'')
|
||||
with patch.object(s.adapter,'EXECUTION',binding),patch.object(s.adapter.subprocess,'run',side_effect=returned):
|
||||
with self.assertRaises(s.adapter.Refused):s.run(['docker','create','synthetic-already-accepted'])
|
||||
self.assertFalse(Path(staging+'-docker-inflight').exists());self.assertTrue(s.daemon_uncertain({'path':staging}))
|
||||
def test_only_policy_mount_is_writable_in_issuer(self):
|
||||
plan=s.plan_session();args=s.mounts(plan,readonly=True)
|
||||
mounts=args[1::2]
|
||||
for value in mounts:
|
||||
self.assertEqual(not value.endswith(',readonly'),'target=/var/lib/keycape-policy' in value)
|
||||
def test_docker_timeout_retains_independent_uncertainty_marker(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
staging=str(Path(directory)/'stage');Path(staging).mkdir()
|
||||
binding={'pid':os.getpid(),'sid':os.getsid(0),'pgid':os.getpgrp(),'deadline':time.monotonic()+10,'staging':staging}
|
||||
def timeout(*args,**kwargs):
|
||||
self.assertTrue(Path(staging+'-docker-inflight').exists())
|
||||
raise subprocess.TimeoutExpired('synthetic Docker',1)
|
||||
with patch.object(s.adapter,'EXECUTION',binding),patch.object(s.adapter.subprocess,'run',side_effect=timeout):
|
||||
with self.assertRaises(subprocess.TimeoutExpired):s.run(['docker','create','fixed-image'])
|
||||
self.assertFalse(Path(staging+'-docker-inflight').exists())
|
||||
self.assertTrue(Path(staging+'-docker-uncertain').exists());self.assertTrue(s.daemon_uncertain({'path':staging}))
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
import copy
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import pty
|
||||
import subprocess
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT=Path(__file__).parents[1]
|
||||
spec=importlib.util.spec_from_file_location('prepare',ROOT/'tools/bootstrap-recovery/prepare-isolated-keycape.py')
|
||||
p=importlib.util.module_from_spec(spec);spec.loader.exec_module(p)
|
||||
|
||||
class PreparationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp=tempfile.TemporaryDirectory();self.addCleanup(self.temp.cleanup)
|
||||
self.path=Path(self.temp.name)/'ciphertext';self.path.write_bytes(b'synthetic ciphertext');self.path.chmod(0o600)
|
||||
self.transfer={'schema':'netkingdom.identity-offsite.v1','status':'readback_verified','provider':'Storage Share',
|
||||
'folder':'Backup/railiance-backups/net-kingdom-identity','artifact':'net-kingdom-identity-20261016T063159Z-5882771aa2ce.tar.age',
|
||||
'ciphertext_sha256':'a'*64,'ciphertext_bytes':len(b'synthetic ciphertext'),'etag':'"synthetic"','fetched_path':str(self.path),
|
||||
'independent_owner_retrieval_verified':True}
|
||||
self.admission={name:'a'*64 for name in ('ciphertext_sha256','manifest_sha256','verifier_sha256','trust_sha256','tls_cert_sha256')}
|
||||
self.admission.update(issuer='https://issuer.example',consumer=dict(client_id='existing',secret_env='KEYCAPE_EXISTING_SECRET',audience='existing-api',scope='read',subject='svc:existing',tenant='existing',roles='reader',deny_scope='write'),
|
||||
source_sha256={name:'sha256:'+hashlib.sha256((ROOT/name).read_bytes()).hexdigest() for name in p.SOURCE_PATHS})
|
||||
def test_complete_public_bindings_admitted_without_reading_ciphertext(self):
|
||||
original=Path.read_bytes
|
||||
def read(path):
|
||||
self.assertNotEqual(path,self.path)
|
||||
return original(path)
|
||||
with patch.object(Path,'read_bytes',read):self.assertTrue(p.validate(self.transfer,self.admission,ROOT))
|
||||
def test_oversize_metadata_refused_before_any_content_read(self):
|
||||
self.path.write_bytes(b'x'*(128*1024+1))
|
||||
with patch.object(Path,'read_bytes',side_effect=AssertionError('unbounded read')):
|
||||
with self.assertRaises(p.Refused):p.read_metadata(self.path)
|
||||
def test_duplicate_json_keys_refused_at_every_level(self):
|
||||
for raw in (b'{"issuer":"one","issuer":"two"}',b'{"consumer":{"client_id":"one","client_id":"two"}}'):
|
||||
with self.subTest(raw=raw),self.assertRaises(p.Refused):p.strict_json(raw)
|
||||
def test_bounded_metadata_reader_accepts_exact_limit(self):
|
||||
self.path.write_bytes(b'x'*128)
|
||||
self.assertEqual(p.read_metadata(self.path,128),b'x'*128)
|
||||
with self.assertRaises(p.Refused):p.read_metadata(self.path,127)
|
||||
def test_wrong_archive_binding_refused(self):
|
||||
self.admission['ciphertext_sha256']='b'*64
|
||||
with self.assertRaises(p.Refused):p.validate(self.transfer,self.admission,ROOT)
|
||||
def test_ciphertext_size_mismatch_refused_without_decryption(self):
|
||||
self.transfer['ciphertext_bytes']+=1
|
||||
with self.assertRaises(p.Refused):p.validate(self.transfer,self.admission,ROOT)
|
||||
def test_missing_independent_transfer_refused(self):
|
||||
self.transfer['independent_owner_retrieval_verified']=False
|
||||
with self.assertRaises(p.Refused):p.validate(self.transfer,self.admission,ROOT)
|
||||
def test_missing_or_drifted_source_refused(self):
|
||||
for source in p.SOURCE_PATHS:
|
||||
bad=copy.deepcopy(self.admission);bad['source_sha256'][source]='0'*64
|
||||
with self.subTest(source=source),self.assertRaises(p.Refused):p.validate(self.transfer,bad,ROOT)
|
||||
bad=copy.deepcopy(self.admission);bad['source_sha256'].pop(next(iter(p.SOURCE_PATHS)))
|
||||
with self.assertRaises(p.Refused):p.validate(self.transfer,bad,ROOT)
|
||||
def test_unreviewed_extra_dependency_refused(self):
|
||||
self.admission['source_sha256']['unreviewed.py']='a'*64
|
||||
with self.assertRaises(p.Refused):p.validate(self.transfer,self.admission,ROOT)
|
||||
def test_ambiguous_or_unsupported_issuer_refused(self):
|
||||
for issuer in ('http://issuer.example','https://u:p@issuer.example','https://issuer.example:8443','https://issuer.example/path','https://issuer.example?x=1','https://issuer.example#fragment'):
|
||||
self.admission['issuer']=issuer
|
||||
with self.subTest(issuer=issuer),self.assertRaises(p.Refused):p.validate(self.transfer,self.admission,ROOT)
|
||||
def test_missing_consumer_or_overlapping_denial_refused(self):
|
||||
self.admission['consumer']['deny_scope']='read'
|
||||
with self.assertRaises(p.Refused):p.validate(self.transfer,self.admission,ROOT)
|
||||
self.admission['consumer'].pop('roles')
|
||||
with self.assertRaises(p.Refused):p.validate(self.transfer,self.admission,ROOT)
|
||||
def test_symlink_private_input_refused_without_read(self):
|
||||
link=self.path.parent/'link';link.symlink_to(self.path)
|
||||
with self.assertRaises(p.Refused):p.metadata_path(link)
|
||||
def test_publicly_readable_private_input_refused(self):
|
||||
self.path.chmod(0o644)
|
||||
with self.assertRaises(p.Refused):p.metadata_path(self.path)
|
||||
def test_bad_admission_digest_never_reads_tls_or_starts_custody(self):
|
||||
admission=self.path.parent/'admission';admission.write_text(json.dumps(self.admission));admission.chmod(0o600)
|
||||
argv=['prepare','--transfer-receipt',str(self.path),'--admission',str(admission),'--admission-sha256','b'*64,
|
||||
'--tls-cert','/never/read-cert','--tls-key','/never/read-key','--trust','/never/read-trust']
|
||||
with patch('sys.argv',argv),patch('builtins.print') as output:
|
||||
self.assertEqual(p.main(),1)
|
||||
self.assertEqual(output.call_args.args,('ERROR: reviewed metadata/source/dependency preflight refused; no key requested.',))
|
||||
def test_actual_launcher_bad_digest_stops_before_hidden_prompt(self):
|
||||
master,slave=pty.openpty()
|
||||
try:
|
||||
args=['bash',str(ROOT/'tools/bootstrap-recovery/run-isolated-keycape.sh'),str(self.path),str(self.path),'b'*64,str(self.path),str(self.path),str(self.path)]
|
||||
process=subprocess.Popen(args,stdin=slave,stdout=slave,stderr=slave)
|
||||
process.wait(timeout=5);os.close(slave);slave=None
|
||||
output=b''
|
||||
while True:
|
||||
try:
|
||||
block=os.read(master,4096)
|
||||
if not block:break
|
||||
output+=block
|
||||
except OSError:break
|
||||
self.assertEqual(process.returncode,1)
|
||||
self.assertIn(b'preflight refused; no key requested.',output)
|
||||
self.assertNotIn(b'Paste custody',output)
|
||||
self.assertNotIn(b'Enter only',output)
|
||||
finally:
|
||||
if slave is not None:os.close(slave)
|
||||
os.close(master)
|
||||
def test_launcher_prepares_before_sealed_descriptor_custody(self):
|
||||
script=(ROOT/'tools/bootstrap-recovery/run-isolated-keycape.sh').read_text()
|
||||
self.assertLess(script.index('python3 tools/bootstrap-recovery/prepare-isolated-keycape.py'),script.index('bash sso-mfa/bootstrap/sops-custody-unlock.sh'))
|
||||
self.assertIn('--sealed-fd --prompt -- python3',script)
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
"""Generated archive/key only; actual age authentication + archive restore.
|
||||
|
||||
Docker and issuer behavior are intentionally mocked here, not native evidence.
|
||||
"""
|
||||
import base64
|
||||
from datetime import datetime,timedelta,timezone
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import time
|
||||
from unittest.mock import MagicMock,patch
|
||||
import pytest
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes,serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
from test_identity_bundle import stage,b
|
||||
|
||||
ROOT=Path(__file__).parents[1]
|
||||
def load(name,path):
|
||||
spec=importlib.util.spec_from_file_location(name,path);m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m);return m
|
||||
s=load('session_manifest',ROOT/'tools/bootstrap-recovery/isolated-keycape-session.py')
|
||||
h=load('helper_manifest',ROOT/'sso-mfa/bootstrap/sops-custody-fd.py')
|
||||
|
||||
@pytest.fixture
|
||||
def envelope(stage,tmp_path,monkeypatch):
|
||||
raw=subprocess.run(['age-keygen'],stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,check=True).stdout
|
||||
key=h.sealed_key(next(line for line in raw.splitlines() if line.startswith(b'AGE-SECRET-KEY-'))+b'\n')
|
||||
recipient=subprocess.run(['age-keygen','-y',f'/proc/self/fd/{key}'],pass_fds=(key,),stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,check=True).stdout.decode().strip()
|
||||
profile=b.profile();profile['public_recipient']=recipient;monkeypatch.setattr(b,'profile',lambda:profile);monkeypatch.setattr(s.bundle,'profile',lambda:profile)
|
||||
artifact=tmp_path/'identity.tar.age';sealed=b.seal(stage,artifact,{'test_only':True})
|
||||
signer=rsa.generate_private_key(public_exponent=65537,key_size=2048);name=x509.Name([x509.NameAttribute(NameOID.COMMON_NAME,'synthetic.invalid')]);now=datetime.now(timezone.utc)
|
||||
cert=(x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(signer.public_key()).serial_number(1).not_valid_before(now-timedelta(minutes=1)).not_valid_after(now+timedelta(hours=1)).sign(signer,hashes.SHA256())).public_bytes(serialization.Encoding.PEM)
|
||||
tls={'/recovery-tls/server.pem':cert,'/recovery-tls/trust.pem':cert,'/recovery-tls/server-key.pem':signer.private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.TraditionalOpenSSL,serialization.NoEncryption())}
|
||||
transfer=dict(schema='netkingdom.identity-offsite.v1',status='readback_verified',provider='Storage Share',folder='Backup/railiance-backups/net-kingdom-identity',artifact='net-kingdom-identity-20261016T063159Z-5882771aa2ce.tar.age',etag='"synthetic"',ciphertext_sha256=sealed['ciphertext_sha256'],ciphertext_bytes=artifact.stat().st_size,fetched_path=str(artifact),independent_owner_retrieval_verified=True)
|
||||
admission=dict(ciphertext_sha256=sealed['ciphertext_sha256'],manifest_binding_mode='ciphertext-derived-v1',issuer='https://synthetic.invalid',tls_cert_sha256=hashlib.sha256(cert).hexdigest(),trust_sha256=hashlib.sha256(cert).hexdigest(),verifier_sha256=hashlib.sha256(b'synthetic executable').hexdigest())
|
||||
try:yield artifact,key,transfer,admission,tls
|
||||
finally:os.close(key)
|
||||
|
||||
def execute(envelope,*,wrong_key=False):
|
||||
artifact,key,transfer,admission,tls=envelope;receipt={};seen={};checkpoints=[]
|
||||
if wrong_key:
|
||||
raw=subprocess.run(['age-keygen'],stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,check=True).stdout
|
||||
key=h.sealed_key(next(line for line in raw.splitlines() if line.startswith(b'AGE-SECRET-KEY-'))+b'\n')
|
||||
actualrun=s.run
|
||||
def run(argv,**kwargs):return actualrun(argv,**kwargs) if argv[0]=='age-keygen' else b''
|
||||
def native(root,manifest,checked,receipt,**kwargs):seen.update(checked);receipt['status']='synthetic_flow_checked'
|
||||
execution={'pid':os.getpid(),'sid':os.getsid(0),'pgid':os.getpgrp(),'uid':os.getuid(),'deadline':time.monotonic()+60}
|
||||
try:
|
||||
with patch.object(s,'verify_execution'),patch.object(s.local,'preflight',return_value={}),patch.object(s.local.verify.custody,'private_key_fd',side_effect=lambda:os.dup(key)),patch.object(s,'start_reaper',return_value=MagicMock()),patch.object(s,'run',side_effect=run),patch.object(s.adapter,'container_file',return_value=b'synthetic executable'),patch.object(s.adapter,'private_payload',return_value={'files':{'/etc/keycape/config.yaml':base64.b64encode(b'issuer: https://synthetic.invalid\nport: 8080').decode()},'env':{}}),patch.object(s,'provision'),patch.object(s.adapter,'check_jwks'),patch.object(s.adapter,'native_checks',side_effect=native),patch.object(s,'cleanup_all'):
|
||||
# Key file must be valid at the later public signer construction.
|
||||
payload_key=rsa.generate_private_key(public_exponent=65537,key_size=2048).private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.TraditionalOpenSSL,serialization.NoEncryption())
|
||||
with patch.object(s.adapter,'private_payload',return_value={'files':{'/etc/keycape/config.yaml':base64.b64encode(b'issuer: https://synthetic.invalid\nport: 8080').decode(),'/etc/keycape/key.pem':base64.b64encode(payload_key).decode()},'env':{}}):
|
||||
s.execute(transfer,admission,tls,receipt,execution,checkpoint=lambda r:checkpoints.append(json.loads(json.dumps(r))))
|
||||
return receipt,seen,checkpoints
|
||||
finally:
|
||||
if wrong_key:os.close(key)
|
||||
|
||||
def test_exact_ciphertext_authenticated_restore_derives_private_manifest_without_safe_leak(envelope):
|
||||
receipt,seen,checkpoints=execute(envelope)
|
||||
derived=seen['manifest_sha256'];assert len(derived)==64
|
||||
safe=json.dumps([receipt,checkpoints]);assert derived not in safe;assert 'manifest_sha256' not in safe
|
||||
assert receipt['all_manifest_hashes_verified'] is True
|
||||
assert receipt['temporary_plaintext_cleanup_verified'] is True
|
||||
|
||||
def test_changed_ciphertext_refuses_before_archive_decryption(envelope):
|
||||
artifact,*_=envelope;artifact.write_bytes(artifact.read_bytes()[:-1]+b'!')
|
||||
with patch.object(s.adapter,'process') as process,pytest.raises(s.adapter.Refused):execute(envelope)
|
||||
assert not process.called
|
||||
|
||||
def test_modified_ciphertext_rebound_metadata_still_fails_authenticated_age(envelope):
|
||||
artifact,key,transfer,admission,tls=envelope
|
||||
raw=artifact.read_bytes();artifact.write_bytes(raw[:-32]+b'!'*32)
|
||||
digest=hashlib.sha256(artifact.read_bytes()).hexdigest()
|
||||
transfer['ciphertext_sha256']=digest;admission['ciphertext_sha256']=digest
|
||||
with pytest.raises(s.adapter.Refused):execute(envelope)
|
||||
|
||||
def test_wrong_key_refuses_before_decryption_and_never_exposes_manifest(envelope):
|
||||
with pytest.raises(s.adapter.Refused):execute(envelope,wrong_key=True)
|
||||
|
||||
def test_wrong_explicit_manifest_refuses_after_full_restore(envelope):
|
||||
admission=envelope[3];admission['manifest_binding_mode']='explicit-digest-v1';admission['manifest_sha256']='0'*64
|
||||
with pytest.raises(s.adapter.Refused):execute(envelope)
|
||||
|
||||
@pytest.mark.parametrize('mode',['derived','ciphertext-derived-v2',None,True,{}])
|
||||
def test_unknown_binding_mode_refuses_before_effects(envelope,mode):
|
||||
envelope[3]['manifest_binding_mode']=mode
|
||||
with patch.object(s.local,'preflight') as preflight,pytest.raises(s.adapter.Refused):execute(envelope)
|
||||
assert not preflight.called
|
||||
|
||||
def test_conflicting_binding_and_unverified_transfer_refuse(envelope):
|
||||
admission=envelope[3];transfer=envelope[2];admission['manifest_sha256']='0'*64
|
||||
with pytest.raises(s.adapter.Refused):s.adapter.manifest_binding_mode(admission,transfer)
|
||||
admission.pop('manifest_sha256');transfer['independent_owner_retrieval_verified']=False
|
||||
with pytest.raises(s.adapter.Refused):s.adapter.manifest_binding_mode(admission,transfer)
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
import fcntl
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import pty
|
||||
import select
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT=Path(__file__).parents[1]
|
||||
def load(name,path):
|
||||
spec=importlib.util.spec_from_file_location(name,path);m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m);return m
|
||||
h=load('fdhelper',ROOT/'sso-mfa/bootstrap/sops-custody-fd.py')
|
||||
c=load('custody',ROOT/'sso-mfa/bootstrap/sops-private.py')
|
||||
|
||||
class SealedCustodyTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
# Capture generated test key in memory only, never into test output/path.
|
||||
r=subprocess.run(['age-keygen'],stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,check=True)
|
||||
cls.material=next(x for x in r.stdout.splitlines() if x.startswith(b'AGE-SECRET-KEY-'))+b'\n'
|
||||
fd=h.sealed_key(cls.material)
|
||||
try:cls.recipient=subprocess.run(['age-keygen','-y',f'/proc/self/fd/{fd}'],pass_fds=(fd,),stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,check=True).stdout.decode().strip()
|
||||
finally:os.close(fd)
|
||||
def test_sealed_descriptor_private_duplicate_is_cloexec_and_decrypts(self):
|
||||
encrypted=subprocess.run(['age','-r',self.recipient],input=b'synthetic recovery material',stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,check=True).stdout
|
||||
original=h.sealed_key(self.material)
|
||||
try:
|
||||
with patch.dict(os.environ,{'NETKINGDOM_CUSTODY_AGE_KEY_FD':str(original)},clear=True):fd=c.private_key_fd()
|
||||
try:
|
||||
self.assertFalse(os.get_inheritable(fd));self.assertEqual(os.fstat(fd).st_nlink,0)
|
||||
r=subprocess.run(['age','-d','-i',f'/proc/self/fd/{fd}'],pass_fds=(fd,),input=encrypted,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL)
|
||||
self.assertEqual(r.returncode,0);self.assertEqual(r.stdout,b'synthetic recovery material')
|
||||
finally:os.close(fd)
|
||||
finally:os.close(original)
|
||||
def test_incomplete_seals_or_named_file_refused(self):
|
||||
for named in (False,True):
|
||||
if named:
|
||||
file=tempfile.TemporaryFile();fd=file.fileno();file.write(self.material);file.flush()
|
||||
else:fd=os.memfd_create('synthetic-unsealed',os.MFD_CLOEXEC|os.MFD_ALLOW_SEALING);os.write(fd,self.material)
|
||||
try:
|
||||
with patch.dict(os.environ,{'NETKINGDOM_CUSTODY_AGE_KEY_FD':str(fd)},clear=True),self.assertRaises((c.Refused,OSError)):
|
||||
c.private_key_fd()
|
||||
finally:
|
||||
if named:file.close()
|
||||
else:os.close(fd)
|
||||
def test_ambiguous_sources_refused(self):
|
||||
with patch.dict(os.environ,{'NETKINGDOM_CUSTODY_AGE_KEY_FD':'3','NETKINGDOM_CUSTODY_AGE_KEY_FILE':'/not/opened'},clear=True),self.assertRaises(c.Refused):c.private_key_fd()
|
||||
def test_malformed_descriptor_text_refused_before_dup(self):
|
||||
for value in ('-1','2','03',' 3','3 ','1000000000','3/4'):
|
||||
with patch.dict(os.environ,{'NETKINGDOM_CUSTODY_AGE_KEY_FD':value},clear=True),patch.object(c.os,'dup') as duplicate:
|
||||
with self.subTest(value=value),self.assertRaises(c.Refused):c.private_key_fd()
|
||||
duplicate.assert_not_called()
|
||||
def test_bad_expected_recipient_refused_before_prompt(self):
|
||||
with patch('sys.argv',['sealed','--expected-recipient','age1bad','--','true']),patch.object(h.os,'isatty',return_value=True),patch.object(h.getpass,'getpass') as prompt,patch('builtins.print'):
|
||||
self.assertEqual(h.main(),1);prompt.assert_not_called()
|
||||
def test_existing_file_mode_still_accepts_private_owner_file(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
path=Path(temp)/'synthetic-key';path.write_bytes(self.material);path.chmod(0o600)
|
||||
with patch.dict(os.environ,{'NETKINGDOM_CUSTODY_AGE_KEY_FILE':str(path)},clear=True):fd=c.private_key_fd()
|
||||
try:self.assertEqual(os.read(fd,16384),self.material)
|
||||
finally:os.close(fd)
|
||||
def test_helper_passes_exact_fd_and_clears_ambient_sources(self):
|
||||
code="""import os,sys
|
||||
fd=int(os.environ['NETKINGDOM_CUSTODY_AGE_KEY_FD'])
|
||||
assert os.fstat(fd).st_nlink==0
|
||||
assert all(k not in os.environ for k in ('NETKINGDOM_CUSTODY_AGE_KEY_FILE','SOPS_AGE_KEY_FILE','SOPS_AGE_KEY','SOPS_AGE_KEY_CMD'))
|
||||
"""
|
||||
with patch.dict(os.environ,{'SOPS_AGE_KEY':'synthetic-competing','NETKINGDOM_CUSTODY_AGE_KEY_FILE':'/not/read'}):
|
||||
self.assertEqual(h.run(self.material,self.recipient,[sys.executable,'-c',code]),0)
|
||||
def test_normal_and_error_return_close_parent_fd(self):
|
||||
actual=h.sealed_key
|
||||
for status in (0,7):
|
||||
fds=[]
|
||||
def capture(material):
|
||||
fd=actual(material);fds.append(fd);return fd
|
||||
with patch.object(h,'sealed_key',side_effect=capture):self.assertEqual(h.run(self.material,self.recipient,[sys.executable,'-c',f'raise SystemExit({status})']),status)
|
||||
with self.assertRaises(OSError):os.fstat(fds[0])
|
||||
def test_wrong_recipient_does_not_start_child_and_closes_fd(self):
|
||||
other=subprocess.run(['age-keygen'],stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,check=True).stdout
|
||||
other=next(x for x in other.splitlines() if x.startswith(b'AGE-SECRET-KEY-'))+b'\n';fd=h.sealed_key(other)
|
||||
try:recipient=subprocess.run(['age-keygen','-y',f'/proc/self/fd/{fd}'],pass_fds=(fd,),stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,check=True).stdout.decode().strip()
|
||||
finally:os.close(fd)
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
marker=Path(temp)/'child'
|
||||
with self.assertRaises(h.Refused):h.run(self.material,recipient,[sys.executable,'-c','import pathlib,sys;pathlib.Path(sys.argv[1]).touch()',str(marker)])
|
||||
self.assertFalse(marker.exists())
|
||||
def test_actual_attended_shell_sealed_mode_hides_key_and_runs_original_fd_consumer(self):
|
||||
code="import importlib.util,os,sys;spec=importlib.util.spec_from_file_location('c',sys.argv[1]);c=importlib.util.module_from_spec(spec);spec.loader.exec_module(c);fd=c.private_key_fd();assert os.fstat(fd).st_nlink==0;os.close(fd)"
|
||||
master,slave=pty.openpty()
|
||||
child=subprocess.Popen(['bash','-x',str(ROOT/'sso-mfa/bootstrap/sops-custody-unlock.sh'),'--expected-recipient',self.recipient,'--sealed-fd','--prompt','--',sys.executable,'-c',code,str(ROOT/'sso-mfa/bootstrap/sops-private.py')],stdin=slave,stdout=slave,stderr=slave)
|
||||
os.close(slave);captured=b'';sent=False;end=time.monotonic()+5
|
||||
try:
|
||||
while time.monotonic()<end:
|
||||
if select.select([master],[],[],.05)[0]:
|
||||
try:chunk=os.read(master,65536)
|
||||
except OSError:break
|
||||
if not chunk:break
|
||||
captured+=chunk
|
||||
if b'input hidden): ' in captured and not sent:os.write(master,self.material);sent=True
|
||||
self.assertEqual(child.wait(timeout=2),0)
|
||||
self.assertTrue(sent);self.assertNotIn(self.material.strip(),captured)
|
||||
finally:
|
||||
if child.poll() is None:child.kill();child.wait(timeout=2)
|
||||
os.close(master)
|
||||
def test_actual_hard_parent_loss_leaves_no_named_key_and_closes_on_child_exit(self):
|
||||
# Public child metadata only crosses the test pipe. The generated key is
|
||||
# supplied over stdin and never becomes an argv/environment/path value.
|
||||
child_code="import json,os,time;fd=int(os.environ['NETKINGDOM_CUSTODY_AGE_KEY_FD']);print(json.dumps({'pid':os.getpid(),'fd':fd,'nlink':os.fstat(fd).st_nlink}),flush=True);time.sleep(30)"
|
||||
parent_code="""import importlib.util,sys
|
||||
spec=importlib.util.spec_from_file_location('h',sys.argv[1]);h=importlib.util.module_from_spec(spec);spec.loader.exec_module(h)
|
||||
h.run(sys.stdin.buffer.read(),sys.argv[2],[sys.executable,'-c',sys.argv[3]])
|
||||
"""
|
||||
parent=subprocess.Popen([sys.executable,'-c',parent_code,str(ROOT/'sso-mfa/bootstrap/sops-custody-fd.py'),self.recipient,child_code],stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,start_new_session=True)
|
||||
info=None;child_pidfd=None
|
||||
try:
|
||||
parent.stdin.write(self.material);parent.stdin.close()
|
||||
info=json.loads(parent.stdout.readline());self.assertEqual(info['nlink'],0)
|
||||
fdpath=Path('/proc')/str(info['pid'])/'fd'/str(info['fd']);self.assertTrue(fdpath.exists())
|
||||
child_pidfd=os.pidfd_open(info['pid'])
|
||||
parent.kill();parent.wait(timeout=5)
|
||||
# Admitted child retains its inherited FD until it exits; do not
|
||||
# confuse parent loss with revocation of that active child.
|
||||
self.assertTrue(fdpath.exists())
|
||||
signal.pidfd_send_signal(child_pidfd,signal.SIGKILL)
|
||||
# pidfd exit readiness proves the exact child's descriptor table was
|
||||
# released. /proc may become inaccessible during process teardown;
|
||||
# do not interpret permission errors or a reused numeric PID as proof.
|
||||
poller=select.poll();poller.register(child_pidfd,select.POLLIN)
|
||||
self.assertTrue(poller.poll(5000))
|
||||
finally:
|
||||
if parent.poll() is None:parent.kill();parent.wait(timeout=5)
|
||||
if child_pidfd is not None:
|
||||
try:signal.pidfd_send_signal(child_pidfd,signal.SIGKILL)
|
||||
except ProcessLookupError:pass
|
||||
finally:os.close(child_pidfd)
|
||||
parent.stdout.close()
|
||||
|
|
@ -0,0 +1,276 @@
|
|||
"""Service helpers against synthetic files and a recording Kubernetes API stub."""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
import bcrypt
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PASSWORD = 'SyntheticServiceCredential12345678901234567890'
|
||||
CHANGED = 'ReplacementSyntheticCredential12345678901234567890'
|
||||
VALUES = {
|
||||
'privacyidea': ['PI_SECRET_KEY', 'PI_PEPPER', 'PI_DB_PASSWORD'],
|
||||
'lldap': ['LLDAP_JWT_SECRET', 'LLDAP_LDAP_USER_PASS'],
|
||||
'authelia': ['AUTHELIA_JWT_SECRET', 'AUTHELIA_SESSION_SECRET', 'AUTHELIA_STORAGE_ENCRYPTION_KEY',
|
||||
'AUTHELIA_OIDC_HMAC_SECRET', 'AUTHELIA_KEYCAPE_CLIENT_SECRET'],
|
||||
}
|
||||
COMPONENTS = ['postgresql', 'privacyidea', 'lldap', 'authelia']
|
||||
ROTATIONS = [('postgresql','PI_DB_PASSWORD','password'), ('privacyidea','PI_SECRET_KEY','PI_SECRET_KEY'),
|
||||
('privacyidea','PI_DB_PASSWORD','PI_SQLALCHEMY_DATABASE_URI'),
|
||||
('lldap','LLDAP_JWT_SECRET','LLDAP_JWT_SECRET'), ('authelia','AUTHELIA_SESSION_SECRET','session_secret')]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def case(tmp_path):
|
||||
private=tmp_path/'private';private.mkdir(mode=0o700)
|
||||
for component,names in VALUES.items():
|
||||
directory=private/component;directory.mkdir(mode=0o700)
|
||||
env=directory/'secrets.env';env.write_text(''.join(f'{name}={PASSWORD}\n' for name in names));env.chmod(0o600)
|
||||
binary=tmp_path/'bin';binary.mkdir()
|
||||
(binary/'kubectl').write_text('''#!/usr/bin/python3
|
||||
import json,os,sys
|
||||
from pathlib import Path
|
||||
args=sys.argv[1:]
|
||||
raw=sys.stdin.read() if '-f' in args or '--patch-file=/dev/stdin' in args 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['MARKER']);print(os.environ['MARKER'],file=sys.stderr);sys.exit(1)
|
||||
p=Path(os.environ['STATE'])
|
||||
if args[0]=='get':
|
||||
if os.environ.get('BAD_GET'):print(os.environ['BAD_GET'])
|
||||
elif p.exists():print(p.read_text())
|
||||
sys.exit(0)
|
||||
if args[0]=='create' and raw:
|
||||
if p.exists() or os.environ.get('RACE'):sys.exit(1)
|
||||
state=json.loads(raw);state['metadata'].update(uid='fixture-uid',resourceVersion='1')
|
||||
elif args[0]=='patch':
|
||||
if os.environ.get('RACE'):sys.exit(1)
|
||||
state=json.loads(p.read_text())
|
||||
for op in json.loads(raw):
|
||||
parts=[s.replace('~1','/').replace('~0','~') for s in op['path'].split('/')[1:]]
|
||||
parent=state
|
||||
for part in parts[:-1]:parent=parent[part]
|
||||
if op['op']=='test':
|
||||
if parent.get(parts[-1])!=op['value']:sys.exit(1)
|
||||
elif op['op']=='remove':del parent[parts[-1]]
|
||||
elif op['op']=='replace':parent[parts[-1]]=op['value']
|
||||
else:sys.exit(1)
|
||||
state['metadata']['resourceVersion']=str(int(state['metadata']['resourceVersion'])+1)
|
||||
else:sys.exit(2)
|
||||
p.write_text(json.dumps(state))
|
||||
if os.environ.get('BAD_RESPONSE'):state['data']['unexpected']='wrong'
|
||||
print(json.dumps(state))
|
||||
''')
|
||||
(binary/'kubectl').chmod(0o700)
|
||||
env={'PATH':str(binary)+':'+os.environ['PATH'],'HOME':str(tmp_path),
|
||||
'CALLS':str(tmp_path/'calls'),'STATE':str(tmp_path/'state'),'MARKER':PASSWORD}
|
||||
return private,env
|
||||
|
||||
|
||||
def invoke(case,component,rotate=None,**extra):
|
||||
args=['bash','-x',str(ROOT/f'sso-mfa/k8s/{component}/create-secrets.sh'),str(case[0])]
|
||||
if rotate:args+=['--rotate-key',rotate]
|
||||
return subprocess.run(args,env={**case[1],**extra},capture_output=True,text=True,timeout=20)
|
||||
|
||||
|
||||
def calls(case):
|
||||
p=Path(case[1]['CALLS']);return [json.loads(line) for line in p.read_text().splitlines()] if p.exists() else []
|
||||
|
||||
|
||||
def state(case):return json.loads(Path(case[1]['STATE']).read_text())
|
||||
|
||||
def put_state(case,obj):Path(case[1]['STATE']).write_text(json.dumps(obj))
|
||||
|
||||
def check_private_result(result):
|
||||
assert PASSWORD not in result.stdout+result.stderr
|
||||
assert CHANGED not in result.stdout+result.stderr
|
||||
assert 'BEGIN PRIVATE KEY' not in result.stdout+result.stderr
|
||||
|
||||
|
||||
def source(component):return 'privacyidea' if component=='postgresql' else component
|
||||
|
||||
|
||||
@pytest.mark.parametrize('component',COMPONENTS)
|
||||
def test_create_uses_literal_inputs_stdin_and_no_apply_annotation(case,component):
|
||||
result=invoke(case,component);assert result.returncode==0,result.stderr;check_private_result(result)
|
||||
assert PASSWORD not in json.dumps([c['args'] for c in calls(case)])
|
||||
writes=[c for c in calls(case) if c['args'][0]!='get']
|
||||
assert len(writes)==1 and writes[0]['args']==['create','-o','json','-f','-']
|
||||
obj=state(case);assert 'annotations' not in obj['metadata']
|
||||
values={k:base64.b64decode(v).decode() for k,v in obj['data'].items()}
|
||||
if component=='postgresql':assert values=={'username':'privacyidea','password':PASSWORD}
|
||||
elif component=='privacyidea':
|
||||
assert values['PI_PEPPER']==PASSWORD and values['PI_SECRET_KEY']==PASSWORD
|
||||
assert values['PI_SQLALCHEMY_DATABASE_URI']==f'postgresql://privacyidea:{PASSWORD}@net-kingdom-pg-rw.databases.svc.cluster.local:5432/privacyidea_db'
|
||||
elif component=='lldap':assert values=={k:PASSWORD for k in VALUES['lldap']}
|
||||
else:
|
||||
key=case[0]/'authelia/oidc_private_key.pem'
|
||||
assert key.stat().st_mode & 0o077==0 and values['oidc_issuer_private_key']==key.read_text()
|
||||
assert bcrypt.checkpw(PASSWORD.encode(),values['keycape_client_secret_hash'].encode())
|
||||
assert values['ldap_password']==PASSWORD
|
||||
|
||||
|
||||
@pytest.mark.parametrize('component',COMPONENTS)
|
||||
def test_repeated_apply_is_noop_and_preserves_other_data(case,component):
|
||||
assert invoke(case,component).returncode==0
|
||||
old=state(case);old['data']['owner-added']='c3ludGhldGlj';old['metadata']['annotations']={'owner':'retained'};put_state(case,old)
|
||||
count=len(calls(case));result=invoke(case,component)
|
||||
assert result.returncode==0;check_private_result(result)
|
||||
assert state(case)==old
|
||||
assert all(c['args'][0]=='get' for c in calls(case)[count:])
|
||||
|
||||
|
||||
@pytest.mark.parametrize('component,key,field',ROTATIONS)
|
||||
def test_explicit_rotation_is_conditional_and_changes_only_named_field(case,component,key,field):
|
||||
assert invoke(case,component).returncode==0
|
||||
old=state(case);old['data']['owner-added']='c3ludGhldGlj';old['metadata']['annotations']={'owner':'retained','kubectl.kubernetes.io/last-applied-configuration':'synthetic-old-copy'};put_state(case,old)
|
||||
file=case[0]/source(component)/'secrets.env';file.write_text(file.read_text().replace(f'{key}={PASSWORD}',f'{key}={CHANGED}'))
|
||||
result=invoke(case,component,rotate=key);assert result.returncode==0,result.stderr;check_private_result(result)
|
||||
updated=state(case)
|
||||
assert updated['metadata']['annotations']=={'owner':'retained'}
|
||||
assert updated['data'][field]!=old['data'][field]
|
||||
assert {k:v for k,v in updated['data'].items() if k!=field}=={k:v for k,v in old['data'].items() if k!=field}
|
||||
patch=next(c for c in reversed(calls(case)) if c['args'][0]=='patch')
|
||||
tests={op['path']:op['value'] for op in json.loads(patch['stdin']) if op['op']=='test'}
|
||||
assert tests['/metadata/uid']==old['metadata']['uid'] and tests['/metadata/resourceVersion']==old['metadata']['resourceVersion']
|
||||
assert PASSWORD not in json.dumps(patch['args']) and CHANGED not in json.dumps(patch['args'])
|
||||
|
||||
|
||||
@pytest.mark.parametrize('component',COMPONENTS)
|
||||
def test_differing_values_require_explicit_rotation(case,component):
|
||||
assert invoke(case,component).returncode==0;old=state(case)
|
||||
file=case[0]/source(component)/'secrets.env';file.write_text(file.read_text().replace(PASSWORD,CHANGED))
|
||||
count=len(calls(case));result=invoke(case,component)
|
||||
assert result.returncode!=0;check_private_result(result);assert state(case)==old
|
||||
assert all(c['args'][0]=='get' for c in calls(case)[count:])
|
||||
|
||||
|
||||
@pytest.mark.parametrize('component',COMPONENTS)
|
||||
@pytest.mark.parametrize('defect',['shell','duplicate','public','symlink','hardlink','public-parent'])
|
||||
def test_bad_private_input_never_reaches_cluster(case,component,defect):
|
||||
file=case[0]/source(component)/'secrets.env';marker=case[0]/'executed'
|
||||
if defect=='shell':file.write_text(file.read_text()+f'$(touch {marker})\n')
|
||||
elif defect=='duplicate':file.write_text(file.read_text()*2)
|
||||
elif defect=='public':file.chmod(0o644)
|
||||
elif defect=='public-parent':file.parent.chmod(0o755)
|
||||
elif defect=='hardlink':os.link(file,file.with_name('alias'))
|
||||
else:
|
||||
target=file.with_name('other');file.rename(target);file.symlink_to(target)
|
||||
result=invoke(case,component);assert result.returncode!=0;check_private_result(result)
|
||||
assert not marker.exists() and not calls(case)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('component',COMPONENTS)
|
||||
@pytest.mark.parametrize('failure',['get','create'])
|
||||
def test_failed_child_diagnostics_never_escape(case,component,failure):
|
||||
result=invoke(case,component,FAIL=failure);assert result.returncode!=0;check_private_result(result)
|
||||
assert not Path(case[1]['STATE']).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('defect',['owner','eso-label','identity','type','basic-auth','data','immutable','missing-uid'])
|
||||
def test_wrong_or_controller_owned_existing_secret_is_refused(case,defect):
|
||||
assert invoke(case,'lldap').returncode==0;old=state(case)
|
||||
if defect=='owner':old['metadata']['ownerReferences']=[{'kind':'ExternalSecret','name':'fixture'}]
|
||||
elif defect=='eso-label':old['metadata']['labels']={'reconcile.external-secrets.io/managed':'true'}
|
||||
elif defect=='missing-uid':old['metadata']['uid']=None
|
||||
elif defect=='identity':old['metadata']['namespace']='other'
|
||||
elif defect=='type':old['type']='kubernetes.io/tls'
|
||||
elif defect=='basic-auth':old['type']='kubernetes.io/basic-auth'
|
||||
elif defect=='data':old['data']['LLDAP_JWT_SECRET']='invalid-base64'
|
||||
else:old['immutable']=True
|
||||
put_state(case,old);count=len(calls(case));result=invoke(case,'lldap')
|
||||
assert result.returncode!=0 and state(case)==old
|
||||
assert all(c['args'][0]=='get' for c in calls(case)[count:])
|
||||
|
||||
|
||||
@pytest.mark.parametrize('component,key,field',ROTATIONS)
|
||||
def test_concurrent_change_stops_rotation_without_retry(case,component,key,field):
|
||||
assert invoke(case,component).returncode==0;old=state(case)
|
||||
file=case[0]/source(component)/'secrets.env';file.write_text(file.read_text().replace(f'{key}={PASSWORD}',f'{key}={CHANGED}'))
|
||||
count=len(calls(case));result=invoke(case,component,rotate=key,RACE='1')
|
||||
assert result.returncode!=0 and state(case)==old;check_private_result(result)
|
||||
assert sum(c['args'][0]=='patch' for c in calls(case)[count:])==1
|
||||
|
||||
|
||||
def test_create_race_never_switches_to_overwrite(case):
|
||||
result=invoke(case,'lldap',RACE='1');assert result.returncode!=0
|
||||
assert [c['args'][0] for c in calls(case)]==['get','create']
|
||||
|
||||
|
||||
def test_authelia_failure_retains_same_key_on_retry(case):
|
||||
result=invoke(case,'authelia',FAIL='create');assert result.returncode!=0
|
||||
key=case[0]/'authelia/oidc_private_key.pem';original=key.read_bytes()
|
||||
assert invoke(case,'authelia').returncode==0 and key.read_bytes()==original
|
||||
|
||||
|
||||
def test_rotating_session_never_generates_or_replaces_existing_signing_key(case):
|
||||
assert invoke(case,'authelia').returncode==0;old=state(case)
|
||||
(case[0]/'authelia/oidc_private_key.pem').unlink()
|
||||
file=case[0]/'authelia/secrets.env';file.write_text(file.read_text().replace(f'AUTHELIA_SESSION_SECRET={PASSWORD}',f'AUTHELIA_SESSION_SECRET={CHANGED}'))
|
||||
assert invoke(case,'authelia',rotate='AUTHELIA_SESSION_SECRET').returncode==0
|
||||
assert state(case)['data']['oidc_issuer_private_key']==old['data']['oidc_issuer_private_key']
|
||||
assert not (case[0]/'authelia/oidc_private_key.pem').exists()
|
||||
|
||||
|
||||
def test_wrong_response_is_not_reported_as_success(case):
|
||||
result=invoke(case,'lldap',BAD_RESPONSE='1');assert result.returncode!=0;check_private_result(result)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('annotation',['', 'synthetic-old-copy'])
|
||||
def test_annotation_cleanup_preserves_secret_values_and_other_annotations(case,annotation):
|
||||
assert invoke(case,'lldap').returncode==0;old=state(case)
|
||||
old['metadata']['annotations']={'owner':'retained','kubectl.kubernetes.io/last-applied-configuration':annotation};put_state(case,old)
|
||||
result=invoke(case,'lldap');assert result.returncode==0
|
||||
assert state(case)['data']==old['data']
|
||||
assert state(case)['metadata']['annotations']=={'owner':'retained'}
|
||||
|
||||
|
||||
@pytest.mark.parametrize('component,key,field',ROTATIONS)
|
||||
def test_rotation_refuses_missing_secret_before_create(case,component,key,field):
|
||||
result=invoke(case,component,rotate=key);assert result.returncode!=0
|
||||
assert all(c['args'][0]=='get' for c in calls(case))
|
||||
assert not (case[0]/'authelia/oidc_private_key.pem').exists()
|
||||
|
||||
|
||||
def test_rotation_cannot_change_an_unrelated_field(case):
|
||||
assert invoke(case,'privacyidea').returncode==0;old=state(case)
|
||||
file=case[0]/'privacyidea/secrets.env';file.write_text(file.read_text().replace(PASSWORD,CHANGED))
|
||||
result=invoke(case,'privacyidea',rotate='PI_SECRET_KEY');assert result.returncode!=0
|
||||
assert state(case)==old
|
||||
assert not any(c['args'][0]=='patch' for c in calls(case))
|
||||
|
||||
|
||||
@pytest.mark.parametrize('defect',['symlink','hardlink','public','public-parent','invalid'])
|
||||
def test_explicit_authelia_key_custody_is_validated_without_overwrite(case,defect):
|
||||
key=case[0]/'authelia/external.pem';key.write_text('synthetic-invalid-key');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)
|
||||
elif defect=='public-parent':key.parent.chmod(0o755)
|
||||
file=case[0]/'authelia/secrets.env';file.write_text(file.read_text()+f'AUTHELIA_OIDC_PRIVATE_KEY_FILE={key}\n')
|
||||
result=invoke(case,'authelia');assert result.returncode!=0
|
||||
assert key.read_text()=='synthetic-invalid-key'
|
||||
assert not any(c['args'][0]!='get' for c in calls(case))
|
||||
|
||||
|
||||
def test_native_kubectl_applies_stdin_patch_and_refuses_stale_version(case,tmp_path):
|
||||
import shutil
|
||||
binary=shutil.which('kubectl')
|
||||
if binary is None:pytest.skip('native kubectl not available')
|
||||
assert invoke(case,'lldap').returncode==0;old=state(case)
|
||||
file=case[0]/'lldap/secrets.env';file.write_text(file.read_text().replace(f'LLDAP_JWT_SECRET={PASSWORD}',f'LLDAP_JWT_SECRET={CHANGED}'))
|
||||
assert invoke(case,'lldap',rotate='LLDAP_JWT_SECRET').returncode==0
|
||||
patch=next(c['stdin'] for c in reversed(calls(case)) if c['args'][0]=='patch')
|
||||
original=tmp_path/'original.json';original.write_text(json.dumps(old));original.chmod(0o600)
|
||||
env={**os.environ,'KUBECONFIG':str(tmp_path/'no-cluster-config'),'HOME':str(tmp_path)}
|
||||
command=[binary,'patch','--local','--type=json','--patch-file=/dev/stdin','-f',str(original),'-o','json']
|
||||
result=subprocess.run(command,input=patch,env=env,capture_output=True,text=True,timeout=20)
|
||||
assert result.returncode==0
|
||||
assert json.loads(result.stdout)['data']==state(case)['data']
|
||||
old['metadata']['resourceVersion']='999';original.write_text(json.dumps(old))
|
||||
refused=subprocess.run(command,input=patch,env=env,capture_output=True,text=True,timeout=20)
|
||||
assert refused.returncode!=0
|
||||
|
|
@ -0,0 +1,227 @@
|
|||
"""Use real SOPS with disposable age custody; no repository secret inputs."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SOPS = shutil.which('sops')
|
||||
pytestmark = pytest.mark.skipif(SOPS is None, reason='SOPS required for real crypto regression')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def case(tmp_path):
|
||||
repo = tmp_path / 'repo'; (repo / 'sso-mfa/bootstrap').mkdir(parents=True)
|
||||
shutil.copyfile(ROOT / 'Makefile', repo / 'Makefile')
|
||||
shutil.copyfile(ROOT / 'sso-mfa/bootstrap/sops-private.py', repo / 'sso-mfa/bootstrap/sops-private.py')
|
||||
(repo / 'keys').mkdir()
|
||||
key = tmp_path / 'disposable.key'
|
||||
subprocess.run(['age-keygen', '-o', str(key)], capture_output=True, check=True)
|
||||
public = subprocess.check_output(['age-keygen', '-y', str(key)], text=True).strip()
|
||||
(repo / 'keys/age.pub').write_text(public + '\n')
|
||||
home = tmp_path / 'home'; home.mkdir(mode=0o700)
|
||||
env = {'HOME': str(home), 'PATH': os.environ['PATH'], 'NETKINGDOM_CUSTODY_AGE_KEY_FILE': str(key)}
|
||||
clear = tmp_path / 'input.json'; clear.write_text(json.dumps({'credential': 'synthetic-secret-payload'}))
|
||||
cipher = repo / 'cipher.json'
|
||||
encrypted = subprocess.run([SOPS, '--encrypt', '--age', public, str(clear)], cwd=tmp_path,
|
||||
env=env, capture_output=True, check=True)
|
||||
cipher.write_bytes(encrypted.stdout)
|
||||
output_dir = tmp_path / 'private-output'; output_dir.mkdir(mode=0o700)
|
||||
return repo, key, env, cipher, output_dir / 'restored.json'
|
||||
|
||||
|
||||
def make(case, target='sops-decrypt', *, env=None, source=None, output=None):
|
||||
repo, _, default_env, cipher, destination = case
|
||||
return subprocess.run(['make', '-s', target, 'FILE=' + str(source or cipher),
|
||||
'OUTPUT=' + str(output or destination)],
|
||||
env=env or default_env, cwd=repo, capture_output=True, text=True, timeout=15)
|
||||
|
||||
|
||||
def ambient_key(case):
|
||||
_, key, env, _, _ = case
|
||||
path = Path(env['HOME']) / '.config/sops/age/keys.txt'
|
||||
path.parent.mkdir(parents=True); path.write_bytes(key.read_bytes()); path.chmod(0o600)
|
||||
|
||||
|
||||
def test_setup_never_installs_private_key(case):
|
||||
_, key, env, _, _ = case
|
||||
legacy = Path(env['HOME']) / '.config/age/key.txt'
|
||||
legacy.parent.mkdir(parents=True); legacy.write_bytes(key.read_bytes())
|
||||
result = make(case, 'sops-setup')
|
||||
assert result.returncode == 0
|
||||
assert not (Path(env['HOME']) / '.config/sops').exists()
|
||||
|
||||
|
||||
def test_decrypt_real_sops_to_private_output_without_transcript(case):
|
||||
ambient_key(case) # Old target succeeds through ambient discovery and leaks.
|
||||
result = make(case)
|
||||
assert result.returncode == 0
|
||||
assert 'synthetic-secret-payload' not in result.stdout + result.stderr
|
||||
assert json.loads(case[4].read_text()) == {'credential': 'synthetic-secret-payload'}
|
||||
assert case[4].stat().st_mode & 0o077 == 0
|
||||
assert not list(case[4].parent.glob('.netkingdom-sops-*'))
|
||||
|
||||
|
||||
def test_wrong_explicit_key_never_falls_back_to_home_or_inline_keys(case, tmp_path):
|
||||
ambient_key(case)
|
||||
wrong = tmp_path / 'wrong.key'
|
||||
subprocess.run(['age-keygen', '-o', str(wrong)], capture_output=True, check=True)
|
||||
env = {**case[2], 'NETKINGDOM_CUSTODY_AGE_KEY_FILE': str(wrong), 'SOPS_AGE_KEY': case[1].read_text()}
|
||||
result = make(case, env=env)
|
||||
assert result.returncode != 0
|
||||
assert 'synthetic-secret-payload' not in result.stdout + result.stderr
|
||||
assert not case[4].exists()
|
||||
|
||||
|
||||
def test_absent_explicit_custody_refused_even_if_home_has_key(case):
|
||||
ambient_key(case)
|
||||
env = dict(case[2]); del env['NETKINGDOM_CUSTODY_AGE_KEY_FILE']
|
||||
assert make(case, env=env).returncode != 0
|
||||
assert not case[4].exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('kind', ['file', 'symlink'])
|
||||
def test_existing_output_is_not_replaced(case, kind):
|
||||
ambient_key(case)
|
||||
if kind == 'file': case[4].write_bytes(b'original-output')
|
||||
else: case[4].symlink_to(case[4].parent / 'absent')
|
||||
assert make(case).returncode != 0
|
||||
if kind == 'file': assert case[4].read_bytes() == b'original-output'
|
||||
else: assert case[4].is_symlink()
|
||||
|
||||
|
||||
def test_git_worktree_output_is_refused(case):
|
||||
ambient_key(case)
|
||||
subprocess.run(['git', 'init', str(case[0])], env=case[2], capture_output=True, check=True)
|
||||
assert make(case, output=case[0] / 'plaintext.json').returncode != 0
|
||||
assert not (case[0] / 'plaintext.json').exists()
|
||||
|
||||
|
||||
def test_shared_output_directory_is_refused(case):
|
||||
ambient_key(case)
|
||||
case[4].parent.chmod(0o755)
|
||||
assert make(case).returncode != 0
|
||||
assert not case[4].exists()
|
||||
|
||||
|
||||
def noisy_tool(case, tmp_path):
|
||||
bin_dir = tmp_path / 'bin'; bin_dir.mkdir()
|
||||
tool = bin_dir / 'sops'
|
||||
tool.write_text('#!/bin/sh\necho synthetic-tool-secret\necho synthetic-error-secret >&2\nexit 23\n')
|
||||
tool.chmod(0o755)
|
||||
return {**case[2], 'PATH': str(bin_dir) + ':' + case[2]['PATH']}
|
||||
|
||||
|
||||
def test_child_output_and_errors_are_not_exposed_on_failure(case, tmp_path):
|
||||
result = make(case, env=noisy_tool(case, tmp_path))
|
||||
assert result.returncode != 0
|
||||
assert 'synthetic-tool-secret' not in result.stdout + result.stderr
|
||||
assert 'synthetic-error-secret' not in result.stdout + result.stderr
|
||||
assert not case[4].exists()
|
||||
assert not list(case[4].parent.glob('.netkingdom-sops-*'))
|
||||
|
||||
|
||||
def test_non_age_metadata_refuses_before_tool(case, tmp_path):
|
||||
data = json.loads(case[3].read_text()); data['sops']['kms'] = [{'arn': 'synthetic'}]
|
||||
case[3].write_text(json.dumps(data))
|
||||
result = make(case, env=noisy_tool(case, tmp_path))
|
||||
assert result.returncode != 0
|
||||
assert 'age-only metadata' in result.stderr
|
||||
assert 'synthetic-tool-secret' not in result.stdout
|
||||
|
||||
|
||||
def test_filename_is_not_shell_code(case):
|
||||
ambient_key(case)
|
||||
odd = case[0] / 'cipher; touch SHELL_EXECUTED; #.json'
|
||||
shutil.copyfile(case[3], odd)
|
||||
result = make(case, source=odd)
|
||||
assert result.returncode == 0
|
||||
assert not (case[0] / 'SHELL_EXECUTED').exists()
|
||||
assert case[4].exists()
|
||||
|
||||
|
||||
def test_yaml_roundtrip(case, tmp_path):
|
||||
public = (case[0] / 'keys/age.pub').read_text().strip()
|
||||
clear = tmp_path / 'input.yaml'; clear.write_text('credential: synthetic-yaml-secret\n')
|
||||
cipher = case[0] / 'cipher.yaml'
|
||||
result = subprocess.run([SOPS, '--encrypt', '--age', public, str(clear)], cwd=tmp_path,
|
||||
env=case[2], capture_output=True, check=True)
|
||||
cipher.write_bytes(result.stdout)
|
||||
result = make(case, source=cipher)
|
||||
assert result.returncode == 0
|
||||
assert 'synthetic-yaml-secret' not in result.stdout + result.stderr
|
||||
assert 'synthetic-yaml-secret' in case[4].read_text()
|
||||
|
||||
|
||||
def test_parser_error_never_echoes_input(case):
|
||||
case[3].write_text('synthetic-malformed-secret')
|
||||
result = make(case)
|
||||
assert result.returncode != 0
|
||||
assert 'synthetic-malformed-secret' not in result.stdout + result.stderr
|
||||
assert not case[4].exists()
|
||||
|
||||
|
||||
def test_shared_custody_key_is_refused(case):
|
||||
case[1].chmod(0o644)
|
||||
result = make(case)
|
||||
assert result.returncode != 0
|
||||
assert not case[4].exists()
|
||||
|
||||
|
||||
def test_creds_init_has_no_permanent_key_requirement(case):
|
||||
subprocess.run(['git', 'init', str(case[0])], env=case[2], capture_output=True, check=True)
|
||||
hooks = case[0] / '.githooks'; hooks.mkdir()
|
||||
(hooks / 'pre-commit').write_text('#!/bin/sh\nexit 0\n')
|
||||
result = make(case, 'creds-init')
|
||||
assert result.returncode == 0
|
||||
assert not (Path(case[2]['HOME']) / '.config/sops').exists()
|
||||
|
||||
|
||||
def test_real_sops_does_not_execute_ambient_key_commands(case, tmp_path):
|
||||
marker = tmp_path / 'ambient-command-executed'
|
||||
command = tmp_path / 'ambient-key-command'
|
||||
command.write_text('#!/bin/sh\ntouch ' + str(marker) + '\nexit 1\n')
|
||||
command.chmod(0o755)
|
||||
env = {**case[2], 'SOPS_AGE_KEY_CMD': str(command), 'SOPS_AGE_SSH_PRIVATE_KEY_CMD': str(command)}
|
||||
result = make(case, env=env)
|
||||
assert result.returncode == 0
|
||||
assert not marker.exists()
|
||||
|
||||
|
||||
def test_yaml_parser_error_is_sanitized(case):
|
||||
bad = case[0] / 'broken.yaml'
|
||||
bad.write_text('secret: [synthetic-malformed-yaml-secret\n')
|
||||
result = make(case, source=bad)
|
||||
assert result.returncode != 0
|
||||
assert 'synthetic-malformed-yaml-secret' not in result.stdout + result.stderr
|
||||
assert 'Traceback' not in result.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize('kind', ['json', 'yaml'])
|
||||
def test_duplicate_metadata_refused_before_sops(case, kind):
|
||||
source = case[0] / ('duplicate.' + kind)
|
||||
source.write_text('{"sops": {}, "sops": {}}' if kind == 'json' else 'sops: {}\nsops: {}\n')
|
||||
result = make(case, source=source)
|
||||
assert result.returncode != 0
|
||||
assert 'duplicate' in result.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize('kind', ['bare', 'linked'])
|
||||
def test_bare_and_linked_git_outputs_are_refused(case, kind):
|
||||
location = case[4].parent / 'git-output'
|
||||
if kind == 'bare':
|
||||
subprocess.run(['git', 'init', '--bare', str(location)], env=case[2], capture_output=True, check=True)
|
||||
else:
|
||||
subprocess.run(['git', 'init', str(case[0])], env=case[2], capture_output=True, check=True)
|
||||
subprocess.run(['git', '-c', 'user.name=Test', '-c', 'user.email=test@example.invalid',
|
||||
'commit', '--allow-empty', '-m', 'fixture'], cwd=case[0], env=case[2],
|
||||
capture_output=True, check=True)
|
||||
subprocess.run(['git', 'worktree', 'add', '--detach', str(location)], cwd=case[0], env=case[2],
|
||||
capture_output=True, check=True)
|
||||
location.chmod(0o700)
|
||||
result = make(case, output=location / 'plaintext.json')
|
||||
assert result.returncode != 0
|
||||
assert not (location / 'plaintext.json').exists()
|
||||
|
|
@ -0,0 +1,244 @@
|
|||
"""Real SOPS write-path regressions with disposable keys and plaintext only."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
SOPS=shutil.which('sops')
|
||||
pytestmark=pytest.mark.skipif(SOPS is None,reason='real SOPS required')
|
||||
PLAIN='synthetic-sops-write-value'
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def case(tmp_path):
|
||||
repo=tmp_path/'repo';boot=repo/'sso-mfa/bootstrap';boot.mkdir(parents=True)
|
||||
shutil.copyfile(ROOT/'Makefile',repo/'Makefile')
|
||||
for name in ['sops-private.py','sops-write.py']:
|
||||
if (ROOT/'sso-mfa/bootstrap'/name).exists():shutil.copyfile(ROOT/'sso-mfa/bootstrap'/name,boot/name)
|
||||
(repo/'keys').mkdir()
|
||||
key=tmp_path/'disposable.key';subprocess.run(['age-keygen','-o',str(key)],capture_output=True,check=True)
|
||||
public=subprocess.check_output(['age-keygen','-y',str(key)],text=True).strip()
|
||||
(repo/'keys/age.pub').write_text(public+'\n')
|
||||
(repo/'.sops.yaml').write_text('creation_rules:\n - age: '+public+'\n')
|
||||
home=tmp_path/'home';home.mkdir(mode=0o700)
|
||||
private=tmp_path/'private';private.mkdir(mode=0o700)
|
||||
replacement=private/'replacement.json';replacement.write_text(json.dumps({'credential':PLAIN+'-edited'}));replacement.chmod(0o600)
|
||||
cipher=repo/'cipher.json'
|
||||
env={'HOME':str(home),'PATH':os.environ['PATH'],'NETKINGDOM_CUSTODY_AGE_KEY_FILE':str(key),'SOPS_AGE_KEY_FILE':str(key)}
|
||||
encrypted=subprocess.run([SOPS,'--encrypt','--age',public,'--input-type','json','--output-type','json','/dev/stdin'],input=json.dumps({'credential':PLAIN}).encode(),cwd=tmp_path,env=env,capture_output=True,check=True)
|
||||
cipher.write_bytes(encrypted.stdout)
|
||||
return {'repo':repo,'key':key,'public':public,'env':env,'cipher':cipher,'input':replacement,'tmp':tmp_path}
|
||||
|
||||
|
||||
def invoke(case,action,*,file=None,input=None,env=None):
|
||||
return subprocess.run(['make','-s','sops-'+action,'FILE='+str(file or case['cipher']),'INPUT='+str(input or case['input'])],cwd=case['repo'],env=env or case['env'],capture_output=True,text=True,timeout=15)
|
||||
|
||||
|
||||
def decode(case,file=None):
|
||||
result=subprocess.run([SOPS,'--decrypt',str(file or case['cipher'])],env=case['env'],capture_output=True,check=True)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def quiet(result):
|
||||
assert PLAIN not in result.stdout+result.stderr
|
||||
assert 'Traceback' not in result.stdout+result.stderr
|
||||
|
||||
|
||||
def plaintext(case,name='plain.json'):
|
||||
path=case['repo']/name;path.write_text(json.dumps({'credential':PLAIN}));path.chmod(0o600);return path
|
||||
|
||||
|
||||
def test_public_only_encrypt_roundtrip(case):
|
||||
path=plaintext(case);env=dict(case['env']);env.pop('NETKINGDOM_CUSTODY_AGE_KEY_FILE');env.pop('SOPS_AGE_KEY_FILE')
|
||||
result=invoke(case,'encrypt',file=path,env=env);assert result.returncode==0,result.stderr;quiet(result)
|
||||
assert decode(case,path)=={'credential':PLAIN}
|
||||
assert json.loads(path.read_text())['sops']['age'][0]['recipient']==case['public']
|
||||
assert path.stat().st_mode & 0o077==0
|
||||
|
||||
|
||||
def test_rotate_changes_ciphertext_not_plaintext_or_recipient(case):
|
||||
before=case['cipher'].read_bytes();result=invoke(case,'rotate');assert result.returncode==0,result.stderr;quiet(result)
|
||||
assert case['cipher'].read_bytes()!=before and decode(case)=={'credential':PLAIN}
|
||||
assert [r['recipient'] for r in json.loads(case['cipher'].read_text())['sops']['age']]==[case['public']]
|
||||
|
||||
|
||||
def test_edit_uses_private_replacement_and_never_launches_ambient_editor(case):
|
||||
marker=case['tmp']/'editor-called';editor=case['tmp']/'editor'
|
||||
editor.write_text('#!/bin/sh\ntouch '+str(marker)+'\nprintf '+PLAIN+'\\n >&2\n');editor.chmod(0o700)
|
||||
result=invoke(case,'edit',env={**case['env'],'SOPS_EDITOR':str(editor),'EDITOR':str(editor)})
|
||||
assert result.returncode==0,result.stderr;quiet(result)
|
||||
assert not marker.exists() and decode(case)=={'credential':PLAIN+'-edited'}
|
||||
assert json.loads(case['input'].read_text())=={'credential':PLAIN+'-edited'}
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name',['cipher; touch SHELL_EXECUTED; #.json','$(shell touch MAKE_EXECUTED).json','with spaces.json'])
|
||||
@pytest.mark.parametrize('action',['encrypt','rotate','edit'])
|
||||
def test_file_names_are_data_not_shell_or_make_programs(case,name,action):
|
||||
file=case['repo']/name
|
||||
if action=='encrypt':file=plaintext(case,name)
|
||||
else:file.write_bytes(case['cipher'].read_bytes())
|
||||
result=invoke(case,action,file=file);assert result.returncode==0,result.stderr;quiet(result)
|
||||
assert not (case['repo']/'SHELL_EXECUTED').exists() and not (case['repo']/'MAKE_EXECUTED').exists()
|
||||
assert decode(case,file)=={'credential':PLAIN+('-edited' if action=='edit' else '')}
|
||||
|
||||
|
||||
@pytest.mark.parametrize('action',['rotate','edit'])
|
||||
def test_explicit_key_required_despite_ambient_identity(case,action):
|
||||
before=case['cipher'].read_bytes();env=dict(case['env']);env.pop('NETKINGDOM_CUSTODY_AGE_KEY_FILE')
|
||||
result=invoke(case,action,env=env);assert result.returncode!=0;quiet(result)
|
||||
assert case['cipher'].read_bytes()==before
|
||||
|
||||
|
||||
@pytest.mark.parametrize('action',['rotate','edit'])
|
||||
def test_unauthorized_recipient_metadata_is_not_a_rotation_grant(case,action):
|
||||
other=case['tmp']/'other.key';subprocess.run(['age-keygen','-o',str(other)],capture_output=True,check=True)
|
||||
public=subprocess.check_output(['age-keygen','-y',str(other)],text=True).strip()
|
||||
data=json.loads(case['cipher'].read_text());data['sops']['age'].append({**data['sops']['age'][0],'recipient':public})
|
||||
case['cipher'].write_text(json.dumps(data));before=case['cipher'].read_bytes()
|
||||
result=invoke(case,action);assert result.returncode!=0;quiet(result);assert case['cipher'].read_bytes()==before
|
||||
|
||||
|
||||
@pytest.mark.parametrize('defect',['public','symlink','hardlink','git','missing'])
|
||||
def test_edit_refuses_unsafe_input_without_changing_cipher(case,defect):
|
||||
file=case['input'];before=case['cipher'].read_bytes()
|
||||
if defect=='public':file.chmod(0o644)
|
||||
elif defect=='missing':file.unlink()
|
||||
elif defect=='symlink':
|
||||
target=file.with_name('other');file.rename(target);file.symlink_to(target)
|
||||
elif defect=='hardlink':os.link(file,file.with_name('alias'))
|
||||
else:subprocess.run(['git','init',str(file.parent)],capture_output=True,check=True)
|
||||
result=invoke(case,'edit');assert result.returncode!=0;quiet(result);assert case['cipher'].read_bytes()==before
|
||||
|
||||
|
||||
@pytest.mark.parametrize('defect',['symlink','hardlink','public-write'])
|
||||
def test_cipher_path_cannot_redirect_or_alias_replacement(case,defect):
|
||||
file=case['cipher'];before=file.read_bytes()
|
||||
if defect=='symlink':
|
||||
target=file.with_name('other.json');file.rename(target);file.symlink_to(target)
|
||||
elif defect=='hardlink':os.link(file,file.with_name('alias.json'))
|
||||
else:file.chmod(0o666)
|
||||
result=invoke(case,'rotate');assert result.returncode!=0;quiet(result);assert file.read_bytes()==before
|
||||
|
||||
|
||||
def test_tracked_plaintext_refused_before_encryption(case):
|
||||
file=plaintext(case)
|
||||
subprocess.run(['git','init',str(case['repo'])],capture_output=True,check=True)
|
||||
subprocess.run(['git','add',file.name],cwd=case['repo'],capture_output=True,check=True)
|
||||
before=file.read_bytes();result=invoke(case,'encrypt',file=file)
|
||||
assert result.returncode!=0;quiet(result);assert file.read_bytes()==before
|
||||
|
||||
|
||||
@pytest.mark.parametrize('action', ['edit', 'rotate'])
|
||||
def test_tampered_cipher_cannot_be_replaced(case, action):
|
||||
data=json.loads(case['cipher'].read_text());data['credential']='tampered';case['cipher'].write_text(json.dumps(data));before=case['cipher'].read_bytes()
|
||||
result=invoke(case,action);assert result.returncode!=0;quiet(result);assert case['cipher'].read_bytes()==before
|
||||
|
||||
|
||||
def test_encryption_does_not_run_ambient_key_command(case):
|
||||
path=plaintext(case);marker=case['tmp']/'key-command';command=case['tmp']/'command'
|
||||
command.write_text('#!/bin/sh\ntouch '+str(marker)+'\n');command.chmod(0o700)
|
||||
env={**case['env'],'SOPS_AGE_KEY_CMD':str(command),'SOPS_KMS_ARN':'unadmitted','SOPS_CONFIG':str(case['repo']/'.sops.yaml')}
|
||||
result=invoke(case,'encrypt',file=path,env=env);assert result.returncode==0,result.stderr;quiet(result);assert not marker.exists()
|
||||
|
||||
|
||||
def test_malformed_plaintext_is_not_echoed_or_replaced(case):
|
||||
path=plaintext(case);path.write_text('invalid '+PLAIN);before=path.read_bytes()
|
||||
result=invoke(case,'encrypt',file=path);assert result.returncode!=0;quiet(result);assert path.read_bytes()==before
|
||||
|
||||
|
||||
def test_make_input_is_literal(case):
|
||||
replacement=case['input'].with_name('$(shell touch MAKE_INPUT_EXECUTED).json');case['input'].rename(replacement)
|
||||
result=invoke(case,'edit',input=replacement);assert result.returncode==0,result.stderr
|
||||
assert not (case['repo']/'MAKE_INPUT_EXECUTED').exists()
|
||||
assert decode(case)=={'credential':PLAIN+'-edited'}
|
||||
|
||||
|
||||
@pytest.mark.parametrize('defect',['hardlink','fifo','parent-symlink'])
|
||||
def test_custody_key_alias_or_nonregular_input_is_refused(case,defect):
|
||||
key=case['key'];env=dict(case['env']);before=case['cipher'].read_bytes()
|
||||
if defect=='hardlink':os.link(key,key.with_name('alias.key'))
|
||||
elif defect=='fifo':
|
||||
key.unlink();os.mkfifo(key,0o600)
|
||||
else:
|
||||
parent=case['tmp']/'key-directory';parent.mkdir(mode=0o700);key.rename(parent/'key');alias=case['tmp']/'alias';alias.symlink_to(parent);env['NETKINGDOM_CUSTODY_AGE_KEY_FILE']=str(alias/'key')
|
||||
result=invoke(case,'rotate',env=env);assert result.returncode!=0;quiet(result)
|
||||
assert case['cipher'].read_bytes()==before
|
||||
|
||||
|
||||
def test_edit_preserves_declared_encryption_policy(case):
|
||||
raw=json.dumps({'credential':PLAIN,'visible_unencrypted':'public-label'}).encode()
|
||||
encrypted=subprocess.run([SOPS,'--encrypt','--age',case['public'],'--input-type','json','--output-type','json','/dev/stdin'],input=raw,cwd=case['tmp'],env=case['env'],capture_output=True,check=True)
|
||||
case['cipher'].write_bytes(encrypted.stdout)
|
||||
case['input'].write_text(json.dumps({'credential':PLAIN+'-edited','visible_unencrypted':'public-label'}))
|
||||
result=invoke(case,'edit');assert result.returncode==0,result.stderr
|
||||
obj=json.loads(case['cipher'].read_text())
|
||||
assert obj['visible_unencrypted']=='public-label' and obj['credential'].startswith('ENC[')
|
||||
assert decode(case)=={'credential':PLAIN+'-edited','visible_unencrypted':'public-label'}
|
||||
|
||||
|
||||
def test_child_diagnostics_and_plaintext_output_are_suppressed(case):
|
||||
directory=case['tmp']/'bin';directory.mkdir();binary=directory/'sops'
|
||||
binary.write_text('#!/bin/sh\nprintf "'+PLAIN+'\\n"\nprintf "'+PLAIN+'\\n" >&2\nexit 1\n');binary.chmod(0o700)
|
||||
file=plaintext(case);before=file.read_bytes()
|
||||
result=invoke(case,'encrypt',file=file,env={**case['env'],'PATH':str(directory)+':'+case['env']['PATH']})
|
||||
assert result.returncode!=0;quiet(result);assert file.read_bytes()==before
|
||||
assert not list(file.parent.glob('.netkingdom-sops-write-*'))
|
||||
|
||||
|
||||
def test_source_change_during_encryption_is_not_overwritten(case):
|
||||
file=plaintext(case);directory=case['tmp']/'bin';directory.mkdir();binary=directory/'sops'
|
||||
binary.write_text('#!/usr/bin/python3\nimport subprocess,sys\nfrom pathlib import Path\nr=subprocess.run('+repr([SOPS])+ '+sys.argv[1:],input=sys.stdin.buffer.read(),capture_output=True)\nPath('+repr(str(file))+').write_text("concurrent-update")\nsys.stdout.buffer.write(r.stdout)\nsys.exit(r.returncode)\n');binary.chmod(0o700)
|
||||
result=invoke(case,'encrypt',file=file,env={**case['env'],'PATH':str(directory)+':'+case['env']['PATH']})
|
||||
assert result.returncode!=0;quiet(result);assert file.read_text()=='concurrent-update'
|
||||
assert not list(file.parent.glob('.netkingdom-sops-write-*'))
|
||||
|
||||
|
||||
def test_make_custody_command_cannot_execute_before_handoff(case):
|
||||
helper=case['repo']/'sso-mfa/bootstrap/sops-custody-unlock.sh'
|
||||
helper.write_text('#!/bin/bash\npython3 - "$@" <<\'PYTHON\'\nimport json,sys\nfrom pathlib import Path\nPath("handoff.json").write_text(json.dumps(sys.argv[1:]))\nPYTHON\n')
|
||||
command="printf 'safe'; touch OUTSIDE_CUSTODY; #"
|
||||
result=subprocess.run(['make','-s','sops-custody-run','COMMAND='+command],cwd=case['repo'],env=case['env'],capture_output=True,text=True,timeout=10)
|
||||
assert result.returncode==0,result.stderr
|
||||
assert not (case['repo']/'OUTSIDE_CUSTODY').exists()
|
||||
assert json.loads((case['repo']/'handoff.json').read_text())[-3:]==['bash','-lc',command]
|
||||
|
||||
|
||||
def test_yaml_encrypt_rotate_edit_roundtrip(case):
|
||||
import yaml
|
||||
file = case['repo'] / 'plain.yaml'
|
||||
file.write_text('credential: ' + PLAIN + '\n'); file.chmod(0o600)
|
||||
replacement = case['input'].with_suffix('.yaml')
|
||||
replacement.write_text('credential: ' + PLAIN + '-edited\n'); replacement.chmod(0o600)
|
||||
for action in ('encrypt', 'rotate', 'edit'):
|
||||
result = invoke(case, action, file=file, input=replacement)
|
||||
assert result.returncode == 0, result.stderr
|
||||
quiet(result)
|
||||
decrypted = subprocess.run([SOPS, '--decrypt', str(file)], env=case['env'], capture_output=True, check=True)
|
||||
assert yaml.safe_load(decrypted.stdout) == {'credential': PLAIN + ('-edited' if action == 'edit' else '')}
|
||||
|
||||
|
||||
def test_make_decrypt_output_is_literal(case):
|
||||
output = case['input'].with_name('$(shell touch MAKE_OUTPUT_EXECUTED).json')
|
||||
result = subprocess.run(['make', '-s', 'sops-decrypt', 'FILE=' + str(case['cipher']), 'OUTPUT=' + str(output)],
|
||||
cwd=case['repo'], env=case['env'], capture_output=True, text=True, timeout=15)
|
||||
assert result.returncode == 0, result.stderr
|
||||
quiet(result)
|
||||
assert not (case['repo'] / 'MAKE_OUTPUT_EXECUTED').exists()
|
||||
assert json.loads(output.read_text()) == {'credential': PLAIN}
|
||||
|
||||
|
||||
def test_make_custody_values_are_not_evaluated(case):
|
||||
helper = case['repo'] / 'sso-mfa/bootstrap/sops-custody-unlock.sh'
|
||||
helper.write_text('#!/bin/bash\nexec python3 -c \'import json,sys; from pathlib import Path; Path("handoff.json").write_text(json.dumps(sys.argv[1:]))\' "$@"\n')
|
||||
command = '$(shell touch MAKE_COMMAND_EXECUTED)'
|
||||
recipient = '$(shell touch MAKE_RECIPIENT_EXECUTED)'
|
||||
result = subprocess.run(['make', '-s', 'sops-custody-run', 'COMMAND=' + command, 'OPERATOR_AGE_PUBKEY=' + recipient],
|
||||
cwd=case['repo'], env=case['env'], capture_output=True, text=True, timeout=15)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert not (case['repo'] / 'MAKE_COMMAND_EXECUTED').exists()
|
||||
assert not (case['repo'] / 'MAKE_RECIPIENT_EXECUTED').exists()
|
||||
assert json.loads((case['repo'] / 'handoff.json').read_text()) == ['--expected-recipient', recipient, '--', 'bash', '-lc', command]
|
||||
|
|
@ -0,0 +1,296 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Owner-run identity capture: metadata plan first, exact bounded execution later.
|
||||
|
||||
Use the admitted Railiance caller. Flags bind reviewed source/targets; they do
|
||||
not grant authority. Private child output and provider diagnostics never print.
|
||||
"""
|
||||
import argparse
|
||||
from datetime import datetime,timezone
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
|
||||
HERE=Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def load(name):
|
||||
spec=importlib.util.spec_from_file_location(name,HERE/(name+'.py'))
|
||||
module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module);return module
|
||||
|
||||
|
||||
bundle=load('identity-bundle');prepare=load('prepare-identity-coverage')
|
||||
IMAGE='ghcr.io/gpappsoft/privacyidea-docker@sha256:af7841adad262f129e0c1d4f553af13f21cb2f4dc713533f316cfe43ed0b4473'
|
||||
SOURCES=['capture-identity.py','identity-files.py','identity-bundle.py','prepare-identity-coverage.py','identity-backup-profile.json']
|
||||
KUBE=['kubectl']
|
||||
|
||||
|
||||
class Refused(Exception):pass
|
||||
|
||||
|
||||
def command(args,payload=None,timeout=60):
|
||||
result=subprocess.run(KUBE+['--request-timeout=30s']+args,input=payload,
|
||||
stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,timeout=timeout)
|
||||
if result.returncode:raise Refused()
|
||||
return result.stdout
|
||||
|
||||
|
||||
def get(ns,kind,name):
|
||||
scope=['-n',ns] if ns else []
|
||||
return json.loads(command(scope+['get',kind,name,'-o','json']))
|
||||
|
||||
|
||||
def selects(selector,labels):
|
||||
if any(labels.get(k)!=v for k,v in selector.get('matchLabels',{}).items()):return False
|
||||
for expression in selector.get('matchExpressions',[]):
|
||||
key=expression['key'];op=expression['operator'];values=expression.get('values',[])
|
||||
if op=='In' and labels.get(key) not in values:return False
|
||||
if op=='NotIn' and key in labels and labels[key] in values:return False
|
||||
if op=='Exists' and key not in labels:return False
|
||||
if op=='DoesNotExist' and key in labels:return False
|
||||
if op not in {'In','NotIn','Exists','DoesNotExist'}:raise Refused()
|
||||
return True
|
||||
|
||||
|
||||
def network_review(run_id,actual_labels=None):
|
||||
rows=[]
|
||||
for ns in ('sso','mfa'):
|
||||
policies=json.loads(command(['-n',ns,'get','networkpolicies','-o','json']))['items']
|
||||
for obj in policies:
|
||||
m=obj['metadata'];s=obj['spec']
|
||||
labels=(actual_labels or {}).get(ns,{'net-kingdom/capture':run_id})
|
||||
if selects(s['podSelector'],labels) and (s.get('ingress') or s.get('egress')):
|
||||
rows.append({'namespace':ns,'name':m['name'],'uid':m['uid'],
|
||||
'spec_sha256':hashlib.sha256(json.dumps(s,sort_keys=True).encode()).hexdigest()})
|
||||
return rows
|
||||
|
||||
|
||||
def validate_helper(actual,wanted):
|
||||
a=actual['spec'];w=wanted['spec']
|
||||
if (any(a.get(k) for k in ('hostNetwork','hostPID','hostIPC','initContainers','ephemeralContainers'))
|
||||
or a.get('automountServiceAccountToken') is not False or a.get('activeDeadlineSeconds')!=900
|
||||
or len(a['containers'])!=1 or a['volumes']!=w['volumes']):raise Refused()
|
||||
c=a['containers'][0];expected=w['containers'][0]
|
||||
if any(c.get(k)!=expected[k] for k in ('name','image','command','volumeMounts')):raise Refused()
|
||||
if c.get('env') or c.get('envFrom') or c.get('securityContext',{}).get('privileged'):raise Refused()
|
||||
if any(c.get('securityContext',{}).get(k)!=v for k,v in expected['securityContext'].items()):raise Refused()
|
||||
if any(a.get('securityContext',{}).get(k)!=v for k,v in w['securityContext'].items()):raise Refused()
|
||||
|
||||
|
||||
def manifests(run_id):
|
||||
result=[]
|
||||
for ns,claims in [('sso',[('lldap','lldap-data'),('authelia','authelia-data'),('keycape','keycape-authentication-policy')]),
|
||||
('mfa',[('privacyidea','privacyidea-data')])]:
|
||||
label={'net-kingdom/capture':run_id};name='nk49-capture-'+run_id
|
||||
result.append({'apiVersion':'networking.k8s.io/v1','kind':'NetworkPolicy',
|
||||
'metadata':{'name':name,'namespace':ns},
|
||||
'spec':{'podSelector':{'matchLabels':label},'policyTypes':['Ingress','Egress'],'ingress':[],'egress':[]}})
|
||||
result.append({'apiVersion':'v1','kind':'Pod','metadata':{'name':name,'namespace':ns,'labels':label},
|
||||
'spec':{'restartPolicy':'Never','activeDeadlineSeconds':900,'automountServiceAccountToken':False,
|
||||
'securityContext':{'runAsUser':0,'runAsGroup':0},
|
||||
'containers':[{'name':'capture','image':IMAGE,'imagePullPolicy':'IfNotPresent',
|
||||
'command':['python3','-c','import time; time.sleep(900)'],
|
||||
'securityContext':{'allowPrivilegeEscalation':False,'readOnlyRootFilesystem':True,
|
||||
'capabilities':{'drop':['ALL'],'add':['DAC_OVERRIDE']}},
|
||||
'resources':{'requests':{'cpu':'50m','memory':'128Mi'},'limits':{'cpu':'1','memory':'2Gi'}},
|
||||
'volumeMounts':[{'name':key,'mountPath':'/source/'+key,'readOnly':True} for key,_ in claims]+
|
||||
[{'name':'scratch','mountPath':'/scratch'}]}],
|
||||
'volumes':[{'name':key,'persistentVolumeClaim':{'claimName':claim,'readOnly':True}} for key,claim in claims]+
|
||||
[{'name':'scratch','emptyDir':{'medium':'Memory','sizeLimit':'512Mi'}}]}})
|
||||
return result
|
||||
|
||||
|
||||
def plan():
|
||||
p=bundle.profile();binding=prepare.prepare(p,get=get)
|
||||
cluster=get('databases','cluster','net-kingdom-pg')
|
||||
primary=cluster['status']['currentPrimary'];pod=get('databases','pod',primary)
|
||||
if (not any(o.get('uid')==cluster['metadata']['uid'] for o in pod['metadata'].get('ownerReferences',[]))
|
||||
or not any(c['type']=='Ready' and c['status']=='True' for c in pod['status'].get('conditions',[]))):raise Refused()
|
||||
run_id=uuid.uuid4().hex[:12]
|
||||
return {'schema':'netkingdom.identity-capture-plan.v1','status':'requires_owner_execution_admission',
|
||||
'created_at':datetime.now(timezone.utc).isoformat(),'run_id':run_id,'binding':binding,'kubeconfig':KUBE[2],
|
||||
'sources':[{'path':name,'sha256':hashlib.sha256((HERE/name).read_bytes()).hexdigest()} for name in SOURCES],
|
||||
'database':{'namespace':'databases','cluster':'net-kingdom-pg','cluster_uid':cluster['metadata']['uid'],
|
||||
'pod':primary,'pod_uid':pod['metadata']['uid'],'database':'privacyidea_db'},
|
||||
'create_resources':manifests(run_id),'service_changes':False,'credential_grants':False,
|
||||
'matching_network_allow_policies':network_review(run_id),
|
||||
'network_policy_enforcement_requires_owner_confirmation':True,
|
||||
'offsite_delivery_included':False,'global_atomic_snapshot_claimed':False}
|
||||
|
||||
|
||||
def checked_plan(path,expected):
|
||||
raw=path.read_bytes()
|
||||
if hashlib.sha256(raw).hexdigest()!=expected:raise Refused()
|
||||
p=json.loads(raw)
|
||||
if p.get('schema')!='netkingdom.identity-capture-plan.v1':raise Refused()
|
||||
if p.get('kubeconfig')!=KUBE[2]:raise Refused()
|
||||
if network_review(p['run_id']):raise Refused()
|
||||
if p['sources']!=[{'path':name,'sha256':hashlib.sha256((HERE/name).read_bytes()).hexdigest()} for name in SOURCES]:raise Refused()
|
||||
if p['create_resources']!=manifests(p['run_id']):raise Refused()
|
||||
now=datetime.now(timezone.utc)
|
||||
if not 0<=(now-datetime.fromisoformat(p['created_at'])).total_seconds()<3600:raise Refused()
|
||||
# Recheck the exact immutable source/target bindings before private reads.
|
||||
current=prepare.prepare(bundle.profile(),get=get)
|
||||
if current['bindings']!=p['binding']['bindings'] or current['profile_sha256']!=p['binding']['profile_sha256']:raise Refused()
|
||||
return p
|
||||
|
||||
|
||||
def delete_created(obj):
|
||||
m=obj['metadata'];ns=m['namespace'];name=m['name'];uid=m['uid']
|
||||
if obj['kind']=='Pod':path=f'/api/v1/namespaces/{ns}/pods/{name}'
|
||||
elif obj['kind']=='NetworkPolicy':path=f'/apis/networking.k8s.io/v1/namespaces/{ns}/networkpolicies/{name}'
|
||||
else:raise Refused()
|
||||
payload=json.dumps({'apiVersion':'v1','kind':'DeleteOptions','preconditions':{'uid':uid},'gracePeriodSeconds':1}).encode()
|
||||
command(['delete','--raw',path,'-f','-'],payload)
|
||||
if obj['kind']=='Pod':command(['-n',ns,'wait','--for=delete','pod/'+name,'--timeout=90s'],timeout=100)
|
||||
|
||||
|
||||
def bounded_file(args,path,limit):
|
||||
# Stream binary dump to private staging, with a byte bound and suppressed
|
||||
# diagnostics. API command argv contains only public resource/database names.
|
||||
proc=subprocess.Popen(KUBE+['--request-timeout=600s']+args,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL)
|
||||
try:
|
||||
count=0
|
||||
with path.open('xb') as out:
|
||||
while block:=proc.stdout.read(1024*1024):
|
||||
count+=len(block)
|
||||
if count>limit:raise Refused()
|
||||
out.write(block)
|
||||
if proc.wait(timeout=600):raise Refused()
|
||||
finally:
|
||||
if proc.poll() is None:proc.kill();proc.wait()
|
||||
|
||||
|
||||
def capture(p,output,receipt,decision,checkpoint=lambda:None):
|
||||
created=[];receipt.update(status='capture_started',decision_ref=decision,plan_run_id=p['run_id'],
|
||||
created_resources=[],unverified_create_outcomes=[],helper_cleanup_verified=False,temporary_plaintext_cleanup_verified=False)
|
||||
checkpoint()
|
||||
with tempfile.TemporaryDirectory(prefix='nk49-identity-',dir='/tmp') as temporary:
|
||||
receipt['temporary_plaintext_directory']=temporary
|
||||
checkpoint()
|
||||
root=Path(temporary)/'payload';root.mkdir(mode=0o700)
|
||||
held={}
|
||||
try:
|
||||
for c in bundle.profile()['components']:
|
||||
for kind,names in [('deployment',[c['deployment']]),('secret',c['secret_refs']),('configmap',c['configmap_refs'])]:
|
||||
for name in names:
|
||||
obj=get(c['namespace'],kind,name)
|
||||
# Keep reconstructable deployed resources inside encryption,
|
||||
# without unrelated stale last-applied annotations.
|
||||
m=obj['metadata'];obj['metadata']={k:m[k] for k in ['name','namespace','uid','resourceVersion']}
|
||||
obj.pop('status',None)
|
||||
raw=json.dumps(obj,sort_keys=True).encode();slot=f"resources/{c['namespace']}/{kind}/{name}.json"
|
||||
held[(c['namespace'],kind,name)]=(m['uid'],m['resourceVersion'])
|
||||
bundle.save(root,slot,raw)
|
||||
for proposed in p['create_resources']:
|
||||
# create, never adopt/apply an existing resource. Unknown create
|
||||
# outcomes stop; only returned exact UIDs are eligible for cleanup.
|
||||
pending={'kind':proposed['kind'],**proposed['metadata']}
|
||||
receipt['unverified_create_outcomes'].append(pending)
|
||||
checkpoint()
|
||||
obj=json.loads(command(['create','-f','-','-o','json'],json.dumps(proposed).encode()))
|
||||
if (obj['kind']!=proposed['kind'] or obj['metadata']['name']!=proposed['metadata']['name']
|
||||
or obj['metadata']['namespace']!=proposed['metadata']['namespace'] or not obj['metadata'].get('uid')):raise Refused()
|
||||
created.append(obj)
|
||||
receipt['unverified_create_outcomes'].remove(pending)
|
||||
receipt['created_resources'].append({'namespace':obj['metadata']['namespace'],
|
||||
'kind':obj['kind'],'name':obj['metadata']['name'],'uid':obj['metadata']['uid']})
|
||||
checkpoint()
|
||||
for ns in ('sso','mfa'):
|
||||
name='nk49-capture-'+p['run_id']
|
||||
command(['-n',ns,'wait','--for=condition=Ready','pod/'+name,'--timeout=180s'],timeout=190)
|
||||
live=get(ns,'pod',name)
|
||||
expected=next(o for o in created if o['kind']=='Pod' and o['metadata']['namespace']==ns)
|
||||
if live['metadata']['uid']!=expected['metadata']['uid']:raise Refused()
|
||||
wanted=next(o for o in p['create_resources'] if o['kind']=='Pod' and o['metadata']['namespace']==ns)
|
||||
validate_helper(live,wanted)
|
||||
if network_review(p['run_id'],{ns:live['metadata']['labels']}):raise Refused()
|
||||
raw=command(['-n',ns,'exec','-i',name,'-c','capture','--','python3','-c',
|
||||
(HERE/'identity-files.py').read_text(),ns],timeout=600)
|
||||
if len(raw)>bundle.LIMIT+2*1024*1024:raise Refused()
|
||||
bundle.import_files(raw,root,ns)
|
||||
if get(ns,'pod',name)['metadata']['uid']!=expected['metadata']['uid']:raise Refused()
|
||||
db=p['database'];current=get('databases','cluster','net-kingdom-pg')
|
||||
if current['metadata']['uid']!=db['cluster_uid'] or current['status']['currentPrimary']!=db['pod']:raise Refused()
|
||||
if get('databases','pod',db['pod'])['metadata']['uid']!=db['pod_uid']:raise Refused()
|
||||
(root/'databases').mkdir(mode=0o700)
|
||||
bounded_file(['-n','databases','exec',db['pod'],'-c','postgres','--','pg_dump','-U','postgres',
|
||||
'-d','privacyidea_db','-Fc','--no-owner','--no-acl'],root/'databases/privacyidea.dump',bundle.FILE_LIMIT)
|
||||
if get('databases','pod',db['pod'])['metadata']['uid']!=db['pod_uid']:raise Refused()
|
||||
for (ns,kind,name),binding in held.items():
|
||||
m=get(ns,kind,name)['metadata']
|
||||
if (m['uid'],m['resourceVersion'])!=binding:raise Refused()
|
||||
# Re-read original keys after the database dump. No rotation is
|
||||
# inferred from file presence; a changed key set refuses publication.
|
||||
raw=command(['-n','mfa','exec','-i','nk49-capture-'+p['run_id'],'-c','capture','--','python3','-c',
|
||||
(HERE/'identity-files.py').read_text(),'mfa'],timeout=60)
|
||||
with tempfile.TemporaryDirectory(prefix='nk49-key-recheck-',dir='/tmp') as keytemp:
|
||||
again=Path(keytemp);bundle.import_files(raw,again,'mfa')
|
||||
for name in ('enckey','private.pem','public.pem'):
|
||||
if (root/'data/privacyidea'/name).read_bytes()!=(again/'data/privacyidea'/name).read_bytes():raise Refused()
|
||||
receipt.update(bundle.seal(root,output,{'started_at':receipt['started_at'],
|
||||
'finished_at':datetime.now(timezone.utc).isoformat(),'database':db,'decision_ref':decision,
|
||||
'cluster_uid':prepare.CLUSTER_UID,'global_atomic_snapshot_claimed':False,
|
||||
'omissions':['historical local backups','SQLite WAL/SHM/journal represented by native snapshots',
|
||||
'Authelia notification.txt','privacyIDEA service logs; audit data retained in database',
|
||||
'OpenBao root/unseal/age private key custody remains independently held']}))
|
||||
finally:
|
||||
failures=[]
|
||||
for obj in reversed(created):
|
||||
try:delete_created(obj)
|
||||
except Exception:failures.append(obj['metadata']['uid'])
|
||||
receipt['helper_cleanup_verified']=not failures and not receipt['unverified_create_outcomes']
|
||||
checkpoint()
|
||||
if not receipt['helper_cleanup_verified']:raise Refused()
|
||||
receipt['temporary_plaintext_cleanup_verified']=not Path(temporary).exists()
|
||||
checkpoint()
|
||||
if not receipt['temporary_plaintext_cleanup_verified']:raise Refused()
|
||||
|
||||
|
||||
def main():
|
||||
global KUBE
|
||||
parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('action',choices=['plan','capture'])
|
||||
parser.add_argument('--kubeconfig',required=True)
|
||||
parser.add_argument('--receipt',required=True);parser.add_argument('--plan');parser.add_argument('--plan-sha256')
|
||||
parser.add_argument('--output');parser.add_argument('--decision-id');a=parser.parse_args();os.umask(0o077)
|
||||
KUBE=['kubectl','--kubeconfig',str(Path(a.kubeconfig).absolute())]
|
||||
fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
os.close(fd)
|
||||
r={'status':'failed','started_at':datetime.now(timezone.utc).isoformat(),'secret_values_exported':False}
|
||||
def checkpoint():
|
||||
fd,temp=tempfile.mkstemp(prefix='.capture-receipt-',dir=Path(a.receipt).absolute().parent)
|
||||
try:
|
||||
with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n');out.flush();os.fsync(out.fileno())
|
||||
os.replace(temp,a.receipt)
|
||||
finally:Path(temp).unlink(missing_ok=True)
|
||||
code=1
|
||||
try:
|
||||
if a.action=='plan':
|
||||
if any([a.plan,a.plan_sha256,a.output,a.decision_id]):raise Refused()
|
||||
r=plan();code=0
|
||||
else:
|
||||
if not all([a.plan,a.plan_sha256,a.output,a.decision_id]):raise Refused()
|
||||
uuid.UUID(a.decision_id)
|
||||
p=checked_plan(Path(a.plan),a.plan_sha256)
|
||||
capture(p,Path(a.output),r,a.decision_id,checkpoint);code=0
|
||||
except Exception:
|
||||
r.update(status='failed',error='capture_refused_or_failed_private_diagnostics_suppressed')
|
||||
if r.get('temporary_plaintext_directory') and not Path(r['temporary_plaintext_directory']).exists():r['temporary_plaintext_cleanup_verified']=True
|
||||
finally:
|
||||
r['finished_at']=datetime.now(timezone.utc).isoformat()
|
||||
checkpoint()
|
||||
print('OK: identity '+a.action+' complete; see safe receipt.' if code==0 else 'ERROR: identity operation failed; inspect safe receipt before retry.')
|
||||
return code
|
||||
|
||||
|
||||
def interrupted(*args):raise Refused()
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
for sig in (signal.SIGHUP,signal.SIGTERM,signal.SIGINT):signal.signal(sig,interrupted)
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Restore the fetched PI database in an isolated, disposable local PostgreSQL.
|
||||
|
||||
Run only through the attended custody helper. No cluster, production database,
|
||||
network, ports, host data mounts, IAM login or token decryption is involved.
|
||||
"""
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[2]
|
||||
spec=importlib.util.spec_from_file_location('custody',ROOT/'sso-mfa/bootstrap/sops-private.py')
|
||||
custody=importlib.util.module_from_spec(spec);spec.loader.exec_module(custody)
|
||||
RECIPIENT='age1zvryunvjhvpkmasskauga2heeg0ztnte9ymgppvjge36ekumk50syr3tsz'
|
||||
IMAGE='postgres@sha256:ff23cdce56cac62ada6f66013e1a50864c0abbe79d132d40b6e05bd80f378a70'
|
||||
OBJECT='r01-net-kingdom-pg/net-kingdom-pg-privacyidea_db-20261015T023000Z.dump.age'
|
||||
SIZE=89129309
|
||||
|
||||
|
||||
class Refused(Exception):pass
|
||||
|
||||
|
||||
def run(args,**kwargs):
|
||||
result=subprocess.run(args,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,timeout=600,**kwargs)
|
||||
if result.returncode:raise Refused()
|
||||
return result.stdout
|
||||
|
||||
|
||||
def transfer_input(receipt):
|
||||
r=json.loads(receipt.read_text())
|
||||
if (r.get('schema')!='netkingdom.privacyidea-offhost-transfer.v1' or
|
||||
r.get('status')!='ciphertext_fetched' or r.get('object')!=OBJECT or
|
||||
r.get('ciphertext_bytes')!=SIZE or r.get('object_unchanged_during_transfer') is not True):raise Refused()
|
||||
path=Path(r['ciphertext_path'])
|
||||
if not path.is_absolute() or path.parent.parent!=Path('/tmp') or not path.parent.name.startswith('nk49-offhost-'):raise Refused()
|
||||
if any(p.is_symlink() for p in (path,*path.parents)):raise Refused()
|
||||
st=path.stat()
|
||||
if st.st_uid!=os.getuid() or st.st_mode&0o077 or st.st_nlink!=1 or not path.is_file() or st.st_size!=SIZE:raise Refused()
|
||||
blob=path.read_bytes()
|
||||
if len(blob)!=SIZE or hashlib.sha256(blob).hexdigest()!=r['ciphertext_sha256']:raise Refused()
|
||||
return blob,r['ciphertext_sha256']
|
||||
|
||||
|
||||
def restore(dump,container):
|
||||
# Require the exact image already present; do not pull through a mutable tag.
|
||||
image_id=run(['docker','image','inspect','--format','{{.Id}}',IMAGE]).decode().strip()
|
||||
run(['docker','run','--detach','--pull=never','--name',container,'--network','none',
|
||||
'--security-opt','no-new-privileges','--memory','2g','--cpus','2',
|
||||
'--tmpfs','/var/lib/postgresql/data:rw,nosuid,nodev,size=1073741824',
|
||||
'--env','POSTGRES_HOST_AUTH_METHOD=trust','--env','POSTGRES_DB=recovery',
|
||||
IMAGE,'postgres','-c','listen_addresses='])
|
||||
for _ in range(60):
|
||||
# The image starts a temporary server during initdb. Wait until the
|
||||
# entrypoint has exec'd the final server before attempting the restore.
|
||||
pid1=subprocess.run(['docker','exec',container,'cat','/proc/1/comm'],
|
||||
stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,timeout=10)
|
||||
if pid1.returncode or pid1.stdout.strip()!=b'postgres':
|
||||
time.sleep(1);continue
|
||||
ready=subprocess.run(['docker','exec',container,'pg_isready','-U','postgres','-d','recovery'],
|
||||
stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=10)
|
||||
if ready.returncode==0:break
|
||||
time.sleep(1)
|
||||
else:raise Refused()
|
||||
version=run(['docker','exec',container,'pg_restore','--version']).decode().strip()
|
||||
with dump.open('rb') as source:
|
||||
run(['docker','exec','-i',container,'pg_restore','-U','postgres','-d','recovery',
|
||||
'--no-owner','--no-privileges','--exit-on-error'],stdin=source)
|
||||
# Fixed aggregate queries only. No identity, resolver config or token values.
|
||||
counts={}
|
||||
for table in ('token','resolver','realm'):
|
||||
raw=run(['docker','exec',container,'psql','-X','-U','postgres','-d','recovery','-At',
|
||||
'-v','ON_ERROR_STOP=1','-c',f'SELECT count(*) FROM public.{table};']).strip()
|
||||
if not raw.isdigit():raise Refused()
|
||||
counts[table]=int(raw)
|
||||
return {'image':IMAGE,'image_id':image_id,'pg_restore_version':version,'aggregate_row_counts':counts}
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--transfer-receipt',required=True);p.add_argument('--receipt',required=True)
|
||||
a=p.parse_args();os.umask(0o077)
|
||||
# Reserve the receipt before any recovery action; never overwrite evidence.
|
||||
fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
r={'schema':'netkingdom.privacyidea-database-drill.v1','status':'failed',
|
||||
'started_at':datetime.now(timezone.utc).isoformat(),'source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
|
||||
'production_contacted':False,'secret_values_exported':False,'native_identity_recovery_proven':False,
|
||||
'token_decryption_proven':False,'audit_signature_continuity_proven':False,
|
||||
'full_identity_file_coverage_proven':False,'container_cleanup_verified':False,
|
||||
'temporary_plaintext_cleanup_verified':False}
|
||||
container='nk49-pi-recovery-'+uuid.uuid4().hex
|
||||
keyfd=None;work=None
|
||||
try:
|
||||
blob,digest=transfer_input(Path(a.transfer_receipt));r['ciphertext_sha256']=digest
|
||||
keyfd=custody.private_key_fd()
|
||||
derived=run(['age-keygen','-y',f'/proc/self/fd/{keyfd}'],pass_fds=(keyfd,)).strip()
|
||||
if derived!=RECIPIENT.encode():raise Refused()
|
||||
r['registered_recipient_match']=True
|
||||
with tempfile.TemporaryDirectory(prefix='nk49-pi-drill-',dir='/tmp') as temporary:
|
||||
work=Path(temporary)
|
||||
env={'PATH':os.environ.get('PATH',os.defpath),'HOME':temporary,'XDG_CONFIG_HOME':temporary}
|
||||
wrong=work/'wrong.key'
|
||||
run(['age-keygen','-o',str(wrong)],env=env)
|
||||
negative=subprocess.run(['age','-d','-i',str(wrong)],input=blob,stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,timeout=60,env=env)
|
||||
if negative.returncode==0:raise Refused()
|
||||
r['wrong_key_refused']=True
|
||||
dump=work/'privacyidea.dump'
|
||||
with dump.open('xb') as output:
|
||||
result=subprocess.run(['age','-d','-i',f'/proc/self/fd/{keyfd}'],input=blob,
|
||||
stdout=output,stderr=subprocess.DEVNULL,timeout=120,pass_fds=(keyfd,),env=env)
|
||||
if result.returncode or not 5<dump.stat().st_size<SIZE:raise Refused()
|
||||
with dump.open('rb') as source:
|
||||
if source.read(5)!=b'PGDMP':raise Refused()
|
||||
r['offhost_ciphertext_decryption_verified']=True
|
||||
try:
|
||||
r.update(restore(dump,container));r['database_restore_and_readback_passed']=True
|
||||
finally:
|
||||
removed=subprocess.run(['docker','rm','--force','--volumes',container],stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,timeout=60)
|
||||
# A removal error is a failed ceremony even if the restore passed.
|
||||
if removed.returncode:raise Refused()
|
||||
absent=subprocess.run(['docker','container','inspect',container],stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,timeout=15)
|
||||
# Confirm the daemon remains reachable, so inspect failure means absent.
|
||||
run(['docker','info','--format','{{.ServerVersion}}'])
|
||||
if absent.returncode==0:raise Refused()
|
||||
r['container_cleanup_verified']=True
|
||||
if work.exists():raise Refused()
|
||||
r['temporary_plaintext_cleanup_verified']=True;r['status']='isolated_database_drill_passed'
|
||||
except Exception:
|
||||
r['error']='database_drill_failed_diagnostics_suppressed'
|
||||
finally:
|
||||
if keyfd is not None:os.close(keyfd)
|
||||
if work is not None and not work.exists():r['temporary_plaintext_cleanup_verified']=True
|
||||
r['finished_at']=datetime.now(timezone.utc).isoformat()
|
||||
with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n')
|
||||
if r['status']=='isolated_database_drill_passed':
|
||||
print('OK: off-host database decrypted, isolated restore/readback passed, disposable data removed. Full identity recovery remains open.')
|
||||
return 0
|
||||
print('ERROR: database drill failed; private diagnostics suppressed. Retain the safe receipt for review.',file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
for sig in (signal.SIGHUP,signal.SIGTERM,signal.SIGINT):signal.signal(sig,custody.interrupted)
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Fetch one fixed ciphertext through Platform's attended Backup-account lane.
|
||||
|
||||
No key read, plaintext, redirects, proxies, grants, deletion or production writes.
|
||||
The object and byte count are bound to the reviewed inventory. Provider diagnostics
|
||||
and credentials remain private. Output is a fresh private directory under /tmp.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import urllib.request
|
||||
|
||||
HERE=Path(__file__).resolve().parent
|
||||
spec=importlib.util.spec_from_file_location('inventory',HERE/'inventory-storage-share.py')
|
||||
owner=importlib.util.module_from_spec(spec);spec.loader.exec_module(owner)
|
||||
OBJECT='r01-net-kingdom-pg/net-kingdom-pg-privacyidea_db-20261015T023000Z.dump.age'
|
||||
SIZE=89129309
|
||||
|
||||
|
||||
def metadata(response,url):
|
||||
etag=response.headers.get('ETag','')
|
||||
if (response.status!=200 or response.url!=url or
|
||||
response.headers.get('Content-Length')!=str(SIZE) or
|
||||
len(etag)>200 or not etag.startswith('"') or not etag.endswith('"') or
|
||||
any(ord(c)<32 for c in etag)):
|
||||
raise owner.Refused()
|
||||
return etag
|
||||
|
||||
|
||||
def transfer(opener,auth,destination):
|
||||
url=owner.HOST+owner.BASE+OBJECT
|
||||
headers={'Authorization':'Basic '+auth,'Accept-Encoding':'identity'}
|
||||
with opener.open(urllib.request.Request(url,method='HEAD',headers=headers),timeout=30) as response:
|
||||
etag=metadata(response,url)
|
||||
headers['If-Match']=etag
|
||||
digest=hashlib.sha256();count=0
|
||||
with opener.open(urllib.request.Request(url,headers=headers),timeout=60) as response:
|
||||
if metadata(response,url)!=etag:raise owner.Refused()
|
||||
fd=os.open(destination,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
with os.fdopen(fd,'wb') as out:
|
||||
while chunk:=response.read(1024*1024):
|
||||
count+=len(chunk)
|
||||
if count>SIZE:raise owner.Refused()
|
||||
digest.update(chunk);out.write(chunk)
|
||||
out.flush();os.fsync(out.fileno())
|
||||
if count!=SIZE:raise owner.Refused()
|
||||
with opener.open(urllib.request.Request(url,method='HEAD',headers=headers),timeout=30) as response:
|
||||
if metadata(response,url)!=etag:raise owner.Refused()
|
||||
# ETag used for request binding only; it is not asserted to be a content hash.
|
||||
return {'ciphertext_bytes':count,'ciphertext_sha256':digest.hexdigest(),
|
||||
'object_unchanged_during_transfer':True}
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--receipt',required=True)
|
||||
a=p.parse_args();os.umask(0o077)
|
||||
fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
r={'schema':'netkingdom.privacyidea-offhost-transfer.v1','status':'failed',
|
||||
'started_at':datetime.now(timezone.utc).isoformat(),'provider':'Storage Share',
|
||||
'object':OBJECT,'age_keys_read':False,'plaintext_created':False,
|
||||
'production_writes':False,'source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
|
||||
'owner_accessor_sha256':hashlib.sha256((HERE/'inventory-storage-share.py').read_bytes()).hexdigest()}
|
||||
destination=None
|
||||
try:
|
||||
identity,credentials=owner.caller_credentials()
|
||||
r['caller_auth_path']=identity['path'];r['caller_entity_id']=identity.get('entity_id')
|
||||
auth=base64.b64encode((credentials['BACKUP_USERNAME']+':'+credentials['BACKUP_PASSWORD']).encode()).decode()
|
||||
destination=Path(tempfile.mkdtemp(prefix='nk49-offhost-',dir='/tmp'))/'privacyidea.dump.age'
|
||||
opener=urllib.request.build_opener(urllib.request.ProxyHandler({}),owner.NoRedirect())
|
||||
r.update(transfer(opener,auth,destination))
|
||||
r.update(status='ciphertext_fetched',ciphertext_path=str(destination))
|
||||
except Exception:
|
||||
r['error']='bounded_transfer_failed_diagnostics_suppressed'
|
||||
if destination is not None:
|
||||
destination.unlink(missing_ok=True);destination.parent.rmdir()
|
||||
finally:
|
||||
r['finished_at']=datetime.now(timezone.utc).isoformat()
|
||||
with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n')
|
||||
return int(r['status']=='failed')
|
||||
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
{
|
||||
"schema": "netkingdom.identity-backup-profile.v1",
|
||||
"status": "implemented_pending_owner_admission",
|
||||
"owner": "net-kingdom with railiance-platform/rapp-postgres runtime and storage admission",
|
||||
"public_recipient": "age1zvryunvjhvpkmasskauga2heeg0ztnte9ymgppvjge36ekumk50syr3tsz",
|
||||
"archive_profile": "netkingdom-identity-v1; new profile, not the bootstrap secrets-only tar profile",
|
||||
"components": [
|
||||
{
|
||||
"namespace": "sso",
|
||||
"deployment": "lldap",
|
||||
"deployment_uid": "743a86bd-5cb0-4ca0-9c56-a3438f4746a8",
|
||||
"pvc_refs": [
|
||||
{
|
||||
"name": "lldap-data",
|
||||
"uid": "0a06b65a-e7b3-42d3-916e-e74dbe5fc7da"
|
||||
}
|
||||
],
|
||||
"secret_refs": [
|
||||
"lldap-secrets"
|
||||
],
|
||||
"configmap_refs": [],
|
||||
"required_assets": [
|
||||
"SQLite online-backup snapshot of /data/users.db with integrity check",
|
||||
"remaining non-backup durable /data files, including any private identity material",
|
||||
"current deployment configuration and exact lldap-secrets fields"
|
||||
],
|
||||
"native_acceptance": [
|
||||
"directory user/group/membership preservation",
|
||||
"existing-user password authentication in isolated environment"
|
||||
]
|
||||
},
|
||||
{
|
||||
"namespace": "sso",
|
||||
"deployment": "authelia",
|
||||
"deployment_uid": "dc40a925-2306-4a3b-89c0-e714187503c3",
|
||||
"pvc_refs": [
|
||||
{
|
||||
"name": "authelia-data",
|
||||
"uid": "86826554-b014-4df4-8b28-866aa64f6413"
|
||||
}
|
||||
],
|
||||
"secret_refs": [
|
||||
"authelia-secrets",
|
||||
"authelia-smtp"
|
||||
],
|
||||
"configmap_refs": [
|
||||
"authelia-config"
|
||||
],
|
||||
"required_assets": [
|
||||
"SQLite online-backup snapshot of /var/authelia/data/db.sqlite3 with integrity check",
|
||||
"current configuration.yml and all Secret-mounted encryption/OIDC/session fields",
|
||||
"explicit disposition for notifier credentials and remaining durable files"
|
||||
],
|
||||
"native_acceptance": [
|
||||
"storage decryption",
|
||||
"same OIDC signing identity and preserved client configuration",
|
||||
"isolated authentication flow with outbound notification disabled"
|
||||
]
|
||||
},
|
||||
{
|
||||
"namespace": "sso",
|
||||
"deployment": "keycape",
|
||||
"deployment_uid": "99ddd83c-cb3f-4847-bcf8-35f1aa87627f",
|
||||
"pvc_refs": [
|
||||
{
|
||||
"name": "keycape-authentication-policy",
|
||||
"uid": "423f558f-8c11-4edf-956b-243559df4a8e"
|
||||
}
|
||||
],
|
||||
"secret_refs": [
|
||||
"keycape-approval-engine-operator-client",
|
||||
"keycape-config",
|
||||
"keycape-factor-read",
|
||||
"keycape-informed-decision-sitting-requester-client",
|
||||
"keycape-rapp-qonto-client",
|
||||
"keycape-secrets-engine-approval-client",
|
||||
"keycape-secrets-engine-openbao-client",
|
||||
"keycape-secrets-engine-requester-client"
|
||||
],
|
||||
"configmap_refs": [],
|
||||
"required_assets": [
|
||||
"exact current keycape-config config.yaml and key.pem",
|
||||
"complete stable policy/receipt PVC snapshot",
|
||||
"current deployment and owner disposition for every referenced credential: encrypted recovery or independently recoverable provider reissue path"
|
||||
],
|
||||
"native_acceptance": [
|
||||
"same issuer/JWKS signing identity",
|
||||
"registry, authentication policy and receipt continuity",
|
||||
"current consumer positive/negative authentication under isolated test endpoints"
|
||||
]
|
||||
},
|
||||
{
|
||||
"namespace": "mfa",
|
||||
"deployment": "privacyidea",
|
||||
"deployment_uid": "58c7f96d-61cb-4dd4-bca2-54661c0ac375",
|
||||
"pvc_refs": [
|
||||
{
|
||||
"name": "privacyidea-data",
|
||||
"uid": "65650fc1-02d8-4c0f-84fc-e0015c8f3ed9"
|
||||
},
|
||||
{
|
||||
"name": "privacyidea-logs",
|
||||
"uid": "39fa90d9-bc54-4a03-a0d6-ae85165e0cb9"
|
||||
}
|
||||
],
|
||||
"secret_refs": [
|
||||
"privacyidea-config"
|
||||
],
|
||||
"configmap_refs": [
|
||||
"factor-recovery-01119187d90d7b76",
|
||||
"keycape-onboarding-9465e5e0b98f7986",
|
||||
"privacyidea-cfg"
|
||||
],
|
||||
"required_assets": [
|
||||
"original /etc/privacyidea/enckey, private.pem and public.pem with matching RSA pair validation",
|
||||
"current privacyidea-config and mounted configuration/scripts",
|
||||
"bounded native PostgreSQL custom dump with exact database identity and key-generation linkage",
|
||||
"explicit disposition for service logs; audit database/signatures must be preserved"
|
||||
],
|
||||
"native_acceptance": [
|
||||
"decrypt existing retained token material without exporting it",
|
||||
"verify pre-backup audit signatures and create/verify a new isolated audit entry",
|
||||
"existing-token MFA and negative-key refusal"
|
||||
]
|
||||
}
|
||||
],
|
||||
"capture_contract": {
|
||||
"plaintext": "private task staging outside Git; never process output, State Hub or logs; verified cleanup after encryption",
|
||||
"consistency": "native SQLite online-backup and PostgreSQL logical snapshot; explicit capture interval per asset; no global atomic snapshot claim; before/after source identities/config versions and directory inventories must match",
|
||||
"key_policy": "preserve original service keys; no age private key in the payload, no replacement generation, no root/unseal collection",
|
||||
"encryption": "public-recipient-only streaming archive to age; private fresh ciphertext, no overwrite; manifest inside ciphertext with source identities, times, complete required paths, digests and omissions",
|
||||
"limits": "measure first; proposed 512 MiB encrypted admission ceiling, no silent truncation/exclusions",
|
||||
"historical_database": "existing verified October15 dump remains protected; new bundle must link its own exact database/key capture generation and recovery receipt"
|
||||
},
|
||||
"proposed_destinations": {
|
||||
"initial": "Storage Share Backup/railiance-backups/net-kingdom-identity/",
|
||||
"future_secondary": "s3://railiance-platform-pg-backup/platform-pg/application-archives/net-kingdom/; not implemented/admitted in this rollout",
|
||||
"admitted": false,
|
||||
"publisher": "CCR-2026-0048: new folder-only read/create public DAV share, delivered by exact ESO reader; no operator password in runtime; explicitly owner-approved provider-added re-sharing (permissions21)",
|
||||
"retention": "CCR-2026-0049: separate folder-only read/delete share and process; newest plus seven daily/two weekly and independently recovered pinned generation; unknown/unindexed points never deleted; explicitly owner-approved provider-added re-sharing (permissions25)",
|
||||
"schedule": "source prepared: daily 03:45 UTC plus up to five minutes jitter, hourly 25-hour freshness check; installation leaves timers disabled until independent archive recovery",
|
||||
"quota": "512 MiB ceiling, two GiB fresh provider headroom; initial 10 GiB account quota checked; unsupported public-share quota fails closed"
|
||||
},
|
||||
"admission_gates": [
|
||||
"Approve exact CCR-2026-0048/0049 grants and node runtime packet; three required owner reviews, confirmed native SA/namespace/audience boundaries before acceptance",
|
||||
"Exclude helper capture label from existing broad DNS allow policies; validate read-only helper manifests and cluster/PVC/source bindings before private capture",
|
||||
"Preserve every referenced credential encrypted with original keys; fresh native SQLite/PG snapshots and exact manifest, with documented omissions and no global atomicity claim",
|
||||
"Create fresh ciphertext and exact Storage Share object, read back ciphertext and receipt by strong ETag/hash, independently retrieve using operator custody",
|
||||
"Attended independent age-key proof: wrong-key denial, complete archive hashes, SQLite/RSA checks, disposable native PostgreSQL restore and verified cleanup before timer activation",
|
||||
"Full NK49 closure separately requires native directory/issuer/MFA authentication, retained token decryption, audit signature continuity, current callers and legacy credential dispositions; archive acceptance does not close those gates",
|
||||
"Host-local health cannot report a dead host; external outage monitoring and second-provider copy remain separate coverage gaps"
|
||||
],
|
||||
"runtime_host": {
|
||||
"ssh_alias": "railiance01",
|
||||
"hostname": "239.62.205.92.host.secureserver.net",
|
||||
"machine_id_sha256": "fe7367a60951922122af7ba18ed2cde65c7e0a232bbedfb045cb575de2094c2f"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Strict data-bearing identity archive format, native checks and private sealing.
|
||||
|
||||
No custody key is used to create a bundle. Its manifest and plaintext digests
|
||||
stay inside encryption. Public receipts expose only ciphertext provenance.
|
||||
"""
|
||||
import base64
|
||||
from datetime import datetime,timezone
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path,PurePosixPath
|
||||
import re
|
||||
import sqlite3
|
||||
import stat
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[2]
|
||||
PROFILE=ROOT/'tools/bootstrap-recovery/identity-backup-profile.json'
|
||||
LIMIT=512*1024*1024
|
||||
FILE_LIMIT=256*1024*1024
|
||||
NAME=re.compile(r'[A-Za-z0-9_.-]{1,160}\Z')
|
||||
|
||||
|
||||
class Refused(Exception):pass
|
||||
|
||||
|
||||
def profile():return json.loads(PROFILE.read_text())
|
||||
|
||||
|
||||
def private(path,directory=False,max_bytes=FILE_LIMIT):
|
||||
if any(p.is_symlink() for p in (path,*path.parents)):raise Refused()
|
||||
s=path.lstat()
|
||||
if s.st_uid!=os.getuid() or s.st_mode&0o077:raise Refused()
|
||||
if directory:
|
||||
if not stat.S_ISDIR(s.st_mode):raise Refused()
|
||||
elif not stat.S_ISREG(s.st_mode) or s.st_nlink!=1 or s.st_size>max_bytes:raise Refused()
|
||||
|
||||
|
||||
def allowed(name,profile_data=None):
|
||||
p=PurePosixPath(name)
|
||||
if p.is_absolute() or any(not NAME.fullmatch(x) or x in {'.','..'} for x in p.parts):return False
|
||||
if len(p.parts)>=3 and p.parts[:2] in [('data','lldap'),('data','authelia'),('data','keycape'),('data','privacyidea')]:return True
|
||||
if name=='databases/privacyidea.dump':return True
|
||||
for c in (profile_data or profile())['components']:
|
||||
prefix=f"resources/{c['namespace']}/"
|
||||
if name==prefix+'deployment/'+c['deployment']+'.json':return True
|
||||
if any(name==prefix+'secret/'+n+'.json' for n in c['secret_refs']):return True
|
||||
if any(name==prefix+'configmap/'+n+'.json' for n in c['configmap_refs']):return True
|
||||
return False
|
||||
|
||||
|
||||
def save(root,name,value,profile_data=None):
|
||||
if not allowed(name,profile_data) or len(value)>FILE_LIMIT or b'AGE-SECRET-KEY-' in value:raise Refused()
|
||||
target=root/name;target.parent.mkdir(mode=0o700,parents=True,exist_ok=True)
|
||||
fd=os.open(target,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
with os.fdopen(fd,'wb') as out:out.write(value)
|
||||
|
||||
|
||||
def import_files(raw,root,mode):
|
||||
names=set();total=0
|
||||
with tarfile.open(fileobj=io.BytesIO(raw),mode='r:') as archive:
|
||||
for member in archive:
|
||||
expected=('data/privacyidea/',) if mode=='mfa' else ('data/lldap/','data/authelia/','data/keycape/')
|
||||
if (not member.isfile() or not member.name.startswith(expected) or member.name in names
|
||||
or member.size<0 or member.size>FILE_LIMIT or len(names)>=2048):raise Refused()
|
||||
names.add(member.name);total+=member.size
|
||||
if total>LIMIT:raise Refused()
|
||||
data=archive.extractfile(member).read()
|
||||
if len(data)!=member.size:raise Refused()
|
||||
save(root,member.name,data)
|
||||
|
||||
|
||||
def resource(root,ns,kind,name):
|
||||
p=root/f'resources/{ns}/{kind}/{name}.json'
|
||||
data=json.loads(p.read_bytes())
|
||||
if data['metadata']['name']!=name or data['metadata']['namespace']!=ns:raise Refused()
|
||||
return data
|
||||
|
||||
|
||||
def checks(root,profile_data=None):
|
||||
# Checks describe stored formats/integrity, not current login validity.
|
||||
for c in (profile_data or profile())['components']:
|
||||
deployment=resource(root,c['namespace'],'deployment',c['deployment'])
|
||||
if deployment.get('kind')!='Deployment' or deployment['metadata']['uid']!=c['deployment_uid']:raise Refused()
|
||||
for name in c['secret_refs']:
|
||||
data=resource(root,c['namespace'],'secret',name)
|
||||
if data.get('kind')!='Secret' or not isinstance(data.get('data'),dict) or not data['data']:raise Refused()
|
||||
for value in data['data'].values():
|
||||
if b'AGE-SECRET-KEY-' in base64.b64decode(value,validate=True):raise Refused()
|
||||
for name in c['configmap_refs']:
|
||||
if resource(root,c['namespace'],'configmap',name).get('kind')!='ConfigMap':raise Refused()
|
||||
for name,file in [('lldap','users.db'),('authelia','db.sqlite3')]:
|
||||
db=root/f'data/{name}/{file}'
|
||||
with sqlite3.connect(db.as_uri()+'?mode=ro',uri=True) as con:
|
||||
if con.execute('PRAGMA integrity_check').fetchall()!=[('ok',)]:raise Refused()
|
||||
with (root/'databases/privacyidea.dump').open('rb') as source:
|
||||
if source.read(5)!=b'PGDMP':raise Refused()
|
||||
spec=importlib.util.spec_from_file_location('pi_keys',ROOT/'sso-mfa/k8s/privacyidea/key-custody-remote.py')
|
||||
keys=importlib.util.module_from_spec(spec);spec.loader.exec_module(keys)
|
||||
keys.validate({name:(root/'data/privacyidea'/name).read_bytes() for name in ('enckey','private.pem','public.pem')})
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
config=resource(root,'sso','secret','keycape-config')['data']
|
||||
if not base64.b64decode(config['config.yaml'],validate=True):raise Refused()
|
||||
signing=serialization.load_pem_private_key(base64.b64decode(config['key.pem'],validate=True),password=None)
|
||||
if not isinstance(signing,rsa.RSAPrivateKey) or signing.key_size<2048:raise Refused()
|
||||
# Policy must exist, even for an empty set of receipts; do not silently
|
||||
# restore an issuer without its current authentication policy.
|
||||
policy=json.loads((root/'data/keycape/policy.json').read_bytes())
|
||||
if not isinstance(policy,dict):raise Refused()
|
||||
return {'sqlite_integrity_passed':True,'privacyidea_key_format_and_pair_passed':True,
|
||||
'keycape_signing_key_format_passed':True,'policy_json_present':True,
|
||||
'database_custom_format_present':True,'native_identity_recovery_proven':False}
|
||||
|
||||
|
||||
def inventory(root):
|
||||
private(root,True);entries=[];total=0
|
||||
for path in sorted(root.rglob('*')):
|
||||
if path.is_dir():private(path,True);continue
|
||||
private(path);name=path.relative_to(root).as_posix()
|
||||
if not allowed(name):raise Refused()
|
||||
with path.open('rb') as source:value=source.read(FILE_LIMIT+1)
|
||||
if len(value)>FILE_LIMIT or b'AGE-SECRET-KEY-' in value:raise Refused()
|
||||
total+=len(value)
|
||||
if total>LIMIT or len(entries)>=4096:raise Refused()
|
||||
entries.append({'path':name,'bytes':len(value),'sha256':hashlib.sha256(value).hexdigest()})
|
||||
if not entries:raise Refused()
|
||||
return entries
|
||||
|
||||
|
||||
def seal(root,output,capture):
|
||||
output=output.absolute();private(output.parent,True)
|
||||
if output.exists() or output.is_symlink():raise Refused()
|
||||
checked=checks(root);entries=inventory(root)
|
||||
manifest={'schema':'netkingdom.identity-archive.v1','profile_sha256':hashlib.sha256(PROFILE.read_bytes()).hexdigest(),
|
||||
'profile':profile(),
|
||||
'captured_at':datetime.now(timezone.utc).isoformat(),'capture':capture,'files':entries,'checks':checked}
|
||||
with tempfile.TemporaryDirectory(prefix='.identity-seal-',dir=output.parent) as temporary:
|
||||
ciphertext=Path(temporary)/'ciphertext'
|
||||
fd=os.open(ciphertext,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
|
||||
proc=subprocess.Popen(['age','-r',profile()['public_recipient']],stdin=subprocess.PIPE,stdout=fd,stderr=subprocess.DEVNULL)
|
||||
os.close(fd)
|
||||
try:
|
||||
with tarfile.open(fileobj=proc.stdin,mode='w|') as archive:
|
||||
raw=json.dumps(manifest,sort_keys=True).encode();m=tarfile.TarInfo('manifest.json');m.size=len(raw);m.mode=0o600
|
||||
archive.addfile(m,io.BytesIO(raw))
|
||||
for entry in entries:
|
||||
with (root/entry['path']).open('rb') as source:
|
||||
value=source.read(FILE_LIMIT+1)
|
||||
if hashlib.sha256(value).hexdigest()!=entry['sha256']:raise Refused()
|
||||
m=tarfile.TarInfo(entry['path']);m.size=len(value);m.mode=0o600
|
||||
archive.addfile(m,io.BytesIO(value))
|
||||
proc.stdin.close()
|
||||
if proc.wait(timeout=120) or not 0<ciphertext.stat().st_size<=LIMIT:raise Refused()
|
||||
# Check source completeness after encryption too; changed/new files
|
||||
# refuse publication. This is not same-UID adversary isolation.
|
||||
if inventory(root)!=entries:raise Refused()
|
||||
with ciphertext.open('rb') as source:
|
||||
os.fsync(source.fileno());digest=hashlib.file_digest(source,'sha256').hexdigest()
|
||||
os.link(ciphertext,output)
|
||||
finally:
|
||||
if proc.poll() is None:proc.kill();proc.wait()
|
||||
return {'schema':'netkingdom.identity-capture.v1','status':'encrypted_bundle_created',
|
||||
'ciphertext_sha256':digest,'ciphertext_bytes':output.stat().st_size,'ciphertext_path':str(output),
|
||||
'profile_sha256':manifest['profile_sha256'],'file_count':len(entries),
|
||||
'checks':checked,'offsite_recovery_proven':False,'secret_values_exported':False}
|
||||
|
||||
|
||||
def restore(archive_path,root):
|
||||
private(root,True)
|
||||
with tarfile.open(archive_path,mode='r:') as archive:
|
||||
first=archive.next()
|
||||
if first is None or first.name!='manifest.json' or not first.isfile() or not 0<first.size<2*1024*1024:raise Refused()
|
||||
manifest=json.load(archive.extractfile(first))
|
||||
if manifest.get('schema')!='netkingdom.identity-archive.v1':raise Refused()
|
||||
captured=manifest['profile']
|
||||
if captured.get('schema')!='netkingdom.identity-backup-profile.v1':raise Refused()
|
||||
if len(captured['components'])!=4:raise Refused()
|
||||
if {(c['namespace'],c['deployment']) for c in captured['components']}!={('sso','lldap'),('sso','authelia'),('sso','keycape'),('mfa','privacyidea')}:raise Refused()
|
||||
for c in captured['components']:
|
||||
for key in ('secret_refs','configmap_refs'):
|
||||
if len(set(c[key]))!=len(c[key]) or any(not NAME.fullmatch(n) or n in {'.','..'} for n in c[key]):raise Refused()
|
||||
entries=manifest['files'];expected={e['path']:e for e in entries}
|
||||
if len(expected)!=len(entries) or len(entries)>4096:raise Refused()
|
||||
seen=set();total=0
|
||||
# Iteration starts with the already-read first member; explicitly skip
|
||||
# only that member. A later duplicate manifest is never accepted.
|
||||
for index,member in enumerate(archive):
|
||||
if index==0 and member is first:continue
|
||||
name=member.name
|
||||
if (not member.isfile() or name in seen or name not in expected or not allowed(name,captured)
|
||||
or not 0<=member.size<=FILE_LIMIT or member.size!=expected[name]['bytes']):raise Refused()
|
||||
seen.add(name);total+=member.size
|
||||
if total>LIMIT:raise Refused()
|
||||
raw=archive.extractfile(member).read()
|
||||
if len(raw)!=member.size or hashlib.sha256(raw).hexdigest()!=expected[name]['sha256']:raise Refused()
|
||||
save(root,name,raw,captured)
|
||||
if seen!=set(expected):raise Refused()
|
||||
return manifest,checks(root,captured)
|
||||
|
||||
|
||||
def require_runtime_host():
|
||||
import socket
|
||||
host=profile()['runtime_host']
|
||||
if (os.geteuid()!=0 or socket.gethostname()!=host['hostname']
|
||||
or hashlib.sha256(Path('/etc/machine-id').read_bytes()).hexdigest()!=host['machine_id_sha256']):raise Refused()
|
||||
|
|
@ -0,0 +1,254 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Attended Platform/Backup owner ceremony for two exact new backup shares.
|
||||
|
||||
Requires approved, hash-bound CCRs. No operator password/age key is delivered to
|
||||
the runtime; only a folder-scoped share token is stored with initial KV CAS=0.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
HERE=Path(__file__).resolve().parent
|
||||
PLATFORM=HERE.parents[1].parent/'railiance-platform'
|
||||
sys.path.insert(0,str(PLATFORM/'scripts'))
|
||||
from state_hub_preflight_lane import bao,data,capabilities,revoke
|
||||
from repair_eso_kubernetes_auth import role_payload,check_role
|
||||
from telemetry_smtp_delivery import ensure
|
||||
|
||||
|
||||
def load(name,path):
|
||||
spec=importlib.util.spec_from_file_location(name,path);m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m);return m
|
||||
|
||||
|
||||
inventory=load('storage_inventory',HERE/'inventory-storage-share.py')
|
||||
offsite=load('offsite',HERE/'identity-offsite.py')
|
||||
FOLDER='/railiance-backups/net-kingdom-identity'
|
||||
PROFILES={'publisher':(48,21),'retention':(49,25)}
|
||||
|
||||
|
||||
class Refused(Exception):pass
|
||||
|
||||
|
||||
def contract(action,expected):
|
||||
number,permission=PROFILES[action]
|
||||
path=PLATFORM/f'credential-change-requests/CCR-2026-{number:04d}-net-kingdom-backup-{action}.yaml'
|
||||
raw=path.read_bytes()
|
||||
if hashlib.sha256(raw).hexdigest()!=expected:raise Refused()
|
||||
module=load('credential_change',PLATFORM/'scripts/credential-change.py')
|
||||
ccr,errors,_=module.validate_ccr(path)
|
||||
if errors or ccr['status'] not in {'approved','applied','verified','active'}:raise Refused()
|
||||
required={'platform-owner','net-kingdom-owner','backup-share-owner'}
|
||||
approved={r['reviewer'].removeprefix('User (').removesuffix(')') for r in ccr.get('review',{}).get('comments',[])
|
||||
if r.get('decision')=='approved' and r.get('source_ref')}
|
||||
name='net-kingdom-backup-'+action+'-eso'
|
||||
expected_path='platform/workloads/net-kingdom/identity-backup/'+action
|
||||
a=ccr['openbao'];provider=ccr['provider']
|
||||
if (not required<=approved or a['kv_path']!=expected_path or a['fields']!=['NC_WEBDAV_TOKEN']
|
||||
or a['policy_name']!=name or a['auth']['role']!=name
|
||||
or a['auth']['bound_claims']!={'service_account_names':[name],'service_account_namespaces':['sso']}
|
||||
or provider['folder']!=FOLDER or provider['share_permissions']!=permission or provider['share_type']!=3
|
||||
or provider['label']!='net-kingdom-identity-'+action
|
||||
or provider.get('provider_added_resharing_approved') is not True):raise Refused()
|
||||
if (a.get('metadata_read') is not False or a.get('token_self_lifecycle') is not True
|
||||
or a['auth'].get('audience')!='openbao' or a['auth'].get('token_no_default_policy') is not True
|
||||
or any(a['auth'].get(k)!='15m' for k in ['ttl','token_max_ttl','token_explicit_max_ttl'])
|
||||
or a['auth'].get('policies')!=[name] or provider.get('host')!='nx4069.your-storageshare.de'):raise Refused()
|
||||
lane={'service_account':name,'namespace':'sso','role':name,'policy':name,
|
||||
'kv_path':expected_path.replace('platform/','platform/data/',1)}
|
||||
wanted=('path "'+lane['kv_path']+'" { capabilities = ["read"] }\n'
|
||||
'path "auth/token/lookup-self" { capabilities = ["read"] }\n'
|
||||
'path "auth/token/revoke-self" { capabilities = ["update"] }\n')
|
||||
if (PLATFORM/a['policy_file']).read_text()!=wanted:raise Refused()
|
||||
return ccr,lane
|
||||
|
||||
|
||||
def request(client,url,auth,method='GET',body=None,extra=None):
|
||||
if not url.startswith(offsite.HOST+'/'):raise Refused()
|
||||
h={'Authorization':'Basic '+base64.b64encode((auth[0]+':'+auth[1]).encode()).decode(),
|
||||
'X-Requested-With':'XMLHttpRequest'};h.update(extra or {})
|
||||
try:
|
||||
with client.open(urllib.request.Request(url,data=body,method=method,headers=h),timeout=60) as response:
|
||||
if response.url!=url:raise Refused()
|
||||
raw=response.read(1024*1024+1)
|
||||
if len(raw)>1024*1024:raise Refused()
|
||||
return response.status,raw
|
||||
except urllib.error.HTTPError as error:return error.code,b''
|
||||
|
||||
|
||||
def ocs(client,auth,suffix='',fields=None):
|
||||
url=offsite.HOST+'/ocs/v2.php/apps/files_sharing/api/v1/shares'+suffix
|
||||
body=urllib.parse.urlencode(fields).encode() if fields is not None else None
|
||||
status,raw=request(client,url,auth,'POST' if fields is not None else 'GET',body,
|
||||
{'OCS-APIRequest':'true','Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'})
|
||||
if status not in (200,201):raise Refused()
|
||||
parsed=json.loads(raw)['ocs']
|
||||
if parsed['meta']['statuscode'] not in (100,200):raise Refused()
|
||||
return parsed['data']
|
||||
|
||||
|
||||
def share(client,auth,action,receipt):
|
||||
owner=offsite.HOST+'/remote.php/dav/files/Backup'+FOLDER
|
||||
status,_=request(client,owner,auth,'MKCOL')
|
||||
if status not in (201,405):raise Refused()
|
||||
receipt['folder_created']=status==201
|
||||
matches=[s for s in ocs(client,auth,'?path='+urllib.parse.quote(FOLDER,safe=''))
|
||||
if s.get('label')=='net-kingdom-identity-'+action]
|
||||
if len(matches)>1:raise Refused()
|
||||
if matches:result=matches[0];receipt['share_created']=False
|
||||
else:
|
||||
result=ocs(client,auth,fields={'path':FOLDER,'shareType':3,'permissions':PROFILES[action][1],
|
||||
'label':'net-kingdom-identity-'+action});receipt['share_created']=True
|
||||
if (result.get('path')!=FOLDER or int(result.get('permissions',-1))!=PROFILES[action][1]
|
||||
or int(result.get('share_type',-1))!=3 or not result.get('token')):raise Refused()
|
||||
offsite.headers(result['token'])
|
||||
receipt.update(share_id=str(result['id']),share_permissions=int(result['permissions']),folder=FOLDER)
|
||||
return result['token']
|
||||
|
||||
|
||||
def boundary(client,auth,token,action,receipt):
|
||||
name='nk49-boundary-'+uuid.uuid4().hex+'.age'
|
||||
payload=subprocess.run(['age','-r',json.loads((HERE/'identity-backup-profile.json').read_text())['public_recipient']],
|
||||
input=b'NK49 disposable provider boundary fixture',stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,timeout=10)
|
||||
if payload.returncode:raise Refused()
|
||||
owner=offsite.HOST+'/remote.php/dav/files/Backup'+FOLDER+'/'+name
|
||||
public=offsite.BASE+name;created=False;deleted=False
|
||||
receipt['provider_test_artifacts']=[name]
|
||||
receipt['boundary_fixture_cleanup_verified']=False
|
||||
try:
|
||||
receipt['boundary_phase']='create_owner_fixture'
|
||||
status,_=request(client,owner,auth,'PUT',payload.stdout,{'If-None-Match':'*'})
|
||||
if status!=201:raise Refused()
|
||||
created=True
|
||||
receipt['boundary_phase']='read_public_fixture'
|
||||
status,raw=request(client,public,(token,''))
|
||||
if status!=200 or raw!=payload.stdout:raise Refused()
|
||||
receipt['boundary_phase']='deny_public_overwrite'
|
||||
status,_=request(client,public,(token,''),'PUT',payload.stdout)
|
||||
if status not in (401,403):raise Refused()
|
||||
denied_name='nk49-absent-'+uuid.uuid4().hex+'.age'
|
||||
if action=='retention':receipt['provider_test_artifacts'].append(denied_name)
|
||||
status,_=request(client,offsite.BASE+denied_name,(token,''),'PUT',payload.stdout,{'If-None-Match':'*'}) if action=='retention' else (403,b'')
|
||||
if status not in (401,403):raise Refused()
|
||||
receipt['boundary_phase']='public_delete'
|
||||
status,_=request(client,public,(token,''),'DELETE')
|
||||
if action=='publisher':
|
||||
if status not in (401,403):raise Refused()
|
||||
new=offsite.BASE+'nk49-boundary-'+uuid.uuid4().hex+'.age'
|
||||
receipt['provider_test_artifacts'].append(new.rsplit('/',1)[-1])
|
||||
receipt['boundary_phase']='create_public_fixture'
|
||||
code,_=request(client,new,(token,''),'PUT',payload.stdout,{'If-None-Match':'*'})
|
||||
if code!=201:raise Refused()
|
||||
# This fixture belongs to the current ceremony, not retained data.
|
||||
receipt['boundary_phase']='cleanup_public_fixture'
|
||||
remove_fixture(client,offsite.HOST+'/remote.php/dav/files/Backup'+FOLDER+'/'+new.rsplit('/',1)[-1],auth,payload.stdout)
|
||||
else:
|
||||
if status!=204:raise Refused()
|
||||
deleted=True
|
||||
receipt['boundary_phase']='deny_parent_path'
|
||||
status,_=request(client,offsite.BASE+'%2e%2e/r01-net-kingdom-pg/',(token,''))
|
||||
receipt['parent_probe_status']=status
|
||||
if status not in (400,401,403,404):raise Refused()
|
||||
receipt['parent_path_rejected']=True
|
||||
# A malformed-path400 proves request rejection, not authorization.
|
||||
# Separately probe a known existing account folder with only this share
|
||||
# token: it must not authenticate as the Backup account.
|
||||
receipt['boundary_phase']='deny_other_account_folder'
|
||||
status,_=request(client,offsite.HOST+'/remote.php/dav/files/Backup/r01-net-kingdom-pg/',(token,''))
|
||||
receipt['other_account_folder_status']=status
|
||||
if status not in (401,403,404):raise Refused()
|
||||
receipt['provider_boundary_verified']=True
|
||||
finally:
|
||||
if created and not deleted:
|
||||
remove_fixture(client,owner,auth,payload.stdout)
|
||||
receipt['owner_fixture_cleanup_verified']=True
|
||||
# A rejected operation may have had an unexpected side effect. Only a
|
||||
# successful complete boundary test claims cleanup; names remain in the
|
||||
# receipt for owner reconciliation on every failure.
|
||||
receipt['boundary_fixture_cleanup_verified']=receipt.get('provider_boundary_verified',False)
|
||||
|
||||
|
||||
def remove_fixture(client,url,auth,expected):
|
||||
h={'Authorization':'Basic '+base64.b64encode((auth[0]+':'+auth[1]).encode()).decode()}
|
||||
with client.open(urllib.request.Request(url,method='HEAD',headers=h),timeout=30) as response:
|
||||
version=offsite.etag(response,url,len(expected))
|
||||
code,raw=request(client,url,auth,extra={'If-Match':version})
|
||||
if code!=200 or raw!=expected:raise Refused()
|
||||
code,_=request(client,url,auth,'DELETE',extra={'If-Match':version})
|
||||
if code!=204:raise Refused()
|
||||
|
||||
|
||||
def run(action,expected,receipt):
|
||||
ccr,lane=contract(action,expected)
|
||||
identity,credentials=inventory.caller_credentials()
|
||||
receipt['caller_entity_id']=identity.get('entity_id');receipt['caller_auth_path']=identity['path']
|
||||
client=offsite.opener();auth=(credentials['BACKUP_USERNAME'],credentials['BACKUP_PASSWORD'])
|
||||
# Use the owner quota accessor only with proxies removed from its environment.
|
||||
quota=load('owner_quota',PLATFORM/'scripts/migrate_nextcloud_backup_account.py')
|
||||
quota.request=lambda url,method='GET',body=None,auth=None,headers=None:request(client,url,auth,method,body,headers)
|
||||
receipt['quota_before']=quota.quota(auth)
|
||||
if receipt['quota_before']['available_bytes']<offsite.HEADROOM+offsite.LIMIT:raise Refused()
|
||||
token=share(client,auth,action,receipt)
|
||||
boundary(client,auth,token,action,receipt)
|
||||
if action=='publisher':
|
||||
receipt['publisher_quota_available']=offsite.quota(client,token)
|
||||
if receipt['publisher_quota_available']<offsite.HEADROOM+offsite.LIMIT:raise Refused()
|
||||
# Fence agent reads before any initial secret publication.
|
||||
boundary_path='sys/policies/acl/agent-high-risk-boundary'
|
||||
current=data(bao(['read','-format=json',boundary_path]))['data']['policy'];added=''
|
||||
for path in [lane['kv_path'],lane['kv_path'].replace('/data/','/metadata/',1)]:
|
||||
stanza='path "'+path+'" { capabilities = ["deny"] }\n'
|
||||
if '"'+path+'"' not in current:added+=stanza
|
||||
elif stanza.strip() not in current:raise Refused()
|
||||
if added:
|
||||
if data(bao(['read','-format=json',boundary_path]))['data']['policy']!=current:raise Refused()
|
||||
bao(['write',boundary_path,'-'],payload={'policy':current+'\n'+added})
|
||||
if data(bao(['read','-format=json',boundary_path]))['data']['policy']!=current+'\n'+added:raise Refused()
|
||||
receipt['coding_agent_fence_verified_before_publication']=True
|
||||
existing=bao(['read','-format=json',lane['kv_path']],allow_failure=True)
|
||||
if existing.returncode==0:
|
||||
native=data(existing)['data']
|
||||
if native['data']!={'NC_WEBDAV_TOKEN':token}:raise Refused()
|
||||
receipt['kv_version']=native['metadata']['version'];receipt['existing_identical_custody']=True
|
||||
else:
|
||||
if b'No value found' not in existing.stdout+existing.stderr:raise Refused()
|
||||
native=data(bao(['write','-format=json',lane['kv_path'],'-'],payload={'options':{'cas':0},'data':{'NC_WEBDAV_TOKEN':token}}))
|
||||
receipt['kv_version']=native['data']['version']
|
||||
policy=(PLATFORM/ccr['openbao']['policy_file']).read_text()
|
||||
expected_policy=('path "'+lane['kv_path']+'" { capabilities = ["read"] }\n'
|
||||
'path "auth/token/lookup-self" { capabilities = ["read"] }\n'
|
||||
'path "auth/token/revoke-self" { capabilities = ["update"] }\n')
|
||||
if policy!=expected_policy:raise Refused()
|
||||
ensure('sys/policies/acl/'+lane['policy'],{'policy':policy},lambda actual: None if actual['policy']==policy else (_ for _ in ()).throw(Refused()))
|
||||
ensure('auth/kubernetes/role/'+lane['role'],role_payload(lane),lambda actual:check_role(actual,lane))
|
||||
child=data(bao(['token','create','-format=json','-policy='+lane['policy'],'-policy=agent-high-risk-boundary','-no-default-policy','-ttl=60s']))['auth']['client_token']
|
||||
try:
|
||||
if any(v!=['deny'] for v in capabilities(child,[lane['kv_path'],lane['kv_path'].replace('/data/','/metadata/',1)]).values()):raise Refused()
|
||||
finally:revoke(child)
|
||||
receipt.update(status='provider_and_reader_prepared',coding_agents_denied=True,escrow_read=False,
|
||||
operator_password_delivered=False,retained_backups_deleted=False,source_ccr=ccr['id'],ccr_sha256=expected,
|
||||
eso_delivery_and_native_login_verification_pending=True)
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__);p.add_argument('action',choices=PROFILES)
|
||||
p.add_argument('--ccr-sha256',required=True);p.add_argument('--receipt',required=True);a=p.parse_args()
|
||||
fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
r={'status':'failed','action':a.action,'source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest()}
|
||||
try:run(a.action,a.ccr_sha256,r)
|
||||
except Exception:r.update(status='failed',error='contained_setup_failed_inspect_partial_state_before_retry')
|
||||
finally:
|
||||
with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n')
|
||||
return int(r['status']=='failed')
|
||||
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
{
|
||||
"apiVersion": "v1",
|
||||
"kind": "List",
|
||||
"items": [
|
||||
{
|
||||
"apiVersion": "v1",
|
||||
"kind": "ServiceAccount",
|
||||
"metadata": {
|
||||
"name": "net-kingdom-backup-publisher-eso",
|
||||
"namespace": "sso"
|
||||
},
|
||||
"automountServiceAccountToken": false
|
||||
},
|
||||
{
|
||||
"apiVersion": "external-secrets.io/v1",
|
||||
"kind": "SecretStore",
|
||||
"metadata": {
|
||||
"name": "net-kingdom-backup-publisher",
|
||||
"namespace": "sso"
|
||||
},
|
||||
"spec": {
|
||||
"provider": {
|
||||
"vault": {
|
||||
"server": "http://openbao.openbao.svc:8200",
|
||||
"path": "platform",
|
||||
"version": "v2",
|
||||
"auth": {
|
||||
"kubernetes": {
|
||||
"mountPath": "kubernetes",
|
||||
"role": "net-kingdom-backup-publisher-eso",
|
||||
"serviceAccountRef": {
|
||||
"name": "net-kingdom-backup-publisher-eso",
|
||||
"audiences": [
|
||||
"openbao"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"apiVersion": "external-secrets.io/v1",
|
||||
"kind": "ExternalSecret",
|
||||
"metadata": {
|
||||
"name": "net-kingdom-backup-publisher",
|
||||
"namespace": "sso"
|
||||
},
|
||||
"spec": {
|
||||
"refreshInterval": "1h",
|
||||
"secretStoreRef": {
|
||||
"kind": "SecretStore",
|
||||
"name": "net-kingdom-backup-publisher"
|
||||
},
|
||||
"target": {
|
||||
"name": "net-kingdom-backup-publisher",
|
||||
"creationPolicy": "Owner",
|
||||
"template": {
|
||||
"type": "Opaque"
|
||||
}
|
||||
},
|
||||
"data": [
|
||||
{
|
||||
"secretKey": "token",
|
||||
"remoteRef": {
|
||||
"key": "workloads/net-kingdom/identity-backup/publisher",
|
||||
"property": "NC_WEBDAV_TOKEN"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"apiVersion": "v1",
|
||||
"kind": "ServiceAccount",
|
||||
"metadata": {
|
||||
"name": "net-kingdom-backup-retention-eso",
|
||||
"namespace": "sso"
|
||||
},
|
||||
"automountServiceAccountToken": false
|
||||
},
|
||||
{
|
||||
"apiVersion": "external-secrets.io/v1",
|
||||
"kind": "SecretStore",
|
||||
"metadata": {
|
||||
"name": "net-kingdom-backup-retention",
|
||||
"namespace": "sso"
|
||||
},
|
||||
"spec": {
|
||||
"provider": {
|
||||
"vault": {
|
||||
"server": "http://openbao.openbao.svc:8200",
|
||||
"path": "platform",
|
||||
"version": "v2",
|
||||
"auth": {
|
||||
"kubernetes": {
|
||||
"mountPath": "kubernetes",
|
||||
"role": "net-kingdom-backup-retention-eso",
|
||||
"serviceAccountRef": {
|
||||
"name": "net-kingdom-backup-retention-eso",
|
||||
"audiences": [
|
||||
"openbao"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"apiVersion": "external-secrets.io/v1",
|
||||
"kind": "ExternalSecret",
|
||||
"metadata": {
|
||||
"name": "net-kingdom-backup-retention",
|
||||
"namespace": "sso"
|
||||
},
|
||||
"spec": {
|
||||
"refreshInterval": "1h",
|
||||
"secretStoreRef": {
|
||||
"kind": "SecretStore",
|
||||
"name": "net-kingdom-backup-retention"
|
||||
},
|
||||
"target": {
|
||||
"name": "net-kingdom-backup-retention",
|
||||
"creationPolicy": "Owner",
|
||||
"template": {
|
||||
"type": "Opaque"
|
||||
}
|
||||
},
|
||||
"data": [
|
||||
{
|
||||
"secretKey": "token",
|
||||
"remoteRef": {
|
||||
"key": "workloads/net-kingdom/identity-backup/retention",
|
||||
"property": "NC_WEBDAV_TOKEN"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Private in-helper PVC capture. Stdout is a secret-bearing tar: parent MUST capture.
|
||||
|
||||
Sources are mounted read-only; SQLite uses its online backup API, never raw copy
|
||||
or immutable=1. No production file is generated, replaced, chmod'd or removed.
|
||||
"""
|
||||
import hashlib
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import sqlite3
|
||||
import stat
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
|
||||
MAX_FILE=256*1024*1024
|
||||
MAX_TOTAL=512*1024*1024
|
||||
MAX_COUNT=2048
|
||||
|
||||
|
||||
class Refused(Exception):pass
|
||||
|
||||
|
||||
def read(path):
|
||||
if any(p.is_symlink() for p in (path,*path.parents)):raise Refused()
|
||||
fd=os.open(path,os.O_RDONLY|os.O_NOFOLLOW|os.O_NONBLOCK)
|
||||
try:
|
||||
before=os.fstat(fd)
|
||||
if not stat.S_ISREG(before.st_mode) or before.st_nlink!=1 or before.st_size>MAX_FILE:raise Refused()
|
||||
with os.fdopen(os.dup(fd),'rb') as stream:data=stream.read(MAX_FILE+1)
|
||||
after=os.stat(path,follow_symlinks=False)
|
||||
if (len(data)>MAX_FILE or b'AGE-SECRET-KEY-' in data or
|
||||
any(getattr(before,k)!=getattr(after,k) for k in ('st_dev','st_ino','st_size','st_mtime_ns','st_ctime_ns'))):raise Refused()
|
||||
return data
|
||||
finally:os.close(fd)
|
||||
|
||||
|
||||
def tree(root,database=None,omit_notification=False,omit_backups=True):
|
||||
result={}
|
||||
for directory,dirs,files in os.walk(root,followlinks=False):
|
||||
path=Path(directory)
|
||||
if path.is_symlink():raise Refused()
|
||||
# Historical local snapshots are redundant; do not recursively include
|
||||
# accumulated backups. WAL/SHM are represented by the native snapshot.
|
||||
if omit_backups:dirs[:]=[name for name in dirs if name!='backups']
|
||||
for name in dirs:
|
||||
if (path/name).is_symlink():raise Refused()
|
||||
for name in files:
|
||||
relative=(path/name).relative_to(root).as_posix()
|
||||
if database and relative in {database,database+'-wal',database+'-shm',database+'-journal'}:continue
|
||||
if omit_notification and relative=='notification.txt':continue
|
||||
result[relative]=read(path/name)
|
||||
if len(result)>MAX_COUNT or sum(map(len,result.values()))>MAX_TOTAL:raise Refused()
|
||||
return result
|
||||
|
||||
|
||||
def snapshot(source,target):
|
||||
if source.is_symlink() or not source.is_file():raise Refused()
|
||||
# A read-only URI intentionally fails if the engine needs a write to its
|
||||
# production WAL/SHM. Never fall back to a raw copy or mutate the mount.
|
||||
with sqlite3.connect(source.as_uri()+'?mode=ro',uri=True,timeout=10) as origin:
|
||||
with sqlite3.connect(target) as dest:
|
||||
origin.backup(dest,pages=128,sleep=0.05)
|
||||
if dest.execute('PRAGMA integrity_check').fetchall()!=[('ok',)]:raise Refused()
|
||||
return read(target)
|
||||
|
||||
|
||||
def capture(mode,root=Path('/source'),scratch=Path('/scratch')):
|
||||
values={}
|
||||
with tempfile.TemporaryDirectory(prefix='snapshot-',dir=scratch) as temporary:
|
||||
if mode=='sso':
|
||||
for name,database in [('lldap','users.db'),('authelia','db.sqlite3'),('keycape',None)]:
|
||||
source=root/name
|
||||
if source.is_symlink() or not source.is_dir():raise Refused()
|
||||
first=tree(source,database,name=='authelia',name!='keycape')
|
||||
if database:first[database]=snapshot(source/database,Path(temporary)/(name+'.sqlite'))
|
||||
second=tree(source,database,name=='authelia',name!='keycape')
|
||||
if {k:v for k,v in first.items() if k!=database}!=second:raise Refused()
|
||||
values.update({f'data/{name}/{k}':v for k,v in first.items()})
|
||||
elif mode=='mfa':
|
||||
first=tree(root/'privacyidea')
|
||||
if not {'enckey','private.pem','public.pem'}<=set(first) or first!=tree(root/'privacyidea'):raise Refused()
|
||||
values={f'data/privacyidea/{name}':value for name,value in first.items()}
|
||||
else:raise Refused()
|
||||
if len(values)>MAX_COUNT or sum(map(len,values.values()))>MAX_TOTAL:raise Refused()
|
||||
return values
|
||||
|
||||
|
||||
def emit(values,destination):
|
||||
with tarfile.open(fileobj=destination,mode='w|') as archive:
|
||||
for name,value in sorted(values.items()):
|
||||
member=tarfile.TarInfo(name);member.size=len(value);member.mode=0o600
|
||||
archive.addfile(member,io.BytesIO(value))
|
||||
|
||||
|
||||
def interrupted(*args):raise Refused()
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
for sig in (signal.SIGTERM,signal.SIGHUP,signal.SIGINT):signal.signal(sig,interrupted)
|
||||
try:
|
||||
os.umask(0o077)
|
||||
if len(sys.argv)!=2:raise Refused()
|
||||
emit(capture(sys.argv[1]),sys.stdout.buffer)
|
||||
except Exception:
|
||||
print('ERROR: private file capture refused; diagnostics suppressed.',file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Plan two exact DNS-selector exclusions; apply only an owner-admitted hash-bound plan."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
HERE=Path(__file__).resolve().parent
|
||||
spec=importlib.util.spec_from_file_location('capture',HERE/'capture-identity.py')
|
||||
capture=importlib.util.module_from_spec(spec);spec.loader.exec_module(capture)
|
||||
SELECTOR={'matchExpressions':[{'key':'net-kingdom/capture','operator':'DoesNotExist'}]}
|
||||
|
||||
|
||||
def plan():
|
||||
if capture.get(None,'namespace','kube-system')['metadata']['uid']!=capture.prepare.CLUSTER_UID:raise capture.Refused()
|
||||
rows=[]
|
||||
for ns in ('sso','mfa'):
|
||||
obj=capture.get(ns,'networkpolicy','allow-egress-dns');s=obj['spec'];m=obj['metadata']
|
||||
if s['podSelector'] not in ({},SELECTOR):raise capture.Refused()
|
||||
pods=json.loads(capture.command(['-n',ns,'get','pods','-o','json']))['items']
|
||||
if any('net-kingdom/capture' in p['metadata'].get('labels',{}) for p in pods):raise capture.Refused()
|
||||
# Preserve every peer, port and policy type. Existing unlabelled service
|
||||
# pods retain their exact selector match. UID/RV/spec tests guard drift.
|
||||
patch=[{'op':'test','path':'/metadata/uid','value':m['uid']},
|
||||
{'op':'test','path':'/metadata/resourceVersion','value':m['resourceVersion']},
|
||||
{'op':'test','path':'/spec','value':s},
|
||||
{'op':'replace','path':'/spec/podSelector','value':SELECTOR}]
|
||||
rows.append({'namespace':ns,'name':m['name'],'uid':m['uid'],'patch':patch})
|
||||
return {'schema':'netkingdom.identity-isolation.v1','status':'prepared_not_applied',
|
||||
'kubeconfig':capture.KUBE[2],'source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),'policies':rows}
|
||||
|
||||
|
||||
def apply(packet,receipt,dry_run=False):
|
||||
if packet!=plan():raise capture.Refused()
|
||||
for row in packet['policies']:
|
||||
args=['-n',row['namespace'],'patch','networkpolicy',row['name'],'--type=json','-p',json.dumps(row['patch']),'-o','json']
|
||||
if dry_run:args+=['--dry-run=server']
|
||||
receipt['attempted'].append({'namespace':row['namespace'],'name':row['name'],'uid':row['uid']})
|
||||
actual=json.loads(capture.command(args));expected=row['patch'][2]['value'].copy();expected['podSelector']=SELECTOR
|
||||
if actual['metadata']['uid']!=row['uid'] or actual['spec']!=expected:raise capture.Refused()
|
||||
receipt['verified'].append({'namespace':row['namespace'],'name':row['name'],'uid':row['uid']})
|
||||
receipt['status']='server_dry_run_passed' if dry_run else 'capture_dns_exclusions_verified'
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__);p.add_argument('action',choices=['plan','dry-run','apply'])
|
||||
p.add_argument('--kubeconfig',required=True);p.add_argument('--plan');p.add_argument('--plan-sha256');p.add_argument('--receipt',required=True)
|
||||
a=p.parse_args();os.umask(0o077);capture.KUBE=['kubectl','--kubeconfig',a.kubeconfig]
|
||||
fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
r={'status':'failed','attempted':[],'verified':[]}
|
||||
try:
|
||||
if a.action=='plan':r=plan()
|
||||
else:
|
||||
raw=Path(a.plan).read_bytes()
|
||||
if hashlib.sha256(raw).hexdigest()!=a.plan_sha256:raise capture.Refused()
|
||||
apply(json.loads(raw),r,a.action=='dry-run')
|
||||
except Exception:r.update(status='failed',error='isolation_refused_review_partial_attempts')
|
||||
finally:
|
||||
with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n')
|
||||
return int(r['status']=='failed')
|
||||
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Folder-scoped Storage Share ciphertext publication and verified readback.
|
||||
|
||||
Runtime gets only a dedicated read/create share token. Account administration,
|
||||
deletion and age private keys have no route in this publisher.
|
||||
"""
|
||||
import base64
|
||||
from datetime import datetime,timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
HOST='https://nx4069.your-storageshare.de'
|
||||
BASE=HOST+'/public.php/webdav/'
|
||||
NAME=re.compile(r'net-kingdom-identity-\d{8}T\d{6}Z-[a-f0-9]{12}\.tar\.age\Z')
|
||||
LIMIT=512*1024*1024
|
||||
HEADROOM=2*1024**3
|
||||
|
||||
|
||||
class Refused(Exception):pass
|
||||
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self,*args,**kwargs):raise Refused()
|
||||
|
||||
|
||||
def opener():return urllib.request.build_opener(urllib.request.ProxyHandler({}),NoRedirect())
|
||||
|
||||
|
||||
def headers(token):
|
||||
if not isinstance(token,str) or not re.fullmatch('[A-Za-z0-9]{10,128}',token):raise Refused()
|
||||
return {'Authorization':'Basic '+base64.b64encode((token+':').encode()).decode(),
|
||||
'X-Requested-With':'XMLHttpRequest','Accept-Encoding':'identity'}
|
||||
|
||||
|
||||
def digest(path):
|
||||
with path.open('rb') as source:return hashlib.file_digest(source,'sha256').hexdigest()
|
||||
|
||||
|
||||
def quota(client,token):
|
||||
body=b'<d:propfind xmlns:d="DAV:"><d:prop><d:quota-available-bytes/></d:prop></d:propfind>'
|
||||
h=headers(token);h.update(Depth='0');h['Content-Type']='application/xml'
|
||||
with client.open(urllib.request.Request(BASE,data=body,method='PROPFIND',headers=h),timeout=30) as response:
|
||||
if response.status!=207 or response.url!=BASE:raise Refused()
|
||||
raw=response.read(1024*1024+1)
|
||||
if len(raw)>1024*1024:raise Refused()
|
||||
amount=ET.fromstring(raw).findtext('.//{DAV:}quota-available-bytes') or ''
|
||||
if not amount.isdigit():raise Refused()
|
||||
return int(amount)
|
||||
|
||||
|
||||
def etag(response,url,size):
|
||||
value=response.headers.get('ETag','')
|
||||
if (response.status!=200 or response.url!=url or response.headers.get('Content-Length')!=str(size)
|
||||
or not re.fullmatch(r'"[^"\x00-\x20]{1,180}"',value)):raise Refused()
|
||||
return value
|
||||
|
||||
|
||||
def readback(client,token,name,size,expected,target):
|
||||
valid=NAME.fullmatch(name) or (name.endswith('.receipt.json') and NAME.fullmatch(name.removesuffix('.receipt.json')) and size<=65536)
|
||||
if not valid or not 0<size<=LIMIT:raise Refused()
|
||||
url=BASE+name;h=headers(token)
|
||||
with client.open(urllib.request.Request(url,method='HEAD',headers=h),timeout=30) as response:version=etag(response,url,size)
|
||||
h['If-Match']=version;count=0;sha=hashlib.sha256()
|
||||
with client.open(urllib.request.Request(url,headers=h),timeout=180) as response:
|
||||
if etag(response,url,size)!=version:raise Refused()
|
||||
fd=os.open(target,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
with os.fdopen(fd,'wb') as out:
|
||||
while block:=response.read(1024*1024):
|
||||
count+=len(block)
|
||||
if count>size:raise Refused()
|
||||
sha.update(block);out.write(block)
|
||||
out.flush();os.fsync(out.fileno())
|
||||
if count!=size or sha.hexdigest()!=expected:raise Refused()
|
||||
with client.open(urllib.request.Request(url,method='HEAD',headers=h),timeout=30) as response:
|
||||
if etag(response,url,size)!=version:raise Refused()
|
||||
return version
|
||||
|
||||
|
||||
def publish(client,token,source,capture,target):
|
||||
if (not NAME.fullmatch(source.name) or source.is_symlink() or source.stat().st_mode&0o077
|
||||
or source.stat().st_uid!=os.getuid() or source.stat().st_nlink!=1 or not source.is_file()
|
||||
or source.stat().st_size>LIMIT or capture.get('status')!='encrypted_bundle_created'
|
||||
or capture.get('helper_cleanup_verified') is not True or capture.get('temporary_plaintext_cleanup_verified') is not True
|
||||
or digest(source)!=capture.get('ciphertext_sha256') or source.stat().st_size!=capture.get('ciphertext_bytes')):raise Refused()
|
||||
with source.open('rb') as stream:
|
||||
if stream.read(22)!=b'age-encryption.org/v1\n':raise Refused()
|
||||
available=quota(client,token)
|
||||
if available<source.stat().st_size+HEADROOM:raise Refused()
|
||||
url=BASE+source.name;h=headers(token)
|
||||
try:
|
||||
with client.open(urllib.request.Request(url,method='HEAD',headers=h),timeout=30):raise Refused()
|
||||
except urllib.error.HTTPError as error:
|
||||
if error.code!=404:raise Refused() from None
|
||||
h.update({'If-None-Match':'*','Content-Type':'application/octet-stream','Content-Length':str(source.stat().st_size)})
|
||||
# An unknown upload outcome is never retried under this name automatically.
|
||||
with source.open('rb') as stream:
|
||||
with client.open(urllib.request.Request(url,data=stream,method='PUT',headers=h),timeout=600) as response:
|
||||
if response.status!=201 or response.url!=url:raise Refused()
|
||||
version=readback(client,token,source.name,source.stat().st_size,capture['ciphertext_sha256'],target)
|
||||
index={'schema':'netkingdom.identity-offsite-index.v1','status':'readback_verified',
|
||||
'artifact':source.name,'ciphertext_sha256':capture['ciphertext_sha256'],
|
||||
'ciphertext_bytes':source.stat().st_size,'captured_at':capture['finished_at']}
|
||||
raw=json.dumps(index,sort_keys=True).encode();index_name=source.name+'.receipt.json';index_url=BASE+index_name
|
||||
h=headers(token);h.update({'If-None-Match':'*','Content-Type':'application/json','Content-Length':str(len(raw))})
|
||||
with client.open(urllib.request.Request(index_url,data=raw,method='PUT',headers=h),timeout=30) as response:
|
||||
if response.status!=201 or response.url!=index_url:raise Refused()
|
||||
with tempfile.TemporaryDirectory(prefix='.identity-index-',dir=target.parent) as temporary:
|
||||
readback(client,token,index_name,len(raw),hashlib.sha256(raw).hexdigest(),Path(temporary)/'index.json')
|
||||
return {'schema':'netkingdom.identity-offsite.v1','status':'readback_verified',
|
||||
'observed_at':datetime.now(timezone.utc).isoformat(),'provider':'Storage Share',
|
||||
'folder':'Backup/railiance-backups/net-kingdom-identity','artifact':source.name,
|
||||
'ciphertext_sha256':capture['ciphertext_sha256'],'ciphertext_bytes':source.stat().st_size,
|
||||
'fetched_path':str(target),'etag':version,'quota_available_before':available,
|
||||
'new_object_created':True,'downloaded_digest_verified':True,
|
||||
'verified_index_published':True,'index_name':index_name,
|
||||
'age_private_key_used':False,'secret_values_exported':False,'retained_objects_deleted':False,
|
||||
'native_identity_recovery_proven':False}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Separate folder-scoped read/delete executor for verified new identity backups.
|
||||
|
||||
Unknown/unindexed files are never eligible. The recovered point, newest point,
|
||||
seven daily and two weekly points remain protected. No grant or account login.
|
||||
"""
|
||||
from datetime import datetime,timezone
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
spec=importlib.util.spec_from_file_location('offsite',Path(__file__).with_name('identity-offsite.py'))
|
||||
offsite=importlib.util.module_from_spec(spec);spec.loader.exec_module(offsite)
|
||||
|
||||
|
||||
def listing(client,token):
|
||||
h=offsite.headers(token);h.update({'Depth':'1','Content-Type':'application/xml'})
|
||||
body=b'<d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/><d:getcontentlength/><d:getetag/></d:prop></d:propfind>'
|
||||
with client.open(urllib.request.Request(offsite.BASE,data=body,method='PROPFIND',headers=h),timeout=30) as response:
|
||||
if response.status!=207 or response.url!=offsite.BASE:raise offsite.Refused()
|
||||
raw=response.read(4*1024*1024+1)
|
||||
if len(raw)>4*1024*1024:raise offsite.Refused()
|
||||
result={}
|
||||
for response in ET.fromstring(raw).findall('{DAV:}response'):
|
||||
href=urllib.parse.urlsplit(response.findtext('{DAV:}href') or '')
|
||||
if href.netloc and href.netloc!=urllib.parse.urlsplit(offsite.HOST).netloc:raise offsite.Refused()
|
||||
name=urllib.parse.unquote(href.path.rstrip('/').rsplit('/',1)[-1])
|
||||
for row in response.findall('{DAV:}propstat'):
|
||||
if ' 200 ' not in (row.findtext('{DAV:}status') or ''):continue
|
||||
p=row.find('{DAV:}prop')
|
||||
if p is None or p.find('{DAV:}resourcetype/{DAV:}collection') is not None:continue
|
||||
is_index=name.endswith('.receipt.json') and offsite.NAME.fullmatch(name.removesuffix('.receipt.json'))
|
||||
if not offsite.NAME.fullmatch(name) and not is_index:continue
|
||||
size=p.findtext('{DAV:}getcontentlength') or '';version=p.findtext('{DAV:}getetag') or ''
|
||||
if not size.isdigit() or not re.fullmatch(r'"[^"\x00-\x20]{1,180}"',version) or name in result:raise offsite.Refused()
|
||||
result[name]={'bytes':int(size),'etag':version}
|
||||
if len(result)>10000:raise offsite.Refused()
|
||||
return result
|
||||
|
||||
|
||||
def generations(client,token,objects):
|
||||
result=[]
|
||||
for name,meta in objects.items():
|
||||
if not offsite.NAME.fullmatch(name) or name+'.receipt.json' not in objects:continue
|
||||
index=objects[name+'.receipt.json']
|
||||
if not 0<index['bytes']<=65536:raise offsite.Refused()
|
||||
url=offsite.BASE+name+'.receipt.json';h=offsite.headers(token);h['If-Match']=index['etag']
|
||||
with client.open(urllib.request.Request(url,headers=h),timeout=30) as response:
|
||||
if offsite.etag(response,url,index['bytes'])!=index['etag']:raise offsite.Refused()
|
||||
raw=response.read(65537)
|
||||
if len(raw)!=index['bytes']:raise offsite.Refused()
|
||||
r=json.loads(raw)
|
||||
if (r.get('schema')!='netkingdom.identity-offsite-index.v1' or r.get('status')!='readback_verified'
|
||||
or r.get('artifact')!=name or r.get('ciphertext_bytes')!=meta['bytes'] or not 0<meta['bytes']<=offsite.LIMIT
|
||||
or not re.fullmatch('[a-f0-9]{64}',r.get('ciphertext_sha256',''))):raise offsite.Refused()
|
||||
stamp=datetime.strptime(name.removeprefix('net-kingdom-identity-')[:16],'%Y%m%dT%H%M%SZ').replace(tzinfo=timezone.utc)
|
||||
captured=datetime.fromisoformat(r['captured_at'])
|
||||
if captured.tzinfo is None or abs((captured-stamp).total_seconds())>3600:raise offsite.Refused()
|
||||
if stamp>datetime.now(timezone.utc):raise offsite.Refused()
|
||||
result.append({**r,'stamp':stamp,'etag':meta['etag'],'index_etag':index['etag']})
|
||||
return sorted(result,key=lambda r:r['stamp'],reverse=True)
|
||||
|
||||
|
||||
def select(rows,protected):
|
||||
if not rows or protected not in {r['artifact'] for r in rows}:raise offsite.Refused()
|
||||
keep={protected,rows[0]['artifact']};days=set();weeks=set()
|
||||
for row in rows:
|
||||
day=row['stamp'].date();week=row['stamp'].isocalendar()[:2]
|
||||
if day not in days and len(days)<7:keep.add(row['artifact']);days.add(day)
|
||||
if week not in weeks and len(weeks)<2:keep.add(row['artifact']);weeks.add(week)
|
||||
return [r for r in rows if r['artifact'] not in keep],keep
|
||||
|
||||
|
||||
def run(client,token,accepted,receipt,execute=False,checkpoint=lambda:None):
|
||||
if (accepted.get('status')!='identity_archive_verified' or accepted.get('container_cleanup_verified') is not True
|
||||
or accepted.get('temporary_plaintext_cleanup_verified') is not True or not offsite.NAME.fullmatch(accepted.get('artifact',''))):raise offsite.Refused()
|
||||
objects=listing(client,token);rows=generations(client,token,objects)
|
||||
candidates,keep=select(rows,accepted['artifact'])
|
||||
recovered=next(r for r in rows if r['artifact']==accepted['artifact'])
|
||||
if recovered['ciphertext_sha256']!=accepted['ciphertext_sha256']:raise offsite.Refused()
|
||||
receipt.update(status='retention_preview',protected=accepted['artifact'],keep=sorted(keep),
|
||||
candidates=[r['artifact'] for r in candidates],deleted=[],unknown_objects_untouched=True)
|
||||
checkpoint()
|
||||
# Validate retained known-good and newest ciphertext before any deletion.
|
||||
with tempfile.TemporaryDirectory(prefix='nk49-retained-check-',dir='/tmp') as temporary:
|
||||
for index,row in enumerate({r['artifact']:r for r in [recovered,rows[0]]}.values()):
|
||||
offsite.readback(client,token,row['artifact'],row['ciphertext_bytes'],row['ciphertext_sha256'],Path(temporary)/str(index))
|
||||
if not execute:return
|
||||
for index,row in enumerate(candidates):
|
||||
version=offsite.readback(client,token,row['artifact'],row['ciphertext_bytes'],row['ciphertext_sha256'],Path(temporary)/('expire-'+str(index)))
|
||||
for name,etag in [(row['artifact'],version),(row['artifact']+'.receipt.json',row['index_etag'])]:
|
||||
h=offsite.headers(token);h['If-Match']=etag
|
||||
receipt['deletion_in_progress']=name
|
||||
checkpoint()
|
||||
with client.open(urllib.request.Request(offsite.BASE+name,method='DELETE',headers=h),timeout=30) as response:
|
||||
if response.status!=204 or response.url!=offsite.BASE+name:raise offsite.Refused()
|
||||
receipt['deleted'].append(name)
|
||||
checkpoint()
|
||||
receipt.pop('deletion_in_progress',None)
|
||||
receipt['status']='retention_applied'
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Root-owned Railiance backup job. No operator password or age private key.
|
||||
|
||||
Publisher and retention are separate processes with separate scoped delivery.
|
||||
Scheduled execution is gated by an independently recovered archive receipt.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
from datetime import datetime,timezone
|
||||
import fcntl
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import shutil
|
||||
import socket
|
||||
import tempfile
|
||||
import urllib.request
|
||||
|
||||
HERE=Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def load(name):
|
||||
spec=importlib.util.spec_from_file_location(name,HERE/(name+'.py'))
|
||||
m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m);return m
|
||||
|
||||
|
||||
capture=load('capture-identity');offsite=load('identity-offsite');retention=load('identity-retention')
|
||||
|
||||
|
||||
def write(path,value):
|
||||
fd,name=tempfile.mkstemp(prefix='.identity-receipt-',dir=path.parent)
|
||||
try:
|
||||
with os.fdopen(fd,'w') as out:json.dump(value,out,indent=2);out.write('\n');out.flush();os.fsync(out.fileno())
|
||||
os.replace(name,path)
|
||||
finally:Path(name).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def token(action):
|
||||
name='net-kingdom-backup-'+action
|
||||
es=capture.get('sso','externalsecret',name)
|
||||
entries=[]
|
||||
for entry in es['spec'].get('data',[]):
|
||||
item=dict(entry);ref=dict(item.get('remoteRef',{}))
|
||||
for key,default in [('conversionStrategy','Default'),('decodingStrategy','None'),('metadataPolicy','None')]:
|
||||
if ref.pop(key,default)!=default:raise offsite.Refused()
|
||||
item['remoteRef']=ref;entries.append(item)
|
||||
if (es['spec']['target']['name']!=name or entries!=[{'secretKey':'token','remoteRef':{
|
||||
'key':'workloads/net-kingdom/identity-backup/'+action,'property':'NC_WEBDAV_TOKEN'}}]
|
||||
or es['spec'].get('dataFrom') or es['spec'].get('secretStoreRef')!={'kind':'SecretStore','name':name}
|
||||
or not any(c['type']=='Ready' and c['status']=='True' for c in es['status'].get('conditions',[]))):raise offsite.Refused()
|
||||
secret=capture.get('sso','secret',name)
|
||||
if (set(secret['data'])!={'token'} or not any(o['uid']==es['metadata']['uid'] for o in secret['metadata'].get('ownerReferences',[]))):raise offsite.Refused()
|
||||
value=base64.b64decode(secret['data']['token'],validate=True).decode();offsite.headers(value)
|
||||
return value
|
||||
|
||||
|
||||
def accepted(path):
|
||||
capture.bundle.private(path)
|
||||
r=json.loads(path.read_text())
|
||||
if (r.get('schema')!='netkingdom.identity-recovery.v1' or r.get('status')!='identity_archive_verified'
|
||||
or r.get('all_manifest_hashes_verified') is not True or r.get('container_cleanup_verified') is not True
|
||||
or r.get('temporary_plaintext_cleanup_verified') is not True or not offsite.NAME.fullmatch(r.get('artifact',''))):raise offsite.Refused()
|
||||
return r
|
||||
|
||||
|
||||
def post_event(state,summary):
|
||||
# Non-secret durable reporting: replay retained notes at the next run.
|
||||
directory=state/'event-outbox';directory.mkdir(mode=0o700,exist_ok=True)
|
||||
import uuid
|
||||
path=directory/(uuid.uuid4().hex+'.json')
|
||||
write(path,{'summary':summary,'event_type':'note','author':'net-kingdom-identity-backup',
|
||||
'workplan_id':'17d315f0-829e-57c7-85cc-3a7914152804','task_id':'d68cd4d3-1ba3-530e-88f9-26ebb8dd6eac'})
|
||||
for pending in sorted(directory.glob('*.json'))[:64]:
|
||||
request=urllib.request.Request('http://10.43.68.154:8000/progress/',data=pending.read_bytes(),
|
||||
headers={'Content-Type':'application/json'},method='POST')
|
||||
try:
|
||||
with urllib.request.build_opener(urllib.request.ProxyHandler({})).open(request,timeout=10) as response:
|
||||
if response.status!=201:break
|
||||
pending.unlink()
|
||||
except Exception:break
|
||||
|
||||
|
||||
def backup(state,decision,first=False):
|
||||
if not first:accepted(state/'accepted-recovery.json')
|
||||
if (state/'capture-in-progress.json').exists():raise offsite.Refused()
|
||||
publisher=token('publisher')
|
||||
client=offsite.opener()
|
||||
if offsite.quota(client,publisher)<offsite.HEADROOM+offsite.LIMIT:raise offsite.Refused()
|
||||
if any(shutil.disk_usage(path).free<2*offsite.LIMIT+offsite.HEADROOM for path in [state,Path('/tmp')]):raise offsite.Refused()
|
||||
p=capture.plan();stamp=datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
|
||||
name='net-kingdom-identity-'+stamp+'-'+p['run_id']+'.tar.age'
|
||||
directory=state/'runs'/name;directory.mkdir(mode=0o700,parents=True)
|
||||
plan_path=directory/'plan.json';write(plan_path,p)
|
||||
import hashlib
|
||||
capture.checked_plan(plan_path,hashlib.sha256(plan_path.read_bytes()).hexdigest())
|
||||
source=directory/name;r={'started_at':datetime.now(timezone.utc).isoformat(),'status':'capture_started'}
|
||||
record=directory/'capture.json';write(record,r)
|
||||
write(state/'capture-in-progress.json',{'receipt':str(record),'artifact':name})
|
||||
try:capture.capture(p,source,r,decision,lambda:write(record,r))
|
||||
except Exception:
|
||||
if r.get('temporary_plaintext_directory') and not Path(r['temporary_plaintext_directory']).exists():r['temporary_plaintext_cleanup_verified']=True
|
||||
r['status']='failed';write(record,r);raise
|
||||
finally:
|
||||
if r.get('helper_cleanup_verified') and r.get('temporary_plaintext_cleanup_verified'):
|
||||
(state/'capture-in-progress.json').unlink()
|
||||
write(record,r)
|
||||
r['finished_at']=datetime.now(timezone.utc).isoformat();write(record,r)
|
||||
write(directory/'transfer-attempt.json',{'status':'pending','artifact':name,'capture_receipt':'capture.json',
|
||||
'ciphertext_sha256':r['ciphertext_sha256'],'ciphertext_bytes':r['ciphertext_bytes']})
|
||||
remote=offsite.publish(client,publisher,source,r,directory/'fetched.tar.age')
|
||||
write(directory/'offsite.json',remote)
|
||||
# The original private local ciphertext and safe receipts remain. Every
|
||||
# successful fetched duplicate is unnecessary after first acceptance and
|
||||
# may be removed by the owner under a separate local-cache policy.
|
||||
write(state/'last-success.json',{'finished_at':datetime.now(timezone.utc).isoformat(),
|
||||
'artifact':name,'directory':str(directory),'ciphertext_sha256':remote['ciphertext_sha256'],
|
||||
'offsite_verified':True})
|
||||
post_event(state,'Identity backup completed: encrypted full coverage captured, Storage Share upload and downloaded digest verified. Native service authentication remains separate evidence.')
|
||||
|
||||
|
||||
def expire(state,execute=False):
|
||||
if (state/'retention-blocked.json').exists():raise offsite.Refused()
|
||||
proof=accepted(state/'accepted-recovery.json')
|
||||
r={'status':'pending','started_at':datetime.now(timezone.utc).isoformat()}
|
||||
stamp=datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S%fZ')
|
||||
record=state/('retention-'+stamp+'.json')
|
||||
write(record,r)
|
||||
try:
|
||||
retention.run(offsite.opener(),token('retention'),proof,r,execute,lambda:write(record,r))
|
||||
if execute:r['local_generations_removed']=prune_local(state,proof)
|
||||
except Exception:
|
||||
r['status']='failed';write(record,r)
|
||||
# Stop future automatic expiration after an unknown/partial outcome.
|
||||
# Owner review must explicitly clear this marker; no blind retries.
|
||||
if execute:write(state/'retention-blocked.json',{'receipt':str(record),'requires_owner_reconciliation':True})
|
||||
raise
|
||||
write(record,r)
|
||||
|
||||
|
||||
def prune_local(state,proof):
|
||||
root=state/'runs';rows=[];known={}
|
||||
if not root.exists():return []
|
||||
capture.bundle.private(root,True)
|
||||
for directory in root.iterdir():
|
||||
if directory.is_symlink() or not directory.is_dir() or not offsite.NAME.fullmatch(directory.name):continue
|
||||
capture.bundle.private(directory,True)
|
||||
allowed={'plan.json','capture.json','transfer-attempt.json','offsite.json','fetched.tar.age',directory.name}
|
||||
if {p.name for p in directory.iterdir()}-allowed:continue
|
||||
try:
|
||||
remote=json.loads((directory/'offsite.json').read_text());local=json.loads((directory/'capture.json').read_text())
|
||||
if (remote.get('status')!='readback_verified' or remote.get('verified_index_published') is not True
|
||||
or remote.get('artifact')!=directory.name or remote.get('ciphertext_sha256')!=local.get('ciphertext_sha256')
|
||||
or local.get('helper_cleanup_verified') is not True or local.get('temporary_plaintext_cleanup_verified') is not True):continue
|
||||
for path in directory.iterdir():capture.bundle.private(path,max_bytes=offsite.LIMIT)
|
||||
if offsite.digest(directory/directory.name)!=remote['ciphertext_sha256']:continue
|
||||
stamp=datetime.strptime(directory.name.removeprefix('net-kingdom-identity-')[:16],'%Y%m%dT%H%M%SZ').replace(tzinfo=timezone.utc)
|
||||
rows.append({'artifact':directory.name,'stamp':stamp});known[directory.name]=directory
|
||||
except Exception:continue
|
||||
rows.sort(key=lambda r:r['stamp'],reverse=True)
|
||||
if not rows:return []
|
||||
protected=proof['artifact'] if proof['artifact'] in known else rows[0]['artifact']
|
||||
candidates,_=retention.select(rows,protected)
|
||||
removed=[]
|
||||
for row in candidates:
|
||||
directory=known[row['artifact']]
|
||||
for path in directory.iterdir():path.unlink()
|
||||
directory.rmdir();removed.append(row['artifact'])
|
||||
return removed
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('action',choices=['first-capture','scheduled','retention-preview','retention','status'])
|
||||
p.add_argument('--kubeconfig',required=True);p.add_argument('--state',default='/var/lib/net-kingdom-backup')
|
||||
p.add_argument('--decision-id');a=p.parse_args();os.umask(0o077)
|
||||
state=Path(a.state).absolute()
|
||||
ready=False
|
||||
try:
|
||||
capture.bundle.require_runtime_host()
|
||||
capture.KUBE=['kubectl','--kubeconfig',a.kubeconfig]
|
||||
uid=capture.get(None,'namespace','kube-system')['metadata']['uid']
|
||||
if uid!=capture.prepare.CLUSTER_UID:raise offsite.Refused()
|
||||
state.mkdir(mode=0o700,parents=True,exist_ok=True);capture.bundle.private(state,True);ready=True
|
||||
fd=None
|
||||
try:
|
||||
if a.action!='status':
|
||||
fd=os.open(state/'.lock',os.O_RDWR|os.O_CREAT|os.O_NOFOLLOW,0o600)
|
||||
fcntl.flock(fd,fcntl.LOCK_EX|fcntl.LOCK_NB)
|
||||
if a.action in {'first-capture','scheduled'}:
|
||||
import uuid
|
||||
uuid.UUID(a.decision_id or '')
|
||||
backup(state,a.decision_id,a.action=='first-capture')
|
||||
elif a.action.startswith('retention'):expire(state,a.action=='retention')
|
||||
else:
|
||||
last=json.loads((state/'last-success.json').read_text())
|
||||
age=(datetime.now(timezone.utc)-datetime.fromisoformat(last['finished_at'])).total_seconds()
|
||||
if not 0<=age<=25*3600:raise offsite.Refused()
|
||||
finally:
|
||||
if fd is not None:os.close(fd)
|
||||
except Exception:
|
||||
if ready:
|
||||
try:post_event(state,'Identity backup '+a.action+' failed or refused. Private diagnostics suppressed; inspect root-owned receipts and reconcile partial state before retry.')
|
||||
except Exception:pass
|
||||
print('ERROR: identity backup operation failed; inspect private owner receipts. No secret diagnostics emitted.');return 1
|
||||
print('OK: identity backup '+a.action+' completed.');return 0
|
||||
|
||||
|
||||
def interrupted(*args):raise offsite.Refused()
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
for sig in (signal.SIGHUP,signal.SIGTERM,signal.SIGINT):signal.signal(sig,interrupted)
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Render/install a source-bound node runtime; activation needs actual recovery.
|
||||
|
||||
Install does not enable timers. Owner applies the separately reviewed delivery
|
||||
and capture-isolation changes, performs first capture and independent recovery,
|
||||
then explicitly activates. No credential values enter this package.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import uuid
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[2]
|
||||
HERE=Path(__file__).resolve().parent
|
||||
FILES=[f'tools/bootstrap-recovery/{name}' for name in ['capture-identity.py','identity-files.py','identity-bundle.py',
|
||||
'identity-runtime.py','identity-offsite.py','identity-retention.py','identity-backup-profile.json',
|
||||
'prepare-identity-coverage.py','install-identity-backup.py']]+['sso-mfa/k8s/privacyidea/key-custody-remote.py']
|
||||
STATE='/var/lib/net-kingdom-backup'
|
||||
KUBECONFIG='/etc/rancher/k3s/k3s.yaml'
|
||||
|
||||
|
||||
class Refused(Exception):pass
|
||||
|
||||
|
||||
def render(decision):
|
||||
uuid.UUID(decision)
|
||||
files=[{'path':name,'sha256':hashlib.sha256((ROOT/name).read_bytes()).hexdigest()} for name in FILES]
|
||||
release=hashlib.sha256(json.dumps(files,sort_keys=True).encode()).hexdigest()
|
||||
directory='/opt/net-kingdom-backup/releases/'+release
|
||||
runtime=directory+'/tools/bootstrap-recovery/identity-runtime.py'
|
||||
common=f' --kubeconfig {KUBECONFIG} --state {STATE}'
|
||||
base=('User=root\nGroup=root\nUMask=0077\nStateDirectory=net-kingdom-backup\nStateDirectoryMode=0700\n'
|
||||
'NoNewPrivileges=true\nPrivateTmp=true\nPrivateDevices=true\nProtectSystem=strict\nProtectHome=true\n'
|
||||
f'ReadWritePaths={STATE}\nCapabilityBoundingSet=\nRestrictAddressFamilies=AF_UNIX AF_INET AF_INET6\n'
|
||||
'ProtectKernelTunables=true\nProtectKernelModules=true\nProtectControlGroups=true\n'
|
||||
'MemoryMax=3G\nCPUQuota=200%\nTasksMax=128\nTimeoutStartSec=45min\nTimeoutStopSec=5min\n')
|
||||
command='/usr/bin/python3 -I '+runtime
|
||||
service=('[Unit]\nDescription=Encrypted full identity backup and verified Storage Share readback\nAfter=network-online.target k3s.service\n'
|
||||
f'ConditionPathExists={STATE}/accepted-recovery.json\n[Service]\nType=oneshot\n'+base+
|
||||
f'ExecStartPre={command} retention{common}\nExecStart={command} scheduled{common} --decision-id {decision}\n'
|
||||
f'ExecStartPost={command} retention{common}\n')
|
||||
health=('[Unit]\nDescription=Identity backup age check with durable failure reporting\nAfter=network-online.target k3s.service\n'
|
||||
'[Service]\nType=oneshot\n'+base+f'ExecStart={command} status{common}\n')
|
||||
units={'net-kingdom-identity-backup.service':service,
|
||||
'net-kingdom-identity-backup.timer':'[Unit]\nDescription=Daily full identity recovery bundle\n[Timer]\nOnCalendar=*-*-* 03:45:00 UTC\nPersistent=true\nRandomizedDelaySec=5min\nUnit=net-kingdom-identity-backup.service\n[Install]\nWantedBy=timers.target\n',
|
||||
'net-kingdom-identity-backup-health.service':health,
|
||||
'net-kingdom-identity-backup-health.timer':'[Unit]\nDescription=Hourly identity backup freshness check\n[Timer]\nOnCalendar=hourly\nPersistent=true\nUnit=net-kingdom-identity-backup-health.service\n[Install]\nWantedBy=timers.target\n'}
|
||||
return {'schema':'netkingdom.identity-runtime-install.v1','status':'prepared_not_installed','decision_ref':decision,
|
||||
'release_id':release,'release_directory':directory,'runtime_files':files,'units':units,
|
||||
'timers_automatically_enabled':False,'first_capture_command':command+' first-capture'+common+' --decision-id '+decision,
|
||||
'activation_requires_independent_recovery':True,'source_contains_credentials':False}
|
||||
|
||||
|
||||
def run(args):
|
||||
r=subprocess.run(args,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,timeout=120)
|
||||
if r.returncode:raise Refused()
|
||||
return r.stdout
|
||||
|
||||
|
||||
def module(name):
|
||||
spec=importlib.util.spec_from_file_location(name,HERE/(name+'.py'));m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m);return m
|
||||
|
||||
|
||||
def owner():
|
||||
module('identity-bundle').require_runtime_host()
|
||||
capture=module('capture-identity');capture.KUBE=['kubectl','--kubeconfig',KUBECONFIG]
|
||||
if capture.get(None,'namespace','kube-system')['metadata']['uid']!=capture.prepare.CLUSTER_UID:raise Refused()
|
||||
|
||||
|
||||
def install(packet):
|
||||
owner();expected=render(packet['decision_ref'])
|
||||
if packet!=expected:raise Refused()
|
||||
destination=Path(packet['release_directory'])
|
||||
# Refuse differing prior units before changing files. Installation never
|
||||
# overwrites a running/unknown version as an implicit upgrade.
|
||||
for name,value in packet['units'].items():
|
||||
path=Path('/etc/systemd/system')/name
|
||||
if path.is_symlink() or (path.exists() and path.read_text()!=value):raise Refused()
|
||||
destination.mkdir(mode=0o700,parents=True,exist_ok=True)
|
||||
for row in packet['runtime_files']:
|
||||
target=destination/row['path'];target.parent.mkdir(mode=0o700,parents=True,exist_ok=True)
|
||||
if target.exists():
|
||||
if target.is_symlink() or hashlib.sha256(target.read_bytes()).hexdigest()!=row['sha256']:raise Refused()
|
||||
else:
|
||||
fd=os.open(target,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
with os.fdopen(fd,'wb') as out:out.write((ROOT/row['path']).read_bytes())
|
||||
if hashlib.sha256(target.read_bytes()).hexdigest()!=row['sha256']:raise Refused()
|
||||
for name,value in packet['units'].items():
|
||||
path=Path('/etc/systemd/system')/name
|
||||
if not path.exists():
|
||||
fd=os.open(path,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o644)
|
||||
with os.fdopen(fd,'w') as out:out.write(value)
|
||||
run(['systemd-analyze','verify',*[str(Path('/etc/systemd/system')/name) for name in packet['units']]])
|
||||
run(['systemctl','daemon-reload'])
|
||||
|
||||
|
||||
def activate(proof):
|
||||
owner();runtime=module('identity-runtime');r=runtime.accepted(proof)
|
||||
state=Path(STATE);runtime.capture.bundle.private(state,True)
|
||||
last=json.loads((state/'last-success.json').read_text())
|
||||
if last.get('offsite_verified') is not True:raise Refused()
|
||||
# Restore proof names the exact protected artifact; require its local
|
||||
# off-site receipt too, not a synthetic JSON worksheet.
|
||||
path=state/'runs'/r['artifact']/'offsite.json'
|
||||
runtime.capture.bundle.private(path);transfer=json.loads(path.read_text())
|
||||
if transfer.get('status')!='readback_verified' or transfer.get('ciphertext_sha256')!=r['ciphertext_sha256']:raise Refused()
|
||||
target=state/'accepted-recovery.json'
|
||||
if target.exists():
|
||||
if target.is_symlink() or json.loads(target.read_text())!=r:raise Refused()
|
||||
else:
|
||||
fd=os.open(target,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n')
|
||||
run(['systemctl','start','net-kingdom-identity-backup-health.service'])
|
||||
run(['systemctl','enable','--now','net-kingdom-identity-backup.timer','net-kingdom-identity-backup-health.timer'])
|
||||
for unit in ['net-kingdom-identity-backup.timer','net-kingdom-identity-backup-health.timer']:
|
||||
if run(['systemctl','is-active',unit]).strip()!=b'active':raise Refused()
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__);p.add_argument('action',choices=['render','install','activate'])
|
||||
p.add_argument('--decision-id');p.add_argument('--packet');p.add_argument('--packet-sha256');p.add_argument('--accepted-receipt')
|
||||
p.add_argument('--receipt',required=True);a=p.parse_args();os.umask(0o077)
|
||||
fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600);r={'status':'failed'}
|
||||
try:
|
||||
if a.action=='render':r=render(a.decision_id)
|
||||
elif a.action=='install':
|
||||
raw=Path(a.packet).read_bytes()
|
||||
if hashlib.sha256(raw).hexdigest()!=a.packet_sha256:raise Refused()
|
||||
packet=json.loads(raw);install(packet)
|
||||
r={'status':'installed_timers_disabled','release_id':packet['release_id']}
|
||||
else:
|
||||
activate(Path(a.accepted_receipt));r={'status':'timers_active_after_independent_recovery'}
|
||||
except Exception:r={'status':'failed','error':'owner_install_or_activation_refused_private_diagnostics_suppressed'}
|
||||
finally:
|
||||
with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n')
|
||||
return int(r['status']=='failed')
|
||||
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Read-only bounded S3 backup metadata through existing scoped runtime delivery.
|
||||
|
||||
No bootstrap API key, new grant, object content read, age key, provider write or
|
||||
retention action. The caller must already have admitted cluster read authority.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
BUCKET='railiance-platform-pg-backup'
|
||||
ENDPOINT='https://s3.nl-ams.scw.cloud'
|
||||
PREFIX='platform-pg/'
|
||||
CLUSTER='a553c742-0115-43d4-99a4-a5ca56fe0786'
|
||||
|
||||
|
||||
class Refused(Exception):pass
|
||||
|
||||
|
||||
def kube(args):
|
||||
p=subprocess.run(['kubectl',*args,'-o','json'],stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,timeout=30)
|
||||
if p.returncode:raise Refused()
|
||||
return json.loads(p.stdout)
|
||||
|
||||
|
||||
def list_prefix(client,prefix):
|
||||
if not prefix.startswith(PREFIX) or not re.fullmatch(r'[A-Za-z0-9._/-]+',prefix) or '..' in prefix.split('/'):raise Refused()
|
||||
directories=[];files=[];continuation=None
|
||||
for _ in range(20):
|
||||
args={'Bucket':BUCKET,'Prefix':prefix,'Delimiter':'/','MaxKeys':1000}
|
||||
if continuation:args['ContinuationToken']=continuation
|
||||
page=client.list_objects_v2(**args)
|
||||
for row in page.get('CommonPrefixes',[]):
|
||||
value=row['Prefix']
|
||||
if not value.startswith(prefix) or not value.endswith('/') or not re.fullmatch(r'[A-Za-z0-9._/-]+',value) or '..' in value.split('/'):raise Refused()
|
||||
directories.append(value)
|
||||
for row in page.get('Contents',[]):
|
||||
name=row['Key']
|
||||
if not name.startswith(prefix) or not re.fullmatch(r'[A-Za-z0-9._/-]+',name) or '..' in name.split('/'):raise Refused()
|
||||
if name.endswith(('.age','.json','.yaml')):
|
||||
size=row['Size']
|
||||
if type(size) is not int or size<0:raise Refused()
|
||||
files.append({'key':name,'bytes':size})
|
||||
if not page.get('IsTruncated'):break
|
||||
continuation=page.get('NextContinuationToken')
|
||||
if not continuation:raise Refused()
|
||||
else:raise Refused()
|
||||
return {'directories':sorted(set(directories)),'files':sorted(files,key=lambda x:x['key'])}
|
||||
|
||||
|
||||
def inventory(client):
|
||||
pending=[(PREFIX,0)];result={}
|
||||
while pending:
|
||||
prefix,depth=pending.pop(0)
|
||||
if prefix in result:raise Refused()
|
||||
if len(result)>=64:raise Refused()
|
||||
row=list_prefix(client,prefix);result[prefix]=row
|
||||
if depth<3:
|
||||
for child in row['directories']:
|
||||
# Native database WAL/base trees are not application/key-file
|
||||
# archives. Record their presence without enumerating every WAL.
|
||||
if any(x in child.split('/') for x in ['wals','base']):continue
|
||||
pending.append((child,depth+1))
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--receipt',required=True)
|
||||
a=p.parse_args();fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
r={'schema':'netkingdom.scaleway-backup-inventory.v1','status':'failed',
|
||||
'started_at':datetime.now(timezone.utc).isoformat(),'read_only':True,
|
||||
'provider':'Scaleway','bucket':BUCKET,'prefix':PREFIX,'source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
|
||||
'bootstrap_api_key_used':False,'age_keys_read':False,'object_contents_downloaded':False,
|
||||
'secret_values_exported':False,'scope':'Bounded prefix metadata, at most three child levels; WAL/base trees not enumerated; other buckets/prefixes unassessed'}
|
||||
try:
|
||||
if kube(['get','namespace','kube-system'])['metadata']['uid']!=CLUSTER:raise Refused()
|
||||
delivered=kube(['-n','databases','get','secret','platform-pg-backup-s3'])
|
||||
if delivered['metadata']['namespace']!='databases' or delivered['metadata']['name']!='platform-pg-backup-s3':raise Refused()
|
||||
values=delivered['data']
|
||||
# Same governed runtime delivery used by Platform's archive verifier;
|
||||
# explicit credentials avoid SDK ambient/profile/metadata discovery.
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
client=boto3.client('s3',endpoint_url=ENDPOINT,region_name='nl-ams',
|
||||
aws_access_key_id=base64.b64decode(values['ACCESS_KEY_ID'],validate=True).decode(),
|
||||
aws_secret_access_key=base64.b64decode(values['ACCESS_SECRET_KEY'],validate=True).decode(),
|
||||
config=Config(signature_version='s3v4',proxies={},connect_timeout=15,read_timeout=30,
|
||||
retries={'max_attempts':1},s3={'addressing_style':'path'}))
|
||||
r['prefixes']=inventory(client);r['status']='metadata_inventory_complete'
|
||||
r['runtime_delivery_uid']=delivered['metadata']['uid']
|
||||
except Exception:r['error']='inventory_failed_diagnostics_suppressed'
|
||||
finally:
|
||||
r['finished_at']=datetime.now(timezone.utc).isoformat()
|
||||
with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n')
|
||||
return int(r['status']=='failed')
|
||||
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Silent read-only inventory in the existing Platform Backup-account procedure.
|
||||
|
||||
Runs only inside the attended platform-admin envelope. Reuses Platform's owner
|
||||
custody accessor and fixed provider/account contract; no age key is read and no
|
||||
backup is downloaded, uploaded, deleted, rotated or granted.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[2]
|
||||
OWNER=ROOT.parent/'railiance-platform/scripts'
|
||||
sys.path.insert(0,str(OWNER))
|
||||
from state_hub_preflight_lane import bao, data
|
||||
from migrate_nextcloud_backup_account import HOST, OPERATOR_PATH
|
||||
BASE='/remote.php/dav/files/Backup/railiance-backups/'
|
||||
LIMIT=4*1024*1024
|
||||
NAME=re.compile(r'[A-Za-z0-9._-]{1,160}\Z')
|
||||
|
||||
|
||||
class Refused(Exception):pass
|
||||
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self,*args,**kwargs):raise Refused()
|
||||
|
||||
|
||||
def parse_listing(raw, relative):
|
||||
if len(raw)>LIMIT:raise Refused()
|
||||
rows=[]
|
||||
for item in ET.fromstring(raw).findall('{DAV:}response'):
|
||||
href=urllib.parse.urlsplit(item.findtext('{DAV:}href') or '')
|
||||
if href.netloc and href.netloc!=urllib.parse.urlsplit(HOST).netloc:raise Refused()
|
||||
path=urllib.parse.unquote(href.path)
|
||||
if not path.startswith(BASE+relative):raise Refused()
|
||||
name=path[len(BASE+relative):].rstrip('/')
|
||||
if not name:continue
|
||||
if not NAME.fullmatch(name) or name in {'.','..'}:raise Refused()
|
||||
for row in item.findall('{DAV:}propstat'):
|
||||
if ' 200 ' not in (row.findtext('{DAV:}status') or ''):continue
|
||||
prop=row.find('{DAV:}prop')
|
||||
if prop is None:continue
|
||||
folder=prop.find('{DAV:}resourcetype/{DAV:}collection') is not None
|
||||
size=prop.findtext('{DAV:}getcontentlength')
|
||||
if size is not None and not size.isdigit():raise Refused()
|
||||
# Export only known-safe operational names/size, not arbitrary DAV
|
||||
# properties, URLs, account metadata or raw provider responses.
|
||||
if folder or name.endswith(('.age','.json','.yaml')):
|
||||
rows.append({'name':name,'directory':folder,'bytes':int(size) if size else None})
|
||||
if len(rows)>10000 or len({r['name'] for r in rows})!=len(rows):raise Refused()
|
||||
return sorted(rows,key=lambda x:x['name'])
|
||||
|
||||
|
||||
def listing(relative,credentials):
|
||||
if relative and (not relative.endswith('/') or not NAME.fullmatch(relative[:-1])):raise Refused()
|
||||
auth=base64.b64encode((credentials['BACKUP_USERNAME']+':'+credentials['BACKUP_PASSWORD']).encode()).decode()
|
||||
req=urllib.request.Request(HOST+BASE+relative,
|
||||
data=b'<d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/><d:getcontentlength/></d:prop></d:propfind>',
|
||||
method='PROPFIND',headers={'Authorization':'Basic '+auth,'Depth':'1','Content-Type':'application/xml'})
|
||||
opener=urllib.request.build_opener(urllib.request.ProxyHandler({}),NoRedirect())
|
||||
with opener.open(req,timeout=30) as response:
|
||||
if response.status!=207 or response.url!=HOST+BASE+relative:raise Refused()
|
||||
raw=response.read(LIMIT+1)
|
||||
return parse_listing(raw,relative)
|
||||
|
||||
|
||||
def caller_credentials():
|
||||
# Lookup payload contains a token: retain only safe caller/role facts.
|
||||
identity=data(bao(['token','lookup','-format=json']))['data']
|
||||
if 'platform-admin' not in identity.get('policies',[]) or 'root' in identity.get('policies',[]):raise Refused()
|
||||
if not str(identity.get('path','')).startswith('auth/netkingdom/'):raise Refused()
|
||||
credentials=data(bao(['read','-format=json',OPERATOR_PATH]))['data']['data']
|
||||
if credentials.get('BACKUP_USERNAME')!='Backup' or not isinstance(credentials.get('BACKUP_PASSWORD'),str) or not credentials['BACKUP_PASSWORD']:raise Refused()
|
||||
return identity,credentials
|
||||
|
||||
|
||||
def inventory():
|
||||
identity,credentials=caller_credentials()
|
||||
root=listing('',credentials)
|
||||
folders={}
|
||||
for row in root:
|
||||
if row['directory'] and re.match(r'^(?:r01-|core-|identity|sso|bootstrap|net-kingdom|openbao)',row['name']):
|
||||
if len(folders)>=32:raise Refused()
|
||||
folders[row['name']]=listing(row['name']+'/',credentials)
|
||||
return {'root':root,'folders':folders,'caller_auth_path':identity['path'],
|
||||
'caller_entity_id':identity.get('entity_id'),'scope':'Backup/railiance-backups root and bounded identity/database subfolders; other accounts/folders unassessed'}
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--receipt',required=True)
|
||||
a=p.parse_args();fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
r={'schema':'netkingdom.storage-share-inventory.v1','status':'failed','started_at':datetime.now(timezone.utc).isoformat(),
|
||||
'read_only':True,'age_keys_read':False,'backup_contents_downloaded':False,'secret_values_exported':False,
|
||||
'source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
|
||||
'owner_sources':[{'path':str(OWNER/name),'sha256':hashlib.sha256((OWNER/name).read_bytes()).hexdigest()} for name in ['state_hub_preflight_lane.py','migrate_nextcloud_backup_account.py']]}
|
||||
try:r.update(inventory());r['status']='metadata_inventory_complete'
|
||||
except Exception:r['error']='inventory_failed_diagnostics_suppressed'
|
||||
finally:
|
||||
r['finished_at']=datetime.now(timezone.utc).isoformat()
|
||||
with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n')
|
||||
return int(r['status']=='failed')
|
||||
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,512 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Owner-run T18 session assembly using the existing archive/containment pipeline.
|
||||
|
||||
Candidate only: native pinned-image tests and review are required before use.
|
||||
Shared Docker local-driver tmpfs is required because Docker cp does not support
|
||||
private tmpfs/mount paths, and the original KeyCape image is distroless.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import re
|
||||
import stat
|
||||
import select
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
|
||||
HERE=Path(__file__).resolve().parent
|
||||
|
||||
def load(name):
|
||||
spec=importlib.util.spec_from_file_location(name,HERE/(name+'.py'))
|
||||
module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module);return module
|
||||
|
||||
adapter=load('isolated-keycape')
|
||||
local=load('isolated-lldap')
|
||||
bundle=local.bundle
|
||||
# Bind the reused preflight itself, not only this module's direct Docker calls.
|
||||
local.command=adapter.command
|
||||
_original_preflight=local.preflight
|
||||
def preflight():
|
||||
contexts=json.loads(adapter.command(['docker','context','inspect']))
|
||||
if len(contexts)!=1 or contexts[0]['Endpoints']['docker']['Host']!=adapter.ENDPOINT:raise adapter.Refused()
|
||||
result=_original_preflight();result['docker_endpoint']=adapter.ENDPOINT;return result
|
||||
local.preflight=preflight
|
||||
WORKER=local.CLIENT
|
||||
STAGING_ROOT=Path('/dev/shm')
|
||||
RUNROOTS=('/etc/keycape','/var/lib/keycape-policy','/etc/keycape-factor','/recovery-tls')
|
||||
VOLUME_OPTIONS={'type':'tmpfs','device':'tmpfs','o':'size=33554432,uid=1000,gid=1000,mode=0700,nosuid,nodev,noexec'}
|
||||
|
||||
IMPORT=r'''
|
||||
import base64,json,os,sys
|
||||
from pathlib import Path
|
||||
os.umask(0o077)
|
||||
p=json.load(sys.stdin)
|
||||
allowed=('/etc/keycape/','/var/lib/keycape-policy/','/etc/keycape-factor/','/recovery-tls/')
|
||||
for name,encoded in p.items():
|
||||
f=Path(name)
|
||||
if not name.startswith(allowed) or '..' in f.parts or any(x.is_symlink() for x in (f,*f.parents)):raise ValueError()
|
||||
f.parent.mkdir(mode=0o700,parents=True,exist_ok=True)
|
||||
fd=os.open(f,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
with os.fdopen(fd,'wb') as out:out.write(base64.b64decode(encoded,validate=True))
|
||||
'''
|
||||
|
||||
# Small transport inside the already pinned helper; it has only loopback routes.
|
||||
# No token handling, auth bypass, dependency imitation, reset or notifier path.
|
||||
TLS=r'''
|
||||
import select,socket,socketserver,ssl,threading
|
||||
context=ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
context.minimum_version=ssl.TLSVersion.TLSv1_2
|
||||
context.load_cert_chain('/recovery-tls/server.pem','/recovery-tls/server-key.pem')
|
||||
class Handler(socketserver.BaseRequestHandler):
|
||||
def handle(self):
|
||||
try:
|
||||
with context.wrap_socket(self.request,server_side=True) as incoming:
|
||||
with socket.create_connection(('127.0.0.1',8080),timeout=10) as outgoing:
|
||||
incoming.settimeout(15);outgoing.settimeout(15)
|
||||
while True:
|
||||
ready,_,_=select.select([incoming,outgoing],[],[],15)
|
||||
if not ready:return
|
||||
for source in ready:
|
||||
data=source.recv(65536)
|
||||
if not data:return
|
||||
(outgoing if source is incoming else incoming).sendall(data)
|
||||
except Exception:pass
|
||||
class Server(socketserver.ThreadingTCPServer):
|
||||
allow_reuse_address=True
|
||||
daemon_threads=True
|
||||
with Server(('127.0.0.1',443),Handler) as server:server.serve_forever()
|
||||
'''
|
||||
|
||||
|
||||
def run(argv,**kwargs):return adapter.command(argv,**kwargs)
|
||||
|
||||
|
||||
def plan_session():
|
||||
run_id=uuid.uuid4().hex
|
||||
stem='nk49-kc-'+run_id
|
||||
worker=stem+'-transport';issuer=stem+'-issuer'
|
||||
volumes={stem+'-v'+str(i):{'target':root,'options':dict(VOLUME_OPTIONS)} for i,root in enumerate(RUNROOTS)}
|
||||
return {'run_id':run_id,'containers':[worker,issuer], 'worker':worker,'issuer':issuer,
|
||||
'images':{worker:WORKER,issuer:adapter.IMAGE},'volumes':volumes,
|
||||
'public_image_probe':stem+'-image-probe','docker_endpoint':adapter.ENDPOINT}
|
||||
|
||||
|
||||
def verify_volume(name,spec,run_id):
|
||||
obj=json.loads(run(['docker','volume','inspect',name]))
|
||||
if (len(obj)!=1 or obj[0]['Name']!=name or obj[0]['Driver']!='local' or obj[0].get('Scope')!='local'
|
||||
or obj[0].get('Options')!=VOLUME_OPTIONS or spec['options']!=VOLUME_OPTIONS
|
||||
or obj[0].get('Labels',{}).get(adapter.LABEL)!=run_id):raise adapter.Refused()
|
||||
|
||||
|
||||
def base_args(session,name,network):
|
||||
return ['docker','run','--detach','--pull=never','--name',name,'--network',network,
|
||||
'--label',adapter.LABEL+'='+session['run_id'],'--read-only','--cap-drop','ALL',
|
||||
'--security-opt','no-new-privileges','--user','1000:1000','--memory','512m','--cpus','1',
|
||||
'--pids-limit','64','--log-driver','none','--restart','no',
|
||||
'--tmpfs','/tmp:rw,nosuid,nodev,noexec,size=16777216,uid=1000,gid=1000,mode=0700']
|
||||
|
||||
|
||||
def mounts(session,readonly=False):
|
||||
args=[]
|
||||
for name,spec in session['volumes'].items():
|
||||
args+=['--mount','type=volume,source='+name+',target='+spec['target']+(',readonly' if readonly and spec['target']!='/var/lib/keycape-policy' else '')]
|
||||
return args
|
||||
|
||||
|
||||
PROVISION_PHASES=(
|
||||
'volume_creation_and_verification','worker_launch','worker_containment',
|
||||
'private_import','issuer_launch','issuer_containment','tls_transport_launch','complete')
|
||||
|
||||
|
||||
def provision(session,payload,tls,issuer,phase=None):
|
||||
"""Inspect containment and driver options before the first private import."""
|
||||
from urllib.parse import urlsplit
|
||||
address=urlsplit(issuer)
|
||||
if address.scheme!='https' or not address.hostname or address.port not in (None,443) or address.path not in ('','/'):
|
||||
raise adapter.Refused()
|
||||
if phase is None:phase=lambda value:None
|
||||
phase('volume_creation_and_verification')
|
||||
for name,spec in session['volumes'].items():
|
||||
run(['docker','volume','create','--driver','local','--label',adapter.LABEL+'='+session['run_id'],
|
||||
'--opt','type=tmpfs','--opt','device=tmpfs','--opt','o='+VOLUME_OPTIONS['o'],name])
|
||||
verify_volume(name,spec,session['run_id'])
|
||||
phase('worker_launch')
|
||||
args=base_args(session,session['worker'],'none')+mounts(session)
|
||||
# This pinned helper declares an image VOLUME here. Mask it explicitly so
|
||||
# Docker cannot allocate an unregistered disk-backed anonymous volume.
|
||||
args+=['--tmpfs','/privacyidea/etc/persistent:rw,nosuid,nodev,noexec,size=16777216,uid=1000,gid=1000,mode=0700']
|
||||
args+=['--sysctl','net.ipv4.ip_unprivileged_port_start=0','--add-host',address.hostname+':127.0.0.1',
|
||||
'--entrypoint','python3',WORKER,'-c','import time;time.sleep(1800)']
|
||||
run(args)
|
||||
# Check helper and every private filesystem before touching the ciphertext's
|
||||
# private contents. Issuer is created only after originals are present.
|
||||
phase('worker_containment')
|
||||
adapter.inspect_session({**session,'containers':[session['worker']],'issuer':session['worker']},worker_only=True)
|
||||
phase('private_import')
|
||||
files={**payload['files'],**{k:base64.b64encode(v).decode() for k,v in tls.items()}}
|
||||
run(['docker','exec','-i',session['worker'],'python3','-c',IMPORT],input=json.dumps(files).encode())
|
||||
anchor=run(['docker','container','inspect',session['worker'],'--format','{{.Id}}']).decode().strip()
|
||||
phase('issuer_launch')
|
||||
args=base_args(session,session['issuer'],'container:'+anchor)+mounts(session,readonly=True)
|
||||
# CLI argv carries environment names only. Original credentials stay in the
|
||||
# owner subprocess environment and Docker's private container configuration.
|
||||
env={'PATH':os.environ.get('PATH',os.defpath),'DOCKER_HOST':'unix:///var/run/docker.sock',**payload['env'],
|
||||
'SSL_CERT_FILE':'/recovery-tls/trust.pem'}
|
||||
for name in (*payload['env'],'SSL_CERT_FILE'):args+=['--env',name]
|
||||
args+=[adapter.IMAGE]
|
||||
run(args,env=env)
|
||||
phase('issuer_containment')
|
||||
adapter.inspect_session(session)
|
||||
phase('tls_transport_launch')
|
||||
run(['docker','exec','--detach',session['worker'],'python3','-c',TLS])
|
||||
phase('complete')
|
||||
|
||||
|
||||
def cleanup_all(session,receipt):
|
||||
failed=False
|
||||
try:adapter.cleanup({**session,'containers':session['containers']+[session['public_image_probe']]},receipt)
|
||||
except Exception:failed=True
|
||||
rows=[]
|
||||
for name,spec in session['volumes'].items():
|
||||
row={'volume':name,'status':'unverified'};rows.append(row)
|
||||
try:
|
||||
exists=run(['docker','volume','ls','--filter','name=^'+name+'$','--format','{{.Name}}']).decode().splitlines()
|
||||
if not exists:
|
||||
row['status']='absence_verified'
|
||||
continue
|
||||
verify_volume(name,spec,session['run_id'])
|
||||
run(['docker','volume','rm',name])
|
||||
remaining=run(['docker','volume','ls','--filter','name=^'+name+'$','--format','{{.Name}}']).decode().splitlines()
|
||||
if remaining:row['status']='still_present';failed=True
|
||||
else:row['status']='absence_verified'
|
||||
except Exception:failed=True
|
||||
receipt['tmpfs_volume_cleanup_obligations']=rows
|
||||
receipt['container_cleanup_verified']=not failed
|
||||
if failed:raise adapter.Refused()
|
||||
|
||||
def register_workdir(work, run_id):
|
||||
if work.parent!=STAGING_ROOT or not re.fullmatch('nk49-keycape-'+run_id+'-[a-z0-9_]{8}',work.name):raise adapter.Refused()
|
||||
if any(p.is_symlink() for p in (work,*work.parents)):raise adapter.Refused()
|
||||
info=work.lstat()
|
||||
if not stat.S_ISDIR(info.st_mode) or info.st_uid!=os.getuid() or stat.S_IMODE(info.st_mode)!=0o700:raise adapter.Refused()
|
||||
return {'path':str(work),'run_id':run_id,'device':info.st_dev,'inode':info.st_ino,'owner_uid':info.st_uid}
|
||||
|
||||
|
||||
def cleanup_workdir(staging, receipt):
|
||||
"""Delete only the registered inode below actual /dev/shm, never a replacement."""
|
||||
row={'path':staging['path'],'device':staging['device'],'inode':staging['inode'],'status':'unverified'}
|
||||
receipt['plaintext_cleanup_obligation']=row
|
||||
work=Path(staging['path'])
|
||||
if work.parent!=STAGING_ROOT or not re.fullmatch('nk49-keycape-'+staging['run_id']+'-[a-z0-9_]{8}',work.name):raise adapter.Refused()
|
||||
if not any(line.split()[1:3]==['/dev/shm','tmpfs'] for line in Path('/proc/mounts').read_text().splitlines()):raise adapter.Refused()
|
||||
if any(p.is_symlink() for p in work.parents):raise adapter.Refused()
|
||||
parentfd=os.open(work.parent,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW)
|
||||
try:
|
||||
try:info=os.stat(work.name,dir_fd=parentfd,follow_symlinks=False)
|
||||
except FileNotFoundError:
|
||||
row['status']='absence_verified';receipt['temporary_plaintext_cleanup_verified']=True;return
|
||||
if (not stat.S_ISDIR(info.st_mode) or info.st_uid!=staging['owner_uid'] or info.st_uid!=os.getuid()
|
||||
or info.st_dev!=staging['device'] or info.st_ino!=staging['inode'] or stat.S_IMODE(info.st_mode)!=0o700):
|
||||
row['status']='identity_mismatch_not_removed';raise adapter.Refused()
|
||||
def remove(parent,name,expected):
|
||||
fd=os.open(name,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW,dir_fd=parent)
|
||||
try:
|
||||
current=os.fstat(fd)
|
||||
if (current.st_dev,current.st_ino)!=(expected.st_dev,expected.st_ino):raise adapter.Refused()
|
||||
for child in os.listdir(fd):
|
||||
before=os.stat(child,dir_fd=fd,follow_symlinks=False)
|
||||
if before.st_uid!=staging['owner_uid'] or before.st_dev!=staging['device']:raise adapter.Refused()
|
||||
if stat.S_ISDIR(before.st_mode):remove(fd,child,before)
|
||||
elif stat.S_ISREG(before.st_mode) and before.st_nlink==1:os.unlink(child,dir_fd=fd)
|
||||
else:raise adapter.Refused() # symlinks/special files/hardlinks are uncertain, never followed
|
||||
finally:os.close(fd)
|
||||
after=os.stat(name,dir_fd=parent,follow_symlinks=False)
|
||||
if (after.st_dev,after.st_ino)!=(expected.st_dev,expected.st_ino):raise adapter.Refused()
|
||||
os.rmdir(name,dir_fd=parent)
|
||||
remove(parentfd,work.name,info)
|
||||
try:os.stat(work.name,dir_fd=parentfd,follow_symlinks=False)
|
||||
except FileNotFoundError:
|
||||
row['status']='absence_verified';receipt['temporary_plaintext_cleanup_verified']=True;return
|
||||
raise adapter.Refused()
|
||||
finally:os.close(parentfd)
|
||||
|
||||
|
||||
def recover_obligations(session, staging, receipt):
|
||||
failed=False
|
||||
try:cleanup_all(session,receipt)
|
||||
except Exception:failed=True
|
||||
# The plaintext obligation is attempted even when Docker is unreachable.
|
||||
try:cleanup_workdir(staging,receipt)
|
||||
except Exception:failed=True
|
||||
if failed:raise adapter.Refused()
|
||||
|
||||
|
||||
def process_facts(pid):
|
||||
raw=Path('/proc')/str(pid)/'stat'
|
||||
try:
|
||||
fields=raw.read_text().rsplit(')',1)[1].split()
|
||||
return {'pid':pid,'state':fields[0],'pgid':int(fields[2]),'sid':int(fields[3]),
|
||||
'start_ticks':int(fields[19]),'uid':raw.parent.stat().st_uid}
|
||||
except FileNotFoundError:return None
|
||||
|
||||
|
||||
def isolate_execution():
|
||||
# Called by main before reading TLS/key inputs. Never reuse an inherited
|
||||
# shell, custody-helper, user or unrelated process group.
|
||||
os.setsid()
|
||||
pid=os.getpid();facts=process_facts(pid)
|
||||
if os.getsid(0)!=pid or os.getpgrp()!=pid or facts['sid']!=pid or facts['pgid']!=pid:raise adapter.Refused()
|
||||
return {k:facts[k] for k in ('pid','pgid','sid','start_ticks','uid')}|{'deadline':time.monotonic()+1800}
|
||||
|
||||
|
||||
def verify_execution(execution):
|
||||
if execution['pid']!=execution['sid'] or execution['pid']!=execution['pgid'] or execution['uid']!=os.getuid():raise adapter.Refused()
|
||||
current=process_facts(execution['pid'])
|
||||
if current is not None and any(current[k]!=execution[k] for k in ('pid','pgid','sid','start_ticks','uid')):
|
||||
# Numeric leader PID reuse is not authority to signal a new group.
|
||||
raise adapter.Refused()
|
||||
|
||||
|
||||
def group_members(execution):
|
||||
verify_execution(execution)
|
||||
members=[]
|
||||
for path in Path('/proc').iterdir():
|
||||
if not path.name.isdecimal():continue
|
||||
facts=process_facts(int(path.name))
|
||||
if facts is None or facts['pgid']!=execution['pgid']:continue
|
||||
if facts['sid']!=execution['sid'] or facts['uid']!=execution['uid'] or facts['start_ticks']<execution['start_ticks']:raise adapter.Refused()
|
||||
if facts['state'] not in ('Z','X'):members.append(facts)
|
||||
return members
|
||||
|
||||
|
||||
def pidfd_dead(pidfd):
|
||||
poller=select.poll();poller.register(pidfd,select.POLLIN)
|
||||
return bool(poller.poll(0))
|
||||
|
||||
|
||||
def quiesce_execution(execution,parent_pidfd,receipt):
|
||||
"""Stop only this fresh execution session; cleanup needs no live creator.
|
||||
|
||||
Exact parent pidfd prevents leader reuse. Each remaining session member gets
|
||||
its own pidfd after metadata comparison; no inherited/user-group kill occurs.
|
||||
Unknown membership or uninterruptible survivors refuse cleanup altogether.
|
||||
"""
|
||||
verify_execution(execution)
|
||||
if not pidfd_dead(parent_pidfd):
|
||||
signal.pidfd_send_signal(parent_pidfd,signal.SIGTERM)
|
||||
poller=select.poll();poller.register(parent_pidfd,select.POLLIN);poller.poll(2000)
|
||||
if not pidfd_dead(parent_pidfd):signal.pidfd_send_signal(parent_pidfd,signal.SIGKILL)
|
||||
for attempt in range(50):
|
||||
members=group_members(execution)
|
||||
if pidfd_dead(parent_pidfd) and not members:
|
||||
receipt['execution_quiescence_verified']=True;return
|
||||
for original in members:
|
||||
try:fd=os.pidfd_open(original['pid'])
|
||||
except ProcessLookupError:continue
|
||||
try:
|
||||
current=process_facts(original['pid'])
|
||||
if current is None or pidfd_dead(fd):continue
|
||||
if current!=original: # state may naturally change; bind stable identity below
|
||||
if any(current[k]!=original[k] for k in ('pid','pgid','sid','start_ticks','uid')):raise adapter.Refused()
|
||||
verify_execution(execution)
|
||||
signal.pidfd_send_signal(fd,signal.SIGKILL)
|
||||
except ProcessLookupError:pass
|
||||
finally:os.close(fd)
|
||||
time.sleep(.05)
|
||||
receipt['execution_quiescence_verified']=False
|
||||
raise adapter.Refused()
|
||||
|
||||
|
||||
def daemon_uncertain(staging):
|
||||
return any(Path(staging['path']+suffix).exists() or Path(staging['path']+suffix).is_symlink() for suffix in ('-docker-inflight','-docker-uncertain'))
|
||||
|
||||
|
||||
def reaper_main(session,staging,execution,parent_pidfd):
|
||||
receipt={'status':'failed','execution_quiescence_verified':False}
|
||||
verify_execution(execution)
|
||||
if pidfd_dead(parent_pidfd):raise adapter.Refused()
|
||||
print('registered',flush=True)
|
||||
poller=select.poll();poller.register(parent_pidfd,select.POLLIN)
|
||||
remaining=max(0,int((execution['deadline']-time.monotonic())*1000))
|
||||
parent_lost=bool(poller.poll(remaining))
|
||||
receipt['cleanup_trigger']='parent_exit' if parent_lost else 'execution_deadline'
|
||||
try:
|
||||
# No deletion races a live creator: first stop/wait this exact owner
|
||||
# session and its children. A daemon request already accepted can still
|
||||
# complete after CLI loss; retain that uncertainty even after readback.
|
||||
quiesce_execution(execution,parent_pidfd,receipt)
|
||||
uncertain=daemon_uncertain(staging)
|
||||
receipt['in_flight_daemon_request_uncertain']=uncertain
|
||||
recover_obligations(session,staging,receipt)
|
||||
receipt['status']='bounded_cleanup_uncertain' if uncertain else 'bounded_cleanup_verified'
|
||||
except Exception:receipt['status']='bounded_cleanup_uncertain'
|
||||
finally:
|
||||
os.close(parent_pidfd)
|
||||
try:
|
||||
fd=os.open(staging['path']+'-reaper.json',os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
with os.fdopen(fd,'w') as out:json.dump(receipt,out)
|
||||
except Exception:pass
|
||||
|
||||
|
||||
def start_reaper(session, staging, execution):
|
||||
adapter.docker_environment()
|
||||
verify_execution(execution)
|
||||
if session.get('docker_endpoint')!=adapter.ENDPOINT or staging.get('run_id')!=session['run_id']:raise adapter.Refused()
|
||||
if register_workdir(Path(staging['path']),session['run_id'])!=staging:raise adapter.Refused()
|
||||
parent_pidfd=os.pidfd_open(execution['pid'])
|
||||
code="""import importlib.util,json,sys
|
||||
spec=importlib.util.spec_from_file_location('session',sys.argv[1]);m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m)
|
||||
m.reaper_main(json.loads(sys.argv[2]),json.loads(sys.argv[3]),json.loads(sys.argv[4]),int(sys.argv[5]))
|
||||
"""
|
||||
try:
|
||||
child=subprocess.Popen([sys.executable,'-c',code,str(Path(__file__).resolve()),json.dumps(session),json.dumps(staging),json.dumps(execution),str(parent_pidfd)],
|
||||
stdin=subprocess.DEVNULL,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,
|
||||
env={'PATH':os.environ.get('PATH',os.defpath),'DOCKER_HOST':adapter.ENDPOINT},
|
||||
close_fds=True,pass_fds=(parent_pidfd,),start_new_session=True)
|
||||
finally:os.close(parent_pidfd)
|
||||
ready,_,_=select.select([child.stdout],[],[],10)
|
||||
if not ready or child.stdout.readline()!=b'registered\n':
|
||||
child.terminate();child.wait(timeout=5);raise adapter.Refused()
|
||||
child.stdout.close()
|
||||
return child
|
||||
|
||||
|
||||
def execute(transfer,admission,tls,receipt,execution,checkpoint=None):
|
||||
"""Existing private custody/transfer/archive APIs; no second archive format."""
|
||||
adapter.manifest_binding_mode(admission,transfer)
|
||||
verify_execution(execution)
|
||||
if execution['pid']!=os.getpid():raise adapter.Refused()
|
||||
target=local.preflight()
|
||||
for image in (adapter.IMAGE,):run(['docker','image','inspect',image])
|
||||
local.offline.validate(transfer)
|
||||
if transfer.get('independent_owner_retrieval_verified') is not True:raise adapter.Refused()
|
||||
path=Path(transfer['fetched_path']);bundle.private(path,max_bytes=bundle.LIMIT)
|
||||
if (path.stat().st_size!=transfer['ciphertext_bytes'] or local.offline.offsite.digest(path)!=transfer['ciphertext_sha256']
|
||||
or admission['ciphertext_sha256']!=transfer['ciphertext_sha256']):raise adapter.Refused()
|
||||
if hashlib.sha256(tls['/recovery-tls/trust.pem']).hexdigest()!=admission['trust_sha256']:raise adapter.Refused()
|
||||
if hashlib.sha256(tls['/recovery-tls/server.pem']).hexdigest()!=admission['tls_cert_sha256']:raise adapter.Refused()
|
||||
# Validate exact private certificate/key pair and original issuer name before
|
||||
# opening service containers; do not generate replacement trust or keys.
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
cert=x509.load_pem_x509_certificate(tls['/recovery-tls/server.pem'])
|
||||
key=serialization.load_pem_private_key(tls['/recovery-tls/server-key.pem'],password=None)
|
||||
if cert.public_key().public_numbers()!=key.public_key().public_numbers():raise adapter.Refused()
|
||||
keyfd=local.verify.custody.private_key_fd();work=None;session=None;reaper=None;staging=None
|
||||
receipt.update(status='failed',target=target,session_source_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
|
||||
source_files_sha256={name:hashlib.sha256((HERE/name).read_bytes()).hexdigest() for name in ('isolated-keycape.py','isolated-keycape-session.py','run-isolated-keycape.sh','isolated-lldap.py','identity-bundle.py','verify-identity.py','retrieve-identity-offline.py','identity-offsite.py')},
|
||||
temporary_plaintext_cleanup_verified=False,container_cleanup_verified=False,
|
||||
downstream_consumer_reconnect_proven=False,human_mfa_proven=False)
|
||||
try:
|
||||
if run(['age-keygen','-y',f'/proc/self/fd/{keyfd}'],pass_fds=(keyfd,)).strip()!=bundle.profile()['public_recipient'].encode():raise adapter.Refused()
|
||||
session=plan_session()
|
||||
work=Path(tempfile.mkdtemp(prefix='nk49-keycape-'+session['run_id']+'-',dir='/dev/shm'))
|
||||
staging=register_workdir(work,session['run_id'])
|
||||
reaper=start_reaper(session,staging,execution)
|
||||
adapter.EXECUTION={**execution,'staging':str(work)}
|
||||
receipt.update(execution_binding=execution,bounded_cleanup_reaper_started=True,plaintext_cleanup_obligation={**staging,'status':'registered_before_plaintext'},docker_endpoint=adapter.ENDPOINT,
|
||||
reaper_outcome_path=staging['path']+'-reaper.json',
|
||||
registered_cleanup={'containers':session['containers']+[session['public_image_probe']],'tmpfs_volumes':list(session['volumes'])})
|
||||
if checkpoint is not None:checkpoint(receipt)
|
||||
archive=work/'archive.tar'
|
||||
with archive.open('xb') as output:
|
||||
p=adapter.process(['age','-d','-i',f'/proc/self/fd/{keyfd}',str(path)],stdout=output,stderr=subprocess.DEVNULL,pass_fds=(keyfd,),timeout=180)
|
||||
if p.returncode or archive.stat().st_size>bundle.LIMIT+4*1024*1024:raise adapter.Refused()
|
||||
root=work/'restored';root.mkdir(mode=0o700)
|
||||
manifest,_=bundle.restore(archive,root)
|
||||
# Data binding comes from exact reviewed ciphertext, never a new grant.
|
||||
admission=adapter.private_manifest_admission(admission,manifest)
|
||||
payload=adapter.private_payload(root,manifest,admission['manifest_sha256'])
|
||||
import yaml
|
||||
cfg=yaml.safe_load(base64.b64decode(payload['files']['/etc/keycape/config.yaml']))
|
||||
if cfg.get('port')!=8080 or cfg.get('issuer')!=admission['issuer']:raise adapter.Refused()
|
||||
# Public executable readback uses the daemon archive API only on the
|
||||
# original image rootfs, never a tmpfs or private user-created mount.
|
||||
probe=session['public_image_probe']
|
||||
try:
|
||||
run(['docker','create','--pull=never','--name',probe,'--network','none','--read-only','--label',adapter.LABEL+'='+session['run_id'],adapter.IMAGE])
|
||||
binary=adapter.container_file(probe,'/keycape')
|
||||
if hashlib.sha256(binary).hexdigest()!=admission['verifier_sha256']:raise adapter.Refused()
|
||||
finally:
|
||||
receipt['public_image_probe_cleanup_obligations']=[{'container':probe,'status':'unverified'}]
|
||||
run(['docker','rm',probe])
|
||||
if run(['docker','container','ls','--all','--filter','name=^/'+probe+'$','--format','{{.Names}}']).strip():raise adapter.Refused()
|
||||
receipt['public_image_probe_cleanup_obligations'][0]['status']='absence_verified'
|
||||
def phase(value):
|
||||
# Public enum only: no private payload, exception text or daemon logs.
|
||||
if value not in PROVISION_PHASES:raise adapter.Refused()
|
||||
receipt['provision_phase']=value
|
||||
if checkpoint is not None:checkpoint(receipt)
|
||||
provision(session,payload,tls,admission['issuer'],phase)
|
||||
receipt.update(ciphertext_sha256=transfer['ciphertext_sha256'],all_manifest_hashes_verified=True,manifest_file_count=len(manifest['files']))
|
||||
checked_admission={**admission,'session':session,'trust_path':'/recovery-tls/trust.pem'}
|
||||
# Native checker consumes no service secret argument or new grant.
|
||||
# Retry only bounded public readiness; no account/factor attempt.
|
||||
for attempt in range(40):
|
||||
try:
|
||||
numbers=serialization.load_pem_private_key(base64.b64decode(payload['files']['/etc/keycape/key.pem']),password=None).public_key().public_numbers()
|
||||
adapter.check_jwks(session,checked_admission,numbers);break
|
||||
except Exception:
|
||||
if attempt==39:raise
|
||||
time.sleep(.25)
|
||||
adapter.native_checks(root,manifest,checked_admission,receipt,cleanup_session=False)
|
||||
finally:
|
||||
os.close(keyfd)
|
||||
try:
|
||||
if session is not None and staging is not None:
|
||||
uncertain=daemon_uncertain(staging)
|
||||
receipt['in_flight_daemon_request_uncertain']=uncertain
|
||||
recover_obligations(session,staging,receipt)
|
||||
if uncertain:raise adapter.Refused()
|
||||
if reaper is not None:
|
||||
reaper.terminate();reaper.wait(timeout=10)
|
||||
except Exception:
|
||||
receipt['status']='failed'
|
||||
raise
|
||||
finally:
|
||||
adapter.EXECUTION=None
|
||||
if work is not None and not work.exists():receipt['temporary_plaintext_cleanup_verified']=True
|
||||
|
||||
|
||||
def persist_receipt(fd,receipt):
|
||||
raw=(json.dumps(receipt,indent=2)+'\n').encode()
|
||||
os.lseek(fd,0,os.SEEK_SET);os.ftruncate(fd,0)
|
||||
with os.fdopen(os.dup(fd),'wb') as output:
|
||||
output.write(raw);output.flush();os.fsync(output.fileno())
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
for name in ('transfer-receipt','admission','admission-sha256','tls-cert','tls-key','trust','receipt'):p.add_argument('--'+name,required=True)
|
||||
args=p.parse_args();os.umask(0o077)
|
||||
fd=os.open(args.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
receipt={'schema':'netkingdom.isolated-keycape-recovery.v1','status':'failed','production_contacted':False,'secret_values_exported':False}
|
||||
try:
|
||||
execution=isolate_execution()
|
||||
raw=Path(args.admission).read_bytes()
|
||||
if hashlib.sha256(raw).hexdigest()!=args.admission_sha256:raise adapter.Refused()
|
||||
admission=json.loads(raw)
|
||||
tls={}
|
||||
for name,path in (('server.pem',args.tls_cert),('server-key.pem',args.tls_key),('trust.pem',args.trust)):
|
||||
source=Path(path);bundle.private(source,max_bytes=1024*1024);tls['/recovery-tls/'+name]=source.read_bytes()
|
||||
execute(json.loads(Path(args.transfer_receipt).read_text()),admission,tls,receipt,execution,checkpoint=lambda r:persist_receipt(fd,r))
|
||||
except (Exception,KeyboardInterrupt):receipt.update(status='failed',error='isolated_keycape_failed_private_diagnostics_suppressed')
|
||||
finally:
|
||||
receipt['finished_at']=datetime.now(timezone.utc).isoformat()
|
||||
try:persist_receipt(fd,receipt)
|
||||
finally:os.close(fd)
|
||||
print('OK: isolated KeyCape native checks and cleanup passed.' if receipt['status']=='isolated_keycape_native_registration_checked' else 'ERROR: isolated KeyCape stopped; review safe receipt.')
|
||||
return int(receipt['status']!='isolated_keycape_native_registration_checked')
|
||||
|
||||
if __name__=='__main__':
|
||||
for sig in (signal.SIGHUP,signal.SIGTERM,signal.SIGINT):signal.signal(sig,local.offline.interrupted)
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,478 @@
|
|||
#!/usr/bin/env python3
|
||||
"""T18 native adapter for an owner-admitted, already restored isolated session.
|
||||
|
||||
Called after identity-bundle.restore, never with an unvalidated directory. This
|
||||
module does not provision TLS, grant credentials, or claim downstream acceptance.
|
||||
The existing decrypt/tmpfs wrapper remains responsible for archive cleanup.
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import io
|
||||
import os
|
||||
import secrets
|
||||
import tarfile
|
||||
import time
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
IMAGE = 'forgejo.coulomb.social/coulomb/key-cape@sha256:cdec9dd7c515f7e0a6169fc3fa560ee2cc47bf40d899755dec808c362f5ce010'
|
||||
LABEL = 'net-kingdom.recovery-run'
|
||||
|
||||
|
||||
class Refused(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def load_bundle():
|
||||
spec = importlib.util.spec_from_file_location('identity_bundle', HERE / 'identity-bundle.py')
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def manifest_binding_mode(admission,transfer):
|
||||
"""Versioned private-manifest binding; legacy explicit digests stay valid."""
|
||||
mode=admission.get('manifest_binding_mode','explicit-digest-v1')
|
||||
if mode not in ('explicit-digest-v1','ciphertext-derived-v1'):raise Refused()
|
||||
if (not isinstance(admission.get('ciphertext_sha256'),str)
|
||||
or not re.fullmatch('[a-f0-9]{64}',admission['ciphertext_sha256'])
|
||||
or admission['ciphertext_sha256']!=transfer.get('ciphertext_sha256')
|
||||
or transfer.get('independent_owner_retrieval_verified') is not True):raise Refused()
|
||||
if mode=='explicit-digest-v1':
|
||||
if not isinstance(admission.get('manifest_sha256'),str) or not re.fullmatch('[a-f0-9]{64}',admission['manifest_sha256']):raise Refused()
|
||||
elif 'manifest_sha256' in admission:raise Refused()
|
||||
return mode
|
||||
|
||||
|
||||
def private_manifest_admission(admission,manifest):
|
||||
# Only called after the fixed ciphertext bytes were hashed, authenticated
|
||||
# decryption succeeded and bundle.restore checked the complete archive.
|
||||
# Never return this enriched private object in a safe receipt/checkpoint.
|
||||
digest=hashlib.sha256(json.dumps(manifest,sort_keys=True,separators=(',',':')).encode()).hexdigest()
|
||||
mode=admission.get('manifest_binding_mode','explicit-digest-v1')
|
||||
if mode=='explicit-digest-v1':
|
||||
if digest!=admission['manifest_sha256']:raise Refused()
|
||||
elif mode!='ciphertext-derived-v1' or 'manifest_sha256' in admission:raise Refused()
|
||||
return {**admission,'manifest_sha256':digest}
|
||||
|
||||
|
||||
def private_payload(root, manifest, expected_manifest_sha256):
|
||||
"""Bind the complete validated archive before selecting KeyCape material.
|
||||
|
||||
Private hashes never enter the public receipt. The digest is supplied by the
|
||||
existing private archive admission, not inferred from an untrusted manifest.
|
||||
"""
|
||||
encoded = json.dumps(manifest, sort_keys=True, separators=(',', ':')).encode()
|
||||
if hashlib.sha256(encoded).hexdigest() != expected_manifest_sha256:
|
||||
raise Refused()
|
||||
entries = manifest['files']
|
||||
if len(entries) != len({e['path'] for e in entries}):
|
||||
raise Refused()
|
||||
values = {}
|
||||
bundle = load_bundle()
|
||||
for entry in entries:
|
||||
if not bundle.allowed(entry['path'], manifest['profile']):
|
||||
raise Refused()
|
||||
path = root / entry['path']
|
||||
bundle.private(path)
|
||||
value = path.read_bytes()
|
||||
if len(value) != entry['bytes'] or hashlib.sha256(value).hexdigest() != entry['sha256']:
|
||||
raise Refused()
|
||||
values[entry['path']] = value
|
||||
deployment = json.loads(values['resources/sso/deployment/keycape.json'])
|
||||
main = next(c for c in deployment['spec']['template']['spec']['containers'] if c['name'] == 'keycape')
|
||||
if main.get('command') or main.get('args') or main.get('envFrom'):
|
||||
raise Refused()
|
||||
if main['image'] != IMAGE:
|
||||
# Mutable-tag/historical mismatches need a separate explicit owner admission.
|
||||
raise Refused()
|
||||
secret = json.loads(values['resources/sso/secret/keycape-config.json'])['data']
|
||||
files = {'/etc/keycape/' + name: base64.b64decode(secret[name], validate=True)
|
||||
for name in ('config.yaml', 'key.pem')}
|
||||
for path, value in values.items():
|
||||
if path.startswith('data/keycape/'):
|
||||
relative = path.removeprefix('data/keycape/')
|
||||
files['/var/lib/keycape-policy/' + relative] = value
|
||||
if '/var/lib/keycape-policy/policy.json' not in files:
|
||||
raise Refused()
|
||||
if 'resources/sso/secret/keycape-factor-read.json' in values:
|
||||
factor=json.loads(values['resources/sso/secret/keycape-factor-read.json'])['data']
|
||||
files['/etc/keycape-factor/admin-token']=base64.b64decode(factor['admin-token'],validate=True)
|
||||
# Resolve original delivered environment, including all referenced secrets.
|
||||
env = {}
|
||||
for item in main.get('env', []):
|
||||
if not re.fullmatch(r'KEYCAPE_[A-Z0-9_]+',item['name']):raise Refused()
|
||||
if item['name'] in env:
|
||||
raise Refused()
|
||||
if 'value' in item:
|
||||
env[item['name']] = item['value']
|
||||
else:
|
||||
ref = item['valueFrom']['secretKeyRef']
|
||||
source = json.loads(values['resources/sso/secret/' + ref['name'] + '.json'])['data']
|
||||
env[item['name']] = base64.b64decode(source[ref['key']], validate=True).decode()
|
||||
if env.get('KEYCAPE_CONFIG') != '/etc/keycape/config.yaml' or env.get('KEYCAPE_POLICY_PATH') != '/var/lib/keycape-policy/policy.json':
|
||||
raise Refused()
|
||||
return {'files': {p: base64.b64encode(v).decode() for p, v in files.items()}, 'env': env}
|
||||
|
||||
|
||||
ENDPOINT = 'unix:///var/run/docker.sock'
|
||||
EXECUTION = None
|
||||
|
||||
|
||||
def docker_environment(environment=None):
|
||||
# Even an admitted daemon exposed by another Unix socket is outside this
|
||||
# exact session binding. Conflicting ambient routing is refused, not ignored.
|
||||
for source in (os.environ, environment or {}):
|
||||
if source.get('DOCKER_HOST') not in (None,'',ENDPOINT):raise Refused()
|
||||
if source.get('DOCKER_CONTEXT') not in (None,'','default'):raise Refused()
|
||||
if any(source.get(k) for k in ('DOCKER_TLS_VERIFY','DOCKER_CERT_PATH')):raise Refused()
|
||||
env=dict(os.environ if environment is None else environment)
|
||||
for key in ('DOCKER_CONTEXT','DOCKER_TLS_VERIFY','DOCKER_CERT_PATH'):env.pop(key,None)
|
||||
env['DOCKER_HOST']=ENDPOINT
|
||||
return env
|
||||
|
||||
|
||||
def process(argv, **kwargs):
|
||||
marker=None
|
||||
if EXECUTION is not None and (kwargs.get('start_new_session') or kwargs.get('process_group') not in (None,-1)):raise Refused()
|
||||
if argv[0]=='docker':
|
||||
argv=['docker','--host',ENDPOINT,*argv[1:]]
|
||||
kwargs['env']=docker_environment(kwargs.get('env'))
|
||||
if EXECUTION is not None:
|
||||
if (os.getpid()!=EXECUTION['pid'] or os.getsid(0)!=EXECUTION['sid'] or os.getpgrp()!=EXECUTION['pgid']
|
||||
or time.monotonic()>=EXECUTION['deadline']):raise Refused()
|
||||
marker=Path(EXECUTION['staging']+'-docker-inflight')
|
||||
fd=os.open(marker,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
os.close(fd)
|
||||
try:
|
||||
result=subprocess.run(argv,**kwargs)
|
||||
if marker is not None and result.returncode!=0:
|
||||
fd=os.open(Path(EXECUTION['staging']+'-docker-uncertain'),os.O_WRONLY|os.O_CREAT|os.O_NOFOLLOW,0o600);os.close(fd)
|
||||
return result
|
||||
except BaseException:
|
||||
# Parent loss leaves .docker-inflight; caught timeout/transport failures
|
||||
# retain a sticky uncertainty marker even if later cleanup can run.
|
||||
if marker is not None:
|
||||
fd=os.open(Path(EXECUTION['staging']+'-docker-uncertain'),os.O_WRONLY|os.O_CREAT|os.O_NOFOLLOW,0o600);os.close(fd)
|
||||
raise
|
||||
finally:
|
||||
if marker is not None:
|
||||
try:marker.unlink()
|
||||
except FileNotFoundError:pass
|
||||
|
||||
|
||||
def command(argv, **kwargs):
|
||||
maximum = kwargs.pop('max_output', 32768)
|
||||
result = process(argv, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
||||
timeout=kwargs.pop('timeout', 180), **kwargs)
|
||||
if result.returncode or len(result.stdout) > maximum:
|
||||
raise Refused()
|
||||
return result.stdout
|
||||
|
||||
|
||||
def inspect_session(session, worker_only=False):
|
||||
"""No credential environment is read by Docker inspect or returned to caller."""
|
||||
if not re.fullmatch(r'[a-f0-9]{32}', session['run_id']):
|
||||
raise Refused()
|
||||
names = session['containers']
|
||||
if not names or len(names) > 12 or len(set(names)) != len(names):
|
||||
raise Refused()
|
||||
for name in names:
|
||||
if not re.fullmatch(r'nk49-kc-' + session['run_id'] + r'-[a-z0-9-]{1,24}', name):
|
||||
raise Refused()
|
||||
if session['worker'] not in names or session['issuer'] not in names:
|
||||
raise Refused()
|
||||
import os
|
||||
contexts=json.loads(command(['docker','context','inspect']))
|
||||
endpoints=[contexts[0]['Endpoints']['docker']['Host'],os.environ.get('DOCKER_HOST') or ENDPOINT]
|
||||
if not all(x==ENDPOINT for x in endpoints):raise Refused()
|
||||
info = json.loads(command(['docker', 'info', '--format', '{"ID":{{json .ID}},"Name":{{json .Name}},"OSType":{{json .OSType}}}']))
|
||||
if info != {'ID': '74f93783-6cef-43a8-a3c4-f62586cd3bc1', 'Name': 'bnt-lap001', 'OSType': 'linux'}:
|
||||
raise Refused()
|
||||
# The worker is the anchor; all dependencies/TLS/issuer share its network-none namespace.
|
||||
anchor = command(['docker', 'container', 'inspect', session['worker'], '--format', '{{.Id}}']).decode().strip()
|
||||
for name,spec in session.get('volumes',{}).items():
|
||||
volume=json.loads(command(['docker','volume','inspect',name]))
|
||||
expected={'type':'tmpfs','device':'tmpfs','o':'size=33554432,uid=1000,gid=1000,mode=0700,nosuid,nodev,noexec'}
|
||||
if len(volume)!=1 or volume[0]['Driver']!='local' or volume[0].get('Scope')!='local' or volume[0].get('Options')!=expected or spec['options']!=expected or volume[0].get('Labels',{}).get(LABEL)!=session['run_id']:raise Refused()
|
||||
for name in names:
|
||||
obj = json.loads(command(['docker', 'container', 'inspect', name, '--format',
|
||||
'{"host":{{json .HostConfig}},"mounts":{{json .Mounts}},"labels":{{json .Config.Labels}},"image":{{json .Config.Image}},"user":{{json .Config.User}},"entrypoint":{{json .Config.Entrypoint}},"cmd":{{json .Config.Cmd}}}']))
|
||||
host = obj['host']
|
||||
mounts=obj['mounts']
|
||||
destinations=[m['Destination'] for m in mounts]
|
||||
if len(destinations)!=len(set(destinations)):raise Refused()
|
||||
for volume,expected_spec in session.get('volumes',{}).items():
|
||||
matches=[m for m in mounts if m['Destination']==expected_spec['target']]
|
||||
if len(matches)!=1 or matches[0]['Type']!='volume' or matches[0]['Name']!=volume or matches[0]['RW']!=(name==session['worker'] or expected_spec['target']=='/var/lib/keycape-policy'):raise Refused()
|
||||
if (obj['labels'].get(LABEL) != session['run_id'] or not obj['user'] or obj['user'].split(':')[0] in ('0', 'root')
|
||||
or not host['ReadonlyRootfs'] or host['Privileged'] or host.get('Binds') or host.get('PortBindings')
|
||||
or host.get('PidMode') or host.get('IpcMode') == 'host'
|
||||
or host.get('UTSMode') or host.get('UsernsMode') == 'host' or host.get('CgroupnsMode') == 'host'
|
||||
or host.get('Sysctls',{}) not in ({},{'net.ipv4.ip_unprivileged_port_start':'0'} if name==session['worker'] else {})
|
||||
or host.get('CapAdd') or host.get('Devices') or host.get('DeviceRequests') or host.get('VolumesFrom')
|
||||
or host.get('CapDrop') != ['ALL']
|
||||
or host.get('SecurityOpt') not in (['no-new-privileges'], ['no-new-privileges:true'])
|
||||
or any(m['Type']!='tmpfs' and not (m['Type']=='volume' and m['Name'] in session.get('volumes',{}) and m['Destination']==session['volumes'][m['Name']]['target']) for m in obj['mounts'])
|
||||
or host['NetworkMode'] != ('none' if name == session['worker'] else 'container:' + anchor)
|
||||
or obj['image'] != session['images'][name] or '@sha256:' not in obj['image']):
|
||||
raise Refused()
|
||||
if worker_only:return
|
||||
if session['images'][session['issuer']] != IMAGE:
|
||||
raise Refused()
|
||||
# Known admitted archive has no command/args override; image default /keycape
|
||||
# must be the actual issuer executable, not an alternate same-image command.
|
||||
launch=json.loads(command(['docker','container','inspect',session['issuer'],'--format',
|
||||
'{"entrypoint":{{json .Config.Entrypoint}},"cmd":{{json .Config.Cmd}},"path":{{json .Path}},"args":{{json .Args}}}']))
|
||||
if launch != {'entrypoint':['/keycape'],'cmd':None,'path':'/keycape','args':[]}:
|
||||
raise Refused()
|
||||
|
||||
|
||||
def container_file(container, path):
|
||||
# Docker streams one tar member into memory; never materialize private files.
|
||||
raw = command(['docker', 'cp', container + ':' + path, '-'], max_output=128*1024*1024)
|
||||
with tarfile.open(fileobj=io.BytesIO(raw), mode='r:') as archive:
|
||||
members = archive.getmembers()
|
||||
if len(members) != 1 or not members[0].isfile() or members[0].size > 96*1024*1024:
|
||||
raise Refused()
|
||||
return archive.extractfile(members[0]).read()
|
||||
|
||||
|
||||
JWKS_PROBE = r"""
|
||||
import base64,ipaddress,json,socket,ssl,sys,urllib.request
|
||||
from urllib.parse import urlsplit
|
||||
p=json.load(sys.stdin);issuer=p['issuer'];origin=urlsplit(issuer)
|
||||
def require(condition):
|
||||
if not condition:raise ValueError('public_jwks_binding_refused')
|
||||
require(origin.scheme=='https')
|
||||
resolved=socket.getaddrinfo(origin.hostname,origin.port or 443,type=socket.SOCK_STREAM)
|
||||
require(bool(resolved) and all(ipaddress.ip_address(x[4][0]).is_loopback for x in resolved))
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self,*args,**kwargs):return None
|
||||
opener=urllib.request.build_opener(urllib.request.ProxyHandler({}),NoRedirect(),
|
||||
urllib.request.HTTPSHandler(context=ssl.create_default_context(cadata=p['trust'])))
|
||||
def get(url):
|
||||
u=urlsplit(url)
|
||||
require((u.scheme,u.hostname,u.port or 443)==(origin.scheme,origin.hostname,origin.port or 443))
|
||||
require(not u.username and not u.password and not u.fragment)
|
||||
with opener.open(url,timeout=10) as response:
|
||||
raw=response.read(1024*1024+1);require(len(raw)<=1024*1024)
|
||||
return json.loads(raw)
|
||||
d=get(issuer.rstrip('/')+'/.well-known/openid-configuration');require(d['issuer']==issuer)
|
||||
keys=get(d['jwks_uri'])['keys']
|
||||
def integer(value):return int.from_bytes(base64.urlsafe_b64decode(value+'='*(-len(value)%4)),'big')
|
||||
# The exact admitted owner release constructs one key, kid key-1. Reject every
|
||||
# extra key, including irrelevant keys: a native successful exchange can then
|
||||
# only have used the original signer through the existing verifier.
|
||||
require(isinstance(keys,list) and len(keys)==1)
|
||||
k=keys[0]
|
||||
require(k.get('kty')=='RSA' and k.get('kid')=='key-1' and k.get('use')=='sig' and k.get('alg')=='RS256')
|
||||
require(integer(k['n'])==int(p['n']) and integer(k['e'])==int(p['e']))
|
||||
print('{"original_public_signing_key_in_live_jwks":true}')
|
||||
"""
|
||||
|
||||
# A failed public readiness probe is closed data, not a failing Docker RPC.
|
||||
# Transport/CLI nonzero remains uncertain; no deliberately failing exec is
|
||||
# retried as if daemon acceptance had been established.
|
||||
JWKS_TRANSPORT = ("import contextlib,io\ntry:\n"
|
||||
" with contextlib.redirect_stdout(io.StringIO()):\n"
|
||||
" exec(" + repr(JWKS_PROBE) + ")\n"
|
||||
"except Exception:\n print('{\"original_public_signing_key_in_live_jwks\":false}')\n"
|
||||
"else:\n print('{\"original_public_signing_key_in_live_jwks\":true}')\n")
|
||||
|
||||
|
||||
def check_jwks(session, admission, public_numbers):
|
||||
trust=runtime_file(session,admission['trust_path']).decode()
|
||||
request={'issuer':admission['issuer'],'trust':trust,'n':str(public_numbers.n),'e':str(public_numbers.e)}
|
||||
result=json.loads(command(['docker','exec','-i',session['worker'],'python3','-c',JWKS_TRANSPORT],input=json.dumps(request).encode()))
|
||||
if result != {'original_public_signing_key_in_live_jwks': True}:raise Refused()
|
||||
|
||||
|
||||
RUNTIME_FILES = r"""
|
||||
import base64,json,sys
|
||||
from pathlib import Path
|
||||
p=json.load(sys.stdin)
|
||||
roots=('/etc/keycape/','/var/lib/keycape-policy/','/etc/keycape-factor/','/recovery-tls/')
|
||||
result={}
|
||||
paths=[]
|
||||
if p.get('tree'):
|
||||
paths=list(Path('/var/lib/keycape-policy').rglob('*'))
|
||||
else:paths=[Path(x) for x in p['paths']]
|
||||
for f in paths:
|
||||
if f.is_dir():continue
|
||||
if not str(f).startswith(roots) or not f.is_file() or any(x.is_symlink() for x in (f,*f.parents)) or f.stat().st_size>8*1024*1024:raise ValueError()
|
||||
result[str(f)]=base64.b64encode(f.read_bytes()).decode()
|
||||
if len(result)>4096:raise ValueError()
|
||||
print(json.dumps(result))
|
||||
"""
|
||||
|
||||
|
||||
def runtime_files(session, paths=(), tree=False):
|
||||
raw=command(['docker','exec','-i',session['worker'],'python3','-c',RUNTIME_FILES],
|
||||
input=json.dumps({'paths':list(paths),'tree':tree}).encode(),max_output=48*1024*1024)
|
||||
return {k:base64.b64decode(v,validate=True) for k,v in json.loads(raw).items()}
|
||||
|
||||
|
||||
def runtime_file(session,path):
|
||||
return runtime_files(session,[path])[path]
|
||||
|
||||
|
||||
def run_verifier(session, admission, payload):
|
||||
issuer = session['issuer']
|
||||
if {v['target'] for v in session.get('volumes',{}).values()}!={'/etc/keycape','/var/lib/keycape-policy','/etc/keycape-factor','/recovery-tls'}:raise Refused()
|
||||
from urllib.parse import urlsplit
|
||||
import ipaddress
|
||||
address = urlsplit(admission['issuer'])
|
||||
if (address.scheme != 'https' or not address.hostname or address.username or address.password
|
||||
or address.query or address.fragment):
|
||||
raise Refused()
|
||||
if hashlib.sha256(container_file(issuer, '/keycape')).hexdigest() != admission['verifier_sha256']:
|
||||
raise Refused()
|
||||
if hashlib.sha256(runtime_file(session, admission['trust_path'])).hexdigest() != admission['trust_sha256']:
|
||||
raise Refused()
|
||||
# Capture private environment only in owner process memory, never receipt.
|
||||
environment = json.loads(command(['docker','container','inspect',issuer,'--format','{{json .Config.Env}}']))
|
||||
env = dict(item.split('=',1) for item in environment)
|
||||
if len(env)!=len(environment):raise Refused()
|
||||
if set(env)-set(payload['env'])-{'PATH','HOME','HOSTNAME','SSL_CERT_FILE'}:raise Refused()
|
||||
if env.get('SSL_CERT_FILE') != admission['trust_path'] or any(k.lower().endswith('_proxy') and v for k,v in env.items()):
|
||||
raise Refused()
|
||||
if {k:v for k,v in env.items() if k.startswith('KEYCAPE_')}!={k:v for k,v in payload['env'].items() if k.startswith('KEYCAPE_')}:raise Refused()
|
||||
for name,value in payload['env'].items():
|
||||
if env.get(name) != value:
|
||||
raise Refused()
|
||||
files = {k:base64.b64decode(v,validate=True) for k,v in payload['files'].items()}
|
||||
def equal():
|
||||
if runtime_files(session,files)!=files:raise Refused()
|
||||
durable=runtime_files(session,tree=True)
|
||||
if durable!={k:v for k,v in files.items() if k.startswith('/var/lib/keycape-policy/')}:
|
||||
raise Refused()
|
||||
equal()
|
||||
# Reading YAML is data parsing only; no archived executable is evaluated.
|
||||
import yaml
|
||||
cfg = yaml.safe_load(files['/etc/keycape/config.yaml'])
|
||||
if cfg['issuer'] != admission['issuer']:
|
||||
raise Refused()
|
||||
consumer = admission['consumer']
|
||||
client = next(x for x in cfg['clients'] if x['clientId']==consumer['client_id'])
|
||||
if (client['secretRef']!='env:'+consumer['secret_env'] or client['clientType']!='confidential'
|
||||
or 'client_credentials' not in client['grantTypes'] or not env.get(consumer['secret_env'])):
|
||||
raise Refused()
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
key=serialization.load_pem_private_key(files['/etc/keycape/key.pem'],password=None)
|
||||
check_jwks(session,admission,key.public_key().public_numbers())
|
||||
wrong = secrets.token_urlsafe(48)
|
||||
if wrong == env[consumer['secret_env']]:raise Refused()
|
||||
args = ['docker','exec','--env','NK49_UNRELATED_NEGATIVE_SECRET='+wrong,issuer,'/keycape','verify-client',
|
||||
'-issuer',admission['issuer'],'-previous-secret-env','NK49_UNRELATED_NEGATIVE_SECRET']
|
||||
flags = {'client_id':'client-id','secret_env':'secret-env','audience':'audience','scope':'scope',
|
||||
'subject':'expect-subject','tenant':'expect-tenant','roles':'expect-roles','deny_scope':'deny-scope'}
|
||||
for name,flag in flags.items():args+=['-'+flag,consumer[name]]
|
||||
# Native command reports on stderr. Do not return or print its raw response.
|
||||
result = process(args,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=150)
|
||||
raw = (result.stdout+result.stderr).decode()
|
||||
required = ['discovery and endpoint origin','exchange and JWKS signature for granted scopes','access-token claim bindings',
|
||||
'principal_type is service','sub matches the registration','tenant matches the registration','roles present',
|
||||
'granted scope carries no excess grant','predecessor secret refused']
|
||||
required += ['excess scope refused: '+scope for scope in consumer['deny_scope'].split()]
|
||||
if result.returncode or any('PASS '+name+'\n' not in raw for name in required) or 'FAIL ' in raw:
|
||||
raise Refused()
|
||||
equal()
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
key=serialization.load_pem_private_key(files['/etc/keycape/key.pem'],password=None)
|
||||
check_jwks(session,admission,key.public_key().public_numbers())
|
||||
public=key.public_key().public_bytes(serialization.Encoding.DER,serialization.PublicFormat.SubjectPublicKeyInfo)
|
||||
return {'native_service_registration_verified':True,'wrong_unrelated_secret_explicitly_refused':True,
|
||||
'denied_scopes_refused':True,'original_config_registry_policy_receipts_equal':True,
|
||||
'public_signing_spki_sha256':hashlib.sha256(public).hexdigest(),
|
||||
'original_https_issuer_and_trust_preserved':True,'original_public_signing_key_in_live_jwks':True}
|
||||
|
||||
|
||||
def cleanup(session, receipt=None):
|
||||
"""Attempt every owned cleanup even when one obligation fails.
|
||||
|
||||
Preserve exact safe container names and outcomes so the owner can recover an
|
||||
uncertain/partial cleanup. An inspect/daemon failure never means absence.
|
||||
"""
|
||||
obligations = []
|
||||
failed = False
|
||||
for name in reversed(session['containers']):
|
||||
row = {'container': name, 'status': 'unverified'}
|
||||
obligations.append(row)
|
||||
try:
|
||||
present=command(['docker','container','ls','--all','--filter','name=^/'+name+'$','--format','{{.Names}}']).decode().splitlines()
|
||||
if not present:
|
||||
row['status']='absence_verified'
|
||||
continue
|
||||
label = command(['docker', 'container', 'inspect', name, '--format',
|
||||
'{{index .Config.Labels "' + LABEL + '"}}']).decode().strip()
|
||||
if label != session['run_id']:
|
||||
row['status'] = 'ownership_mismatch_not_removed'
|
||||
failed = True
|
||||
continue
|
||||
# A failed remove is followed by a successful explicit inventory;
|
||||
# interrupted remove may actually have completed.
|
||||
try:
|
||||
command(['docker', 'rm', '--force', '--volumes', name])
|
||||
except Exception:
|
||||
pass
|
||||
remaining = command(['docker','container','ls','--all','--filter',
|
||||
'name=^/' + name + '$','--format','{{.Names}}']).decode().splitlines()
|
||||
if remaining:
|
||||
row['status'] = 'still_present'
|
||||
failed = True
|
||||
else:
|
||||
row['status'] = 'absence_verified'
|
||||
except Exception:
|
||||
failed = True
|
||||
if receipt is not None:
|
||||
receipt['container_cleanup_obligations'] = obligations
|
||||
if failed:
|
||||
raise Refused()
|
||||
|
||||
|
||||
|
||||
def native_checks(root, manifest, admission, receipt, cleanup_session=True):
|
||||
"""Owner wrapper provides a reviewed session after existing restore pipeline.
|
||||
|
||||
Admission must bind ciphertext, canonical private manifest digest, executable
|
||||
and trust bytes, exact image/target list, original issuer and selected consumer.
|
||||
None of these admission fields grant authority or carry a client secret.
|
||||
"""
|
||||
session = admission['session']
|
||||
# Validate ownership/containment before permitting either exec or cleanup.
|
||||
inspect_session(session)
|
||||
receipt.update(status='failed', source_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
|
||||
ciphertext_sha256=admission['ciphertext_sha256'], production_contacted=False,
|
||||
human_mfa_proven=False, downstream_consumer_reconnect_proven=False,
|
||||
container_cleanup_verified=False)
|
||||
try:
|
||||
for field in ('ciphertext_sha256', 'manifest_sha256', 'verifier_sha256', 'trust_sha256'):
|
||||
if not re.fullmatch('[a-f0-9]{64}', admission[field]):
|
||||
raise Refused()
|
||||
for field in ('client_id','secret_env','audience','scope','subject','tenant','roles','deny_scope'):
|
||||
if not isinstance(admission['consumer'].get(field), str) or not admission['consumer'][field].strip():
|
||||
raise Refused()
|
||||
payload = private_payload(root, manifest, admission['manifest_sha256'])
|
||||
result = run_verifier(session, admission, payload)
|
||||
required = {'native_service_registration_verified','wrong_unrelated_secret_explicitly_refused',
|
||||
'denied_scopes_refused','original_config_registry_policy_receipts_equal',
|
||||
'original_https_issuer_and_trust_preserved','original_public_signing_key_in_live_jwks'}
|
||||
if any(result.get(k) is not True for k in required) or not re.fullmatch('[a-f0-9]{64}', result.get('public_signing_spki_sha256','')):
|
||||
raise Refused()
|
||||
# Closed receipt allowlist: never copy arbitrary worker output.
|
||||
receipt.update({k: True for k in required})
|
||||
receipt['public_signing_spki_sha256'] = result['public_signing_spki_sha256']
|
||||
receipt['status'] = 'isolated_keycape_native_registration_checked'
|
||||
finally:
|
||||
try:
|
||||
if cleanup_session:
|
||||
cleanup(session, receipt)
|
||||
receipt['container_cleanup_verified'] = True
|
||||
except Exception:
|
||||
receipt['status'] = 'failed'
|
||||
raise
|
||||
|
|
@ -0,0 +1,343 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Owner-run native LLDAP restore from the verified identity bundle.
|
||||
|
||||
Private inputs remain in the owner process/pipe. Decrypted staging uses Linux
|
||||
tmpfs. Containers have no network, ports, host mounts or production API access.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import signal
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
import tomllib
|
||||
import uuid
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def load(name):
|
||||
spec = importlib.util.spec_from_file_location(name, HERE / (name + '.py'))
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
verify = load('verify-identity')
|
||||
offline = load('retrieve-identity-offline')
|
||||
bundle = verify.bundle
|
||||
IMAGE = 'docker.io/lldap/lldap@sha256:9e605a66c02514bfcffd1b67cafb1e98d50992216bb2871d7ae44622047dd09d'
|
||||
CLIENT = 'ghcr.io/gpappsoft/privacyidea-docker@sha256:af7841adad262f129e0c1d4f553af13f21cb2f4dc713533f316cfe43ed0b4473'
|
||||
ENGINE_ID = '74f93783-6cef-43a8-a3c4-f62586cd3bc1'
|
||||
ENGINE_NAME = 'bnt-lap001'
|
||||
|
||||
|
||||
def command(args, **kwargs):
|
||||
result = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
||||
timeout=kwargs.pop('timeout', 90), **kwargs)
|
||||
if result.returncode:
|
||||
raise bundle.Refused()
|
||||
return result.stdout
|
||||
|
||||
|
||||
def preflight():
|
||||
contexts = json.loads(command(['docker', 'context', 'inspect']))
|
||||
endpoints = [contexts[0]['Endpoints']['docker']['Host']]
|
||||
if os.environ.get('DOCKER_HOST'):
|
||||
endpoints.append(os.environ['DOCKER_HOST'])
|
||||
if not all(url.startswith('unix://') for url in endpoints):
|
||||
raise bundle.Refused()
|
||||
facts = json.loads(command(['docker', 'info', '--format',
|
||||
'{"ID":{{json .ID}},"Name":{{json .Name}},"OSType":{{json .OSType}}}']))
|
||||
if facts['ID'] != ENGINE_ID or facts['Name'] != ENGINE_NAME or facts['OSType'] != 'linux':
|
||||
raise bundle.Refused()
|
||||
# /dev/shm must actually be tmpfs, not a similarly named disk directory.
|
||||
mounts = Path('/proc/mounts').read_text().splitlines()
|
||||
if not any(line.split()[1:3] == ['/dev/shm', 'tmpfs'] for line in mounts):
|
||||
raise bundle.Refused()
|
||||
images = []
|
||||
for image in (IMAGE, CLIENT):
|
||||
image_id = command(['docker', 'image', 'inspect', '--format', '{{.Id}}', image]).decode().strip()
|
||||
images.append({'reference': image, 'image_id': image_id})
|
||||
return {'engine_id': facts['ID'], 'engine_name': facts['Name'], 'images': images,
|
||||
'production_contacted': False, 'network_mode': 'none', 'host_ports_published': False}
|
||||
|
||||
|
||||
def snapshot(path):
|
||||
# Include all fields of durable identity tables, including stored password
|
||||
# hashes. Only counts and the equality boolean leave this process.
|
||||
result = {}
|
||||
with sqlite3.connect(path.as_uri() + '?mode=ro', uri=True) as db:
|
||||
if db.execute('PRAGMA integrity_check').fetchall() != [('ok',)]:
|
||||
raise bundle.Refused()
|
||||
tables = {row[0] for row in db.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||
for name in ('users', 'groups', 'memberships', 'user_attributes', 'group_attributes',
|
||||
'user_attribute_values', 'group_attribute_values',
|
||||
'user_attribute_schema', 'group_attribute_schema'):
|
||||
if name in tables:
|
||||
rows = db.execute('SELECT * FROM "' + name + '"').fetchall()
|
||||
encoded = sorted(repr(row) for row in rows)
|
||||
result[name] = {'count': len(rows), 'digest': hashlib.sha256('\n'.join(encoded).encode()).digest()}
|
||||
if not {'users', 'groups', 'memberships'} <= result.keys():
|
||||
raise bundle.Refused()
|
||||
return result
|
||||
|
||||
|
||||
def configuration(root):
|
||||
data = root / 'data/lldap'
|
||||
existing = data / 'lldap_config.toml'
|
||||
config = tomllib.loads(existing.read_text()) if existing.is_file() else {}
|
||||
deployment = bundle.resource(root, 'sso', 'deployment', 'lldap')
|
||||
main = next(c for c in deployment['spec']['template']['spec']['containers'] if c['name'] == 'lldap')
|
||||
env = {e['name']: e['value'] for e in main.get('env', []) if 'value' in e}
|
||||
secret = bundle.resource(root, 'sso', 'secret', 'lldap-secrets')['data']
|
||||
jwt = base64.b64decode(secret['LLDAP_JWT_SECRET'], validate=True)
|
||||
password = base64.b64decode(secret['LLDAP_LDAP_USER_PASS'], validate=True)
|
||||
if not jwt or not password or b'\n' in jwt or b'\n' in password:
|
||||
raise bundle.Refused()
|
||||
base = env.get('LLDAP_LDAP_BASE_DN', config.get('ldap_base_dn'))
|
||||
user = env.get('LLDAP_LDAP_USER_DN', config.get('ldap_user_dn', 'admin'))
|
||||
if not isinstance(base, str) or not base or not isinstance(user, str):
|
||||
raise bundle.Refused()
|
||||
chosen = {'database_url': 'sqlite:///data/users.db?mode=rwc',
|
||||
'ldap_base_dn': base, 'ldap_user_dn': user,
|
||||
'ldap_host': '127.0.0.1', 'http_host': '127.0.0.1',
|
||||
'ldap_port': 3890, 'http_port': 17170,
|
||||
'force_ldap_user_pass_reset': False, 'force_update_private_key': False}
|
||||
seed = env.get('LLDAP_KEY_SEED', config.get('key_seed'))
|
||||
if seed:
|
||||
if not isinstance(seed, str):
|
||||
raise bundle.Refused()
|
||||
chosen['key_seed'] = seed
|
||||
else:
|
||||
value = env.get('LLDAP_KEY_FILE', config.get('key_file', '/data/private_key'))
|
||||
key = PurePosixPath(value)
|
||||
if not key.is_absolute():
|
||||
key = PurePosixPath('/data') / key
|
||||
if key.parent != PurePosixPath('/data') or not (data / key.name).is_file():
|
||||
raise bundle.Refused()
|
||||
chosen['key_file'] = str(key)
|
||||
# JSON strings are valid TOML basic strings for these service inputs.
|
||||
raw = '\n'.join(k + ' = ' + json.dumps(v) for k, v in chosen.items())
|
||||
raw += '\n[smtp_options]\nenable_password_reset = false\n[ldaps_options]\nenabled = false\n'
|
||||
return raw.encode(), jwt, password, base
|
||||
|
||||
|
||||
def archive_files(files):
|
||||
out = io.BytesIO()
|
||||
with tarfile.open(fileobj=out, mode='w') as archive:
|
||||
for name, value in files.items():
|
||||
if PurePosixPath(name).is_absolute() or '..' in PurePosixPath(name).parts:
|
||||
raise bundle.Refused()
|
||||
entry = tarfile.TarInfo(name)
|
||||
entry.size = len(value)
|
||||
entry.mode = 0o600
|
||||
entry.uid = entry.gid = 1000
|
||||
archive.addfile(entry, io.BytesIO(value))
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
PROBE = r'''
|
||||
import contextlib,io,json,logging,socket,sys,time
|
||||
logging.disable(logging.CRITICAL)
|
||||
result={'status':'failed'}
|
||||
stage='network_isolation'
|
||||
try:
|
||||
with contextlib.redirect_stdout(io.StringIO()),contextlib.redirect_stderr(io.StringIO()):
|
||||
from pathlib import Path
|
||||
assert {p.name for p in Path('/sys/class/net').iterdir()}=={'lo'}
|
||||
assert len(Path('/proc/net/route').read_text().splitlines())==1
|
||||
stage='native_client_import'
|
||||
from ldap3 import Server,Connection,NONE
|
||||
from ldap3.utils.dn import escape_rdn
|
||||
values=json.load(sys.stdin)
|
||||
stage='directory_startup'
|
||||
for _ in range(60):
|
||||
try:
|
||||
with socket.create_connection(('127.0.0.1',3890),timeout=1):pass
|
||||
break
|
||||
except OSError:time.sleep(.25)
|
||||
else:raise RuntimeError()
|
||||
dn='uid='+escape_rdn(values['user'])+',ou=people,'+values['base']
|
||||
server=Server('127.0.0.1',port=3890,get_info=NONE,connect_timeout=3)
|
||||
stage='existing_user_bind'
|
||||
connection=Connection(server,user=dn,password=values['password'],auto_bind=True,raise_exceptions=True)
|
||||
assert connection.bound
|
||||
connection.unbind()
|
||||
stage='wrong_password_bind'
|
||||
wrong=Connection(server,user=dn,password=values['wrong_password'],auto_bind=False,raise_exceptions=False)
|
||||
refused=not wrong.bind() and wrong.result.get('result')==49
|
||||
wrong.unbind()
|
||||
assert refused
|
||||
result={'status':'verified','existing_user_native_ldap_bind':True,'wrong_password_refused':True,
|
||||
'only_loopback_interface_verified':True,'external_routes_absent':True}
|
||||
except Exception as error:result={'status':'failed','failed_stage':stage,'failure_type':type(error).__name__}
|
||||
print(json.dumps(result))
|
||||
'''
|
||||
|
||||
|
||||
def restore_lldap(root, user, password, receipt):
|
||||
before = snapshot(root / 'data/lldap/users.db')
|
||||
config, jwt, admin_password, base = configuration(root)
|
||||
name = 'nk49-lldap-' + uuid.uuid4().hex
|
||||
client_name = name + '-client'
|
||||
try:
|
||||
command(['docker', 'run', '--detach', '--pull=never', '--name', name,
|
||||
'--network', 'none', '--read-only', '--cap-drop', 'ALL',
|
||||
'--security-opt', 'no-new-privileges', '--user', '1000:1000',
|
||||
'--memory', '256m', '--cpus', '1', '--pids-limit', '128',
|
||||
'--tmpfs', '/data:rw,nosuid,nodev,noexec,size=268435456,uid=1000,gid=1000,mode=0700',
|
||||
'--tmpfs', '/run/recovery:rw,nosuid,nodev,noexec,size=1048576,uid=1000,gid=1000,mode=0700',
|
||||
'--tmpfs', '/tmp:rw,nosuid,nodev,noexec,size=16777216,uid=1000,gid=1000',
|
||||
'--env', 'LLDAP_JWT_SECRET_FILE=/run/recovery/jwt',
|
||||
'--env', 'LLDAP_LDAP_USER_PASS_FILE=/run/recovery/admin-password',
|
||||
'--entrypoint', '/bin/sh', IMAGE, '-c', 'sleep 1800'])
|
||||
guard = json.loads(command(['docker', 'container', 'inspect', name, '--format',
|
||||
'{"host":{{json .HostConfig}},"mounts":{{json .Mounts}},"user":{{json .Config.User}}}']))
|
||||
host = guard['host']
|
||||
if (host['NetworkMode'] != 'none' or not host['ReadonlyRootfs'] or host['Privileged']
|
||||
or host.get('Binds') or host.get('PortBindings') or 'ALL' not in host.get('CapDrop', [])
|
||||
or 'no-new-privileges' not in host.get('SecurityOpt', [])
|
||||
or guard['user'] != '1000:1000'
|
||||
or any(mount['Type'] != 'tmpfs' for mount in guard['mounts'])):
|
||||
raise bundle.Refused()
|
||||
receipt['container_isolation_verified_before_private_import'] = True
|
||||
payload = {p.relative_to(root / 'data/lldap').as_posix(): p.read_bytes()
|
||||
for p in (root / 'data/lldap').rglob('*') if p.is_file()}
|
||||
command(['docker', 'exec', '-i', name, 'tar', '-xf', '-', '-C', '/data'], input=archive_files(payload))
|
||||
command(['docker', 'exec', '-i', name, 'tar', '-xf', '-', '-C', '/run/recovery'],
|
||||
input=archive_files({'lldap.toml': config, 'jwt': jwt, 'admin-password': admin_password}))
|
||||
# No credential values in Docker configuration, environment or argv.
|
||||
command(['docker', 'exec', '--detach', name, '/bin/sh', '-c',
|
||||
'echo $$ > /run/recovery/server.pid; exec /app/lldap run --config-file /run/recovery/lldap.toml > /dev/null 2>&1'])
|
||||
raw = command(['docker', 'run', '--interactive', '--rm', '--name', client_name, '--pull=never', '--network', 'container:' + name,
|
||||
'--read-only', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges',
|
||||
'--memory', '256m', '--cpus', '1', '--pids-limit', '64',
|
||||
'--entrypoint', 'python3', CLIENT, '-c', PROBE],
|
||||
input=json.dumps({'user': user, 'password': password, 'base': base,
|
||||
'wrong_password': 'nk49-wrong-' + uuid.uuid4().hex}).encode())
|
||||
result = json.loads(raw)
|
||||
receipt.update(result)
|
||||
if result.get('status') != 'verified':
|
||||
raise bundle.Refused()
|
||||
command(['docker', 'exec', name, '/bin/sh', '-c', 'kill -TERM "$(cat /run/recovery/server.pid)"'])
|
||||
for _ in range(60):
|
||||
running = subprocess.run(['docker', 'exec', name, '/bin/sh', '-c',
|
||||
'kill -0 "$(cat /run/recovery/server.pid)"'],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10)
|
||||
if running.returncode:
|
||||
break
|
||||
time.sleep(.1)
|
||||
else:
|
||||
raise bundle.Refused()
|
||||
raw = command(['docker', 'exec', name, '/bin/sh', '-c',
|
||||
'test ! -s /data/users.db-wal && cat /data/users.db'])
|
||||
after_path = root / 'post-restore-users.db'
|
||||
after_path.write_bytes(raw)
|
||||
if snapshot(after_path) != before:
|
||||
raise bundle.Refused()
|
||||
receipt.update(directory_identity_rows_preserved=True,
|
||||
directory_comparison_scope=sorted(before),
|
||||
directory_table_counts={k: v['count'] for k, v in before.items()})
|
||||
finally:
|
||||
# A failed create can still have created a container (e.g. lost reply).
|
||||
for owned in (client_name, name):
|
||||
found = subprocess.run(['docker', 'container', 'inspect', owned], stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, timeout=15)
|
||||
if found.returncode == 0:
|
||||
command(['docker', 'rm', '--force', '--volumes', owned])
|
||||
absent = subprocess.run(['docker', 'container', 'inspect', owned], stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, timeout=15)
|
||||
command(['docker', 'info', '--format', '{{.ID}}'])
|
||||
if absent.returncode == 0:
|
||||
raise bundle.Refused()
|
||||
receipt['container_cleanup_verified'] = True
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--transfer-receipt', required=True)
|
||||
parser.add_argument('--receipt', required=True)
|
||||
parser.add_argument('--user', required=True)
|
||||
args = parser.parse_args()
|
||||
os.umask(0o077)
|
||||
fd = os.open(args.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
result = {'schema': 'netkingdom.isolated-lldap-recovery.v1', 'status': 'failed',
|
||||
'production_contacted': False, 'secret_values_exported': False,
|
||||
'container_cleanup_verified': False, 'temporary_plaintext_cleanup_verified': False,
|
||||
'source_sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest()}
|
||||
keyfd = None
|
||||
work = None
|
||||
stage = 'target_preflight'
|
||||
try:
|
||||
result['target'] = preflight()
|
||||
stage = 'input_validation'
|
||||
if not re.fullmatch(r'[A-Za-z0-9_.-]{1,128}', args.user):
|
||||
raise bundle.Refused()
|
||||
transfer = json.loads(Path(args.transfer_receipt).read_text())
|
||||
offline.validate(transfer)
|
||||
if transfer.get('independent_owner_retrieval_verified') is not True:
|
||||
raise bundle.Refused()
|
||||
path = Path(transfer['fetched_path'])
|
||||
bundle.private(path, max_bytes=bundle.LIMIT)
|
||||
if path.stat().st_size != transfer['ciphertext_bytes'] or offline.offsite.digest(path) != transfer['ciphertext_sha256']:
|
||||
raise bundle.Refused()
|
||||
keyfd = verify.custody.private_key_fd()
|
||||
derived = command(['age-keygen', '-y', f'/proc/self/fd/{keyfd}'], pass_fds=(keyfd,)).strip()
|
||||
if derived != bundle.profile()['public_recipient'].encode():
|
||||
raise bundle.Refused()
|
||||
stage = 'private_existing_password_prompt'
|
||||
password = offline.private_prompt('Existing account password for the isolated LLDAP check: ')
|
||||
with tempfile.TemporaryDirectory(prefix='nk49-service-recovery-', dir='/dev/shm') as temporary:
|
||||
work = Path(temporary)
|
||||
archive = work / 'archive.tar'
|
||||
stage = 'archive_decryption'
|
||||
with archive.open('xb') as output:
|
||||
proc = subprocess.run(['age', '-d', '-i', f'/proc/self/fd/{keyfd}', str(path)],
|
||||
stdout=output, stderr=subprocess.DEVNULL, pass_fds=(keyfd,), timeout=180)
|
||||
if proc.returncode or archive.stat().st_size > bundle.LIMIT + 4 * 1024 * 1024:
|
||||
raise bundle.Refused()
|
||||
restored = work / 'restored'
|
||||
restored.mkdir(mode=0o700)
|
||||
stage = 'archive_validation'
|
||||
manifest, _ = bundle.restore(archive, restored)
|
||||
result.update(ciphertext_sha256=transfer['ciphertext_sha256'],
|
||||
manifest_file_count=len(manifest['files']), all_manifest_hashes_verified=True)
|
||||
stage = 'native_directory_check'
|
||||
restore_lldap(restored, args.user, password, result)
|
||||
password = None
|
||||
if work.exists():
|
||||
raise bundle.Refused()
|
||||
result.update(status='isolated_lldap_verified', temporary_plaintext_cleanup_verified=True)
|
||||
except (Exception, KeyboardInterrupt):
|
||||
result.update(status='failed', error='isolated_lldap_check_failed_private_diagnostics_suppressed')
|
||||
result.setdefault('failed_stage', stage)
|
||||
finally:
|
||||
if keyfd is not None:
|
||||
os.close(keyfd)
|
||||
if work is not None and not work.exists():
|
||||
result['temporary_plaintext_cleanup_verified'] = True
|
||||
result['finished_at'] = datetime.now(timezone.utc).isoformat()
|
||||
with os.fdopen(fd, 'w') as output:
|
||||
json.dump(result, output, indent=2)
|
||||
output.write('\n')
|
||||
success = result['status'] == 'isolated_lldap_verified'
|
||||
print('OK: restored LLDAP existing-user bind, wrong-password refusal, directory preservation and cleanup passed.'
|
||||
if success else 'ERROR: isolated LLDAP recovery refused; review the safe receipt. No production change.')
|
||||
return 0 if success else 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
|
||||
signal.signal(sig, offline.interrupted)
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,309 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Private native privacyIDEA token-material and audit recovery checks.
|
||||
|
||||
This does not authenticate a user's MFA code. It restores only a disposable
|
||||
database, decrypts token material internally and verifies stored audit rows.
|
||||
"""
|
||||
import argparse
|
||||
import ast
|
||||
import base64
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
|
||||
# Import the already reviewed local target/transport boundary without changing
|
||||
# the accepted LLDAP executor or its source digest.
|
||||
import importlib.util
|
||||
HERE = Path(__file__).resolve().parent
|
||||
spec = importlib.util.spec_from_file_location('isolated_lldap', HERE / 'isolated-lldap.py')
|
||||
local = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(local)
|
||||
bundle = local.bundle
|
||||
IMAGE = local.CLIENT
|
||||
POSTGRES = local.verify.pg.IMAGE
|
||||
|
||||
|
||||
def run(args, **kwargs):
|
||||
return local.command(args, **kwargs)
|
||||
|
||||
|
||||
def preflight():
|
||||
target = local.preflight()
|
||||
target['images'].append({'reference': POSTGRES,
|
||||
'image_id': run(['docker', 'image', 'inspect', '--format', '{{.Id}}', POSTGRES]).decode().strip()})
|
||||
return target
|
||||
|
||||
|
||||
def isolation(name, expected_network, user):
|
||||
info = json.loads(run(['docker', 'container', 'inspect', name, '--format',
|
||||
'{"host":{{json .HostConfig}},"mounts":{{json .Mounts}},"user":{{json .Config.User}}}']))
|
||||
host = info['host']
|
||||
if (host['NetworkMode'] != expected_network or not host['ReadonlyRootfs'] or host['Privileged']
|
||||
or host.get('Binds') or host.get('PortBindings') or 'ALL' not in host.get('CapDrop', [])
|
||||
or 'no-new-privileges' not in host.get('SecurityOpt', []) or info['user'] != user
|
||||
or any(m['Type'] != 'tmpfs' for m in info['mounts'])):
|
||||
raise bundle.Refused()
|
||||
|
||||
|
||||
def cleanup(names):
|
||||
for name in names:
|
||||
present = subprocess.run(['docker', 'container', 'inspect', name],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=15)
|
||||
if present.returncode == 0:
|
||||
run(['docker', 'rm', '--force', '--volumes', name])
|
||||
absent = subprocess.run(['docker', 'container', 'inspect', name],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=15)
|
||||
run(['docker', 'info', '--format', '{{.ID}}'])
|
||||
if absent.returncode == 0:
|
||||
raise bundle.Refused()
|
||||
|
||||
|
||||
def start_database(name):
|
||||
run(['docker', 'run', '--detach', '--pull=never', '--name', name, '--network', 'none',
|
||||
'--read-only', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges',
|
||||
'--user', '999:999', '--memory', '2g', '--cpus', '2', '--pids-limit', '128',
|
||||
'--tmpfs', '/var/lib/postgresql/data:rw,nosuid,nodev,noexec,size=1073741824,uid=999,gid=999,mode=0700',
|
||||
'--tmpfs', '/var/run/postgresql:rw,nosuid,nodev,noexec,size=16777216,uid=999,gid=999',
|
||||
'--tmpfs', '/tmp:rw,nosuid,nodev,noexec,size=16777216,uid=999,gid=999',
|
||||
'--env', 'POSTGRES_HOST_AUTH_METHOD=trust', '--env', 'POSTGRES_DB=recovery',
|
||||
'--entrypoint', '/usr/bin/timeout', POSTGRES, '--kill-after=15s', '1800s',
|
||||
'/usr/local/bin/docker-entrypoint.sh', 'postgres', '-c', 'listen_addresses=127.0.0.1'])
|
||||
isolation(name, 'none', '999:999')
|
||||
for _ in range(80):
|
||||
# The entrypoint's temporary init server has TCP disabled. Require TCP
|
||||
# readiness so we cannot mistake it for the final local-only server.
|
||||
ready = subprocess.run(['docker', 'exec', name, 'pg_isready', '-h', '127.0.0.1', '-U', 'postgres', '-d', 'recovery'],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10)
|
||||
if ready.returncode == 0:
|
||||
return
|
||||
time.sleep(.25)
|
||||
raise bundle.Refused()
|
||||
|
||||
|
||||
def start_worker(name, database, root):
|
||||
secret = bundle.resource(root, 'mfa', 'secret', 'privacyidea-config')['data']
|
||||
config = {'SQLALCHEMY_DATABASE_URI': 'postgresql+psycopg2://postgres@127.0.0.1:5432/recovery',
|
||||
'PI_AUDIT_SQL_URI': 'postgresql+psycopg2://postgres@127.0.0.1:5432/recovery',
|
||||
'SECRET_KEY': base64.b64decode(secret['PI_SECRET_KEY'], validate=True).decode(),
|
||||
'PI_PEPPER': base64.b64decode(secret['PI_PEPPER'], validate=True).decode(),
|
||||
'PI_ENCFILE': '/recovery/enckey', 'PI_AUDIT_KEY_PRIVATE': '/recovery/private.pem',
|
||||
'PI_AUDIT_KEY_PUBLIC': '/recovery/public.pem', 'PI_AUDIT_NO_SIGN': False,
|
||||
'PI_CHECK_OLD_SIGNATURES': legacy_setting(root), 'PI_NO_RESPONSE_SIGN': True,
|
||||
'PI_LOGFILE': '/recovery/runtime.log', 'PI_LOGLEVEL': 50,
|
||||
'PI_UUID_FILE': '/recovery/node-uuid',
|
||||
'PI_TRUSTED_JWT': []}
|
||||
# This deliberately does not execute archived pi.cfg or reuse its live DB URI.
|
||||
payload = {'pi.cfg': '\n'.join(k + '=' + repr(v) for k, v in config.items()).encode()}
|
||||
for key in ('enckey', 'private.pem', 'public.pem'):
|
||||
payload[key] = (root / 'data/privacyidea' / key).read_bytes()
|
||||
run(['docker', 'run', '--detach', '--pull=never', '--name', name,
|
||||
'--network', 'container:' + database, '--read-only', '--cap-drop', 'ALL',
|
||||
'--security-opt', 'no-new-privileges', '--user', '1000:1000',
|
||||
'--memory', '1g', '--cpus', '2', '--pids-limit', '128',
|
||||
'--tmpfs', '/recovery:rw,nosuid,nodev,noexec,size=33554432,uid=1000,gid=1000,mode=0700',
|
||||
'--tmpfs', '/tmp:rw,nosuid,nodev,noexec,size=16777216,uid=1000,gid=1000',
|
||||
'--tmpfs', '/privacyidea/etc/persistent:rw,nosuid,nodev,noexec,size=1048576,uid=1000,gid=1000,mode=0700',
|
||||
'--env', 'PRIVACYIDEA_CONFIGFILE=/recovery/pi.cfg',
|
||||
'--entrypoint', 'python3', IMAGE, '-c', 'import time;time.sleep(1800)'])
|
||||
# Docker normalizes container:<name> to container:<full ID> in HostConfig.
|
||||
database_id = run(['docker', 'container', 'inspect', database, '--format', '{{.Id}}']).decode().strip()
|
||||
isolation(name, 'container:' + database_id, '1000:1000')
|
||||
run(['docker', 'exec', '-i', name, 'tar', '-xf', '-', '-C', '/recovery'],
|
||||
input=local.archive_files(payload))
|
||||
|
||||
|
||||
def legacy_setting(root):
|
||||
path = root / 'resources/mfa/configmap/privacyidea-cfg.json'
|
||||
if not path.exists():
|
||||
return False # Only the lower-level synthetic fixture omits ConfigMaps.
|
||||
raw = bundle.resource(root, 'mfa', 'configmap', 'privacyidea-cfg')['data']['pi.cfg']
|
||||
selected = False
|
||||
for node in ast.parse(raw).body:
|
||||
if isinstance(node, ast.Assign) and any(isinstance(t, ast.Name) and t.id == 'PI_CHECK_OLD_SIGNATURES' for t in node.targets):
|
||||
if not isinstance(node.value, ast.Constant) or type(node.value.value) is not bool:
|
||||
raise bundle.Refused()
|
||||
selected = node.value.value
|
||||
return selected
|
||||
|
||||
|
||||
PROBE = r'''
|
||||
import contextlib,io,json,logging,os,sys
|
||||
logging.disable(logging.CRITICAL)
|
||||
os.umask(0o077)
|
||||
result={'status':'failed'}
|
||||
stage='network_isolation'
|
||||
try:
|
||||
with contextlib.redirect_stdout(io.StringIO()),contextlib.redirect_stderr(io.StringIO()):
|
||||
from pathlib import Path
|
||||
assert {p.name for p in Path('/sys/class/net').iterdir()}=={'lo'}
|
||||
assert len(Path('/proc/net/route').read_text().splitlines())==1
|
||||
stage='native_application_configuration'
|
||||
from privacyidea.app import create_app
|
||||
app=create_app(config_name='testing',config_file='/recovery/pi.cfg',silent=True)
|
||||
from privacyidea.models import db,Token
|
||||
from privacyidea.lib.security.default import DefaultSecurityModule
|
||||
from privacyidea.lib.auditmodules.sqlaudit import Audit,LogEntry
|
||||
from privacyidea.lib.crypto import Sign
|
||||
import binascii
|
||||
with app.app_context():
|
||||
assert str(db.engine.url)=='postgresql+psycopg2://postgres@127.0.0.1:5432/recovery'
|
||||
stage='retained_token_decryption'
|
||||
security=DefaultSecurityModule({'file':'/recovery/enckey'})
|
||||
wrong_path=Path('/recovery/wrong-enckey');wrong_path.write_bytes(os.urandom(96))
|
||||
wrong=DefaultSecurityModule({'file':str(wrong_path)})
|
||||
total=Token.query.count()
|
||||
assert 0<total<=10000
|
||||
checked=0;negative=0
|
||||
for token in Token.query.yield_per(100):
|
||||
if not token.key_enc:continue
|
||||
encrypted=binascii.unhexlify(token.key_enc);iv=binascii.unhexlify(token.key_iv)
|
||||
material=security.decrypt(encrypted,iv)
|
||||
assert isinstance(material,bytes) and 0<len(material)<=4096
|
||||
try:
|
||||
invalid=wrong.decrypt(encrypted,iv)
|
||||
assert invalid!=material
|
||||
except (ValueError,binascii.Error):pass
|
||||
negative+=1;checked+=1
|
||||
del material
|
||||
assert checked>0
|
||||
result.update(retained_tokens_total=total,retained_tokens_with_native_decryption=checked,
|
||||
different_key_cannot_reproduce_material_count=negative,
|
||||
retained_token_material_decrypted_inside_process=True,existing_token_mfa_proven=False)
|
||||
stage='historical_audit_verification'
|
||||
audit=Audit(app.config)
|
||||
allow_legacy=bool(app.config['PI_CHECK_OLD_SIGNATURES'])
|
||||
historical=audit.session.query(LogEntry).count()
|
||||
assert 0<historical<=2000000
|
||||
signed=0;unsigned=0;invalid=0;legacy=0
|
||||
for entry in audit.session.query(LogEntry).yield_per(1000):
|
||||
if not entry.signature:
|
||||
unsigned+=1;continue
|
||||
signed+=1
|
||||
if not str(entry.signature).startswith(Sign.sig_ver+':'):legacy+=1
|
||||
if not audit.sign_object.verify(audit._log_to_string(entry),entry.signature,allow_legacy):invalid+=1
|
||||
result.update(historical_audit_rows=historical,historical_signed_rows=signed,
|
||||
historical_unsigned_rows=unsigned,historical_invalid_signatures=invalid,
|
||||
historical_legacy_signatures=legacy,all_stored_audit_rows_checked=True,
|
||||
legacy_signature_verification_enabled=allow_legacy,
|
||||
event_completeness_claimed=False)
|
||||
stage='new_isolated_audit_entry'
|
||||
audit.log({'action':'NK49 isolated recovery verification','success':True,
|
||||
'info':'disposable restored database only','client':'127.0.0.1'})
|
||||
audit.finalize_log()
|
||||
fresh=Audit(app.config)
|
||||
assert fresh.session.query(LogEntry).count()==historical+1
|
||||
entry=fresh.session.query(LogEntry).order_by(LogEntry.id.desc()).first()
|
||||
message=fresh._log_to_string(entry)
|
||||
assert fresh.sign_object.verify(message,entry.signature,False)
|
||||
assert not fresh.sign_object.verify(message+' tampered',entry.signature,False)
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
public=rsa.generate_private_key(public_exponent=65537,key_size=2048).public_key().public_bytes(
|
||||
serialization.Encoding.PEM,serialization.PublicFormat.SubjectPublicKeyInfo)
|
||||
assert not Sign(public_key=public).verify(message,entry.signature,False)
|
||||
result.update(new_audit_entry_signed_verified=True,tampered_entry_refused=True,
|
||||
wrong_audit_public_key_refused=True,
|
||||
audit_signature_continuity_proven=signed==historical and invalid==0,
|
||||
only_loopback_interface_verified=True,external_routes_absent=True,
|
||||
status='native_checks_completed')
|
||||
except Exception as error:
|
||||
result.update(status='failed',failed_stage=stage,failure_type=type(error).__name__)
|
||||
print(json.dumps(result))
|
||||
'''
|
||||
|
||||
|
||||
def native_checks(root, receipt):
|
||||
stem = 'nk49-pi-check-' + uuid.uuid4().hex
|
||||
database = stem + '-db'
|
||||
worker = stem + '-worker'
|
||||
try:
|
||||
start_database(database)
|
||||
with (root / 'databases/privacyidea.dump').open('rb') as dump:
|
||||
run(['docker', 'exec', '-i', database, 'pg_restore', '-U', 'postgres', '-d', 'recovery',
|
||||
'--no-owner', '--no-privileges', '--exit-on-error'], stdin=dump, timeout=600)
|
||||
start_worker(worker, database, root)
|
||||
receipt['container_isolation_verified_before_private_import'] = True
|
||||
result = json.loads(run(['docker', 'exec', worker, 'python3', '-c', PROBE], timeout=600))
|
||||
receipt.update(result)
|
||||
if result.get('status') != 'native_checks_completed':
|
||||
raise bundle.Refused()
|
||||
finally:
|
||||
cleanup([worker, database])
|
||||
receipt['container_cleanup_verified'] = True
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--transfer-receipt', required=True)
|
||||
parser.add_argument('--receipt', required=True)
|
||||
args = parser.parse_args()
|
||||
os.umask(0o077)
|
||||
fd = os.open(args.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
result = {'schema': 'netkingdom.isolated-privacyidea-recovery.v1', 'status': 'failed',
|
||||
'production_contacted': False, 'secret_values_exported': False,
|
||||
'existing_token_mfa_proven': False, 'audit_signature_continuity_proven': False,
|
||||
'container_cleanup_verified': False, 'temporary_plaintext_cleanup_verified': False,
|
||||
'source_sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest()}
|
||||
keyfd = None
|
||||
work = None
|
||||
stage = 'target_preflight'
|
||||
try:
|
||||
result['target'] = preflight()
|
||||
stage = 'transfer_validation'
|
||||
transfer = json.loads(Path(args.transfer_receipt).read_text())
|
||||
local.offline.validate(transfer)
|
||||
if transfer.get('independent_owner_retrieval_verified') is not True:
|
||||
raise bundle.Refused()
|
||||
path = Path(transfer['fetched_path'])
|
||||
bundle.private(path, max_bytes=bundle.LIMIT)
|
||||
if path.stat().st_size != transfer['ciphertext_bytes'] or local.offline.offsite.digest(path) != transfer['ciphertext_sha256']:
|
||||
raise bundle.Refused()
|
||||
keyfd = local.verify.custody.private_key_fd()
|
||||
if run(['age-keygen', '-y', f'/proc/self/fd/{keyfd}'], pass_fds=(keyfd,)).strip() != bundle.profile()['public_recipient'].encode():
|
||||
raise bundle.Refused()
|
||||
with tempfile.TemporaryDirectory(prefix='nk49-pi-recovery-', dir='/dev/shm') as temporary:
|
||||
work = Path(temporary)
|
||||
archive = work / 'archive.tar'
|
||||
stage = 'archive_decryption'
|
||||
with archive.open('xb') as output:
|
||||
p = subprocess.run(['age', '-d', '-i', f'/proc/self/fd/{keyfd}', str(path)],
|
||||
stdout=output, stderr=subprocess.DEVNULL, pass_fds=(keyfd,), timeout=180)
|
||||
if p.returncode or archive.stat().st_size > bundle.LIMIT + 4 * 1024 * 1024:
|
||||
raise bundle.Refused()
|
||||
restored = work / 'restored';restored.mkdir(mode=0o700)
|
||||
stage = 'archive_validation'
|
||||
manifest, _ = bundle.restore(archive, restored)
|
||||
result.update(ciphertext_sha256=transfer['ciphertext_sha256'],
|
||||
manifest_file_count=len(manifest['files']), all_manifest_hashes_verified=True)
|
||||
stage = 'native_privacyidea_checks'
|
||||
native_checks(restored, result)
|
||||
result['status'] = 'isolated_privacyidea_checked'
|
||||
except (Exception, KeyboardInterrupt):
|
||||
result.update(status='failed', error='isolated_privacyidea_check_failed_private_diagnostics_suppressed')
|
||||
result.setdefault('failed_stage', stage)
|
||||
finally:
|
||||
if keyfd is not None:os.close(keyfd)
|
||||
if work is not None and not work.exists():result['temporary_plaintext_cleanup_verified'] = True
|
||||
result['finished_at'] = datetime.now(timezone.utc).isoformat()
|
||||
with os.fdopen(fd, 'w') as output:
|
||||
json.dump(result, output, indent=2);output.write('\n')
|
||||
if result['status'] == 'failed':
|
||||
print('ERROR: isolated privacyIDEA check failed; review safe receipt. No production change.')
|
||||
return 1
|
||||
if not result['audit_signature_continuity_proven']:
|
||||
print('REVIEW: token-material checks completed; historical audit exceptions require owner review. Cleanup passed; see safe receipt.')
|
||||
return 2
|
||||
print('OK: retained token material decrypted internally; historical/new audit signatures and negative checks passed; cleanup passed. Existing-token MFA remains separate.')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):signal.signal(sig, local.offline.interrupted)
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Prepare exact identity backup coverage from read-only workload/PVC metadata.
|
||||
|
||||
This produces a proposal, never reads Secrets, executes in pods, changes cluster
|
||||
resources, captures private files, uploads, schedules or grants credentials.
|
||||
"""
|
||||
import argparse
|
||||
from datetime import datetime,timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[2]
|
||||
CLUSTER_UID='a553c742-0115-43d4-99a4-a5ca56fe0786'
|
||||
PROFILE=ROOT/'tools/bootstrap-recovery/identity-backup-profile.json'
|
||||
|
||||
|
||||
class Refused(Exception):pass
|
||||
|
||||
|
||||
def query(namespace,kind,name):
|
||||
# Closed read-only types; Secret/ConfigMap data and arbitrary API paths have
|
||||
# no transport in this preparation command.
|
||||
if kind not in {'namespace','deployment','pvc'}:raise Refused()
|
||||
args=['kubectl','--request-timeout=20s']
|
||||
if namespace:args+=['-n',namespace]
|
||||
r=subprocess.run(args+['get',kind,name,'-o','json'],capture_output=True,timeout=25)
|
||||
if r.returncode:raise Refused()
|
||||
return json.loads(r.stdout)
|
||||
|
||||
|
||||
def references(spec):
|
||||
secrets=set();configmaps=set();pvcs=set()
|
||||
for c in spec.get('containers',[])+spec.get('initContainers',[]):
|
||||
for e in c.get('env',[]):
|
||||
v=e.get('valueFrom',{})
|
||||
if 'secretKeyRef' in v:secrets.add(v['secretKeyRef']['name'])
|
||||
if 'configMapKeyRef' in v:configmaps.add(v['configMapKeyRef']['name'])
|
||||
for e in c.get('envFrom',[]):
|
||||
if 'secretRef' in e:secrets.add(e['secretRef']['name'])
|
||||
if 'configMapRef' in e:configmaps.add(e['configMapRef']['name'])
|
||||
for v in spec.get('volumes',[]):
|
||||
if 'secret' in v:secrets.add(v['secret']['secretName'])
|
||||
if 'configMap' in v:configmaps.add(v['configMap']['name'])
|
||||
if 'persistentVolumeClaim' in v:pvcs.add(v['persistentVolumeClaim']['claimName'])
|
||||
for p in v.get('projected',{}).get('sources',[]):
|
||||
if 'secret' in p:secrets.add(p['secret']['name'])
|
||||
if 'configMap' in p:configmaps.add(p['configMap']['name'])
|
||||
return secrets,configmaps,pvcs
|
||||
|
||||
|
||||
def prepare(profile,get=query):
|
||||
if get(None,'namespace','kube-system')['metadata']['uid']!=CLUSTER_UID:raise Refused()
|
||||
bindings=[]
|
||||
for component in profile['components']:
|
||||
ns=component['namespace'];name=component['deployment']
|
||||
d=get(ns,'deployment',name);m=d['metadata']
|
||||
if (m['uid']!=component['deployment_uid'] or m.get('deletionTimestamp') or
|
||||
d['status'].get('observedGeneration')!=m['generation'] or d['status'].get('readyReplicas')!=1):raise Refused()
|
||||
refs=references(d['spec']['template']['spec'])
|
||||
expected_pvcs={p['name']:p['uid'] for p in component['pvc_refs']}
|
||||
if refs[0]!=set(component['secret_refs']) or refs[1]!=set(component['configmap_refs']) or refs[2]!=set(expected_pvcs):raise Refused()
|
||||
pvcs=[]
|
||||
for pvc,expected in expected_pvcs.items():
|
||||
obj=get(ns,'pvc',pvc)
|
||||
if obj['metadata']['uid']!=expected or obj['metadata'].get('deletionTimestamp') or obj['status'].get('phase')!='Bound':raise Refused()
|
||||
pvcs.append({'name':pvc,'uid':expected})
|
||||
bindings.append({'namespace':ns,'deployment':name,'uid':m['uid'],'generation':m['generation'],
|
||||
'pvc_bindings':pvcs,'secret_names_only':sorted(refs[0]),'configmap_names_only':sorted(refs[1])})
|
||||
if get(None,'namespace','kube-system')['metadata']['uid']!=CLUSTER_UID:raise Refused()
|
||||
return {'schema':'netkingdom.identity-backup-preparation.v1','status':'prepared_not_admitted',
|
||||
'observed_at':datetime.now(timezone.utc).isoformat(),'cluster_uid':CLUSTER_UID,
|
||||
'profile_sha256':hashlib.sha256(PROFILE.read_bytes()).hexdigest(),
|
||||
'source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
|
||||
'bindings':bindings,'secret_values_read':False,'backup_captured':False,'uploaded':False,
|
||||
'runtime_changed':False,'credential_grant_created':False,
|
||||
'owner_admission_required':profile['admission_gates']}
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--receipt',required=True)
|
||||
a=p.parse_args()
|
||||
try:
|
||||
r=prepare(json.loads(PROFILE.read_text()))
|
||||
fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n')
|
||||
except Exception:
|
||||
print('ERROR: coverage preparation refused; private diagnostics suppressed.');return 1
|
||||
print('OK: exact coverage proposal prepared; capture, delivery and owner admission remain pending.');return 0
|
||||
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Validate reviewed public recovery metadata before requesting custody input.
|
||||
|
||||
Never opens TLS material, archive plaintext or any key. This validates bindings,
|
||||
not owner assent, archive contents or recovery authority.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[2]
|
||||
# The executable closure extends the synthetic fixture closure with the attended
|
||||
# custody entry point and this validator. Tests and historical receipts do not run.
|
||||
SOURCE_PATHS=tuple(sorted([
|
||||
'sso-mfa/bootstrap/sops-custody-unlock.sh','sso-mfa/bootstrap/sops-private.py','sso-mfa/bootstrap/sops-custody-fd.py',
|
||||
'sso-mfa/k8s/privacyidea/key-custody-remote.py',
|
||||
*('tools/bootstrap-recovery/'+name for name in (
|
||||
'isolated-keycape.py','isolated-keycape-session.py','run-isolated-keycape.sh',
|
||||
'prepare-isolated-keycape.py','isolated-lldap.py','identity-bundle.py',
|
||||
'identity-backup-profile.json','retrieve-identity-offline.py','identity-offsite.py',
|
||||
'verify-identity.py','identity-custody.json','identity-files.py',
|
||||
'check-privacyidea-database.py'))]))
|
||||
|
||||
class Refused(Exception):pass
|
||||
|
||||
def require(value):
|
||||
if not value:raise Refused()
|
||||
|
||||
def metadata_path(path):
|
||||
path=Path(os.path.abspath(path))
|
||||
require(not any(p.is_symlink() for p in (path,*path.parents)))
|
||||
st=path.stat()
|
||||
require(stat.S_ISREG(st.st_mode) and st.st_uid==os.getuid() and st.st_nlink==1 and not st.st_mode&0o077)
|
||||
return path
|
||||
|
||||
def read_metadata(path,limit=128*1024):
|
||||
path=metadata_path(path)
|
||||
require(0<path.stat().st_size<=limit)
|
||||
fd=os.open(path,os.O_RDONLY|os.O_NOFOLLOW|os.O_NONBLOCK)
|
||||
with os.fdopen(fd,'rb') as source:
|
||||
info=os.fstat(source.fileno())
|
||||
require(stat.S_ISREG(info.st_mode) and info.st_uid==os.getuid() and info.st_nlink==1 and not info.st_mode&0o077 and 0<info.st_size<=limit)
|
||||
raw=source.read(limit+1)
|
||||
require(0<len(raw)<=limit)
|
||||
return raw
|
||||
|
||||
|
||||
def strict_json(raw):
|
||||
def unique(pairs):
|
||||
result={}
|
||||
for key,value in pairs:
|
||||
require(key not in result)
|
||||
result[key]=value
|
||||
return result
|
||||
return json.loads(raw,object_pairs_hook=unique)
|
||||
|
||||
|
||||
def validate(transfer,admission,root=ROOT):
|
||||
require(isinstance(admission,dict))
|
||||
for field in ('ciphertext_sha256','verifier_sha256','trust_sha256','tls_cert_sha256'):
|
||||
require(isinstance(admission.get(field),str) and re.fullmatch('[a-f0-9]{64}',admission[field]))
|
||||
require(admission['ciphertext_sha256']==transfer.get('ciphertext_sha256'))
|
||||
require(transfer.get('independent_owner_retrieval_verified') is True)
|
||||
u=urlsplit(admission.get('issuer',''))
|
||||
require(u.scheme=='https' and u.hostname and not u.username and not u.password and not u.query and not u.fragment and u.port in (None,443) and u.path in ('','/'))
|
||||
consumer=admission.get('consumer',{})
|
||||
fields=('client_id','secret_env','audience','scope','subject','tenant','roles','deny_scope')
|
||||
require(isinstance(consumer,dict) and set(consumer)==set(fields))
|
||||
for field in fields:
|
||||
value=consumer[field]
|
||||
require(isinstance(value,str) and 0<len(value)<=1024 and value.strip()==value and not any(ord(c)<32 or ord(c)==127 for c in value))
|
||||
require(re.fullmatch('KEYCAPE_[A-Z0-9_]+',consumer['secret_env']))
|
||||
require(not set(consumer['scope'].split())&set(consumer['deny_scope'].split()))
|
||||
sources=admission.get('source_sha256')
|
||||
require(isinstance(sources,dict) and set(sources)==set(SOURCE_PATHS))
|
||||
for name,expected in sources.items():
|
||||
require(isinstance(expected,str) and re.fullmatch('sha256:[a-f0-9]{64}',expected))
|
||||
path=root/name
|
||||
require(not any(p.is_symlink() for p in (path,*path.parents)))
|
||||
require('sha256:'+hashlib.sha256(path.read_bytes()).hexdigest()==expected)
|
||||
spec=importlib.util.spec_from_file_location('adapter',root/'tools/bootstrap-recovery/isolated-keycape.py')
|
||||
adapter=importlib.util.module_from_spec(spec);spec.loader.exec_module(adapter)
|
||||
adapter.manifest_binding_mode(admission,transfer)
|
||||
# Validate provider receipt through the existing fixed contract, with no I/O.
|
||||
spec=importlib.util.spec_from_file_location('offline',root/'tools/bootstrap-recovery/retrieve-identity-offline.py')
|
||||
offline=importlib.util.module_from_spec(spec);spec.loader.exec_module(offline)
|
||||
offline.validate(transfer)
|
||||
ciphertext=metadata_path(transfer['fetched_path']) # no content read
|
||||
require(ciphertext.stat().st_size==transfer['ciphertext_bytes'])
|
||||
return True
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
for name in ('transfer-receipt','admission','admission-sha256','tls-cert','tls-key','trust'):p.add_argument('--'+name,required=True)
|
||||
args=p.parse_args()
|
||||
try:
|
||||
require(re.fullmatch('[a-f0-9]{64}',args.admission_sha256))
|
||||
raw=read_metadata(args.admission)
|
||||
require(hashlib.sha256(raw).hexdigest()==args.admission_sha256)
|
||||
transfer_raw=read_metadata(args.transfer_receipt)
|
||||
validate(strict_json(transfer_raw),strict_json(raw))
|
||||
for path in (args.tls_cert,args.tls_key,args.trust):metadata_path(path)
|
||||
# Import all third-party dependencies before the attended custody prompt.
|
||||
import yaml
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
except Exception:
|
||||
print('ERROR: reviewed metadata/source/dependency preflight refused; no key requested.')
|
||||
return 1
|
||||
print('OK: reviewed metadata and source closure verified; private contents not inspected.')
|
||||
return 0
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Build a secret-free offline recovery kit; optionally publish exact new files.
|
||||
|
||||
Publication creates only three fixed versioned objects beside identity backups,
|
||||
never changes shares, credentials, existing files, retention or runtime jobs.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import zipfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
HERE = Path(__file__).resolve().parent
|
||||
spec = importlib.util.spec_from_file_location('offline', HERE / 'retrieve-identity-offline.py')
|
||||
offline = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(offline)
|
||||
offsite = offline.offsite
|
||||
FILES = [
|
||||
'docs/disaster-recovery-storage-share.md',
|
||||
'docs/identity-service-restore-packet.md',
|
||||
'docs/evidence/2026-10-16-identity-backup-live/actual-offsite.json',
|
||||
'docs/evidence/2026-10-16-identity-outage-recovery/image-observation.json',
|
||||
'tools/bootstrap-recovery/retrieve-identity-offline.py',
|
||||
'tools/bootstrap-recovery/identity-offsite.py',
|
||||
'tools/bootstrap-recovery/verify-identity.py',
|
||||
'tools/bootstrap-recovery/identity-bundle.py',
|
||||
'tools/bootstrap-recovery/check-privacyidea-database.py',
|
||||
'tools/bootstrap-recovery/identity-backup-profile.json',
|
||||
'sso-mfa/bootstrap/sops-custody-unlock.sh',
|
||||
'sso-mfa/bootstrap/sops-private.py',
|
||||
'keys/age.pub',
|
||||
]
|
||||
|
||||
|
||||
def build():
|
||||
content = {name: (ROOT / name).read_bytes() for name in FILES}
|
||||
if any(b'AGE-SECRET-KEY-1' in value for name, value in content.items()
|
||||
if not name.endswith(('.py', '.sh', '.md'))):
|
||||
raise offsite.Refused()
|
||||
sums = ''.join(hashlib.sha256(value).hexdigest() + ' ' + name + '\n'
|
||||
for name, value in content.items()).encode()
|
||||
output = io.BytesIO()
|
||||
with zipfile.ZipFile(output, 'w', compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
for name, value in {**content, 'SHA256SUMS': sums}.items():
|
||||
entry = zipfile.ZipInfo(name, date_time=(2026, 10, 16, 0, 0, 0))
|
||||
entry.external_attr = 0o100600 << 16
|
||||
entry.compress_type = zipfile.ZIP_DEFLATED
|
||||
archive.writestr(entry, value)
|
||||
kit = output.getvalue()
|
||||
version = hashlib.sha256(kit).hexdigest()
|
||||
stem = 'net-kingdom-recovery-' + version[:16]
|
||||
return {
|
||||
stem + '-START-HERE.md': content['docs/disaster-recovery-storage-share.md'],
|
||||
stem + '.zip': kit,
|
||||
stem + '.zip.sha256': (version + ' ' + stem + '.zip\n').encode(),
|
||||
}
|
||||
|
||||
|
||||
def publish(files, headers, client=None):
|
||||
client = client or offsite.opener()
|
||||
result = []
|
||||
for name, raw in files.items():
|
||||
url = offline.ACCOUNT_BASE + name
|
||||
digest = hashlib.sha256(raw).hexdigest()
|
||||
# A retry may accept an identical object after exact readback, but must
|
||||
# never overwrite a conflicting object or retry an unknown PUT outcome.
|
||||
present = False
|
||||
try:
|
||||
with client.open(urllib.request.Request(url, method='HEAD', headers=headers), timeout=30) as response:
|
||||
version = offsite.etag(response, url, len(raw))
|
||||
present = True
|
||||
except urllib.error.HTTPError as error:
|
||||
if error.code != 404:
|
||||
raise
|
||||
if not present:
|
||||
h = dict(headers, **{'If-None-Match': '*', 'Content-Length': str(len(raw)),
|
||||
'Content-Type': 'application/zip' if name.endswith('.zip') else 'text/plain; charset=utf-8'})
|
||||
with client.open(urllib.request.Request(url, data=raw, method='PUT', headers=h), timeout=60) as response:
|
||||
if response.status != 201 or response.url != url:
|
||||
raise offsite.Refused()
|
||||
with client.open(urllib.request.Request(url, method='HEAD', headers=headers), timeout=30) as response:
|
||||
version = offsite.etag(response, url, len(raw))
|
||||
h = dict(headers, **{'If-Match': version})
|
||||
with client.open(urllib.request.Request(url, headers=h), timeout=60) as response:
|
||||
if offsite.etag(response, url, len(raw)) != version:
|
||||
raise offsite.Refused()
|
||||
downloaded = response.read(len(raw) + 1)
|
||||
if downloaded != raw:
|
||||
raise offsite.Refused()
|
||||
result.append({'name': name, 'bytes': len(raw), 'sha256': digest,
|
||||
'etag': version, 'readback_verified': True})
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--output-dir', required=True)
|
||||
p.add_argument('--publish', action='store_true')
|
||||
p.add_argument('--auth-source', choices=('prompt', 'attended-openbao'), default='prompt')
|
||||
p.add_argument('--receipt', required=True)
|
||||
p.add_argument('--offline-transfer-receipt', help='After prompted publication, retrieve the known recovered generation using the same independently held access')
|
||||
a = p.parse_args()
|
||||
os.umask(0o077)
|
||||
fd = os.open(a.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
r = {'schema': 'netkingdom.recovery-kit.v1', 'status': 'failed',
|
||||
'secret_values_exported': False, 'new_grants_created': False}
|
||||
stage = 'option_validation'
|
||||
try:
|
||||
if a.offline_transfer_receipt and (not a.publish or a.auth_source != 'prompt'):
|
||||
raise offsite.Refused()
|
||||
stage = 'build_kit'
|
||||
files = build()
|
||||
stage = 'write_local_kit'
|
||||
directory = Path(a.output_dir)
|
||||
directory.mkdir(mode=0o700, exist_ok=False)
|
||||
for name, raw in files.items():
|
||||
(directory / name).write_bytes(raw)
|
||||
r.update(status='built', files=[{'name': n, 'bytes': len(v), 'sha256': hashlib.sha256(v).hexdigest()}
|
||||
for n, v in files.items()])
|
||||
if a.publish:
|
||||
if a.auth_source == 'prompt':
|
||||
stage = 'private_provider_prompt'
|
||||
secret = offline.private_prompt('Independently held Storage Share Backup app password: ')
|
||||
else:
|
||||
stage = 'attended_owner_custody'
|
||||
spec = importlib.util.spec_from_file_location('inventory', HERE / 'inventory-storage-share.py')
|
||||
owner = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(owner)
|
||||
_, credentials = owner.caller_credentials()
|
||||
secret = credentials['BACKUP_PASSWORD']
|
||||
if not secret or any(c in secret for c in '\r\n\x00'):
|
||||
raise offsite.Refused()
|
||||
try:
|
||||
headers = {'Authorization': 'Basic ' + base64.b64encode(('Backup:' + secret).encode()).decode(),
|
||||
'Accept-Encoding': 'identity'}
|
||||
stage = 'provider_publication'
|
||||
r.update(status='published_readback_verified', files=publish(files, headers),
|
||||
folder='Backup/railiance-backups/net-kingdom-identity',
|
||||
provider='Storage Share', credential_source=a.auth_source)
|
||||
if a.offline_transfer_receipt:
|
||||
stage = 'offline_provider_download'
|
||||
transfer_fd = os.open(a.offline_transfer_receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
transfer = {'status': 'failed', 'error': 'offline_retrieval_failed_private_diagnostics_suppressed'}
|
||||
try:
|
||||
record = json.loads((ROOT / FILES[2]).read_text())
|
||||
transfer = offline.retrieve(record, secret, 'account')
|
||||
finally:
|
||||
with os.fdopen(transfer_fd, 'w') as out:
|
||||
json.dump(transfer, out, indent=2)
|
||||
out.write('\n')
|
||||
r['offline_retrieval_verified'] = transfer['status'] == 'readback_verified'
|
||||
finally:
|
||||
secret = None
|
||||
r['finished_at'] = datetime.now(timezone.utc).isoformat()
|
||||
except (Exception, KeyboardInterrupt) as error:
|
||||
r.update(status='failed', error='kit_operation_failed_private_diagnostics_suppressed',
|
||||
failed_stage=stage, **offline.failure_metadata(error))
|
||||
finally:
|
||||
with os.fdopen(fd, 'w') as output:
|
||||
json.dump(r, output, indent=2)
|
||||
output.write('\n')
|
||||
# Silent: safe to use in the existing captured attended-owner conduit.
|
||||
return int(r['status'] == 'failed')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Download one reviewed ciphertext using privately entered provider access.
|
||||
|
||||
No OpenBao, SSO, Kubernetes, credential discovery, provider writes or age key.
|
||||
Run by the operator in their own terminal, never through a recorded agent PTY.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
from datetime import datetime, timezone
|
||||
import getpass
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import warnings
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
spec = importlib.util.spec_from_file_location('offsite', HERE / 'identity-offsite.py')
|
||||
offsite = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(offsite)
|
||||
ACCOUNT_BASE = offsite.HOST + '/remote.php/dav/files/Backup/railiance-backups/net-kingdom-identity/'
|
||||
|
||||
|
||||
def private_prompt(label):
|
||||
# getpass opens the terminal with os.open, which does not require seeking.
|
||||
# Reject redirected input and turn its echoing fallback warning into an
|
||||
# exception before it can read a credential without echo suppression.
|
||||
if not sys.stdin.isatty() or not sys.stderr.isatty():
|
||||
raise offsite.Refused()
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter('error', getpass.GetPassWarning)
|
||||
return getpass.getpass(label)
|
||||
|
||||
|
||||
def failure_metadata(error):
|
||||
# No exception messages, response bodies, URLs or headers enter receipts.
|
||||
if isinstance(error, urllib.error.HTTPError):
|
||||
return {'failure_kind': 'provider_http_error', 'http_status': int(error.code)}
|
||||
if isinstance(error, urllib.error.URLError):
|
||||
return {'failure_kind': 'provider_transport_error'}
|
||||
if isinstance(error, getpass.GetPassWarning):
|
||||
return {'failure_kind': 'terminal_echo_suppression_unavailable'}
|
||||
if isinstance(error, offsite.Refused):
|
||||
return {'failure_kind': 'scope_or_integrity_refusal'}
|
||||
return {'failure_kind': 'local_operation_failed'}
|
||||
|
||||
|
||||
def validate(record):
|
||||
if (record.get('schema') != 'netkingdom.identity-offsite.v1'
|
||||
or record.get('status') != 'readback_verified'
|
||||
or record.get('provider') != 'Storage Share'
|
||||
or record.get('folder') != 'Backup/railiance-backups/net-kingdom-identity'
|
||||
or not offsite.NAME.fullmatch(record.get('artifact', ''))
|
||||
or not re.fullmatch('[a-f0-9]{64}', record.get('ciphertext_sha256', ''))
|
||||
or type(record.get('ciphertext_bytes')) is not int
|
||||
or not 0 < record['ciphertext_bytes'] <= offsite.LIMIT
|
||||
or not re.fullmatch(r'"[^"\x00-\x20]{1,180}"', record.get('etag', ''))):
|
||||
raise offsite.Refused()
|
||||
|
||||
|
||||
def retrieve(record, secret, mode, client=None):
|
||||
validate(record)
|
||||
if mode not in {'account', 'folder-share'} or not secret or any(c in secret for c in '\r\n\x00'):
|
||||
raise offsite.Refused()
|
||||
auth = offsite.headers(secret) if mode == 'folder-share' else {
|
||||
'Authorization': 'Basic ' + base64.b64encode(('Backup:' + secret).encode()).decode(),
|
||||
'Accept-Encoding': 'identity'}
|
||||
url = (offsite.BASE if mode == 'folder-share' else ACCOUNT_BASE) + record['artifact']
|
||||
client = client or offsite.opener()
|
||||
directory = None
|
||||
complete = False
|
||||
try:
|
||||
with client.open(urllib.request.Request(url, method='HEAD', headers=auth), timeout=30) as response:
|
||||
if offsite.etag(response, url, record['ciphertext_bytes']) != record['etag']:
|
||||
raise offsite.Refused()
|
||||
directory = Path(tempfile.mkdtemp(prefix='nk49-identity-fetched-', dir='/tmp'))
|
||||
target = directory / record['artifact']
|
||||
auth['If-Match'] = record['etag']
|
||||
count = 0
|
||||
digest = hashlib.sha256()
|
||||
with client.open(urllib.request.Request(url, headers=auth), timeout=180) as response:
|
||||
if offsite.etag(response, url, record['ciphertext_bytes']) != record['etag']:
|
||||
raise offsite.Refused()
|
||||
fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
with os.fdopen(fd, 'wb') as output:
|
||||
while block := response.read(1024 * 1024):
|
||||
count += len(block)
|
||||
if count > record['ciphertext_bytes']:
|
||||
raise offsite.Refused()
|
||||
digest.update(block)
|
||||
output.write(block)
|
||||
output.flush()
|
||||
os.fsync(output.fileno())
|
||||
if count != record['ciphertext_bytes'] or digest.hexdigest() != record['ciphertext_sha256']:
|
||||
raise offsite.Refused()
|
||||
with client.open(urllib.request.Request(url, method='HEAD', headers=auth), timeout=30) as response:
|
||||
if offsite.etag(response, url, record['ciphertext_bytes']) != record['etag']:
|
||||
raise offsite.Refused()
|
||||
complete = True
|
||||
# Closed allowlist: never copy arbitrary source-receipt fields to output.
|
||||
result = {k: record[k] for k in ('schema', 'provider', 'folder', 'artifact',
|
||||
'ciphertext_bytes', 'ciphertext_sha256', 'etag')}
|
||||
result.update(status='readback_verified', fetched_path=str(target),
|
||||
independent_owner_retrieval_verified=True,
|
||||
credential_source='independently_held_provider_access',
|
||||
provider_auth_mode=mode, openbao_contacted=False, sso_contacted=False,
|
||||
provider_writes_performed=False, age_private_key_used=False,
|
||||
secret_values_exported=False, object_unchanged_during_transfer=True,
|
||||
downloaded_at=datetime.now(timezone.utc).isoformat())
|
||||
return result
|
||||
finally:
|
||||
if directory is not None and not complete:
|
||||
shutil.rmtree(directory)
|
||||
|
||||
|
||||
def interrupted(signum, frame):
|
||||
raise InterruptedError()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--source-receipt', required=True)
|
||||
parser.add_argument('--receipt', required=True)
|
||||
parser.add_argument('--auth', choices=('account', 'folder-share'), default='account')
|
||||
args = parser.parse_args()
|
||||
os.umask(0o077)
|
||||
fd = os.open(args.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
result = {'status': 'failed', 'error': 'offline_retrieval_failed_private_diagnostics_suppressed'}
|
||||
stage = 'source_receipt_validation'
|
||||
try:
|
||||
record = json.loads(Path(args.source_receipt).read_text())
|
||||
validate(record)
|
||||
# getpass must never fall back to echoed stdin (e.g. a captured agent tool).
|
||||
stage = 'private_provider_prompt'
|
||||
secret = private_prompt('Storage Share Backup app password or exact folder-share token: ')
|
||||
try:
|
||||
stage = 'provider_download'
|
||||
result = retrieve(record, secret, args.auth)
|
||||
finally:
|
||||
secret = None
|
||||
except (Exception, KeyboardInterrupt) as error:
|
||||
result.update(failed_stage=stage, **failure_metadata(error))
|
||||
finally:
|
||||
with os.fdopen(fd, 'w') as output:
|
||||
json.dump(result, output, indent=2)
|
||||
output.write('\n')
|
||||
ok = result['status'] == 'readback_verified'
|
||||
print('OK: ciphertext retrieved without OpenBao or SSO; ETag, size and SHA256 verified.' if ok
|
||||
else 'ERROR: retrieval refused; private diagnostics suppressed. Use a new receipt path to retry.')
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
|
||||
signal.signal(sig, interrupted)
|
||||
sys.exit(main())
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Silent attended owner retrieval of an existing identity ciphertext.
|
||||
|
||||
Uses Backup-account recovery custody only in memory; no private age key, upload,
|
||||
share, grant, deletion or production restore operation.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
from datetime import datetime,timezone
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import tempfile
|
||||
import urllib.request
|
||||
|
||||
HERE=Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def load(name):
|
||||
spec=importlib.util.spec_from_file_location(name,HERE/(name+'.py'))
|
||||
m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m);return m
|
||||
|
||||
|
||||
inventory=load('inventory-storage-share');offsite=load('identity-offsite')
|
||||
|
||||
|
||||
def retrieve(record,out):
|
||||
if (record.get('schema')!='netkingdom.identity-offsite.v1' or record.get('status')!='readback_verified'
|
||||
or not offsite.NAME.fullmatch(record.get('artifact','')) or not re.fullmatch('[a-f0-9]{64}',record.get('ciphertext_sha256',''))
|
||||
or not 0<record.get('ciphertext_bytes',0)<=offsite.LIMIT):raise offsite.Refused()
|
||||
identity,credentials=inventory.caller_credentials()
|
||||
auth=base64.b64encode((credentials['BACKUP_USERNAME']+':'+credentials['BACKUP_PASSWORD']).encode()).decode()
|
||||
url=offsite.HOST+'/remote.php/dav/files/Backup/railiance-backups/net-kingdom-identity/'+record['artifact']
|
||||
h={'Authorization':'Basic '+auth,'Accept-Encoding':'identity'}
|
||||
client=offsite.opener()
|
||||
with client.open(urllib.request.Request(url,method='HEAD',headers=h),timeout=30) as response:
|
||||
version=offsite.etag(response,url,record['ciphertext_bytes'])
|
||||
if version!=record['etag']:raise offsite.Refused()
|
||||
h['If-Match']=version
|
||||
directory=Path(tempfile.mkdtemp(prefix='nk49-identity-fetched-',dir='/tmp'))
|
||||
target=directory/record['artifact'];count=0;digest=hashlib.sha256()
|
||||
with client.open(urllib.request.Request(url,headers=h),timeout=180) as response:
|
||||
if offsite.etag(response,url,record['ciphertext_bytes'])!=version:raise offsite.Refused()
|
||||
with target.open('xb') as output:
|
||||
while block:=response.read(1024*1024):
|
||||
count+=len(block)
|
||||
if count>record['ciphertext_bytes']:raise offsite.Refused()
|
||||
digest.update(block);output.write(block)
|
||||
if count!=record['ciphertext_bytes'] or digest.hexdigest()!=record['ciphertext_sha256']:raise offsite.Refused()
|
||||
with client.open(urllib.request.Request(url,method='HEAD',headers=h),timeout=30) as response:
|
||||
if offsite.etag(response,url,record['ciphertext_bytes'])!=version:raise offsite.Refused()
|
||||
out.update(record);out.update(status='readback_verified',fetched_path=str(target),
|
||||
independent_owner_retrieval_verified=True,caller_entity_id=identity.get('entity_id'),
|
||||
age_private_key_used=False,secret_values_exported=False,downloaded_at=datetime.now(timezone.utc).isoformat())
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--source-receipt',required=True);p.add_argument('--receipt',required=True)
|
||||
a=p.parse_args();os.umask(0o077);fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
r={'status':'failed','source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest()}
|
||||
try:retrieve(json.loads(Path(a.source_receipt).read_text()),r)
|
||||
except Exception:r.update(status='failed',error='contained_owner_retrieval_failed')
|
||||
finally:
|
||||
with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n')
|
||||
return int(r['status']=='failed')
|
||||
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
#!/usr/bin/env bash
|
||||
# Candidate owner launcher. Review and native fixture proof required before use.
|
||||
set +x
|
||||
set -euo pipefail
|
||||
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
cd -- "$ROOT"
|
||||
umask 077
|
||||
[[ $# == 6 ]] || { printf '%s\n' 'Usage: run-isolated-keycape.sh VERIFIED_TRANSFER_RECEIPT ADMISSION ADMISSION_SHA256 TLS_CERT TLS_KEY TRUST'; exit 1; }
|
||||
[[ "$3" =~ ^[a-f0-9]{64}$ ]] || { printf '%s\n' 'ERROR: exact reviewed admission digest required.'; exit 1; }
|
||||
for index in 1 2 4 5 6; do
|
||||
[[ -f "${!index}" && ! -L "${!index}" ]] || { printf '%s\n' 'ERROR: provide regular owner-controlled input files.'; exit 1; }
|
||||
done
|
||||
[[ -t 0 && -t 1 && -t 2 ]] || { printf '%s\n' 'ERROR: run in your own interactive terminal.'; exit 1; }
|
||||
for tool in python3 age age-keygen docker; do
|
||||
command -v "$tool" >/dev/null || { printf 'ERROR: required tool unavailable: %s\n' "$tool"; exit 1; }
|
||||
done
|
||||
python3 tools/bootstrap-recovery/prepare-isolated-keycape.py \
|
||||
--transfer-receipt "$1" --admission "$2" --admission-sha256 "$3" \
|
||||
--tls-cert "$4" --tls-key "$5" --trust "$6"
|
||||
python3 - <<'PY'
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
p=Path('tools/bootstrap-recovery/isolated-keycape-session.py')
|
||||
spec=importlib.util.spec_from_file_location('session',p)
|
||||
m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m)
|
||||
try:
|
||||
m.local.preflight()
|
||||
m.run(['docker','image','inspect',m.adapter.IMAGE])
|
||||
except Exception:
|
||||
raise SystemExit('ERROR: exact local Docker/images/tmpfs preflight failed; no key requested.')
|
||||
print('OK: fixed local target and cached pinned images available.')
|
||||
PY
|
||||
NK49_KC_RUN="$(mktemp -d /tmp/nk49-isolated-keycape-XXXXXXXX)"
|
||||
printf 'Safe evidence directory: %s\n' "$NK49_KC_RUN"
|
||||
printf '%s\n' 'Isolated original issuer/trust and selected registered service-client checks only.'
|
||||
printf '%s\n' 'No browser, downstream consumer or retained-factor MFA acceptance is claimed.'
|
||||
printf '%s\n' 'Enter only the independently held backup age key at the hidden prompt.'
|
||||
bash sso-mfa/bootstrap/sops-custody-unlock.sh \
|
||||
--expected-recipient age1zvryunvjhvpkmasskauga2heeg0ztnte9ymgppvjge36ekumk50syr3tsz \
|
||||
--sealed-fd --prompt -- python3 tools/bootstrap-recovery/isolated-keycape-session.py \
|
||||
--transfer-receipt "$1" --admission "$2" --admission-sha256 "$3" \
|
||||
--tls-cert "$4" --tls-key "$5" --trust "$6" --receipt "$NK49_KC_RUN/recovery.json"
|
||||
printf 'Safe receipt: %s/recovery.json\n' "$NK49_KC_RUN"
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Isolated ciphertext recovery check; no cluster, credentials or values emitted.
|
||||
|
||||
This verifies the handoff transport and exact requested file coverage. Current
|
||||
service validity, independent source custody and native consumer recovery need
|
||||
separate owner evidence; this tool cannot infer them from a decrypted archive.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
spec = importlib.util.spec_from_file_location('private_age_recovery', ROOT / 'sso-mfa/bootstrap/sops-private.py')
|
||||
custody = importlib.util.module_from_spec(spec); spec.loader.exec_module(custody)
|
||||
LIMIT = 64 * 1024 * 1024
|
||||
ALLOWED = {f'{c}/secrets.env' for c in ('privacyidea', 'postgres', 'lldap', 'authelia', 'keycape', 'breakglass')} | {
|
||||
'privacyidea/pi.enc', 'privacyidea/private.pem', 'privacyidea/public.pem',
|
||||
'keycape/key.pem', 'authelia/oidc_private_key.pem', 'openbao/init.json'}
|
||||
|
||||
|
||||
class Refused(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def recover(ciphertext, keyfd, required, work):
|
||||
# Exclude ambient age keys/plugins and unrelated credential environments.
|
||||
env = {'PATH': os.environ.get('PATH', os.defpath), 'HOME': str(work), 'XDG_CONFIG_HOME': str(work)}
|
||||
result = subprocess.run(['age', '-d', '-i', f'/proc/self/fd/{keyfd}'], input=ciphertext,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, pass_fds=(keyfd,),
|
||||
timeout=60, env=env, cwd=work)
|
||||
if result.returncode or not result.stdout or len(result.stdout) > LIMIT:
|
||||
raise Refused()
|
||||
restored = set(); names = set(); total = 0
|
||||
with tarfile.open(fileobj=io.BytesIO(result.stdout), mode='r:') as archive:
|
||||
for index, member in enumerate(archive):
|
||||
if index >= 128 or member.name in names:
|
||||
raise Refused()
|
||||
names.add(member.name)
|
||||
path = PurePosixPath(member.name)
|
||||
if (path.is_absolute() or '..' in path.parts or '.' in path.parts
|
||||
or not path.parts or path.parts[0] != 'secrets'):
|
||||
raise Refused()
|
||||
if member.isdir():
|
||||
if len(path.parts) > 2 or (len(path.parts) == 2 and path.parts[1] not in {p.split('/')[0] for p in ALLOWED}):
|
||||
raise Refused()
|
||||
continue
|
||||
relative = '/'.join(path.parts[1:])
|
||||
if not member.isfile() or relative not in ALLOWED or not 0 <= member.size <= 16 * 1024 * 1024:
|
||||
raise Refused()
|
||||
total += member.size
|
||||
if total > LIMIT or member.size == 0:
|
||||
raise Refused()
|
||||
content = archive.extractfile(member).read()
|
||||
if len(content) != member.size or b'AGE-SECRET-KEY-' in content:
|
||||
raise Refused()
|
||||
target = work / 'restored' / relative
|
||||
target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
with os.fdopen(fd, 'wb') as dest:
|
||||
dest.write(content); dest.flush(); os.fsync(dest.fileno())
|
||||
if target.read_bytes() != content:
|
||||
raise Refused()
|
||||
restored.add(relative)
|
||||
if not required.issubset(restored):
|
||||
raise Refused()
|
||||
return sorted(restored)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--bundle', required=True)
|
||||
parser.add_argument('--bundle-sha256', required=True)
|
||||
parser.add_argument('--required-file', action='append', required=True)
|
||||
parser.add_argument('--receipt', required=True)
|
||||
args = parser.parse_args()
|
||||
if not re.fullmatch('[a-f0-9]{64}', args.bundle_sha256): raise Refused()
|
||||
required = set(args.required_file)
|
||||
if not required or required - ALLOWED or len(required) != len(args.required_file): raise Refused()
|
||||
path = Path(args.bundle).absolute()
|
||||
if any(p.is_symlink() for p in (path, *path.parents)): raise Refused()
|
||||
with path.open('rb') as file:
|
||||
data = file.read(LIMIT + 1)
|
||||
if len(data) > LIMIT or hashlib.sha256(data).hexdigest() != args.bundle_sha256: raise Refused()
|
||||
recipient = custody.public_recipient()
|
||||
keyfd = custody.private_key_fd()
|
||||
try:
|
||||
derived = subprocess.run(['age-keygen', '-y', f'/proc/self/fd/{keyfd}'],
|
||||
pass_fds=(keyfd,), stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=10)
|
||||
if derived.returncode or derived.stdout.strip() != recipient.encode(): raise Refused()
|
||||
# Check the negative path with a disposable unrelated key. It never
|
||||
# replaces the registered key, leaves temporary custody, or contacts IAM.
|
||||
with tempfile.TemporaryDirectory(prefix='netkingdom-recovery-', dir='/tmp') as temporary:
|
||||
work = Path(temporary)
|
||||
disposable = work / 'wrong.key'
|
||||
created = subprocess.run(['age-keygen', '-o', str(disposable)], stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, timeout=10)
|
||||
if created.returncode: raise Refused()
|
||||
wrong = subprocess.run(['age', '-d', '-i', str(disposable)], input=data,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=60,
|
||||
env={'PATH': os.environ.get('PATH', os.defpath), 'HOME': temporary,
|
||||
'XDG_CONFIG_HOME': temporary}, cwd=temporary)
|
||||
if wrong.returncode == 0: raise Refused()
|
||||
restored = recover(data, keyfd, required, work)
|
||||
if work.exists(): raise Refused()
|
||||
finally:
|
||||
os.close(keyfd)
|
||||
receipt = {'schema': 'netkingdom.bootstrap-recovery-transport.v1',
|
||||
'observed_at': datetime.now(timezone.utc).isoformat(), 'bundle_sha256': args.bundle_sha256,
|
||||
'registered_recipient_match': True, 'wrong_key_refused': True,
|
||||
'required_files': sorted(required), 'restored_files': restored,
|
||||
'isolated_restore_readback_passed': True, 'temporary_plaintext_cleanup_verified': True,
|
||||
'secret_values_exported': False, 'cluster_contacted': False,
|
||||
'independent_custody_proven': False, 'current_service_validity_proven': False,
|
||||
'native_consumer_recovery_proven': False}
|
||||
# Publish only the closed, safe receipt shape; preserve existing receipts.
|
||||
fd = os.open(args.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
with os.fdopen(fd, 'w') as dest:
|
||||
json.dump(receipt, dest, indent=2); dest.write('\n')
|
||||
print('Ciphertext transport/isolated restore checks passed. Independent custody and current service recovery remain separate owner evidence.')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
for sig in (signal.SIGHUP, signal.SIGTERM, signal.SIGINT): signal.signal(sig, custody.interrupted)
|
||||
main()
|
||||
except (Refused, custody.Refused, OSError, ValueError, TypeError, KeyError, tarfile.TarError, subprocess.SubprocessError):
|
||||
print('ERROR: recovery transport check refused or failed; no acceptance receipt. Private diagnostics suppressed.', file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Attended native reader/ESO verification; only closed booleans leave custody."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
HERE=Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def load(name):
|
||||
spec=importlib.util.spec_from_file_location(name,HERE/(name+'.py'))
|
||||
m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m);return m
|
||||
|
||||
|
||||
setup=load('identity-credential-setup');runtime=load('identity-runtime')
|
||||
from state_hub_preflight_lane import bao,data,capabilities,revoke
|
||||
|
||||
|
||||
def verify(action,expected,receipt,kubeconfig):
|
||||
receipt['phase']='validate_approved_contract'
|
||||
ccr,lane=setup.contract(action,expected)
|
||||
receipt['phase']='verify_attended_identity'
|
||||
ident=data(bao(['token','lookup','-format=json']))['data']
|
||||
if 'platform-admin' not in ident.get('policies',[]) or 'root' in ident.get('policies',[]) or not ident.get('path','').startswith('auth/netkingdom/'):raise setup.Refused()
|
||||
capture=runtime.capture;capture.KUBE=['kubectl','--kubeconfig',kubeconfig]
|
||||
receipt['phase']='verify_cluster_identity'
|
||||
if capture.get(None,'namespace','kube-system')['metadata']['uid']!=capture.prepare.CLUSTER_UID:raise setup.Refused()
|
||||
def login(sa,audience,negative=False,namespace='sso'):
|
||||
jwt=capture.command(['-n',namespace,'create','token',sa,'--audience='+audience,'--duration=10m']).decode().strip()
|
||||
result=bao(['write','-format=json','auth/kubernetes/login','-'],payload={'role':lane['role'],'jwt':jwt},allow_failure=negative)
|
||||
if negative:
|
||||
if result.returncode==0:
|
||||
revoke(data(result)['auth']['client_token']);raise setup.Refused()
|
||||
if b'403' not in result.stderr and b'400' not in result.stderr:raise setup.Refused()
|
||||
return None
|
||||
return data(result)['auth']
|
||||
receipt['phase']='native_reader_login'
|
||||
auth=login(lane['service_account'],'openbao');token=auth['client_token']
|
||||
receipt['native_reader_metadata']={k:auth.get(k) for k in ['token_policies','identity_policies','policies','lease_duration']}
|
||||
try:
|
||||
receipt['phase']='effective_reader_policy_ttl'
|
||||
if (auth['token_policies']!=[lane['policy']] or auth.get('identity_policies')
|
||||
or auth.get('policies',auth['token_policies'])!=[lane['policy']] or not 0<auth['lease_duration']<=900):raise setup.Refused()
|
||||
paths=[lane['kv_path'],lane['kv_path'].replace('/data/','/metadata/',1),lane['kv_path']+'-sibling',
|
||||
'platform/data/workloads/railiance/backup/offsite-lane','auth/token/create']
|
||||
receipt['phase']='reader_capabilities'
|
||||
cap=capabilities(token,paths)
|
||||
receipt['capabilities_checked']=cap
|
||||
if cap[paths[0]]!=['read'] or any(cap[p]!=['deny'] for p in paths[1:]):raise setup.Refused()
|
||||
receipt['phase']='native_kv_eso_equality'
|
||||
native=data(bao(['read','-format=json',lane['kv_path']],token=token))['data']
|
||||
if set(native['data'])!={'NC_WEBDAV_TOKEN'} or native['data']['NC_WEBDAV_TOKEN']!=runtime.token(action):raise setup.Refused()
|
||||
receipt['kv_version']=native['metadata']['version']
|
||||
finally:revoke(token)
|
||||
receipt['phase']='negative_reader_logins'
|
||||
login('default','openbao',True);login(lane['service_account'],'wrong-audience',True)
|
||||
proposed={'apiVersion':'v1','kind':'ServiceAccount','metadata':{'name':lane['service_account'],'namespace':'default'},'automountServiceAccountToken':False}
|
||||
receipt['wrong_namespace_probe_attempted']=proposed['metadata']
|
||||
test=json.loads(capture.command(['create','-f','-','-o','json'],json.dumps(proposed).encode()))
|
||||
if test['metadata']['name']!=lane['service_account'] or test['metadata']['namespace']!='default':raise setup.Refused()
|
||||
try:login(lane['service_account'],'openbao',True,'default')
|
||||
finally:
|
||||
path='/api/v1/namespaces/default/serviceaccounts/'+lane['service_account']
|
||||
capture.command(['delete','--raw',path,'-f','-'],json.dumps({'apiVersion':'v1','kind':'DeleteOptions',
|
||||
'preconditions':{'uid':test['metadata']['uid']}}).encode())
|
||||
receipt['wrong_namespace_probe_cleanup_verified']=True
|
||||
row=data(bao(['read','-format=json','auth/kubernetes/role/'+lane['role']]))['data']
|
||||
setup.check_role(row,lane)
|
||||
receipt.update(status='reader_and_delivery_verified',exact_namespace_binding_verified=True,
|
||||
exact_policy_and_ttl_verified=True,wrong_service_account_denied=True,wrong_audience_denied=True,
|
||||
wrong_namespace_denied=True,
|
||||
metadata_sibling_escrow_and_token_create_denied=True,kv_write_denied=True,
|
||||
eso_delivery_equality_verified=True,temporary_reader_revoked=True,secret_values_exported=False,
|
||||
source_ccr=ccr['id'],ccr_sha256=expected)
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__);p.add_argument('action',choices=setup.PROFILES)
|
||||
p.add_argument('--ccr-sha256',required=True);p.add_argument('--kubeconfig',required=True);p.add_argument('--receipt',required=True)
|
||||
a=p.parse_args();fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
r={'status':'failed','source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest()}
|
||||
try:verify(a.action,a.ccr_sha256,r,a.kubeconfig)
|
||||
except Exception:r.update(status='failed',error='native_delivery_verification_failed_diagnostics_suppressed')
|
||||
finally:
|
||||
with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n')
|
||||
return int(r['status']=='failed')
|
||||
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Attended independent-key recovery of the new data-bearing off-host bundle."""
|
||||
import argparse
|
||||
from datetime import datetime,timezone
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
|
||||
HERE=Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def load(name,path=None):
|
||||
spec=importlib.util.spec_from_file_location(name,path or HERE/(name+'.py'))
|
||||
m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m);return m
|
||||
|
||||
|
||||
bundle=load('identity-bundle');pg=load('check-privacyidea-database')
|
||||
custody=load('sops_private',HERE.parents[1]/'sso-mfa/bootstrap/sops-private.py')
|
||||
|
||||
|
||||
def verify(transfer,keyfd,receipt):
|
||||
if (transfer.get('schema')!='netkingdom.identity-offsite.v1' or transfer.get('status')!='readback_verified'
|
||||
or transfer.get('independent_owner_retrieval_verified') is not True
|
||||
or transfer.get('ciphertext_bytes',0)>bundle.LIMIT):raise bundle.Refused()
|
||||
path=Path(transfer['fetched_path']);bundle.private(path,max_bytes=bundle.LIMIT)
|
||||
with path.open('rb') as source:digest=hashlib.file_digest(source,'sha256').hexdigest()
|
||||
if path.stat().st_size!=transfer['ciphertext_bytes'] or digest!=transfer['ciphertext_sha256']:raise bundle.Refused()
|
||||
derived=pg.run(['age-keygen','-y',f'/proc/self/fd/{keyfd}'],pass_fds=(keyfd,)).strip()
|
||||
if derived!=bundle.profile()['public_recipient'].encode():raise bundle.Refused()
|
||||
receipt.update(ciphertext_sha256=digest,artifact=transfer['artifact'],registered_recipient_match=True)
|
||||
with tempfile.TemporaryDirectory(prefix='nk49-identity-restore-',dir='/tmp') as temporary:
|
||||
work=Path(temporary);env={'PATH':os.environ.get('PATH',os.defpath),'HOME':temporary,'XDG_CONFIG_HOME':temporary}
|
||||
wrong=work/'wrong.key';pg.run(['age-keygen','-o',str(wrong)],env=env)
|
||||
negative=subprocess.run(['age','-d','-i',str(wrong),str(path)],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,env=env,timeout=120)
|
||||
if negative.returncode==0:raise bundle.Refused()
|
||||
receipt['wrong_key_refused']=True
|
||||
plain=work/'archive.tar'
|
||||
with plain.open('xb') as output:
|
||||
result=subprocess.run(['age','-d','-i',f'/proc/self/fd/{keyfd}',str(path)],stdout=output,
|
||||
stderr=subprocess.DEVNULL,env=env,pass_fds=(keyfd,),timeout=180)
|
||||
if result.returncode or plain.stat().st_size>bundle.LIMIT+4*1024*1024:raise bundle.Refused()
|
||||
restored=work/'restored';restored.mkdir(mode=0o700)
|
||||
manifest,checks=bundle.restore(plain,restored)
|
||||
receipt.update(checks=checks,manifest_file_count=len(manifest['files']),all_manifest_hashes_verified=True,
|
||||
profile_sha256=manifest['profile_sha256'])
|
||||
container='nk49-identity-db-'+uuid.uuid4().hex
|
||||
try:
|
||||
receipt['database_restore']=pg.restore(restored/'databases/privacyidea.dump',container)
|
||||
finally:
|
||||
pg.run(['docker','rm','--force','--volumes',container])
|
||||
absent=subprocess.run(['docker','container','inspect',container],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=15)
|
||||
pg.run(['docker','info','--format','{{.ServerVersion}}'])
|
||||
if absent.returncode==0:raise bundle.Refused()
|
||||
receipt['container_cleanup_verified']=True
|
||||
if work.exists():raise bundle.Refused()
|
||||
receipt.update(temporary_plaintext_cleanup_verified=True,status='identity_archive_verified',
|
||||
full_service_authentication_proven=False,existing_token_decryption_proven=False,audit_signature_continuity_proven=False)
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--transfer-receipt',required=True);p.add_argument('--receipt',required=True)
|
||||
a=p.parse_args();os.umask(0o077)
|
||||
fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
r={'schema':'netkingdom.identity-recovery.v1','status':'failed','started_at':datetime.now(timezone.utc).isoformat(),
|
||||
'production_contacted':False,'secret_values_exported':False,'container_cleanup_verified':False,
|
||||
'temporary_plaintext_cleanup_verified':False,'source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest()}
|
||||
keyfd=None
|
||||
try:
|
||||
transfer=json.loads(Path(a.transfer_receipt).read_text());keyfd=custody.private_key_fd()
|
||||
verify(transfer,keyfd,r)
|
||||
except Exception:r.update(status='failed',error='identity_recovery_failed_private_diagnostics_suppressed')
|
||||
finally:
|
||||
if keyfd is not None:os.close(keyfd)
|
||||
r['finished_at']=datetime.now(timezone.utc).isoformat()
|
||||
with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n')
|
||||
if r['status']=='identity_archive_verified':
|
||||
print('OK: off-host identity bundle decrypted; complete manifest, key pairs, SQLite integrity and PostgreSQL restore passed; disposable data removed. Service authentication remains separate acceptance.')
|
||||
return 0
|
||||
print('ERROR: identity recovery failed; review safe receipt. Private diagnostics suppressed.');return 1
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
for sig in (signal.SIGHUP,signal.SIGTERM,signal.SIGINT):signal.signal(sig,custody.interrupted)
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"status": "proposed_not_integrated_not_projected",
|
||||
"owner_head": "878abc93588e12b6da1bac055cfbb3abb8d7adf1",
|
||||
"source_path": "workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md",
|
||||
"baseline_sha256": "sha256:cfe7ee36c6c4cae677eb9f43121e3910e1274e0f5e3589cd147ede25a991947d",
|
||||
"proposal_sha256": "sha256:c1798c52aeff150f672fbe2ad097aaadc2bb98107741b2784fb09993d509703c",
|
||||
"patch_sha256": "sha256:aa11ac3b3cbb36febc436736e7eaa611f8769fbae70b0f026fa9534954aa604c",
|
||||
"baseline_counts": {
|
||||
"done": 23,
|
||||
"progress": 2,
|
||||
"wait": 6,
|
||||
"todo": 2
|
||||
},
|
||||
"proposed_counts": {
|
||||
"done": 24,
|
||||
"progress": 2,
|
||||
"wait": 7,
|
||||
"todo": 3
|
||||
},
|
||||
"all_existing_task_ids_and_hub_uuids_preserved": true,
|
||||
"only_existing_task_status_changed": {
|
||||
"NK-WP-0049-T33": "todo -> wait"
|
||||
},
|
||||
"new_tasks": {
|
||||
"NK-WP-0049-T34": "done: source preparation only",
|
||||
"NK-WP-0049-T35": "todo: original TLS/trust recovery ownership",
|
||||
"NK-WP-0049-T36": "todo: selected registration/private admission bindings"
|
||||
},
|
||||
"canonical_writes": false,
|
||||
"cross_lane": "Coordinated group_provenance; that lane owns NK-WP-0048 only."
|
||||
}
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
--- a/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md
|
||||
+++ b/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md
|
||||
@@ -8,7 +8,7 @@
|
||||
owner: codex
|
||||
topic_slug: netkingdom
|
||||
created: "2026-10-12"
|
||||
-updated: "2026-10-16"
|
||||
+updated: "2026-10-17"
|
||||
flavor: implementation
|
||||
depends_on: []
|
||||
state_hub_workstream_id: "17d315f0-829e-57c7-85cc-3a7914152804"
|
||||
@@ -28,8 +28,8 @@
|
||||
or newly granted authority. The earlier dated narrative remains in Git at
|
||||
`cc2dd01`; its evidence references are carried into the tasks below.
|
||||
|
||||
-**Current position:** 22 tasks done; 2 in progress; 1 ready to start (`todo`);
|
||||
-6 waiting on explicit prerequisites. These are task counts, not an effort-based
|
||||
+**Current position, source refinement 2026-10-17:** 24 tasks done; 2 in
|
||||
+progress; 3 ready to start (`todo`); 7 waiting on explicit prerequisites. These are task counts, not an effort-based
|
||||
completion percentage. Assignees are also written in task descriptions because
|
||||
the current Forge projector does not populate the dedicated Hub assignee field. The outage-retrieval tool and restore-packet draft are now prepared;
|
||||
independent outage retrieval and Storage Share publication are accepted;
|
||||
@@ -958,27 +958,147 @@
|
||||
|
||||
```task
|
||||
id: NK-WP-0049-T33
|
||||
+status: wait
|
||||
+priority: high
|
||||
+assignee: codex
|
||||
+depends_on: [NK-WP-0049-T32, NK-WP-0049-T34, NK-WP-0049-T35, NK-WP-0049-T36]
|
||||
+blocking_reason: "Source custody/preflight preparation is evidenced in T34; await original TLS/trust recovery disposition T35 and selected original registration/private-admission metadata T36 before final reviewed command and existing owner process."
|
||||
+state_hub_task_id: "56c8d469-7a10-5d50-a30e-95cbf3cd4756"
|
||||
+```
|
||||
+
|
||||
+**Accountable worker:** NetKingdom codex with KeyCape and selected consumer owners.
|
||||
+This umbrella retains final attempt readiness. Completed custody/preflight source
|
||||
+preparation is extracted into T34; T35 resolves original TLS/trust recovery and
|
||||
+T36 prepares the selected original registration and private admission. This split
|
||||
+records distinct outcomes without counting source preparation as recovery.
|
||||
+
|
||||
+After those deliverables, assemble the exact existing six-argument attended
|
||||
+command and admission digest, bind the integrated source/dependency revision and
|
||||
+fixed local target, and record execution deadline, safe receipt location, abort,
|
||||
+rollback and independent receipt verification through the existing owner process.
|
||||
+Keep private manifest derivation inside custody and original TLS/client/key inputs
|
||||
+in the owner's own terminal; the current null fields must refuse execution.
|
||||
+Existing session authorization is retained within scope; this record does not
|
||||
+supply missing owner assent or approve any actual recovery attempt.
|
||||
+
|
||||
+**Done when:** reviewed executable command and complete metadata-only/private
|
||||
+admission packet are ready for the existing attended custody path; prerequisites
|
||||
+and independent receipt verification are explicit. T18 still owns actual
|
||||
+execution, original consumer continuity and acceptance. T19 retains retained-factor
|
||||
+MFA. This task neither authorizes a production consumer redirect nor creates a
|
||||
+new client grant.
|
||||
+
|
||||
+## Deliver attended custody, metadata preflight and private manifest preparation source
|
||||
+
|
||||
+```task
|
||||
+id: NK-WP-0049-T34
|
||||
+status: done
|
||||
+priority: high
|
||||
+assignee: codex
|
||||
+depends_on: [NK-WP-0049-T32]
|
||||
+```
|
||||
+
|
||||
+**Accountable worker:** NetKingdom codex; independent Custodian source review.
|
||||
+Completed 2026-10-17 as source preparation. Existing attended custody entry points
|
||||
+now have a reviewed candidate sealed anonymous descriptor mode: hidden prompt,
|
||||
+actual public-recipient verification, strict owned/private/full-seal descriptor
|
||||
+admission, non-inheritable returned duplicate and explicit descriptor passing.
|
||||
+The existing file mode stays compatible, with its original hard-loss limitation;
|
||||
+no improved file-mode cleanup is claimed. Exact parent-loss tests prove descriptor
|
||||
+lifetime without signalling inherited user shell groups. An admitted child retains
|
||||
+its descriptor until it exits; parent loss is not child revocation.
|
||||
+
|
||||
+The candidate checks bounded, duplicate-free public JSON, exact admission digest,
|
||||
+transfer/archive and selected service bindings, typed hashes for all 17 invoked
|
||||
+local source/config dependencies and required Python imports before requesting a
|
||||
+key. TLS and ciphertext preparation uses file metadata only. Legacy explicit
|
||||
+manifest digests remain supported; versioned ciphertext-derived binding computes
|
||||
+the canonical digest privately only after exact reviewed ciphertext, authenticated
|
||||
+age decryption and full existing archive validation. The derived private digest
|
||||
+never enters safe receipts/checkpoints and grants no new consumer authority.
|
||||
+
|
||||
+The coordinator independently passed 111 focused checks. The existing custody
|
||||
+regression suite passed 94 checks; 61 real-SOPS tests skipped because that trusted
|
||||
+runtime is unavailable, so that compatibility coverage remains unverified. Tests
|
||||
+use generated keys/archive material only, including actual age cryptography,
|
||||
+synthetic hidden-prompt and exact parent-loss processes. Manifest-builder tests
|
||||
+mock Docker/issuer behavior and confer no new native proof. Frozen R6/R7 proof,
|
||||
+intermediate test failures and scanner history remain retained.
|
||||
+
|
||||
+Bounded public image readback separately establishes the cached original image's
|
||||
+13670142-byte `/keycape` executable as
|
||||
+`sha256:cd89121393e1465aa76009763602a5b8c75f9dddb5679aea6242623f135b51b4`.
|
||||
+This is executable identity metadata, not recovery or custody acceptance.
|
||||
+
|
||||
+Evidence: Custodian `docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/`
|
||||
+(`candidate.patch`, `review.json`, source closure, attempt/admission metadata and
|
||||
+`public-verifier-image-readback.json`). Canonical integration/publication and
|
||||
+projection receipts must be recorded separately when performed; this completed
|
||||
+source-preparation record does not invent them. T33 remains open for complete
|
||||
+attempt readiness; actual recovery/consumer acceptance remains T18 and MFA T19.
|
||||
+
|
||||
+## Establish original KeyCape TLS and trust recovery ownership for outage use
|
||||
+
|
||||
+```task
|
||||
+id: NK-WP-0049-T35
|
||||
status: todo
|
||||
priority: high
|
||||
assignee: codex
|
||||
-depends_on: [NK-WP-0049-T32]
|
||||
-state_hub_task_id: "56c8d469-7a10-5d50-a30e-95cbf3cd4756"
|
||||
-```
|
||||
-
|
||||
-**Accountable worker:** NetKingdom codex with KeyCape and selected consumer owners.
|
||||
-Prepare the exact archive/manifest, original issuer/TLS/trust and executable
|
||||
-bindings, selected current registration and dependency graph using public metadata.
|
||||
-Use the existing independently held archive-key and owner-controlled TLS custody
|
||||
-paths; never put secret values in the packet. Validate the attended launcher and
|
||||
-its full dependency closure, execution deadline, recovery obligations and safe
|
||||
-receipt path before asking for any owner-held input. Bind approved source revision
|
||||
-and rollback/abort limits through the existing owner process.
|
||||
-
|
||||
-**Done when:** reviewed executable command and metadata-only admission packet
|
||||
-are ready for the existing attended custody path; prerequisites and independent
|
||||
-receipt verification are explicit. T18 still owns actual execution, original
|
||||
-consumer continuity and acceptance. T19 retains retained-factor MFA. This task
|
||||
-neither authorizes a production consumer redirect nor creates a new client grant.
|
||||
+depends_on: [NK-WP-0049-T34]
|
||||
+```
|
||||
+
|
||||
+**Accountable worker:** NetKingdom codex, coordinating with the source owner of
|
||||
+Ingress/cert-manager/Traefik and the original TLS custody/provider boundary.
|
||||
+**Next action:** identify and record that actual owner's existing recovery path,
|
||||
+then obtain its metadata/disposition through the ordinary owner process. A missing
|
||||
+exact credential-routing catalog match is not evidence that no owner exists.
|
||||
+
|
||||
+Current source declares `kc.coulomb.social`, `sso/kc-tls`, cert-manager
|
||||
+`ClusterIssuer/letsencrypt-prod` and Traefik TLS termination before KeyCape 8080.
|
||||
+The identity backup profile excludes `kc-tls`; the verified archive therefore
|
||||
+establishes no independent recovery of that TLS identity/trust. Determine whether
|
||||
+the exact original certificate/key/trust is independently recoverable during an
|
||||
+issuer/cluster outage, or whether a documented reissue path is acceptable for the
|
||||
+selected continuity claim. Do not read or place the TLS key in work records, use
|
||||
+a fresh self-signed trust substitute, mutate production or claim owner assent.
|
||||
+
|
||||
+**Done when:** named owner/provider, independent outage prerequisites, original
|
||||
+public identity/certificate/trust hashes or accepted reissue disposition, private
|
||||
+custody entry point and verification/abort limits are explicit and reviewed.
|
||||
+Record any unavailable recovery capability as a live owner work record and bind
|
||||
+its dependency/unblock condition; a prose-only gap or untested archive is not
|
||||
+completion. This delivers preparation/disposition, not actual TLS recovery.
|
||||
+
|
||||
+## Prepare selected original service registration and private admission bindings
|
||||
+
|
||||
+```task
|
||||
+id: NK-WP-0049-T36
|
||||
+status: todo
|
||||
+priority: high
|
||||
+assignee: codex
|
||||
+depends_on: [NK-WP-0049-T34]
|
||||
+```
|
||||
+
|
||||
+**Accountable worker:** NetKingdom codex with KeyCape and the selected existing
|
||||
+service consumer's owner. **Next action:** identify that owner and the exact
|
||||
+original registration's non-secret selector metadata using existing source/owner
|
||||
+evidence; preserve independent private custody and never retrieve a client secret.
|
||||
+
|
||||
+Bind reviewed ciphertext `7117b3fe1e4840c86b301c94c85e6b16709e5626bb0884bd0580bde630e30e30`,
|
||||
+its independently verified transfer, source/image/executable closure and an
|
||||
+explicit versioned manifest-binding mode. For the original service registration,
|
||||
+record client identifier, secret environment name, audience, granted scope,
|
||||
+subject, tenant, required roles and denied scope without values or new grants.
|
||||
+Describe the native service-client dependency graph and distinguish unexercised
|
||||
+LLDAP/Authelia/privacyIDEA/downstream flows. Keep private manifest material inside
|
||||
+the existing custody process, never in Git, safe receipts, State Hub or chat.
|
||||
+
|
||||
+**Done when:** the selected existing service owner and exact non-secret registration
|
||||
+bindings are independently established, the private admission builder validates
|
||||
+positive/negative source/archive/selector cases, and its metadata is ready to
|
||||
+combine with T35's original TLS/trust disposition under T33. This task may prepare
|
||||
+registration metadata while T35 proceeds independently; it does not finalize the
|
||||
+whole admission, perform actual recovery, redirect a consumer or confer assent.
|
||||
|
||||
## Accept remaining recovery and caller evidence and close the G08 handoff
|
||||
|
||||
@@ -999,7 +1119,7 @@
|
||||
G08 receiving the final evidence. The completed archive/job milestones remain
|
||||
accepted independently of this final gate.
|
||||
|
||||
-**Done when:** T02 and T15–T31 have their stated completed or explicitly accepted
|
||||
+**Done when:** T02 and T15–T36 have their stated completed or explicitly accepted
|
||||
handoff outcomes; service authentication/token/audit proof and current/legacy
|
||||
caller dispositions are accepted; the source workplan and projected tasks agree;
|
||||
and G08 has an exact evidence/handoff record. Record actionable residuals as live
|
||||
@@ -1035,3 +1155,11 @@
|
||||
remain retained. Custodian identity-restore-adapter-next/canonical-r7-integration.json
|
||||
binds source/projection/native-fixture evidence separately. T18 remains waiting
|
||||
for the actual owner-held attempt and selected consumer acceptance.
|
||||
+
|
||||
+
|
||||
+2026-10-17 source/attempt refinement: T34 records completed attended custody,
|
||||
+preflight/private-manifest source preparation and its bounded tests; T35 and T36
|
||||
+make original TLS recovery ownership and selected original admission metadata live
|
||||
+work. T33 retains final attempt readiness and its original Hub UUID. This adds no
|
||||
+actual recovery, MFA, native authority or owner assent. Source task counts above
|
||||
+refer to this file; projection has not been claimed for this proposed refinement.
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
"status": "derived_local_objects_not_central_publication",
|
||||
"mode_source": "coordination-engine/spec/cross-owner-wait-mode-v0.1.md",
|
||||
"new_driver_created": false,
|
||||
"runtime_worker_injection_enabled": false,
|
||||
"cases": [
|
||||
{
|
||||
"scenario_type": "cross_owner_wait",
|
||||
"case_id": "nk49-t35-rcluster18",
|
||||
"obligor_workplan_id": "RCLUSTER-WP-0018",
|
||||
"obligor_task_id": "RCLUSTER-WP-0018-T02",
|
||||
"beneficiary_workplan_id": "NK-WP-0049",
|
||||
"beneficiary_task_id": "NK-WP-0049-T35",
|
||||
"flavor": "implementation",
|
||||
"state": "observed",
|
||||
"reason": "Owner source handoff prepared; canonical projection pending",
|
||||
"coordination_context": "CUST-WP-0080"
|
||||
},
|
||||
{
|
||||
"scenario_type": "cross_owner_wait",
|
||||
"case_id": "nk49-t36-secrets15",
|
||||
"obligor_workplan_id": "SECRETS-WP-0015",
|
||||
"obligor_task_id": "SECRETS-WP-0015-T03",
|
||||
"beneficiary_workplan_id": "NK-WP-0049",
|
||||
"beneficiary_task_id": "NK-WP-0049-T36",
|
||||
"flavor": "implementation",
|
||||
"state": "observed",
|
||||
"reason": "Owner source handoff prepared; canonical projection pending",
|
||||
"coordination_context": "CUST-WP-0080"
|
||||
},
|
||||
{
|
||||
"scenario_type": "cross_owner_wait",
|
||||
"case_id": "secrets15-nk49-readiness",
|
||||
"obligor_workplan_id": "NK-WP-0049",
|
||||
"obligor_task_id": "NK-WP-0049-T33",
|
||||
"beneficiary_workplan_id": "SECRETS-WP-0015",
|
||||
"beneficiary_task_id": "SECRETS-WP-0015-T04",
|
||||
"flavor": "implementation",
|
||||
"state": "waiting",
|
||||
"reason": "Foreign source readiness remains incomplete; beneficiary also has a separate attended human gate",
|
||||
"coordination_context": "CUST-WP-0080",
|
||||
"human_gate_never_woken": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"schema": "secrets-engine.recovery-selector-preflight.v1",
|
||||
"status": "source_selector_matches_not_native_assent",
|
||||
"source_sha256": "sha256:c0ade429edf452ad17f05d690b7ebef091db7f1a42b5ac350f1e40955c8586c0",
|
||||
"consumer": {
|
||||
"client_id": "secrets-engine-openbao",
|
||||
"secret_env": "KEYCAPE_SECRETS_ENGINE_OPENBAO_CLIENT_SECRET",
|
||||
"audience": "secrets-engine-openbao",
|
||||
"scope": "openbao:login",
|
||||
"subject": "service:secrets-engine",
|
||||
"tenant": "tenant:platform",
|
||||
"roles": "secrets-engine",
|
||||
"deny_scope": "openbao:admin"
|
||||
},
|
||||
"credential_source_exercised": false,
|
||||
"network_contacted": false
|
||||
}
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
{
|
||||
"schema": "nk49.recovery-source-owner-handoffs.v1",
|
||||
"status": "isolated_patches_ready_for_root_review_not_integrated",
|
||||
"owners": {
|
||||
"railiance-cluster": {
|
||||
"baseline_head": "cac8eabef5a0d0990a7b0de8f5e2c762666ac7f0",
|
||||
"paths": {
|
||||
"tools/public-tls-metadata.py": "sha256:87c167e44f0fcf154c9f2afae2187bebd33e5f220c05f579c1233b58bfe4f30b",
|
||||
"tests/test_public_tls_metadata.py": "sha256:200a5be79dc6a715e22798b63e481431c3afeb2c4d7b71b26375d016a0c45866",
|
||||
"docs/keycape-tls-recovery-boundary.md": "sha256:f4bcb1d1832c566f9c87793ed2a461c8cd8a00859dc4ae66cd3c160d7ceefa70",
|
||||
"workplans/RCLUSTER-WP-0018-keycape-original-tls-recovery.md": "sha256:4a0d616ccbea199053531e82276441be21d0f168a63056a456375e27f58fc9af"
|
||||
},
|
||||
"workplan": "workplans/RCLUSTER-WP-0018-keycape-original-tls-recovery.md",
|
||||
"tasks": [
|
||||
"RCLUSTER-WP-0018-T01",
|
||||
"RCLUSTER-WP-0018-T02",
|
||||
"RCLUSTER-WP-0018-T03",
|
||||
"RCLUSTER-WP-0018-T04",
|
||||
"RCLUSTER-WP-0018-T05"
|
||||
],
|
||||
"canonical_additions_all_absent": true
|
||||
},
|
||||
"secrets-engine": {
|
||||
"baseline_head": "535681052cd55ca613706ecc60c39d299929b3c5",
|
||||
"paths": {
|
||||
"tools/check-keycape-recovery-selector.py": "sha256:e3e5e3ee743a212247701d6cb40fcf5b7f19349126fa9b32cbef9a3cc9459150",
|
||||
"tests/test_keycape_recovery_selector.py": "sha256:221112ba303c58bc4205b577359ca90d8a6f60d09eb0b538990b1e53a156e262",
|
||||
"docs/keycape-isolated-recovery-selector.md": "sha256:ed23b0df3d4b49e4b76df795455153c805971ccd37886836c960f550709e0739",
|
||||
"docs/keycape-recovery-selector.json": "sha256:02077af3c18b24f5e4a85992a3d4d0df51a8e458de1e48b48f7eb5f482257012",
|
||||
"workplans/SECRETS-WP-0015-original-keycape-recovery-selector.md": "sha256:22b148f0f403b8a9d471f34b2fae6938066123203d5c6f21ad463c5c262705c5"
|
||||
},
|
||||
"workplan": "workplans/SECRETS-WP-0015-original-keycape-recovery-selector.md",
|
||||
"tasks": [
|
||||
"SECRETS-WP-0015-T01",
|
||||
"SECRETS-WP-0015-T02",
|
||||
"SECRETS-WP-0015-T03",
|
||||
"SECRETS-WP-0015-T04"
|
||||
],
|
||||
"canonical_additions_all_absent": true
|
||||
}
|
||||
},
|
||||
"patches": {
|
||||
"railiance-cluster": "sha256:7a8e4c9620881b18a5752424f5f46e6bd263eba2cc0bbf24a96968b7f9d04287",
|
||||
"secrets-engine": "sha256:a797d299c719f8c998a7514c8b6685a3f75652e38c12e940569d37bb6bd03da2"
|
||||
},
|
||||
"prior_public_sources_revalidated": [
|
||||
{
|
||||
"repo": "net-kingdom",
|
||||
"path": "sso-mfa/k8s/keycape/ingress.yaml",
|
||||
"recorded_sha256": "sha256:bc5c7524a82de5e8b9755e7d7d408b4a66e034424cb2663c1d21352d292d5a72",
|
||||
"current_bytes_match": true
|
||||
},
|
||||
{
|
||||
"repo": "net-kingdom",
|
||||
"path": "tools/bootstrap-recovery/identity-backup-profile.json",
|
||||
"recorded_sha256": "sha256:40f9e5b5bb957e5b23e03b31701702b850502d16a92a0fa0ebf92618cc3f18a1",
|
||||
"current_bytes_match": true
|
||||
},
|
||||
{
|
||||
"repo": "net-kingdom",
|
||||
"path": "sso-mfa/k8s/keycape/secrets-engine-openbao-client-rollout.py",
|
||||
"recorded_sha256": "sha256:8b834e5f0353a5e11797637420609e06e7bf7de47777c7e78b774f74d4a6afbd",
|
||||
"current_bytes_match": true
|
||||
},
|
||||
{
|
||||
"repo": "net-kingdom",
|
||||
"path": "sso-mfa/k8s/keycape/deployment.yaml",
|
||||
"recorded_sha256": "sha256:83777942bc922c7d1719b0ad0b09e873105c342dae3b013bb65eeccf6d905626",
|
||||
"current_bytes_match": true
|
||||
},
|
||||
{
|
||||
"repo": "net-kingdom",
|
||||
"path": "workplans/NK-WP-0046-secrets-engine-openbao-client-registration.md",
|
||||
"recorded_sha256": "sha256:ef38481d26e599802d94f8b06a4d89c7c99a83c81ca5be9bcf7422b885867479",
|
||||
"current_bytes_match": true
|
||||
},
|
||||
{
|
||||
"repo": "net-kingdom",
|
||||
"path": "sso-mfa/k8s/keycape/README.md",
|
||||
"recorded_sha256": "sha256:e7a002996fd57477f6bcd054264076fe04da17f8685095163c1c80dd57b753f2",
|
||||
"current_bytes_match": true
|
||||
},
|
||||
{
|
||||
"repo": "key-cape",
|
||||
"path": "workplans/KEY-WP-0037-secrets-engine-openbao-registration-custody.md",
|
||||
"recorded_sha256": "sha256:3a90a0c460fc439570820b96d1ddc3b7f8646c557ca3a8aa27c6d546bb1a845a",
|
||||
"current_bytes_match": true
|
||||
},
|
||||
{
|
||||
"repo": "key-cape",
|
||||
"path": "docs/evidence/2026-10-07-secrets-engine-openbao-issuance.json",
|
||||
"recorded_sha256": "sha256:a405ef5289159c525695352ba71da7eca8e897b6f2dc7e5795f9b239361d8068",
|
||||
"current_bytes_match": true
|
||||
},
|
||||
{
|
||||
"repo": "secrets-engine",
|
||||
"path": "docs/evidence/2026-10-10-keycape-openbao-probe.json",
|
||||
"recorded_sha256": "sha256:bf0b5df9f030499d96770f1d9fb2091a8d37da71422a6d456be7200d3a1a1782",
|
||||
"current_bytes_match": true
|
||||
},
|
||||
{
|
||||
"repo": "secrets-engine",
|
||||
"path": "docs/keycape-openbao-consumer-custody.md",
|
||||
"recorded_sha256": "sha256:73efdd549f3feaccc53eefd6f1b95c8b9eb7091648f94ad80978ba5381be9124",
|
||||
"current_bytes_match": true
|
||||
},
|
||||
{
|
||||
"repo": "railiance-cluster",
|
||||
"path": "SCOPE.md",
|
||||
"recorded_sha256": "sha256:e4f6a8dd3d72a1f04f0d94721d57ca2a4c7e98790a8373876a5005b87ec5d8a6",
|
||||
"current_bytes_match": true
|
||||
},
|
||||
{
|
||||
"repo": "railiance-cluster",
|
||||
"path": "docs/rail-kubernetes-substrate-profile.md",
|
||||
"recorded_sha256": "sha256:75beeca7ef9670efc0b92c1ef8a33e24b72acbcdee06ee8de3c6492cb69d4f6b",
|
||||
"current_bytes_match": true
|
||||
},
|
||||
{
|
||||
"repo": "railiance-cluster",
|
||||
"path": "tools/cmd/railiance-backup-s2",
|
||||
"recorded_sha256": "sha256:35c60b7ccccd7b4d5a1cf2134c1f2ca78c45040a7ffcc55f2c32c385a0a682a3",
|
||||
"current_bytes_match": true
|
||||
},
|
||||
{
|
||||
"repo": "railiance-cluster",
|
||||
"path": "tools/cmd/railiance-restore-s2",
|
||||
"recorded_sha256": "sha256:25be58968fee3eeb6069e5af08abe601e8f60f04241e8de4bb917a7c639882e5",
|
||||
"current_bytes_match": true
|
||||
},
|
||||
{
|
||||
"repo": "railiance-cluster",
|
||||
"path": "docs/backup-restore.md",
|
||||
"recorded_sha256": "sha256:4d0bbaad3d69f10179ba9590d3fa12fba2967202c756ecf1952436d1e27de902",
|
||||
"current_bytes_match": true
|
||||
},
|
||||
{
|
||||
"repo": "railiance-master",
|
||||
"path": "docs/rail-kubernetes-boundary.md",
|
||||
"recorded_sha256": "sha256:b7a382622e241c174b11a8645aba1801693f22950bb132760b2170fcb828ced1",
|
||||
"current_bytes_match": true
|
||||
},
|
||||
{
|
||||
"repo": "railiance-platform",
|
||||
"path": "docs/backup-provider-coverage.md",
|
||||
"recorded_sha256": "sha256:f5983aac1e8cc1828e9f73340b1f66c0e67cdcddc1a1e49cbe6f5e39e90c6641",
|
||||
"current_bytes_match": true
|
||||
}
|
||||
],
|
||||
"tests": {
|
||||
"public_tls_and_selector_checks_passed": 18,
|
||||
"actual_current_consumer_source_preflight": "passed",
|
||||
"real_certificate_or_key_read": false,
|
||||
"network_contacted": false
|
||||
},
|
||||
"supervision": "Custodian assignment remains supervised; no owner-specific .kaizen record found, no promotion/scope authority inherited. Source preparation only.",
|
||||
"existing_records": {
|
||||
"cluster": "0017 signer only;0007 backlog/HA gated;archived safety-net local backup only; no live original TLS recovery record located",
|
||||
"secrets": "0012 remains finished;0008T06+0013 retain functional/backend entitlement scope"
|
||||
},
|
||||
"canonical_mutations": false,
|
||||
"secrets_retrieved": false,
|
||||
"messages_sent": false,
|
||||
"native_assent": false,
|
||||
"publication_next": "Root independent review and authorized canonical apply; normal per-repo consistency projection; verify new IDs/statuses and attach derived case receipts without claiming queued/absent projection."
|
||||
}
|
||||
|
|
@ -0,0 +1,278 @@
|
|||
--- /dev/null
|
||||
+++ b/tools/public-tls-metadata.py
|
||||
@@ -0,0 +1,55 @@
|
||||
+#!/usr/bin/env python3
|
||||
+"""Owner-run public certificate/trust metadata; no private-key argument or network.
|
||||
+
|
||||
+The owner supplies public-only PEM files from the independently established
|
||||
+original recovery source. This does not identify that source or accept reissue.
|
||||
+"""
|
||||
+import argparse
|
||||
+from datetime import timezone
|
||||
+import hashlib
|
||||
+import json
|
||||
+import os
|
||||
+from pathlib import Path
|
||||
+import re
|
||||
+import stat
|
||||
+from cryptography import x509
|
||||
+from cryptography.hazmat.primitives import serialization
|
||||
+
|
||||
+LIMIT=1024*1024
|
||||
+CERT=re.compile(br'-----BEGIN CERTIFICATE-----\r?\n[A-Za-z0-9+/=\r\n]+-----END CERTIFICATE-----')
|
||||
+
|
||||
+def public_bundle(path):
|
||||
+ # Bound the read before allocation; refuse every non-certificate PEM block.
|
||||
+ path=Path(os.path.abspath(path))
|
||||
+ if any(p.is_symlink() for p in (path,*path.parents)) or not stat.S_ISREG(path.stat().st_mode) or not 0<path.stat().st_size<=LIMIT:raise ValueError()
|
||||
+ fd=os.open(path,os.O_RDONLY|os.O_NOFOLLOW|os.O_NONBLOCK)
|
||||
+ with os.fdopen(fd,'rb') as source:
|
||||
+ if not stat.S_ISREG(os.fstat(source.fileno()).st_mode) or not 0<os.fstat(source.fileno()).st_size<=LIMIT:raise ValueError()
|
||||
+ raw=source.read(LIMIT+1)
|
||||
+ if len(raw)>LIMIT:raise ValueError()
|
||||
+ blocks=CERT.findall(raw)
|
||||
+ if not blocks or len(blocks)>512 or CERT.sub(b'',raw).strip():raise ValueError()
|
||||
+ return raw,[x509.load_pem_x509_certificate(block) for block in blocks]
|
||||
+
|
||||
+def describe(cert_path,trust_path):
|
||||
+ certificate,chain=public_bundle(cert_path);trust,roots=public_bundle(trust_path);leaf=chain[0]
|
||||
+ try:names=leaf.extensions.get_extension_for_class(x509.SubjectAlternativeName).value.get_values_for_type(x509.DNSName)
|
||||
+ except x509.ExtensionNotFound:names=[]
|
||||
+ spki=leaf.public_key().public_bytes(serialization.Encoding.DER,serialization.PublicFormat.SubjectPublicKeyInfo)
|
||||
+ def utc_date(name):
|
||||
+ value=getattr(leaf,name+'_utc',None)
|
||||
+ if value is None:value=getattr(leaf,name).replace(tzinfo=timezone.utc)
|
||||
+ return value.isoformat()
|
||||
+ return {'schema':'nk49.public-tls-metadata.v1','status':'public_metadata_only_not_originality_or_owner_assent',
|
||||
+ 'tls_cert_sha256':hashlib.sha256(certificate).hexdigest(),'trust_sha256':hashlib.sha256(trust).hexdigest(),
|
||||
+ 'leaf_public_spki_sha256':'sha256:'+hashlib.sha256(spki).hexdigest(),'leaf_dns_sans':names,
|
||||
+ 'leaf_not_before':utc_date('not_valid_before'),'leaf_not_after':utc_date('not_valid_after'),
|
||||
+ 'server_chain_certificates':len(chain),'trust_certificates':len(roots),'network_contacted':False,'private_key_argument_supported':False}
|
||||
+
|
||||
+def main():
|
||||
+ p=argparse.ArgumentParser(description=__doc__);p.add_argument('--public-cert',required=True);p.add_argument('--public-trust',required=True);a=p.parse_args()
|
||||
+ try:result=describe(a.public_cert,a.public_trust)
|
||||
+ except Exception:print('{"status":"public_metadata_refused","private_diagnostics_suppressed":true}');return 1
|
||||
+ print(json.dumps(result,indent=2));return 0
|
||||
+
|
||||
+if __name__=='__main__':raise SystemExit(main())
|
||||
--- /dev/null
|
||||
+++ b/tests/test_public_tls_metadata.py
|
||||
@@ -0,0 +1,34 @@
|
||||
+import importlib.util
|
||||
+from pathlib import Path
|
||||
+from datetime import datetime,timedelta,timezone
|
||||
+from unittest.mock import patch
|
||||
+import pytest
|
||||
+from cryptography import x509
|
||||
+from cryptography.hazmat.primitives import hashes,serialization
|
||||
+from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
+from cryptography.x509.oid import NameOID
|
||||
+
|
||||
+spec=importlib.util.spec_from_file_location('metadata',Path(__file__).parents[1]/'tools/public-tls-metadata.py');m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m)
|
||||
+@pytest.fixture
|
||||
+def cert(tmp_path):
|
||||
+ key=rsa.generate_private_key(public_exponent=65537,key_size=2048);name=x509.Name([x509.NameAttribute(NameOID.COMMON_NAME,'synthetic.invalid')]);now=datetime.now(timezone.utc)
|
||||
+ cert=(x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(key.public_key()).serial_number(1).not_valid_before(now-timedelta(minutes=1)).not_valid_after(now+timedelta(hours=1)).add_extension(x509.SubjectAlternativeName([x509.DNSName('synthetic.invalid')]),critical=False).sign(key,hashes.SHA256()))
|
||||
+ p=tmp_path/'public.pem';p.write_bytes(cert.public_bytes(serialization.Encoding.PEM));return p
|
||||
+
|
||||
+def test_public_only_metadata_and_hashes(cert):
|
||||
+ result=m.describe(cert,cert)
|
||||
+ assert result['leaf_dns_sans']==['synthetic.invalid'];assert result['network_contacted'] is False
|
||||
+ assert '-----BEGIN' not in str(result)
|
||||
+
|
||||
+def test_mixed_or_private_pem_is_refused(cert):
|
||||
+ cert.write_bytes(cert.read_bytes()+b'\n-----BEGIN PRIVATE KEY-----\nsynthetic\n-----END PRIVATE KEY-----')
|
||||
+ with pytest.raises(ValueError):m.public_bundle(cert)
|
||||
+
|
||||
+def test_oversize_is_refused_before_read(cert):
|
||||
+ with patch.object(m,'LIMIT',1),patch.object(m.os,'open') as opened:
|
||||
+ with pytest.raises(ValueError):m.public_bundle(cert)
|
||||
+ opened.assert_not_called()
|
||||
+
|
||||
+def test_symlink_refused(cert):
|
||||
+ link=cert.with_name('link.pem');link.symlink_to(cert)
|
||||
+ with pytest.raises(ValueError):m.public_bundle(link)
|
||||
--- /dev/null
|
||||
+++ b/docs/keycape-tls-recovery-boundary.md
|
||||
@@ -0,0 +1,51 @@
|
||||
+# Original KeyCape TLS recovery boundary
|
||||
+
|
||||
+Source disposition, 2026-10-17, scoped to railiance-cluster INTENT: cluster runtime,
|
||||
+ingress/certificate plumbing and backup posture. This is source preparation only.
|
||||
+It is not native acceptance, an issuer/key custody assent, a reissue decision or
|
||||
+permission to mutate the cluster.
|
||||
+
|
||||
+NetKingdom's ingress source names `kc.coulomb.social`, `sso/kc-tls`,
|
||||
+`ClusterIssuer/letsencrypt-prod` and Traefik termination before KeyCape8080.
|
||||
+NetKingdom owns that namespace intent; railiance-cluster owns cert-manager,
|
||||
+ingress controller and cluster backup posture. No inspected source establishes
|
||||
+who independently holds the original private TLS recovery material when the
|
||||
+ordinary issuer/cluster is unavailable. A missing exact routing-catalog entry
|
||||
+cannot prove absence of an owning subsystem.
|
||||
+
|
||||
+The identity archive excludes `kc-tls`. Current S2 backup source encrypts SQLite
|
||||
+state, Helm values and kubeconfig to the same host's `/opt/backup/railiance/cluster`
|
||||
+and retains seven versions. No inspected source proves an actually fresh,
|
||||
+independent offsite object or original certificate/key/trust recovery. Archived
|
||||
+RAIL-BS-WP-0004 is historical local-backup delivery; backlog RCLUSTER-WP-0007-T05
|
||||
+is gated HA issuance/renewal; active RCLUSTER-WP-0017 is signer runtime/storage.
|
||||
+None is an existing live task for this original TLS recovery demand.
|
||||
+
|
||||
+`railiance-backup-s2` also stages raw state/Helm output in named `/tmp` paths
|
||||
+without early hard-loss cleanup; `railiance-restore-s2` prints whole-state restore
|
||||
+instructions. Do not use either as an isolated TLS extraction path. RCLUSTER-WP-0018
|
||||
+tracks exact inventory, safe private staging/extraction preparation where required,
|
||||
+independent outage proof and future archive-boundary disposition. No original
|
||||
+ciphertext or historical outcome is rewritten by that work.
|
||||
+
|
||||
+`tools/public-tls-metadata.py` is an offline public-only step. After the source
|
||||
+owner identifies independent original **public certificate/chain and trust files**:
|
||||
+
|
||||
+```sh
|
||||
+python3 tools/public-tls-metadata.py --public-cert ORIGINAL_PUBLIC_CERT_CHAIN.pem --public-trust ORIGINAL_PUBLIC_TRUST.pem
|
||||
+```
|
||||
+
|
||||
+It accepts no private-key argument, bounds regular-file reads, refuses symlinks and
|
||||
+non-certificate PEM material, and reports public hashes/SANs/validity/counts only.
|
||||
+The file names are owner-local placeholders, not a key request. Output establishes
|
||||
+public metadata, not originality, hostname/chain acceptance, reissue permission or
|
||||
+custody. Do not supply a Secret object or combined certificate/key PEM. Fail closed
|
||||
+when original source, trust or exact bindings are missing; no fresh self-signed
|
||||
+trust workaround is admitted.
|
||||
+
|
||||
+For FUTURE NetKingdom archives, the source owners must explicitly disposition
|
||||
+original TLS coverage or a reviewed reissue contract: producer/custody owner,
|
||||
+private artifact boundary, offline recovery independence, original public hashes,
|
||||
+scoped capture/read permissions, retention and native verification. Merely adding
|
||||
+`kc-tls` to secret_refs would change capture/permission/custody scope and is not
|
||||
+authorized by this source review. It cannot repair the current archive retroactively.
|
||||
--- /dev/null
|
||||
+++ b/workplans/RCLUSTER-WP-0018-keycape-original-tls-recovery.md
|
||||
@@ -0,0 +1,126 @@
|
||||
+---
|
||||
+id: RCLUSTER-WP-0018
|
||||
+type: workplan
|
||||
+title: "Establish original KeyCape TLS recovery and future archive boundary"
|
||||
+domain: financials
|
||||
+repo: railiance-cluster
|
||||
+status: ready
|
||||
+owner: codex
|
||||
+topic_slug: railiance
|
||||
+created: "2026-10-17"
|
||||
+updated: "2026-10-17"
|
||||
+flavor: implementation
|
||||
+depends_on: []
|
||||
+related: [CUST-WP-0080, NK-WP-0049, RCLUSTER-WP-0007, RCLUSTER-WP-0017]
|
||||
+---
|
||||
+
|
||||
+Required owner source work for NK-WP-0049-T35 and CUST-WP-0080 G08. Reviewed
|
||||
+against current INTENT/SCOPE, backup sources and existing work records; ready
|
||||
+means work can proceed, not privileged execute authority. This does not promote
|
||||
+HA/backlog work, reopen archived local backup delivery or duplicate signer0017.
|
||||
+Source-owner scope is certificate plumbing/backup posture; NetKingdom owns
|
||||
+namespace ingress, while exact original independent TLS custody remains to be
|
||||
+established. No secret read, reissue, live change or native assent occurred.
|
||||
+
|
||||
+## Record source boundary and public-only metadata preparation
|
||||
+
|
||||
+```task
|
||||
+id: RCLUSTER-WP-0018-T01
|
||||
+status: done
|
||||
+priority: high
|
||||
+assignee: codex
|
||||
+```
|
||||
+
|
||||
+Completed 2026-10-17 as isolated source preparation. Independently reviewed the
|
||||
+17 public source references against current owner bytes, checked current owner
|
||||
+instructions/environment/supervision and existing plans, and recorded the exact
|
||||
+boundary in docs/keycape-tls-recovery-boundary.md. Prepared bounded public-only
|
||||
+certificate/trust metadata projection with generated-certificate tests; no actual
|
||||
+certificate/key read. Source integration/publication and task projection remain
|
||||
+separate receipts, not claimed by this completed preparation task.
|
||||
+
|
||||
+Evidence: the-custodian docs/changes/netkingdom-target-2026-10-16/
|
||||
+recovery-owner-handoffs-r1/ and prior recovery-attempt-r1/t35-t36-source-next/.
|
||||
+
|
||||
+## Establish exact independent original TLS artifact and provider custody metadata
|
||||
+
|
||||
+```task
|
||||
+id: RCLUSTER-WP-0018-T02
|
||||
+status: todo
|
||||
+priority: high
|
||||
+assignee: codex
|
||||
+```
|
||||
+
|
||||
+Next: identify the actual original sso/kc-tls custody owner and independent public
|
||||
+provenance through existing owner processes. Record artifact/provider, timestamp,
|
||||
+public certificate/trust hashes, private custody entry point and outage dependencies
|
||||
+without values. Source review cannot infer a fresh backup, original key coverage
|
||||
+or independent decryption custody from a local backup script. Use credential
|
||||
+routing before any later credential need; missing route is not proof of absence.
|
||||
+Done when exact independent recovery or an explicitly owned unavailable capability
|
||||
+is evidenced as metadata and a reviewed next action, with no guessed assent.
|
||||
+
|
||||
+## Prepare safe isolated original extraction or explicit reissue alternative
|
||||
+
|
||||
+```task
|
||||
+id: RCLUSTER-WP-0018-T03
|
||||
+status: wait
|
||||
+priority: high
|
||||
+assignee: codex
|
||||
+depends_on: [RCLUSTER-WP-0018-T02]
|
||||
+blocking_reason: "Exact original artifact/provider/custody disposition is required before selecting extraction or reissue source."
|
||||
+```
|
||||
+
|
||||
+From T02, reuse the actual owner path to prepare an exact attended private-only
|
||||
+recovery command and full dependency/cleanup proof. Do not run whole-state restore
|
||||
+or expose raw state/Secret output. If existing backup staging is required, correct
|
||||
+only the needed path with bounded memory-only staging and early exact cleanup,
|
||||
+including error/parent-loss tests before use. Original recovery and accepted
|
||||
+reissue are distinct claims; no self-signed trust substitute, new broker or
|
||||
+implicit production action. Done when the exact source proposal, public metadata
|
||||
+preflight, negatives, timeout, abort and custody obligations are reviewed.
|
||||
+
|
||||
+## Verify admitted original TLS recovery through the actual owner boundary
|
||||
+
|
||||
+```task
|
||||
+id: RCLUSTER-WP-0018-T04
|
||||
+status: wait
|
||||
+priority: high
|
||||
+assignee: codex
|
||||
+needs_human: true
|
||||
+depends_on: [RCLUSTER-WP-0018-T02, RCLUSTER-WP-0018-T03]
|
||||
+blocking_reason: "Await exact reviewed attended owner custody path and its existing scoped authority; no private input or native attempt requested yet."
|
||||
+```
|
||||
+
|
||||
+Only the actual admitted owner action can prove recovered certificate/key pairing,
|
||||
+original hostname/trust and independent outage operation, or the expressly accepted
|
||||
+reissue result. Retain exact source/artifact/target/identity/negative/cleanup receipts.
|
||||
+Human custody/authority gates are not worker wakeups. Source and historical local
|
||||
+backup proof never fill native assent. Return bounded public proof to NK49T35;
|
||||
+no production mutation, credential retrieval or approval is granted by this task.
|
||||
+
|
||||
+## Disposition original TLS coverage in the future identity archive contract
|
||||
+
|
||||
+```task
|
||||
+id: RCLUSTER-WP-0018-T05
|
||||
+status: todo
|
||||
+priority: high
|
||||
+assignee: codex
|
||||
+```
|
||||
+
|
||||
+Next: deliver the source-owned requirement in docs/keycape-tls-recovery-boundary.md
|
||||
+to NetKingdom's existing contract process: original TLS custody or accepted reissue,
|
||||
+independent provider/key recovery, public identity hashes, scoped capture/read
|
||||
+permissions and verification/retention. Record named owner dependencies as live
|
||||
+work before closure. Do not append kc-tls to secret_refs, expand capture credentials
|
||||
+or rewrite a historical archive without those owner reviews/authority. Done when
|
||||
+the future contract requirement has an exact tracked disposition/handoff; it is
|
||||
+not proof of current archive coverage or actual future capture.
|
||||
+
|
||||
+## Coordination and completion
|
||||
+
|
||||
+Beneficiary NK-WP-0049-T35/CUST-WP-0080 remains linked to this obligor record by
|
||||
+derived cross_owner_wait case nk49-t35-rcluster18 in the packet. No new project
|
||||
+driver, live worker injection or message was created. Own T03 wait has an explicit
|
||||
+local prerequisite; T04 is a human gate. Close only after source/native obligations
|
||||
+are proved or an explicit bounded scope disposition hands off remaining live work.
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
# Original KeyCape TLS recovery boundary
|
||||
|
||||
Source disposition, 2026-10-17, scoped to railiance-cluster INTENT: cluster runtime,
|
||||
ingress/certificate plumbing and backup posture. This is source preparation only.
|
||||
It is not native acceptance, an issuer/key custody assent, a reissue decision or
|
||||
permission to mutate the cluster.
|
||||
|
||||
NetKingdom's ingress source names `kc.coulomb.social`, `sso/kc-tls`,
|
||||
`ClusterIssuer/letsencrypt-prod` and Traefik termination before KeyCape8080.
|
||||
NetKingdom owns that namespace intent; railiance-cluster owns cert-manager,
|
||||
ingress controller and cluster backup posture. No inspected source establishes
|
||||
who independently holds the original private TLS recovery material when the
|
||||
ordinary issuer/cluster is unavailable. A missing exact routing-catalog entry
|
||||
cannot prove absence of an owning subsystem.
|
||||
|
||||
The identity archive excludes `kc-tls`. Current S2 backup source encrypts SQLite
|
||||
state, Helm values and kubeconfig to the same host's `/opt/backup/railiance/cluster`
|
||||
and retains seven versions. No inspected source proves an actually fresh,
|
||||
independent offsite object or original certificate/key/trust recovery. Archived
|
||||
RAIL-BS-WP-0004 is historical local-backup delivery; backlog RCLUSTER-WP-0007-T05
|
||||
is gated HA issuance/renewal; active RCLUSTER-WP-0017 is signer runtime/storage.
|
||||
None is an existing live task for this original TLS recovery demand.
|
||||
|
||||
`railiance-backup-s2` also stages raw state/Helm output in named `/tmp` paths
|
||||
without early hard-loss cleanup; `railiance-restore-s2` prints whole-state restore
|
||||
instructions. Do not use either as an isolated TLS extraction path. RCLUSTER-WP-0018
|
||||
tracks exact inventory, safe private staging/extraction preparation where required,
|
||||
independent outage proof and future archive-boundary disposition. No original
|
||||
ciphertext or historical outcome is rewritten by that work.
|
||||
|
||||
`tools/public-tls-metadata.py` is an offline public-only step. After the source
|
||||
owner identifies independent original **public certificate/chain and trust files**:
|
||||
|
||||
```sh
|
||||
python3 tools/public-tls-metadata.py --public-cert ORIGINAL_PUBLIC_CERT_CHAIN.pem --public-trust ORIGINAL_PUBLIC_TRUST.pem
|
||||
```
|
||||
|
||||
It accepts no private-key argument, bounds regular-file reads, refuses symlinks and
|
||||
non-certificate PEM material, and reports public hashes/SANs/validity/counts only.
|
||||
The file names are owner-local placeholders, not a key request. Output establishes
|
||||
public metadata, not originality, hostname/chain acceptance, reissue permission or
|
||||
custody. Do not supply a Secret object or combined certificate/key PEM. Fail closed
|
||||
when original source, trust or exact bindings are missing; no fresh self-signed
|
||||
trust workaround is admitted.
|
||||
|
||||
For FUTURE NetKingdom archives, the source owners must explicitly disposition
|
||||
original TLS coverage or a reviewed reissue contract: producer/custody owner,
|
||||
private artifact boundary, offline recovery independence, original public hashes,
|
||||
scoped capture/read permissions, retention and native verification. Merely adding
|
||||
`kc-tls` to secret_refs would change capture/permission/custody scope and is not
|
||||
authorized by this source review. It cannot repair the current archive retroactively.
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import importlib.util
|
||||
from pathlib import Path
|
||||
from datetime import datetime,timedelta,timezone
|
||||
from unittest.mock import patch
|
||||
import pytest
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes,serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
spec=importlib.util.spec_from_file_location('metadata',Path(__file__).parents[1]/'tools/public-tls-metadata.py');m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m)
|
||||
@pytest.fixture
|
||||
def cert(tmp_path):
|
||||
key=rsa.generate_private_key(public_exponent=65537,key_size=2048);name=x509.Name([x509.NameAttribute(NameOID.COMMON_NAME,'synthetic.invalid')]);now=datetime.now(timezone.utc)
|
||||
cert=(x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(key.public_key()).serial_number(1).not_valid_before(now-timedelta(minutes=1)).not_valid_after(now+timedelta(hours=1)).add_extension(x509.SubjectAlternativeName([x509.DNSName('synthetic.invalid')]),critical=False).sign(key,hashes.SHA256()))
|
||||
p=tmp_path/'public.pem';p.write_bytes(cert.public_bytes(serialization.Encoding.PEM));return p
|
||||
|
||||
def test_public_only_metadata_and_hashes(cert):
|
||||
result=m.describe(cert,cert)
|
||||
assert result['leaf_dns_sans']==['synthetic.invalid'];assert result['network_contacted'] is False
|
||||
assert '-----BEGIN' not in str(result)
|
||||
|
||||
def test_mixed_or_private_pem_is_refused(cert):
|
||||
cert.write_bytes(cert.read_bytes()+b'\n-----BEGIN PRIVATE KEY-----\nsynthetic\n-----END PRIVATE KEY-----')
|
||||
with pytest.raises(ValueError):m.public_bundle(cert)
|
||||
|
||||
def test_oversize_is_refused_before_read(cert):
|
||||
with patch.object(m,'LIMIT',1),patch.object(m.os,'open') as opened:
|
||||
with pytest.raises(ValueError):m.public_bundle(cert)
|
||||
opened.assert_not_called()
|
||||
|
||||
def test_symlink_refused(cert):
|
||||
link=cert.with_name('link.pem');link.symlink_to(cert)
|
||||
with pytest.raises(ValueError):m.public_bundle(link)
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Owner-run public certificate/trust metadata; no private-key argument or network.
|
||||
|
||||
The owner supplies public-only PEM files from the independently established
|
||||
original recovery source. This does not identify that source or accept reissue.
|
||||
"""
|
||||
import argparse
|
||||
from datetime import timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
||||
LIMIT=1024*1024
|
||||
CERT=re.compile(br'-----BEGIN CERTIFICATE-----\r?\n[A-Za-z0-9+/=\r\n]+-----END CERTIFICATE-----')
|
||||
|
||||
def public_bundle(path):
|
||||
# Bound the read before allocation; refuse every non-certificate PEM block.
|
||||
path=Path(os.path.abspath(path))
|
||||
if any(p.is_symlink() for p in (path,*path.parents)) or not stat.S_ISREG(path.stat().st_mode) or not 0<path.stat().st_size<=LIMIT:raise ValueError()
|
||||
fd=os.open(path,os.O_RDONLY|os.O_NOFOLLOW|os.O_NONBLOCK)
|
||||
with os.fdopen(fd,'rb') as source:
|
||||
if not stat.S_ISREG(os.fstat(source.fileno()).st_mode) or not 0<os.fstat(source.fileno()).st_size<=LIMIT:raise ValueError()
|
||||
raw=source.read(LIMIT+1)
|
||||
if len(raw)>LIMIT:raise ValueError()
|
||||
blocks=CERT.findall(raw)
|
||||
if not blocks or len(blocks)>512 or CERT.sub(b'',raw).strip():raise ValueError()
|
||||
return raw,[x509.load_pem_x509_certificate(block) for block in blocks]
|
||||
|
||||
def describe(cert_path,trust_path):
|
||||
certificate,chain=public_bundle(cert_path);trust,roots=public_bundle(trust_path);leaf=chain[0]
|
||||
try:names=leaf.extensions.get_extension_for_class(x509.SubjectAlternativeName).value.get_values_for_type(x509.DNSName)
|
||||
except x509.ExtensionNotFound:names=[]
|
||||
spki=leaf.public_key().public_bytes(serialization.Encoding.DER,serialization.PublicFormat.SubjectPublicKeyInfo)
|
||||
def utc_date(name):
|
||||
value=getattr(leaf,name+'_utc',None)
|
||||
if value is None:value=getattr(leaf,name).replace(tzinfo=timezone.utc)
|
||||
return value.isoformat()
|
||||
return {'schema':'nk49.public-tls-metadata.v1','status':'public_metadata_only_not_originality_or_owner_assent',
|
||||
'tls_cert_sha256':hashlib.sha256(certificate).hexdigest(),'trust_sha256':hashlib.sha256(trust).hexdigest(),
|
||||
'leaf_public_spki_sha256':'sha256:'+hashlib.sha256(spki).hexdigest(),'leaf_dns_sans':names,
|
||||
'leaf_not_before':utc_date('not_valid_before'),'leaf_not_after':utc_date('not_valid_after'),
|
||||
'server_chain_certificates':len(chain),'trust_certificates':len(roots),'network_contacted':False,'private_key_argument_supported':False}
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--public-cert',required=True);p.add_argument('--public-trust',required=True);a=p.parse_args()
|
||||
try:result=describe(a.public_cert,a.public_trust)
|
||||
except Exception:print('{"status":"public_metadata_refused","private_diagnostics_suppressed":true}');return 1
|
||||
print(json.dumps(result,indent=2));return 0
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
---
|
||||
id: RCLUSTER-WP-0018
|
||||
type: workplan
|
||||
title: "Establish original KeyCape TLS recovery and future archive boundary"
|
||||
domain: financials
|
||||
repo: railiance-cluster
|
||||
status: ready
|
||||
owner: codex
|
||||
topic_slug: railiance
|
||||
created: "2026-10-17"
|
||||
updated: "2026-10-17"
|
||||
flavor: implementation
|
||||
depends_on: []
|
||||
related: [CUST-WP-0080, NK-WP-0049, RCLUSTER-WP-0007, RCLUSTER-WP-0017]
|
||||
---
|
||||
|
||||
Required owner source work for NK-WP-0049-T35 and CUST-WP-0080 G08. Reviewed
|
||||
against current INTENT/SCOPE, backup sources and existing work records; ready
|
||||
means work can proceed, not privileged execute authority. This does not promote
|
||||
HA/backlog work, reopen archived local backup delivery or duplicate signer0017.
|
||||
Source-owner scope is certificate plumbing/backup posture; NetKingdom owns
|
||||
namespace ingress, while exact original independent TLS custody remains to be
|
||||
established. No secret read, reissue, live change or native assent occurred.
|
||||
|
||||
## Record source boundary and public-only metadata preparation
|
||||
|
||||
```task
|
||||
id: RCLUSTER-WP-0018-T01
|
||||
status: done
|
||||
priority: high
|
||||
assignee: codex
|
||||
```
|
||||
|
||||
Completed 2026-10-17 as isolated source preparation. Independently reviewed the
|
||||
17 public source references against current owner bytes, checked current owner
|
||||
instructions/environment/supervision and existing plans, and recorded the exact
|
||||
boundary in docs/keycape-tls-recovery-boundary.md. Prepared bounded public-only
|
||||
certificate/trust metadata projection with generated-certificate tests; no actual
|
||||
certificate/key read. Source integration/publication and task projection remain
|
||||
separate receipts, not claimed by this completed preparation task.
|
||||
|
||||
Evidence: the-custodian docs/changes/netkingdom-target-2026-10-16/
|
||||
recovery-owner-handoffs-r1/ and prior recovery-attempt-r1/t35-t36-source-next/.
|
||||
|
||||
## Establish exact independent original TLS artifact and provider custody metadata
|
||||
|
||||
```task
|
||||
id: RCLUSTER-WP-0018-T02
|
||||
status: todo
|
||||
priority: high
|
||||
assignee: codex
|
||||
```
|
||||
|
||||
Next: identify the actual original sso/kc-tls custody owner and independent public
|
||||
provenance through existing owner processes. Record artifact/provider, timestamp,
|
||||
public certificate/trust hashes, private custody entry point and outage dependencies
|
||||
without values. Source review cannot infer a fresh backup, original key coverage
|
||||
or independent decryption custody from a local backup script. Use credential
|
||||
routing before any later credential need; missing route is not proof of absence.
|
||||
Done when exact independent recovery or an explicitly owned unavailable capability
|
||||
is evidenced as metadata and a reviewed next action, with no guessed assent.
|
||||
|
||||
## Prepare safe isolated original extraction or explicit reissue alternative
|
||||
|
||||
```task
|
||||
id: RCLUSTER-WP-0018-T03
|
||||
status: wait
|
||||
priority: high
|
||||
assignee: codex
|
||||
depends_on: [RCLUSTER-WP-0018-T02]
|
||||
blocking_reason: "Exact original artifact/provider/custody disposition is required before selecting extraction or reissue source."
|
||||
```
|
||||
|
||||
From T02, reuse the actual owner path to prepare an exact attended private-only
|
||||
recovery command and full dependency/cleanup proof. Do not run whole-state restore
|
||||
or expose raw state/Secret output. If existing backup staging is required, correct
|
||||
only the needed path with bounded memory-only staging and early exact cleanup,
|
||||
including error/parent-loss tests before use. Original recovery and accepted
|
||||
reissue are distinct claims; no self-signed trust substitute, new broker or
|
||||
implicit production action. Done when the exact source proposal, public metadata
|
||||
preflight, negatives, timeout, abort and custody obligations are reviewed.
|
||||
|
||||
## Verify admitted original TLS recovery through the actual owner boundary
|
||||
|
||||
```task
|
||||
id: RCLUSTER-WP-0018-T04
|
||||
status: wait
|
||||
priority: high
|
||||
assignee: codex
|
||||
needs_human: true
|
||||
depends_on: [RCLUSTER-WP-0018-T02, RCLUSTER-WP-0018-T03]
|
||||
blocking_reason: "Await exact reviewed attended owner custody path and its existing scoped authority; no private input or native attempt requested yet."
|
||||
```
|
||||
|
||||
Only the actual admitted owner action can prove recovered certificate/key pairing,
|
||||
original hostname/trust and independent outage operation, or the expressly accepted
|
||||
reissue result. Retain exact source/artifact/target/identity/negative/cleanup receipts.
|
||||
Human custody/authority gates are not worker wakeups. Source and historical local
|
||||
backup proof never fill native assent. Return bounded public proof to NK49T35;
|
||||
no production mutation, credential retrieval or approval is granted by this task.
|
||||
|
||||
## Disposition original TLS coverage in the future identity archive contract
|
||||
|
||||
```task
|
||||
id: RCLUSTER-WP-0018-T05
|
||||
status: todo
|
||||
priority: high
|
||||
assignee: codex
|
||||
```
|
||||
|
||||
Next: deliver the source-owned requirement in docs/keycape-tls-recovery-boundary.md
|
||||
to NetKingdom's existing contract process: original TLS custody or accepted reissue,
|
||||
independent provider/key recovery, public identity hashes, scoped capture/read
|
||||
permissions and verification/retention. Record named owner dependencies as live
|
||||
work before closure. Do not append kc-tls to secret_refs, expand capture credentials
|
||||
or rewrite a historical archive without those owner reviews/authority. Done when
|
||||
the future contract requirement has an exact tracked disposition/handoff; it is
|
||||
not proof of current archive coverage or actual future capture.
|
||||
|
||||
## Coordination and completion
|
||||
|
||||
Beneficiary NK-WP-0049-T35/CUST-WP-0080 remains linked to this obligor record by
|
||||
derived cross_owner_wait case nk49-t35-rcluster18 in the packet. No new project
|
||||
driver, live worker injection or message was created. Own T03 wait has an explicit
|
||||
local prerequisite; T04 is a human gate. Close only after source/native obligations
|
||||
are proved or an explicit bounded scope disposition hands off remaining live work.
|
||||
|
|
@ -0,0 +1,258 @@
|
|||
--- /dev/null
|
||||
+++ b/tools/check-keycape-recovery-selector.py
|
||||
@@ -0,0 +1,64 @@
|
||||
+#!/usr/bin/env python3
|
||||
+"""Offline public selector preflight; no imports of credential/backend clients.
|
||||
+
|
||||
+Checks current consumer source constants and the producer's already-deployed
|
||||
+public env/denial selector. This is source metadata, not runtime/owner assent.
|
||||
+"""
|
||||
+import argparse
|
||||
+import ast
|
||||
+import hashlib
|
||||
+import json
|
||||
+import os
|
||||
+from pathlib import Path
|
||||
+import re
|
||||
+import stat
|
||||
+
|
||||
+ROOT=Path(__file__).resolve().parents[1]
|
||||
+LIMIT=128*1024
|
||||
+
|
||||
+def read(path):
|
||||
+ path=Path(os.path.abspath(path))
|
||||
+ if any(p.is_symlink() for p in (path,*path.parents)):raise ValueError()
|
||||
+ fd=os.open(path,os.O_RDONLY|os.O_NOFOLLOW|os.O_NONBLOCK)
|
||||
+ with os.fdopen(fd,'rb') as source:
|
||||
+ info=os.fstat(source.fileno())
|
||||
+ if not stat.S_ISREG(info.st_mode) or not 0<info.st_size<=LIMIT:raise ValueError()
|
||||
+ raw=source.read(LIMIT+1)
|
||||
+ if len(raw)>LIMIT:raise ValueError()
|
||||
+ return raw
|
||||
+
|
||||
+def unique(pairs):
|
||||
+ result={}
|
||||
+ for key,value in pairs:
|
||||
+ if key in result:raise ValueError()
|
||||
+ result[key]=value
|
||||
+ return result
|
||||
+
|
||||
+def verify(selector,source,source_sha256):
|
||||
+ if not re.fullmatch('sha256:[a-f0-9]{64}',source_sha256) or 'sha256:'+hashlib.sha256(source).hexdigest()!=source_sha256:raise ValueError()
|
||||
+ needed={'CLIENT_ID','SUBJECT','TENANT','ROLE','SCOPE'};constants={}
|
||||
+ for node in ast.parse(source).body:
|
||||
+ if isinstance(node,ast.Assign):
|
||||
+ for target in node.targets:
|
||||
+ if isinstance(target,ast.Name) and target.id in needed:
|
||||
+ if target.id in constants or not isinstance(node.value,ast.Constant) or not isinstance(node.value.value,str):raise ValueError()
|
||||
+ constants[target.id]=node.value.value
|
||||
+ if set(constants)!=needed:raise ValueError()
|
||||
+ expected={'client_id':constants['CLIENT_ID'],'secret_env':'KEYCAPE_SECRETS_ENGINE_OPENBAO_CLIENT_SECRET',
|
||||
+ 'audience':constants['CLIENT_ID'],'scope':constants['SCOPE'],'subject':constants['SUBJECT'],
|
||||
+ 'tenant':constants['TENANT'],'roles':constants['ROLE'],'deny_scope':'openbao:admin'}
|
||||
+ if selector!=expected:raise ValueError()
|
||||
+ return {'schema':'secrets-engine.recovery-selector-preflight.v1','status':'source_selector_matches_not_native_assent',
|
||||
+ 'source_sha256':source_sha256,'consumer':expected,'credential_source_exercised':False,'network_contacted':False}
|
||||
+
|
||||
+def main():
|
||||
+ p=argparse.ArgumentParser(description=__doc__);p.add_argument('--selector',required=True);p.add_argument('--source-sha256',required=True)
|
||||
+ args=p.parse_args()
|
||||
+ try:
|
||||
+ proposal=json.loads(read(args.selector),object_pairs_hook=unique)
|
||||
+ if not isinstance(proposal,dict) or not isinstance(proposal.get('consumer'),dict):raise ValueError()
|
||||
+ result=verify(proposal['consumer'],read(ROOT/'src/secrets_engine/service_auth.py'),args.source_sha256)
|
||||
+ except Exception:print('{"status":"source_selector_refused","private_diagnostics_suppressed":true}');return 1
|
||||
+ print(json.dumps(result,indent=2));return 0
|
||||
+
|
||||
+if __name__=='__main__':raise SystemExit(main())
|
||||
--- /dev/null
|
||||
+++ b/tests/test_keycape_recovery_selector.py
|
||||
@@ -0,0 +1,31 @@
|
||||
+import hashlib
|
||||
+import importlib.util
|
||||
+import json
|
||||
+from pathlib import Path
|
||||
+import pytest
|
||||
+
|
||||
+spec=importlib.util.spec_from_file_location('check',Path(__file__).parents[1]/'tools/check-keycape-recovery-selector.py');m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m)
|
||||
+SOURCE=b'CLIENT_ID="secrets-engine-openbao"\nSUBJECT="service:secrets-engine"\nTENANT="tenant:platform"\nROLE="secrets-engine"\nSCOPE="openbao:login"\nraise RuntimeError("source is parsed, never executed")\n'
|
||||
+SELECTOR={'client_id':'secrets-engine-openbao','secret_env':'KEYCAPE_SECRETS_ENGINE_OPENBAO_CLIENT_SECRET','audience':'secrets-engine-openbao','scope':'openbao:login','subject':'service:secrets-engine','tenant':'tenant:platform','roles':'secrets-engine','deny_scope':'openbao:admin'}
|
||||
+DIGEST='sha256:'+hashlib.sha256(SOURCE).hexdigest()
|
||||
+def test_source_parsed_not_executed():
|
||||
+ result=m.verify(SELECTOR,SOURCE,DIGEST)
|
||||
+ assert result['network_contacted'] is False
|
||||
+@pytest.mark.parametrize('field',list(SELECTOR))
|
||||
+def test_each_selector_binding_is_exact(field):
|
||||
+ bad={**SELECTOR,field:'different'}
|
||||
+ with pytest.raises(ValueError):m.verify(bad,SOURCE,DIGEST)
|
||||
+def test_source_drift_and_untyped_digest_refused():
|
||||
+ for source,digest in ((SOURCE+b'\n',DIGEST),(SOURCE,DIGEST.removeprefix('sha256:'))):
|
||||
+ with pytest.raises(ValueError):m.verify(SELECTOR,source,digest)
|
||||
+def test_duplicate_constants_refused():
|
||||
+ source=SOURCE+b'CLIENT_ID="other"\n'
|
||||
+ with pytest.raises(ValueError):m.verify(SELECTOR,source,'sha256:'+hashlib.sha256(source).hexdigest())
|
||||
+def test_duplicate_public_metadata_keys_refused():
|
||||
+ with pytest.raises(ValueError):json.loads('{"consumer":{},"consumer":{}}',object_pairs_hook=m.unique)
|
||||
+def test_oversize_public_input_refused(tmp_path):
|
||||
+ p=tmp_path/'selector.json';p.write_bytes(b'x'*(m.LIMIT+1))
|
||||
+ with pytest.raises(ValueError):m.read(p)
|
||||
+def test_symlink_public_input_refused(tmp_path):
|
||||
+ p=tmp_path/'selector.json';p.write_text('{}');link=tmp_path/'link';link.symlink_to(p)
|
||||
+ with pytest.raises(ValueError):m.read(link)
|
||||
--- /dev/null
|
||||
+++ b/docs/keycape-isolated-recovery-selector.md
|
||||
@@ -0,0 +1,38 @@
|
||||
+# KeyCape original service recovery selector
|
||||
+
|
||||
+Source-owner disposition, 2026-10-17, limited to secrets-engine's current consumer
|
||||
+contract. Current src/secrets_engine/service_auth.py fixes client/audience
|
||||
+secrets-engine-openbao, subject service:secrets-engine, tenant tenant:platform,
|
||||
+role secrets-engine and exact openbao:login scope. The NetKingdom producer binds
|
||||
+KEYCAPE_SECRETS_ENGINE_OPENBAO_CLIENT_SECRET; original KeyCape native evidence
|
||||
+uses openbao:admin as the denied-scope case. This selector matches the current
|
||||
+consumer source. This disposition is not native assent or a credential grant.
|
||||
+
|
||||
+SECRETS-WP-0012 finished on historical attended custody/probe evidence and stays
|
||||
+finished. SECRETS-WP-0008-T06 retains functional steady-state adoption and scoped
|
||||
+backend entitlement. SECRETS-WP-0013 retains task-scoped backend execution. The
|
||||
+new SECRETS-WP-0015 covers only this original archive/isolated consumer preparation
|
||||
+and later accepted continuity proof; those existing gates are not duplicated.
|
||||
+
|
||||
+The current consumer profile forbids a client-secret file and requires its
|
||||
+attended in-memory OpenBao source. That source depends on ordinary KeyCape OIDC
|
||||
+and OpenBao availability. The isolated issuer's native checker can consume its
|
||||
+original archive-delivered issuer binding without a provider login, but that does
|
||||
+not prove the secrets-engine consumer path works during an OpenBao/issuer outage.
|
||||
+Do not bypass the no-file/source contract, retrieve a live credential, redirect a
|
||||
+production consumer or invoke probe-openbao to fill this recovery gap. An exact
|
||||
+owner-reviewed outage-compatible proof/custody plan remains required.
|
||||
+
|
||||
+Offline public preflight, after integration of reviewed source:
|
||||
+
|
||||
+```sh
|
||||
+python3 tools/check-keycape-recovery-selector.py --selector docs/keycape-recovery-selector.json --source-sha256 sha256:c0ade429edf452ad17f05d690b7ebef091db7f1a42b5ac350f1e40955c8586c0
|
||||
+```
|
||||
+
|
||||
+Use the exact hash in the owner packet, not a digest inferred at invocation from
|
||||
+an unreviewed file. The tool parses source constants with AST and never imports
|
||||
+the credential/backend module or calls the network. It requires all eight public
|
||||
+selector fields, exact source hash and unambiguous bounded JSON. Successful output
|
||||
+means source correspondence only. Original archived presence/secret binding,
|
||||
+actual TLS trust, native claim negatives and selected consumer continuity remain
|
||||
+separately evidenced. Missing/changed bindings refuse before any custody request.
|
||||
--- /dev/null
|
||||
+++ b/docs/keycape-recovery-selector.json
|
||||
@@ -0,0 +1,15 @@
|
||||
+{
|
||||
+ "schema": "secrets-engine.public-recovery-selector.v1",
|
||||
+ "status": "source_correspondence_only_not_native_assent",
|
||||
+ "issuer": "https://kc.coulomb.social",
|
||||
+ "consumer": {
|
||||
+ "client_id": "secrets-engine-openbao",
|
||||
+ "secret_env": "KEYCAPE_SECRETS_ENGINE_OPENBAO_CLIENT_SECRET",
|
||||
+ "audience": "secrets-engine-openbao",
|
||||
+ "scope": "openbao:login",
|
||||
+ "subject": "service:secrets-engine",
|
||||
+ "tenant": "tenant:platform",
|
||||
+ "roles": "secrets-engine",
|
||||
+ "deny_scope": "openbao:admin"
|
||||
+ }
|
||||
+}
|
||||
--- /dev/null
|
||||
+++ b/workplans/SECRETS-WP-0015-original-keycape-recovery-selector.md
|
||||
@@ -0,0 +1,95 @@
|
||||
+---
|
||||
+id: SECRETS-WP-0015
|
||||
+type: workplan
|
||||
+title: "Prepare original KeyCape service selector and isolated consumer continuity"
|
||||
+domain: infotech
|
||||
+repo: secrets-engine
|
||||
+status: ready
|
||||
+owner: codex
|
||||
+topic_slug: custodian
|
||||
+created: "2026-10-17"
|
||||
+updated: "2026-10-17"
|
||||
+flavor: implementation
|
||||
+depends_on: []
|
||||
+related: [CUST-WP-0080, NK-WP-0049, SECRETS-WP-0012, SECRETS-WP-0008, SECRETS-WP-0013]
|
||||
+---
|
||||
+
|
||||
+Needed source-owner work for NK-WP-0049-T36/T18. Reviewed against current
|
||||
+Lifecycle INTENT, explicit consumer constants/custody source and dated historical
|
||||
+proof. Ready means work can proceed, not native assent. Finished0012 remains
|
||||
+finished; functional adoption/backend entitlement stay0008T06/0013. No new grant,
|
||||
+secret fetch, provider login, production redirect or external message occurred.
|
||||
+
|
||||
+## Establish exact current intended source selector
|
||||
+
|
||||
+```task
|
||||
+id: SECRETS-WP-0015-T01
|
||||
+status: done
|
||||
+priority: high
|
||||
+assignee: codex
|
||||
+```
|
||||
+
|
||||
+Completed 2026-10-17 as source review. Current consumer constants and config guards
|
||||
+agree with secrets-engine-openbao client/audience, service:secrets-engine,
|
||||
+tenant:platform, role secrets-engine and only openbao:login. Producer env selector
|
||||
+and historical denied openbao:admin case are independently referenced, without
|
||||
+accepting the producer's native role. Prior native issuance7Oct and consumer
|
||||
+probe10Oct predate reviewed archive16Oct; they neither prove exact archived bytes
|
||||
+nor current native assent. Recorded docs/keycape-isolated-recovery-selector.md.
|
||||
+Evidence: Custodian recovery-owner-handoffs-r1 and prior17-source review.
|
||||
+
|
||||
+## Deliver bounded offline public source selector preflight
|
||||
+
|
||||
+```task
|
||||
+id: SECRETS-WP-0015-T02
|
||||
+status: done
|
||||
+priority: high
|
||||
+assignee: codex
|
||||
+```
|
||||
+
|
||||
+Completed 2026-10-17 as isolated source preparation. Prepared AST-only source-hash
|
||||
+and all-eight-selector-field checker with bounded strict JSON, source drift,
|
||||
+duplicate, oversize and symlink negatives. It imports no backend/credential client
|
||||
+and never exchanges a token. Tests use public/synthetic metadata only. Canonical
|
||||
+integration/publication/projection must be recorded separately; successful output
|
||||
+is source correspondence, not native acceptance or a new secret source.
|
||||
+
|
||||
+## Prepare outage-compatible original consumer proof without custody bypass
|
||||
+
|
||||
+```task
|
||||
+id: SECRETS-WP-0015-T03
|
||||
+status: todo
|
||||
+priority: high
|
||||
+assignee: codex
|
||||
+```
|
||||
+
|
||||
+Next: map the exact original archive-delivered registration and producer-native
|
||||
+checker result to the intended consumer, then prepare a source-reviewed isolated
|
||||
+proof/custody plan. Current consumer source requires attended OpenBao memory read
|
||||
+and refuses files; ordinary OpenBao/KeyCape outage is therefore a real dependency.
|
||||
+Do not add a fallback, persistent consumer copy or unreviewed injection source.
|
||||
+Clarify which continuity claim can be proved offline and which requires an exact
|
||||
+accepted owner custody/provider path. Bind current original selectors, image/
|
||||
+source/TLS metadata, negative cases, timeout/abort and cleanup; retain protected
|
||||
+backend operation entitlement in existing0008/0013. Done when that exact plan and
|
||||
+remaining owner dependencies are reviewable, without retrieving any value.
|
||||
+
|
||||
+## Verify the separately admitted actual isolated consumer result
|
||||
+
|
||||
+```task
|
||||
+id: SECRETS-WP-0015-T04
|
||||
+status: wait
|
||||
+priority: high
|
||||
+assignee: codex
|
||||
+needs_human: true
|
||||
+depends_on: [SECRETS-WP-0015-T03, NK-WP-0049-T33]
|
||||
+blocking_reason: "Await exact consumer proof/custody plan plus original TLS/private admission readiness; actual attended owner path/authority remains required."
|
||||
+```
|
||||
+
|
||||
+Only a separately admitted owner action can establish actual archived registration,
|
||||
+issuer trust, native negative checks and consumer continuity. Source selector and
|
||||
+historical provider proof do not fill this gate. No production consumer redirect,
|
||||
+new grant, live OpenBao read, backend write or retained-factor MFA is authorized.
|
||||
+Return exact bounded evidence to NK49T36/T18; MFA remains NK49T19. Human gate is
|
||||
+never a worker wakeup. Cross-owner dependency is attached to derived case
|
||||
+secrets15-nk49-readiness in the packet; do not strand the wait or invent a driver.
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
# KeyCape original service recovery selector
|
||||
|
||||
Source-owner disposition, 2026-10-17, limited to secrets-engine's current consumer
|
||||
contract. Current src/secrets_engine/service_auth.py fixes client/audience
|
||||
secrets-engine-openbao, subject service:secrets-engine, tenant tenant:platform,
|
||||
role secrets-engine and exact openbao:login scope. The NetKingdom producer binds
|
||||
KEYCAPE_SECRETS_ENGINE_OPENBAO_CLIENT_SECRET; original KeyCape native evidence
|
||||
uses openbao:admin as the denied-scope case. This selector matches the current
|
||||
consumer source. This disposition is not native assent or a credential grant.
|
||||
|
||||
SECRETS-WP-0012 finished on historical attended custody/probe evidence and stays
|
||||
finished. SECRETS-WP-0008-T06 retains functional steady-state adoption and scoped
|
||||
backend entitlement. SECRETS-WP-0013 retains task-scoped backend execution. The
|
||||
new SECRETS-WP-0015 covers only this original archive/isolated consumer preparation
|
||||
and later accepted continuity proof; those existing gates are not duplicated.
|
||||
|
||||
The current consumer profile forbids a client-secret file and requires its
|
||||
attended in-memory OpenBao source. That source depends on ordinary KeyCape OIDC
|
||||
and OpenBao availability. The isolated issuer's native checker can consume its
|
||||
original archive-delivered issuer binding without a provider login, but that does
|
||||
not prove the secrets-engine consumer path works during an OpenBao/issuer outage.
|
||||
Do not bypass the no-file/source contract, retrieve a live credential, redirect a
|
||||
production consumer or invoke probe-openbao to fill this recovery gap. An exact
|
||||
owner-reviewed outage-compatible proof/custody plan remains required.
|
||||
|
||||
Offline public preflight, after integration of reviewed source:
|
||||
|
||||
```sh
|
||||
python3 tools/check-keycape-recovery-selector.py --selector docs/keycape-recovery-selector.json --source-sha256 sha256:c0ade429edf452ad17f05d690b7ebef091db7f1a42b5ac350f1e40955c8586c0
|
||||
```
|
||||
|
||||
Use the exact hash in the owner packet, not a digest inferred at invocation from
|
||||
an unreviewed file. The tool parses source constants with AST and never imports
|
||||
the credential/backend module or calls the network. It requires all eight public
|
||||
selector fields, exact source hash and unambiguous bounded JSON. Successful output
|
||||
means source correspondence only. Original archived presence/secret binding,
|
||||
actual TLS trust, native claim negatives and selected consumer continuity remain
|
||||
separately evidenced. Missing/changed bindings refuse before any custody request.
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"schema": "secrets-engine.public-recovery-selector.v1",
|
||||
"status": "source_correspondence_only_not_native_assent",
|
||||
"issuer": "https://kc.coulomb.social",
|
||||
"consumer": {
|
||||
"client_id": "secrets-engine-openbao",
|
||||
"secret_env": "KEYCAPE_SECRETS_ENGINE_OPENBAO_CLIENT_SECRET",
|
||||
"audience": "secrets-engine-openbao",
|
||||
"scope": "openbao:login",
|
||||
"subject": "service:secrets-engine",
|
||||
"tenant": "tenant:platform",
|
||||
"roles": "secrets-engine",
|
||||
"deny_scope": "openbao:admin"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,496 @@
|
|||
"""KeyCape service-JWT exchange shared by two separately validated profiles.
|
||||
|
||||
The OpenBao login profile and approval consumer profile have distinct clients,
|
||||
audiences and scopes; both belong to tenant:platform. Provider selection never falls back to another
|
||||
identity. Token parsing is a claim preflight, not signature verification: the
|
||||
receiving OpenBao or approval-engine service verifies RS256 before accepting it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlencode, urlsplit
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from secrets_engine.errors import BackendError, ProvisioningError
|
||||
from secrets_engine.safe_paths import containing_git_worktree
|
||||
|
||||
CLIENT_ID = "secrets-engine-openbao"
|
||||
SUBJECT = "service:secrets-engine"
|
||||
PRINCIPAL_TYPE = "service"
|
||||
TENANT = "tenant:platform"
|
||||
ROLE = "secrets-engine"
|
||||
SCOPE = "openbao:login"
|
||||
MAX_TOKEN_SECONDS = 15 * 60
|
||||
RENEW_WINDOW_SECONDS = 3 * 60
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KeyCapeServiceAuthConfig:
|
||||
token_url: str
|
||||
issuer: str
|
||||
# The OpenBao profile takes no file: its secret is read in memory from
|
||||
# OpenBao per command (OpenBaoOidcClientSecretSource). The approval
|
||||
# profile subclass still uses a consumer file.
|
||||
client_secret_file: Path | None = None
|
||||
client_id: str = CLIENT_ID
|
||||
subject: str = SUBJECT
|
||||
audience: str = CLIENT_ID
|
||||
tenant: str = TENANT
|
||||
required_role: str = ROLE
|
||||
scope: str = SCOPE
|
||||
timeout_seconds: float = 10.0
|
||||
max_future_iat_seconds: int = field(default=60, init=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.token_url.startswith("https://"):
|
||||
raise BackendError("KeyCape token URL must use HTTPS")
|
||||
if not self.issuer.startswith("https://"):
|
||||
raise BackendError("KeyCape issuer must use HTTPS")
|
||||
if self.client_id != CLIENT_ID or self.audience != CLIENT_ID:
|
||||
raise BackendError("KeyCape service client/audience must match accepted contract")
|
||||
if (
|
||||
self.subject != SUBJECT
|
||||
or self.tenant != TENANT
|
||||
or self.required_role != ROLE
|
||||
or self.scope != SCOPE
|
||||
):
|
||||
raise BackendError("KeyCape service identity claims must match accepted contract")
|
||||
if self.timeout_seconds <= 0:
|
||||
raise BackendError("KeyCape timeout must be positive")
|
||||
if type(self) is KeyCapeServiceAuthConfig and self.client_secret_file is not None:
|
||||
raise BackendError(
|
||||
"secrets-engine-openbao client secret file source is refused; "
|
||||
"the secret is read in memory from OpenBao per command"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServiceJWT:
|
||||
token: str = field(repr=False)
|
||||
issued_at: int
|
||||
expires_at: int
|
||||
claims: dict[str, Any] = field(repr=False)
|
||||
|
||||
def needs_renewal(self, now: datetime | None = None) -> bool:
|
||||
current = int((now or datetime.now(timezone.utc)).timestamp())
|
||||
return self.expires_at - current <= RENEW_WINDOW_SECONDS
|
||||
|
||||
|
||||
def _decode_segment(value: str, label: str) -> dict[str, Any]:
|
||||
try:
|
||||
padded = value + "=" * (-len(value) % 4)
|
||||
decoded = base64.urlsafe_b64decode(padded.encode("ascii"))
|
||||
parsed = json.loads(decoded)
|
||||
except (UnicodeEncodeError, binascii.Error, json.JSONDecodeError) as e:
|
||||
raise BackendError(f"KeyCape JWT has invalid {label}") from e
|
||||
if not isinstance(parsed, dict):
|
||||
raise BackendError(f"KeyCape JWT {label} must be an object")
|
||||
return parsed
|
||||
|
||||
|
||||
def preflight_service_jwt(
|
||||
token: str,
|
||||
config: KeyCapeServiceAuthConfig,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> ServiceJWT:
|
||||
"""Validate non-cryptographic JWT shape/claims before resource-server use."""
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3 or not all(parts):
|
||||
raise BackendError("KeyCape access token is not a compact JWT")
|
||||
header = _decode_segment(parts[0], "header")
|
||||
claims = _decode_segment(parts[1], "payload")
|
||||
if header.get("alg") != "RS256":
|
||||
raise BackendError("KeyCape JWT algorithm is not RS256")
|
||||
|
||||
exact_claims = {
|
||||
"iss": config.issuer,
|
||||
"sub": config.subject,
|
||||
"aud": config.audience,
|
||||
"principal_type": PRINCIPAL_TYPE,
|
||||
"tenant": config.tenant,
|
||||
"groups": [],
|
||||
}
|
||||
for name, expected in exact_claims.items():
|
||||
if claims.get(name) != expected:
|
||||
raise BackendError(f"KeyCape JWT claim '{name}' does not match contract")
|
||||
if claims.get("roles") != [config.required_role]:
|
||||
raise BackendError("KeyCape JWT roles do not match contract")
|
||||
if claims.get("scope") != config.scope:
|
||||
raise BackendError("KeyCape JWT scope does not match contract")
|
||||
|
||||
assurance = claims.get("assurance")
|
||||
if not isinstance(assurance, dict) or (
|
||||
assurance.get("level") != "aal1"
|
||||
or assurance.get("methods") != ["client_secret"]
|
||||
or assurance.get("mfa") is not False
|
||||
or assurance.get("source") != "key-cape"
|
||||
):
|
||||
raise BackendError("KeyCape JWT assurance does not match contract")
|
||||
|
||||
issued_at = claims.get("iat")
|
||||
expires_at = claims.get("exp")
|
||||
if (
|
||||
not isinstance(issued_at, int)
|
||||
or isinstance(issued_at, bool)
|
||||
or not isinstance(expires_at, int)
|
||||
or isinstance(expires_at, bool)
|
||||
):
|
||||
raise BackendError("KeyCape JWT iat/exp must be integer timestamps")
|
||||
current = int((now or datetime.now(timezone.utc)).timestamp())
|
||||
if issued_at > current + config.max_future_iat_seconds:
|
||||
raise BackendError("KeyCape JWT issue time is in the future")
|
||||
if expires_at <= current:
|
||||
raise BackendError("KeyCape JWT has expired")
|
||||
if expires_at <= issued_at or expires_at - issued_at > MAX_TOKEN_SECONDS:
|
||||
raise BackendError("KeyCape JWT lifetime exceeds accepted 15-minute bound")
|
||||
return ServiceJWT(
|
||||
token=token,
|
||||
issued_at=issued_at,
|
||||
expires_at=expires_at,
|
||||
claims=dict(claims),
|
||||
)
|
||||
|
||||
|
||||
Transport = Callable[..., Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SecretFileCheck:
|
||||
"""One value-free consumer-file check. Never carries file contents."""
|
||||
|
||||
name: str
|
||||
ok: bool
|
||||
detail: str = ""
|
||||
|
||||
|
||||
def check_client_secret_file(path: Path) -> list[SecretFileCheck]:
|
||||
"""Inspect a confidential-client secret file without exposing its value.
|
||||
|
||||
The consumer copy must be an absolute, canonical (no symlink component),
|
||||
regular, non-empty file owned by the effective user with no group/other
|
||||
permission bits, in a parent directory owned by the same user with no
|
||||
group/other permission bits, outside every Git worktree. Stops at the first
|
||||
structural failure; later checks are then not reported as passed.
|
||||
"""
|
||||
checks: list[SecretFileCheck] = []
|
||||
|
||||
def add(name: str, ok: bool, detail: str = "") -> bool:
|
||||
checks.append(SecretFileCheck(name, ok, detail))
|
||||
return ok
|
||||
|
||||
raw = Path(path)
|
||||
if not add("absolute", raw.is_absolute(), "" if raw.is_absolute() else "path must be absolute"):
|
||||
return checks
|
||||
try:
|
||||
canonical = raw.resolve(strict=True)
|
||||
except (OSError, RuntimeError):
|
||||
add("exists", False, "file not found or unresolvable")
|
||||
return checks
|
||||
add("exists", True)
|
||||
if not add(
|
||||
"canonical",
|
||||
canonical == raw,
|
||||
"" if canonical == raw else "path has a symlink or non-canonical component",
|
||||
):
|
||||
return checks
|
||||
st = os.lstat(raw)
|
||||
if not add(
|
||||
"regular_file",
|
||||
stat.S_ISREG(st.st_mode),
|
||||
"" if stat.S_ISREG(st.st_mode) else "not a regular file",
|
||||
):
|
||||
return checks
|
||||
euid = os.geteuid()
|
||||
add("file_owner", st.st_uid == euid, "" if st.st_uid == euid else "not owned by the running user")
|
||||
mode = stat.S_IMODE(st.st_mode)
|
||||
add("file_mode", not mode & 0o077, "" if not mode & 0o077 else f"group/other-accessible mode {oct(mode)}; must be 0600")
|
||||
pst = os.lstat(raw.parent)
|
||||
add("parent_owner", pst.st_uid == euid, "" if pst.st_uid == euid else "parent not owned by the running user")
|
||||
pmode = stat.S_IMODE(pst.st_mode)
|
||||
add(
|
||||
"parent_mode",
|
||||
not pmode & 0o077,
|
||||
"" if not pmode & 0o077 else f"group/other-accessible parent mode {oct(pmode)}; must be 0700",
|
||||
)
|
||||
worktree = containing_git_worktree(raw)
|
||||
add("outside_git_worktree", worktree is None, "" if worktree is None else "inside a Git worktree")
|
||||
add("non_empty", st.st_size > 0, "" if st.st_size > 0 else "file is empty")
|
||||
return checks
|
||||
|
||||
|
||||
def read_client_secret_file(path: Path, *, purpose: str = "KeyCape client secret") -> str:
|
||||
"""Read the consumer secret only after every value-free check passes."""
|
||||
failed = [c for c in check_client_secret_file(path) if not c.ok]
|
||||
if failed:
|
||||
reasons = "; ".join(f"{c.name}: {c.detail}" for c in failed)
|
||||
raise ProvisioningError(f"{purpose} file {path} refused ({reasons})")
|
||||
value = Path(path).read_text(encoding="utf-8").strip()
|
||||
if not value or any(ch.isspace() for ch in value):
|
||||
raise ProvisioningError(
|
||||
f"{purpose} file {path} refused (must hold exactly one non-empty token)"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KeyCapeServiceAuthProvider:
|
||||
config: KeyCapeServiceAuthConfig
|
||||
transport: Transport = field(default=urlopen, repr=False, compare=False)
|
||||
# In-memory secret source. Required for the OpenBao profile; the approval
|
||||
# profile reads its consumer file when this is None.
|
||||
secret_source: Callable[[], str] | None = field(default=None, repr=False, compare=False)
|
||||
|
||||
def exchange(self, *, now: datetime | None = None) -> ServiceJWT:
|
||||
"""Perform one client-credentials exchange; never retry or fall back."""
|
||||
if self.secret_source is not None:
|
||||
secret = self.secret_source()
|
||||
elif type(self.config) is KeyCapeServiceAuthConfig or self.config.client_secret_file is None:
|
||||
raise BackendError(
|
||||
"secrets-engine-openbao requires the in-memory OpenBao secret source; "
|
||||
"no file fallback"
|
||||
)
|
||||
else:
|
||||
secret = read_client_secret_file(self.config.client_secret_file)
|
||||
basic = base64.b64encode(
|
||||
f"{self.config.client_id}:{secret}".encode("utf-8")
|
||||
).decode("ascii")
|
||||
del secret
|
||||
body = urlencode(
|
||||
{"grant_type": "client_credentials", "scope": self.config.scope}
|
||||
).encode("ascii")
|
||||
request = Request(
|
||||
self.config.token_url,
|
||||
data=body,
|
||||
method="POST",
|
||||
headers={
|
||||
"Authorization": f"Basic {basic}",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
try:
|
||||
response = self.transport(request, timeout=self.config.timeout_seconds)
|
||||
with response:
|
||||
status = getattr(response, "status", 200)
|
||||
raw = response.read(65537)
|
||||
if len(raw) > 65536:
|
||||
raise BackendError("KeyCape token response exceeds size bound")
|
||||
except HTTPError as e:
|
||||
raise BackendError(f"KeyCape token exchange failed with HTTP {e.code}") from e
|
||||
except (URLError, TimeoutError, OSError) as e:
|
||||
raise BackendError("KeyCape token exchange failed") from e
|
||||
if status != 200:
|
||||
raise BackendError(f"KeyCape token exchange failed with HTTP {status}")
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as e:
|
||||
raise BackendError("KeyCape token response is not valid JSON") from e
|
||||
if not isinstance(payload, dict):
|
||||
raise BackendError("KeyCape token response must be an object")
|
||||
if payload.get("id_token") or payload.get("refresh_token"):
|
||||
raise BackendError("KeyCape service exchange returned a forbidden extra token")
|
||||
token = payload.get("access_token")
|
||||
if not isinstance(token, str) or not token:
|
||||
raise BackendError("KeyCape token response has no access token")
|
||||
if str(payload.get("token_type", "")).lower() != "bearer":
|
||||
raise BackendError("KeyCape token response type is not Bearer")
|
||||
expires_in = payload.get("expires_in")
|
||||
if (
|
||||
not isinstance(expires_in, int)
|
||||
or isinstance(expires_in, bool)
|
||||
or not 0 < expires_in <= MAX_TOKEN_SECONDS
|
||||
):
|
||||
raise BackendError("KeyCape token response lifetime is outside contract")
|
||||
return preflight_service_jwt(token, self.config, now=now)
|
||||
|
||||
|
||||
# -- in-memory OpenBao source for the secrets-engine-openbao profile ---------
|
||||
#
|
||||
# ops-mason plan keycape-secrets-engine-openbao-client-custody (658cdbc),
|
||||
# founder direction 2026-10-06: the value lives only in OpenBao. Per command an
|
||||
# attended OIDC login on one narrow role yields a 5m/4-use operator token that
|
||||
# is captured from a pipe, used once in a request header for the KV GET, and
|
||||
# revoked with revoke-self. Nothing is written to file, env, argv or cache.
|
||||
|
||||
OIDC_MOUNT = "netkingdom"
|
||||
OIDC_ROLE = "keycape-secrets-engine-openbao-operator-workload-kv-read"
|
||||
KV_MOUNT = "platform"
|
||||
KV_PATH = "workloads/keycape/secrets-engine-openbao"
|
||||
KV_FIELD = "CLIENT_SECRET"
|
||||
SOURCE_NAME = "openbao-oidc-in-memory"
|
||||
_AMBIENT_TOKEN_ENV = ("BAO_TOKEN", "VAULT_TOKEN")
|
||||
_MAX_KV_RESPONSE = 65536
|
||||
|
||||
Runner = Callable[[list[str], dict[str, str]], "tuple[int, bytes]"]
|
||||
|
||||
|
||||
def _run_login(argv: list[str], env: dict[str, str]) -> tuple[int, bytes]:
|
||||
"""Run the attended login. stdout is a pipe into memory; stderr (the
|
||||
browser prompt, no token) stays on the operator's terminal."""
|
||||
proc = subprocess.run( # noqa: S603 - fixed argv, no shell
|
||||
argv,
|
||||
env=env,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=None,
|
||||
check=False,
|
||||
)
|
||||
return proc.returncode, proc.stdout
|
||||
|
||||
|
||||
def _require_bao_address(addr: str) -> str:
|
||||
try:
|
||||
parts = urlsplit(addr)
|
||||
host = parts.hostname or ""
|
||||
port_ok = parts.port != 0
|
||||
except ValueError:
|
||||
raise BackendError("OpenBao address is invalid for the in-memory secret read") from None
|
||||
if not host or parts.username or parts.password or not port_ok:
|
||||
raise BackendError("OpenBao address is invalid for the in-memory secret read")
|
||||
try:
|
||||
loopback = ipaddress.ip_address(host).is_loopback
|
||||
except ValueError:
|
||||
loopback = host == "localhost"
|
||||
if parts.scheme == "https" or (parts.scheme == "http" and loopback):
|
||||
return addr.rstrip("/")
|
||||
raise BackendError("OpenBao address must be HTTPS or a literal loopback forward")
|
||||
|
||||
|
||||
@dataclass
|
||||
class OpenBaoOidcClientSecretSource:
|
||||
"""Read CLIENT_SECRET in memory through one attended OIDC operator login.
|
||||
|
||||
Evidence is value-free: source name, operator accessor fingerprint, and
|
||||
whether revoke-self was attempted and succeeded.
|
||||
"""
|
||||
|
||||
bao_addr: str
|
||||
bao_bin: str = "bao"
|
||||
oidc_mount: str = OIDC_MOUNT
|
||||
oidc_role: str = OIDC_ROLE
|
||||
kv_mount: str = KV_MOUNT
|
||||
kv_path: str = KV_PATH
|
||||
kv_field: str = KV_FIELD
|
||||
timeout_seconds: float = 10.0
|
||||
runner: Runner = field(default=_run_login, repr=False, compare=False)
|
||||
transport: Transport = field(default=urlopen, repr=False, compare=False)
|
||||
accessor_fingerprint: str = ""
|
||||
revocation_attempted: bool = False
|
||||
revocation_succeeded: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
pinned = (OIDC_MOUNT, OIDC_ROLE, KV_MOUNT, KV_PATH, KV_FIELD)
|
||||
if (self.oidc_mount, self.oidc_role, self.kv_mount, self.kv_path, self.kv_field) != pinned:
|
||||
raise BackendError("OpenBao secret source coordinates do not match the reviewed plan")
|
||||
self.bao_addr = _require_bao_address(self.bao_addr)
|
||||
if not self.bao_bin:
|
||||
raise BackendError("bao CLI is required for the attended OIDC login")
|
||||
|
||||
def evidence(self) -> dict[str, object]:
|
||||
return {
|
||||
"secret_source": SOURCE_NAME,
|
||||
"operator_session_handle": self.accessor_fingerprint,
|
||||
"operator_revocation_attempted": self.revocation_attempted,
|
||||
"operator_revocation_succeeded": self.revocation_succeeded,
|
||||
}
|
||||
|
||||
def argv(self) -> list[str]:
|
||||
# Token never appears here; -no-store keeps it out of the token helper.
|
||||
return [
|
||||
self.bao_bin,
|
||||
"login",
|
||||
"-method=oidc",
|
||||
f"-path={self.oidc_mount}",
|
||||
"-no-store",
|
||||
"-format=json",
|
||||
f"role={self.oidc_role}",
|
||||
]
|
||||
|
||||
def _login_env(self) -> dict[str, str]:
|
||||
env = {k: v for k, v in os.environ.items() if k not in _AMBIENT_TOKEN_ENV}
|
||||
env["BAO_ADDR"] = self.bao_addr
|
||||
env.pop("VAULT_ADDR", None)
|
||||
return env
|
||||
|
||||
def _login(self) -> str:
|
||||
code, out = self.runner(self.argv(), self._login_env())
|
||||
try:
|
||||
if code != 0:
|
||||
raise BackendError(f"attended OpenBao OIDC login failed (exit {code})")
|
||||
try:
|
||||
auth = json.loads(out)["auth"]
|
||||
token = str(auth["client_token"])
|
||||
accessor = str(auth.get("accessor", ""))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError, KeyError, TypeError) as e:
|
||||
raise BackendError("malformed OpenBao OIDC login response") from e
|
||||
finally:
|
||||
del out
|
||||
if not token or any(ch.isspace() for ch in token):
|
||||
raise BackendError("OpenBao OIDC login returned no usable token")
|
||||
self.accessor_fingerprint = (
|
||||
hashlib.sha256(accessor.encode("utf-8")).hexdigest()[:12] if accessor else ""
|
||||
)
|
||||
return token
|
||||
|
||||
def _call(self, method: str, path: str, token: str) -> tuple[int, bytes]:
|
||||
request = Request(
|
||||
f"{self.bao_addr}/v1/{path}",
|
||||
data=b"" if method == "POST" else None,
|
||||
method=method,
|
||||
headers={"X-Vault-Token": token, "Accept": "application/json"},
|
||||
)
|
||||
try:
|
||||
response = self.transport(request, timeout=self.timeout_seconds)
|
||||
with response:
|
||||
status = getattr(response, "status", 200)
|
||||
raw = response.read(_MAX_KV_RESPONSE + 1)
|
||||
except HTTPError as e:
|
||||
return e.code, b""
|
||||
except (URLError, TimeoutError, OSError) as e:
|
||||
raise BackendError(f"OpenBao request failed: {method} {path}") from e
|
||||
if len(raw) > _MAX_KV_RESPONSE:
|
||||
raise BackendError("OpenBao response exceeds size bound")
|
||||
return status, raw
|
||||
|
||||
def _read_field(self, token: str) -> str:
|
||||
status, raw = self._call("GET", f"{self.kv_mount}/data/{self.kv_path}", token)
|
||||
if status != 200:
|
||||
raise BackendError(f"OpenBao KV read of the client secret failed with HTTP {status}")
|
||||
try:
|
||||
value = json.loads(raw)["data"]["data"][self.kv_field]
|
||||
except (UnicodeDecodeError, json.JSONDecodeError, KeyError, TypeError) as e:
|
||||
raise BackendError("OpenBao KV response has no client-secret field") from e
|
||||
finally:
|
||||
del raw
|
||||
if not isinstance(value, str) or not value or any(ch.isspace() for ch in value):
|
||||
raise BackendError("OpenBao client-secret field must hold exactly one token")
|
||||
return value
|
||||
|
||||
def _revoke_self(self, token: str) -> None:
|
||||
self.revocation_attempted = True
|
||||
try:
|
||||
status, _ = self._call("POST", "auth/token/revoke-self", token)
|
||||
except BackendError:
|
||||
return
|
||||
self.revocation_succeeded = status in (200, 204)
|
||||
|
||||
def __call__(self) -> str:
|
||||
token = self._login()
|
||||
try:
|
||||
return self._read_field(token)
|
||||
finally:
|
||||
self._revoke_self(token)
|
||||
del token
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
spec=importlib.util.spec_from_file_location('check',Path(__file__).parents[1]/'tools/check-keycape-recovery-selector.py');m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m)
|
||||
SOURCE=b'CLIENT_ID="secrets-engine-openbao"\nSUBJECT="service:secrets-engine"\nTENANT="tenant:platform"\nROLE="secrets-engine"\nSCOPE="openbao:login"\nraise RuntimeError("source is parsed, never executed")\n'
|
||||
SELECTOR={'client_id':'secrets-engine-openbao','secret_env':'KEYCAPE_SECRETS_ENGINE_OPENBAO_CLIENT_SECRET','audience':'secrets-engine-openbao','scope':'openbao:login','subject':'service:secrets-engine','tenant':'tenant:platform','roles':'secrets-engine','deny_scope':'openbao:admin'}
|
||||
DIGEST='sha256:'+hashlib.sha256(SOURCE).hexdigest()
|
||||
def test_source_parsed_not_executed():
|
||||
result=m.verify(SELECTOR,SOURCE,DIGEST)
|
||||
assert result['network_contacted'] is False
|
||||
@pytest.mark.parametrize('field',list(SELECTOR))
|
||||
def test_each_selector_binding_is_exact(field):
|
||||
bad={**SELECTOR,field:'different'}
|
||||
with pytest.raises(ValueError):m.verify(bad,SOURCE,DIGEST)
|
||||
def test_source_drift_and_untyped_digest_refused():
|
||||
for source,digest in ((SOURCE+b'\n',DIGEST),(SOURCE,DIGEST.removeprefix('sha256:'))):
|
||||
with pytest.raises(ValueError):m.verify(SELECTOR,source,digest)
|
||||
def test_duplicate_constants_refused():
|
||||
source=SOURCE+b'CLIENT_ID="other"\n'
|
||||
with pytest.raises(ValueError):m.verify(SELECTOR,source,'sha256:'+hashlib.sha256(source).hexdigest())
|
||||
def test_duplicate_public_metadata_keys_refused():
|
||||
with pytest.raises(ValueError):json.loads('{"consumer":{},"consumer":{}}',object_pairs_hook=m.unique)
|
||||
def test_oversize_public_input_refused(tmp_path):
|
||||
p=tmp_path/'selector.json';p.write_bytes(b'x'*(m.LIMIT+1))
|
||||
with pytest.raises(ValueError):m.read(p)
|
||||
def test_symlink_public_input_refused(tmp_path):
|
||||
p=tmp_path/'selector.json';p.write_text('{}');link=tmp_path/'link';link.symlink_to(p)
|
||||
with pytest.raises(ValueError):m.read(link)
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Offline public selector preflight; no imports of credential/backend clients.
|
||||
|
||||
Checks current consumer source constants and the producer's already-deployed
|
||||
public env/denial selector. This is source metadata, not runtime/owner assent.
|
||||
"""
|
||||
import argparse
|
||||
import ast
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
LIMIT=128*1024
|
||||
|
||||
def read(path):
|
||||
path=Path(os.path.abspath(path))
|
||||
if any(p.is_symlink() for p in (path,*path.parents)):raise ValueError()
|
||||
fd=os.open(path,os.O_RDONLY|os.O_NOFOLLOW|os.O_NONBLOCK)
|
||||
with os.fdopen(fd,'rb') as source:
|
||||
info=os.fstat(source.fileno())
|
||||
if not stat.S_ISREG(info.st_mode) or not 0<info.st_size<=LIMIT:raise ValueError()
|
||||
raw=source.read(LIMIT+1)
|
||||
if len(raw)>LIMIT:raise ValueError()
|
||||
return raw
|
||||
|
||||
def unique(pairs):
|
||||
result={}
|
||||
for key,value in pairs:
|
||||
if key in result:raise ValueError()
|
||||
result[key]=value
|
||||
return result
|
||||
|
||||
def verify(selector,source,source_sha256):
|
||||
if not re.fullmatch('sha256:[a-f0-9]{64}',source_sha256) or 'sha256:'+hashlib.sha256(source).hexdigest()!=source_sha256:raise ValueError()
|
||||
needed={'CLIENT_ID','SUBJECT','TENANT','ROLE','SCOPE'};constants={}
|
||||
for node in ast.parse(source).body:
|
||||
if isinstance(node,ast.Assign):
|
||||
for target in node.targets:
|
||||
if isinstance(target,ast.Name) and target.id in needed:
|
||||
if target.id in constants or not isinstance(node.value,ast.Constant) or not isinstance(node.value.value,str):raise ValueError()
|
||||
constants[target.id]=node.value.value
|
||||
if set(constants)!=needed:raise ValueError()
|
||||
expected={'client_id':constants['CLIENT_ID'],'secret_env':'KEYCAPE_SECRETS_ENGINE_OPENBAO_CLIENT_SECRET',
|
||||
'audience':constants['CLIENT_ID'],'scope':constants['SCOPE'],'subject':constants['SUBJECT'],
|
||||
'tenant':constants['TENANT'],'roles':constants['ROLE'],'deny_scope':'openbao:admin'}
|
||||
if selector!=expected:raise ValueError()
|
||||
return {'schema':'secrets-engine.recovery-selector-preflight.v1','status':'source_selector_matches_not_native_assent',
|
||||
'source_sha256':source_sha256,'consumer':expected,'credential_source_exercised':False,'network_contacted':False}
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--selector',required=True);p.add_argument('--source-sha256',required=True)
|
||||
args=p.parse_args()
|
||||
try:
|
||||
proposal=json.loads(read(args.selector),object_pairs_hook=unique)
|
||||
if not isinstance(proposal,dict) or not isinstance(proposal.get('consumer'),dict):raise ValueError()
|
||||
result=verify(proposal['consumer'],read(ROOT/'src/secrets_engine/service_auth.py'),args.source_sha256)
|
||||
except Exception:print('{"status":"source_selector_refused","private_diagnostics_suppressed":true}');return 1
|
||||
print(json.dumps(result,indent=2));return 0
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
---
|
||||
id: SECRETS-WP-0015
|
||||
type: workplan
|
||||
title: "Prepare original KeyCape service selector and isolated consumer continuity"
|
||||
domain: infotech
|
||||
repo: secrets-engine
|
||||
status: ready
|
||||
owner: codex
|
||||
topic_slug: custodian
|
||||
created: "2026-10-17"
|
||||
updated: "2026-10-17"
|
||||
flavor: implementation
|
||||
depends_on: []
|
||||
related: [CUST-WP-0080, NK-WP-0049, SECRETS-WP-0012, SECRETS-WP-0008, SECRETS-WP-0013]
|
||||
---
|
||||
|
||||
Needed source-owner work for NK-WP-0049-T36/T18. Reviewed against current
|
||||
Lifecycle INTENT, explicit consumer constants/custody source and dated historical
|
||||
proof. Ready means work can proceed, not native assent. Finished0012 remains
|
||||
finished; functional adoption/backend entitlement stay0008T06/0013. No new grant,
|
||||
secret fetch, provider login, production redirect or external message occurred.
|
||||
|
||||
## Establish exact current intended source selector
|
||||
|
||||
```task
|
||||
id: SECRETS-WP-0015-T01
|
||||
status: done
|
||||
priority: high
|
||||
assignee: codex
|
||||
```
|
||||
|
||||
Completed 2026-10-17 as source review. Current consumer constants and config guards
|
||||
agree with secrets-engine-openbao client/audience, service:secrets-engine,
|
||||
tenant:platform, role secrets-engine and only openbao:login. Producer env selector
|
||||
and historical denied openbao:admin case are independently referenced, without
|
||||
accepting the producer's native role. Prior native issuance7Oct and consumer
|
||||
probe10Oct predate reviewed archive16Oct; they neither prove exact archived bytes
|
||||
nor current native assent. Recorded docs/keycape-isolated-recovery-selector.md.
|
||||
Evidence: Custodian recovery-owner-handoffs-r1 and prior17-source review.
|
||||
|
||||
## Deliver bounded offline public source selector preflight
|
||||
|
||||
```task
|
||||
id: SECRETS-WP-0015-T02
|
||||
status: done
|
||||
priority: high
|
||||
assignee: codex
|
||||
```
|
||||
|
||||
Completed 2026-10-17 as isolated source preparation. Prepared AST-only source-hash
|
||||
and all-eight-selector-field checker with bounded strict JSON, source drift,
|
||||
duplicate, oversize and symlink negatives. It imports no backend/credential client
|
||||
and never exchanges a token. Tests use public/synthetic metadata only. Canonical
|
||||
integration/publication/projection must be recorded separately; successful output
|
||||
is source correspondence, not native acceptance or a new secret source.
|
||||
|
||||
## Prepare outage-compatible original consumer proof without custody bypass
|
||||
|
||||
```task
|
||||
id: SECRETS-WP-0015-T03
|
||||
status: todo
|
||||
priority: high
|
||||
assignee: codex
|
||||
```
|
||||
|
||||
Next: map the exact original archive-delivered registration and producer-native
|
||||
checker result to the intended consumer, then prepare a source-reviewed isolated
|
||||
proof/custody plan. Current consumer source requires attended OpenBao memory read
|
||||
and refuses files; ordinary OpenBao/KeyCape outage is therefore a real dependency.
|
||||
Do not add a fallback, persistent consumer copy or unreviewed injection source.
|
||||
Clarify which continuity claim can be proved offline and which requires an exact
|
||||
accepted owner custody/provider path. Bind current original selectors, image/
|
||||
source/TLS metadata, negative cases, timeout/abort and cleanup; retain protected
|
||||
backend operation entitlement in existing0008/0013. Done when that exact plan and
|
||||
remaining owner dependencies are reviewable, without retrieving any value.
|
||||
|
||||
## Verify the separately admitted actual isolated consumer result
|
||||
|
||||
```task
|
||||
id: SECRETS-WP-0015-T04
|
||||
status: wait
|
||||
priority: high
|
||||
assignee: codex
|
||||
needs_human: true
|
||||
depends_on: [SECRETS-WP-0015-T03, NK-WP-0049-T33]
|
||||
blocking_reason: "Await exact consumer proof/custody plan plus original TLS/private admission readiness; actual attended owner path/authority remains required."
|
||||
```
|
||||
|
||||
Only a separately admitted owner action can establish actual archived registration,
|
||||
issuer trust, native negative checks and consumer continuity. Source selector and
|
||||
historical provider proof do not fill this gate. No production consumer redirect,
|
||||
new grant, live OpenBao read, backend write or retained-factor MFA is authorized.
|
||||
Return exact bounded evidence to NK49T36/T18; MFA remains NK49T19. Human gate is
|
||||
never a worker wakeup. Cross-owner dependency is attached to derived case
|
||||
secrets15-nk49-readiness in the packet; do not strand the wait or invent a driver.
|
||||
|
|
@ -0,0 +1,562 @@
|
|||
{
|
||||
"READY": [
|
||||
{
|
||||
"id": "CUST-WP-0080-T03",
|
||||
"status": "progress",
|
||||
"priority": "high",
|
||||
"reason": "",
|
||||
"blocked_on": "",
|
||||
"repo": "the-custodian"
|
||||
},
|
||||
{
|
||||
"id": "CUST-WP-0080-T06",
|
||||
"status": "progress",
|
||||
"priority": "high",
|
||||
"reason": "",
|
||||
"blocked_on": "",
|
||||
"repo": "the-custodian"
|
||||
},
|
||||
{
|
||||
"id": "FLEX-WP-0024-T06",
|
||||
"status": "progress",
|
||||
"priority": "high",
|
||||
"reason": "",
|
||||
"blocked_on": "",
|
||||
"repo": "access-engine"
|
||||
},
|
||||
{
|
||||
"id": "KEY-WP-0040-T01",
|
||||
"status": "progress",
|
||||
"priority": "high",
|
||||
"reason": "",
|
||||
"blocked_on": "",
|
||||
"repo": "key-cape"
|
||||
},
|
||||
{
|
||||
"id": "NK-WP-0035-T04",
|
||||
"status": "progress",
|
||||
"priority": "high",
|
||||
"reason": "",
|
||||
"blocked_on": "",
|
||||
"repo": "net-kingdom"
|
||||
},
|
||||
{
|
||||
"id": "NK-WP-0048-T12",
|
||||
"status": "todo",
|
||||
"priority": "high",
|
||||
"reason": "",
|
||||
"blocked_on": "",
|
||||
"repo": "net-kingdom"
|
||||
},
|
||||
{
|
||||
"id": "NK-WP-0048-T14",
|
||||
"status": "todo",
|
||||
"priority": "high",
|
||||
"reason": "",
|
||||
"blocked_on": "",
|
||||
"repo": "net-kingdom"
|
||||
},
|
||||
{
|
||||
"id": "NK-WP-0048-T15",
|
||||
"status": "todo",
|
||||
"priority": "high",
|
||||
"reason": "",
|
||||
"blocked_on": "",
|
||||
"repo": "net-kingdom"
|
||||
},
|
||||
{
|
||||
"id": "NK-WP-0048-T16",
|
||||
"status": "todo",
|
||||
"priority": "high",
|
||||
"reason": "",
|
||||
"blocked_on": "",
|
||||
"repo": "net-kingdom"
|
||||
},
|
||||
{
|
||||
"id": "NK-WP-0049-T02",
|
||||
"status": "progress",
|
||||
"priority": "high",
|
||||
"reason": "",
|
||||
"blocked_on": "",
|
||||
"repo": "net-kingdom"
|
||||
},
|
||||
{
|
||||
"id": "NK-WP-0049-T15",
|
||||
"status": "progress",
|
||||
"priority": "high",
|
||||
"reason": "",
|
||||
"blocked_on": "",
|
||||
"repo": "net-kingdom"
|
||||
},
|
||||
{
|
||||
"id": "NK-WP-0050-T02",
|
||||
"status": "progress",
|
||||
"priority": "high",
|
||||
"reason": "",
|
||||
"blocked_on": "",
|
||||
"repo": "net-kingdom"
|
||||
},
|
||||
{
|
||||
"id": "RPF-WP-0035-T12",
|
||||
"status": "progress",
|
||||
"priority": "high",
|
||||
"reason": "",
|
||||
"blocked_on": "",
|
||||
"repo": "railiance-platform"
|
||||
},
|
||||
{
|
||||
"id": "RPF-WP-0053-T01",
|
||||
"status": "progress",
|
||||
"priority": "high",
|
||||
"reason": "",
|
||||
"blocked_on": "",
|
||||
"repo": "railiance-platform"
|
||||
},
|
||||
{
|
||||
"id": "RPF-WP-0053-T02",
|
||||
"status": "todo",
|
||||
"priority": "high",
|
||||
"reason": "Platform must admit the exact Scaleway primary archive caller/prefix, reconcile its current custody front door and retention/capacity, and deliver bounded credentials before activation.",
|
||||
"blocked_on": "",
|
||||
"repo": "railiance-platform"
|
||||
},
|
||||
{
|
||||
"id": "RPF-WP-0053-T03",
|
||||
"status": "todo",
|
||||
"priority": "high",
|
||||
"reason": "Needs T01/T02 owner admission and founder go for the exact first quiescence/upload and schedule; independent offsite retrieval and recovery proof remain required.",
|
||||
"blocked_on": "",
|
||||
"repo": "railiance-platform"
|
||||
},
|
||||
{
|
||||
"id": "RPF-WP-0053-T04",
|
||||
"status": "todo",
|
||||
"priority": "high",
|
||||
"reason": "",
|
||||
"blocked_on": "",
|
||||
"repo": "railiance-platform"
|
||||
},
|
||||
{
|
||||
"id": "RPF-WP-0059-T04",
|
||||
"status": "progress",
|
||||
"priority": "high",
|
||||
"reason": "",
|
||||
"blocked_on": "",
|
||||
"repo": "railiance-platform"
|
||||
},
|
||||
{
|
||||
"id": "SECRETS-WP-0014-T02",
|
||||
"status": "progress",
|
||||
"priority": "high",
|
||||
"reason": "",
|
||||
"blocked_on": "",
|
||||
"repo": "secrets-engine"
|
||||
},
|
||||
{
|
||||
"id": "USER-WP-0038-T01",
|
||||
"status": "todo",
|
||||
"priority": "high",
|
||||
"reason": "",
|
||||
"blocked_on": "",
|
||||
"repo": "user-engine"
|
||||
},
|
||||
{
|
||||
"id": "GH-WP-0004-T06",
|
||||
"status": "progress",
|
||||
"priority": "medium",
|
||||
"reason": "",
|
||||
"blocked_on": "",
|
||||
"repo": "gate-house"
|
||||
},
|
||||
{
|
||||
"id": "KEY-WP-0040-T02",
|
||||
"status": "progress",
|
||||
"priority": "medium",
|
||||
"reason": "",
|
||||
"blocked_on": "",
|
||||
"repo": "key-cape"
|
||||
},
|
||||
{
|
||||
"id": "NK-WP-0049-T24",
|
||||
"status": "todo",
|
||||
"priority": "medium",
|
||||
"reason": "",
|
||||
"blocked_on": "",
|
||||
"repo": "net-kingdom"
|
||||
},
|
||||
{
|
||||
"id": "RPF-WP-0057-T04",
|
||||
"status": "progress",
|
||||
"priority": "medium",
|
||||
"reason": "",
|
||||
"blocked_on": "",
|
||||
"repo": "railiance-platform"
|
||||
},
|
||||
{
|
||||
"id": "WARDEN-WP-0027-T02",
|
||||
"status": "todo",
|
||||
"priority": "medium",
|
||||
"reason": "",
|
||||
"blocked_on": "",
|
||||
"repo": "ops-warden"
|
||||
}
|
||||
],
|
||||
"UNBLOCKED": [
|
||||
{
|
||||
"id": "FLEX-WP-ADHOC-2026-10-03-T02",
|
||||
"status": "wait",
|
||||
"priority": "high",
|
||||
"reason": "Install accepted retroactively (877c268b); steps 6-7 follow WARDEN-WP-0039-T03 token receipts and fetch URL.",
|
||||
"blocked_on": "",
|
||||
"repo": "access-engine"
|
||||
},
|
||||
{
|
||||
"id": "MASON-WP-0004-T03",
|
||||
"status": "wait",
|
||||
"priority": "low",
|
||||
"reason": "operators/nextcloud base entry removed 2026-10-14 after the founder retired it. Remaining draft gaps still need their owners.",
|
||||
"blocked_on": "owners",
|
||||
"repo": "ops-mason"
|
||||
}
|
||||
],
|
||||
"ATTENDED": [
|
||||
{
|
||||
"id": "ACTIVITY-WP-0042-T08",
|
||||
"status": "wait",
|
||||
"priority": "high",
|
||||
"reason": "KV4 provider repair is delivered; repo-manager historical-prefix acceptance is delivered (receipt 39f2c067, 2026-10-06). Separate approved CNPG hostPath/Forgejo GitOps bundle delivery, ESO/env refresh and native consumer receipt remain. Founder per-change go is required before live promotion.",
|
||||
"blocked_on": "",
|
||||
"repo": "activity-core",
|
||||
"pending_dependencies": []
|
||||
},
|
||||
{
|
||||
"id": "CUST-WP-0079-T06",
|
||||
"status": "wait",
|
||||
"priority": "high",
|
||||
"reason": "Partly done 2026-10-11: list cut from 14 to 3; U05-U07 accepted, U08 waits on KEY-WP-0040-T01. Remaining founder acts: USER-WP-0026-T03 readback, RPF-WP-0044-T08 coulombcore check, the U08 re-run, and Sol's added RPF-WP-0044-T04 issuer login.",
|
||||
"blocked_on": "",
|
||||
"repo": "the-custodian",
|
||||
"pending_dependencies": []
|
||||
},
|
||||
{
|
||||
"id": "FLEX-WP-0036-T01",
|
||||
"status": "wait",
|
||||
"priority": "high",
|
||||
"reason": "Exact new runtime principals/naming are included in the single daily red batch with the nine TokenReview grants; no per-item question.",
|
||||
"blocked_on": "",
|
||||
"repo": "access-engine",
|
||||
"pending_dependencies": []
|
||||
},
|
||||
{
|
||||
"id": "PNEX-WP-0006-T01",
|
||||
"status": "wait",
|
||||
"priority": "high",
|
||||
"reason": "Public publication is red-lane; add PNEX-WP-0006 to the single daily founder batch. Local code and tests are prepared; no per-item approval request.",
|
||||
"blocked_on": "",
|
||||
"repo": "policy-nexus",
|
||||
"pending_dependencies": []
|
||||
},
|
||||
{
|
||||
"id": "PNEX-WP-0006-T02",
|
||||
"status": "wait",
|
||||
"priority": "high",
|
||||
"reason": "Public publication is red-lane; add PNEX-WP-0006 to the single daily founder batch. Local code and tests are prepared; no per-item approval request.",
|
||||
"blocked_on": "",
|
||||
"repo": "policy-nexus",
|
||||
"pending_dependencies": []
|
||||
},
|
||||
{
|
||||
"id": "RPF-WP-0044-T04",
|
||||
"status": "wait",
|
||||
"priority": "high",
|
||||
"reason": "Orange attended Platform OpenBao OIDC/MFA login for the already D2-approved exact repository reader; no second source-scope approval.",
|
||||
"blocked_on": "",
|
||||
"repo": "railiance-platform",
|
||||
"pending_dependencies": []
|
||||
},
|
||||
{
|
||||
"id": "RPF-WP-0053-T05",
|
||||
"status": "progress",
|
||||
"priority": "high",
|
||||
"reason": "email-connect primary.env is delivered from the accepted reader. Retention, capacity, independent receipts, escrow and the first capture remain open. Coulomb node delivery was not requested.",
|
||||
"blocked_on": "",
|
||||
"repo": "railiance-platform",
|
||||
"pending_dependencies": []
|
||||
},
|
||||
{
|
||||
"id": "USER-WP-0026-T03",
|
||||
"status": "wait",
|
||||
"priority": "high",
|
||||
"reason": "Signed-in readback live and correct for the deny path (tegwick: Informed Decision - Not available, founder-confirmed correct; docs/evidence/2026-10-11-signed-in-application-readback.md). Remaining: attended positive check with a net-kingdom-admins account (expect Available), then catalogue-management restoration before customers (FLEX-IN-0003).",
|
||||
"blocked_on": "",
|
||||
"repo": "user-engine",
|
||||
"pending_dependencies": []
|
||||
},
|
||||
{
|
||||
"id": "ACTIVITY-WP-0043-T04",
|
||||
"status": "wait",
|
||||
"priority": "medium",
|
||||
"reason": "Founder accepted memo 2 (approval 77993081); trials wait for the first live C-25 owner return",
|
||||
"blocked_on": "",
|
||||
"repo": "activity-core",
|
||||
"pending_dependencies": []
|
||||
},
|
||||
{
|
||||
"id": "ACTIVITY-WP-0044-T03",
|
||||
"status": "wait",
|
||||
"priority": "medium",
|
||||
"reason": "Activation follows RMGR-WP-0020-T04 and is a founder action",
|
||||
"blocked_on": "",
|
||||
"repo": "activity-core",
|
||||
"pending_dependencies": [
|
||||
"RMGR-WP-0020-T04"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "FLEX-WP-0036-T04",
|
||||
"status": "wait",
|
||||
"priority": "medium",
|
||||
"reason": "New public coulomb/access-engine image package publication is included in the single daily red batch. Local source/build checks pass; paired publication has not run.",
|
||||
"blocked_on": "",
|
||||
"repo": "access-engine",
|
||||
"pending_dependencies": [
|
||||
"FLEX-WP-0036-T01"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "CUST-WP-0073-T05",
|
||||
"status": "wait",
|
||||
"priority": "low",
|
||||
"reason": "Deferred by founder decision 2026-09-24; reopened only by the founder on one of the three triggers below, in one attended rotation window.",
|
||||
"blocked_on": "",
|
||||
"repo": "the-custodian",
|
||||
"pending_dependencies": []
|
||||
}
|
||||
],
|
||||
"STALLED": [
|
||||
{
|
||||
"id": "ACTIVITY-WP-0041-T03",
|
||||
"status": "wait",
|
||||
"priority": "high",
|
||||
"reason": "RPF-IN-0011: return dedicated release principal/custody owner task and native acceptance; D2 scope already accepted",
|
||||
"blocked_on": "message-from:railiance-platform",
|
||||
"repo": "activity-core"
|
||||
},
|
||||
{
|
||||
"id": "ACTIVITY-WP-0042-T05",
|
||||
"status": "wait",
|
||||
"priority": "high",
|
||||
"reason": "Platform owner must accept the candidate request/receipt contract, execution authority checks, limits and credential scope before runtime wiring.",
|
||||
"blocked_on": "message-from:railiance-platform",
|
||||
"repo": "activity-core"
|
||||
},
|
||||
{
|
||||
"id": "FLEX-WP-0020-T11",
|
||||
"status": "wait",
|
||||
"priority": "high",
|
||||
"reason": "Cleanup approved conditionally (e071b34e): runs once the listed soak evidence, SBOM-IN-0001 receipt and residual handoffs exist.",
|
||||
"blocked_on": "message-from:sbom-nexus",
|
||||
"repo": "access-engine"
|
||||
},
|
||||
{
|
||||
"id": "RAPP-POLICY-NEXUS-WP-0002-T02",
|
||||
"status": "wait",
|
||||
"priority": "high",
|
||||
"reason": "Answered railiance-platform's RPF-WP-0043-T02 question on 2026-10-03; waiting on its confirmation and on railiance-apps' values path/revision.",
|
||||
"blocked_on": "message-from:railiance-platform",
|
||||
"repo": "rapp-policy-nexus"
|
||||
},
|
||||
{
|
||||
"id": "RPF-WP-0027-T03",
|
||||
"status": "wait",
|
||||
"priority": "high",
|
||||
"reason": "Owner work first: NetKingdom packages the complete resolver sequence as one receipt-producing command; then it enters the attended-window checklist (D1 standing rule).",
|
||||
"blocked_on": "message-from:net-kingdom",
|
||||
"repo": "railiance-platform"
|
||||
},
|
||||
{
|
||||
"id": "RPF-WP-0027-T06",
|
||||
"status": "wait",
|
||||
"priority": "high",
|
||||
"reason": "Owner work first: the OpenBao owner confirms mount/path/fields and writer/reader authority and publishes the non-secret routing receipt; the handoff enters the attended window only when a command exists.",
|
||||
"blocked_on": "message-from:rapp-openbao",
|
||||
"repo": "railiance-platform"
|
||||
},
|
||||
{
|
||||
"id": "RPF-WP-0035-T08",
|
||||
"status": "wait",
|
||||
"priority": "high",
|
||||
"reason": "Approved conditionally (ab85ae8a, standing change rule): executes once independent archive readback and the interim bearer revocation receipt exist; owner work with audit-core.",
|
||||
"blocked_on": "message-from:audit-core",
|
||||
"repo": "railiance-platform"
|
||||
},
|
||||
{
|
||||
"id": "RPF-WP-0056-T03",
|
||||
"status": "wait",
|
||||
"priority": "high",
|
||||
"reason": "User admitted all identified platform humans and coding agents; their supported Kubernetes authentication/group-mapping route remains unimplemented.",
|
||||
"blocked_on": "message-from:railiance-cluster",
|
||||
"repo": "railiance-platform"
|
||||
},
|
||||
{
|
||||
"id": "SECRETS-WP-0006-T05",
|
||||
"status": "wait",
|
||||
"priority": "high",
|
||||
"reason": "Owner work first: publish a reviewed scoped-apply command per lane. Per-lane approvals are no longer needed (D1 standing rule); each lane with a command enters the attended window.",
|
||||
"blocked_on": "message-from:railiance-platform",
|
||||
"repo": "secrets-engine"
|
||||
},
|
||||
{
|
||||
"id": "SECRETS-WP-0009-T03",
|
||||
"status": "wait",
|
||||
"priority": "high",
|
||||
"reason": "Next owner action: reconcile held parent reservation 6efa9436-6a91-47c0-94e5-b31b5a0e7e3a (EUR 10, observed USD still null) from existing provider records. No corrected runtime digest is admitted; do not retry or replay consumed approvals.",
|
||||
"blocked_on": "message-from:glas-harness",
|
||||
"repo": "secrets-engine"
|
||||
},
|
||||
{
|
||||
"id": "SECRETS-WP-0013-T01",
|
||||
"status": "wait",
|
||||
"priority": "high",
|
||||
"reason": "Independent Platform mediated-contract review and identity/approval/PDP owner returns remain required; D3 wrapped primitive source is implemented, no native entitlement is inferred.",
|
||||
"blocked_on": "message-from:railiance-platform",
|
||||
"repo": "secrets-engine"
|
||||
},
|
||||
{
|
||||
"id": "USER-WP-0035-T02",
|
||||
"status": "wait",
|
||||
"priority": "high",
|
||||
"reason": "Existing mail service lacks an admitted provider password-setup template and recipient-bound opaque issuer handoff. EMAIL-IN-0001 and NET-IN-0005 request exact owner implementation records; no new founder choice or credential request.",
|
||||
"blocked_on": "message-from:email-connect",
|
||||
"repo": "user-engine"
|
||||
},
|
||||
{
|
||||
"id": "USER-WP-0037-T03",
|
||||
"status": "wait",
|
||||
"priority": "high",
|
||||
"reason": "T02 delivered empty no-grant custody integration; real independently authenticated Hub caller credential admission and withdrawal/outage/private integration acceptance remain with state-hub (original f3bcc516; renewed request 350c3e12-284c-43a7-9ab1-116df8bcc007). Hub repository registration is not caller admission.",
|
||||
"blocked_on": "message-from:state-hub",
|
||||
"repo": "user-engine"
|
||||
},
|
||||
{
|
||||
"id": "CUST-WP-0079-T05",
|
||||
"status": "wait",
|
||||
"priority": "medium",
|
||||
"reason": "Local guard and repeatable shared-hook installation are implemented. Fleet generator adoption and the required full week of clean history remain unverified; repo-manager owner acceptance is needed.",
|
||||
"blocked_on": "message-from:repo-manager",
|
||||
"repo": "the-custodian"
|
||||
},
|
||||
{
|
||||
"id": "RPF-WP-0035-T10",
|
||||
"status": "wait",
|
||||
"priority": "medium",
|
||||
"reason": "Blue lane under the standing change rule (removing access, with reviewed order, rollback and receipts): executes once approval-engine confirms there is no presenter. No founder window needed.",
|
||||
"blocked_on": "message-from:approval-engine",
|
||||
"repo": "railiance-platform"
|
||||
},
|
||||
{
|
||||
"id": "RPF-WP-0036-T06",
|
||||
"status": "wait",
|
||||
"priority": "medium",
|
||||
"reason": "Owner-ready handoffs to railiance-forge, rapp-openbao and the ArgoCD bootstrap owner await acceptance; S3 custody contracts stay here until then.",
|
||||
"blocked_on": "message-from:railiance-forge",
|
||||
"repo": "railiance-platform"
|
||||
}
|
||||
],
|
||||
"DEPENDENT": [
|
||||
{
|
||||
"id": "CUST-WP-0080-T04",
|
||||
"status": "todo",
|
||||
"priority": "high",
|
||||
"reason": "",
|
||||
"blocked_on": "",
|
||||
"repo": "the-custodian",
|
||||
"pending_dependencies": [
|
||||
"CUST-WP-0080-T03"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "CUST-WP-0080-T05",
|
||||
"status": "progress",
|
||||
"priority": "high",
|
||||
"reason": "",
|
||||
"blocked_on": "",
|
||||
"repo": "the-custodian",
|
||||
"pending_dependencies": [
|
||||
"RPF-WP-0056",
|
||||
"RAPP-PLAYWRIGHT-WP-0004"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "FLEX-WP-0036-T02",
|
||||
"status": "progress",
|
||||
"priority": "high",
|
||||
"reason": "",
|
||||
"blocked_on": "",
|
||||
"repo": "access-engine",
|
||||
"pending_dependencies": [
|
||||
"FLEX-WP-0036-T01"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "MASON-WP-0005-T05",
|
||||
"status": "progress",
|
||||
"priority": "high",
|
||||
"reason": "ops-mason checks and the non-member refusal pass. tegwick enrolled TOTP on 2026-10-11 (message 36b443ab). The founder still runs scripts/verify-fluid-telegram-lane.sh member via the 127.0.0.1:18200 tunnel. fluid-telegram runs the salt create-once probe (FT-WP-0002).",
|
||||
"blocked_on": "human",
|
||||
"repo": "ops-mason",
|
||||
"pending_dependencies": []
|
||||
},
|
||||
{
|
||||
"id": "MASON-WP-0010-T03",
|
||||
"status": "progress",
|
||||
"priority": "high",
|
||||
"reason": "Typed exact structural evidence request is prepared. Native materialization waits on independently authenticated policy/role/layout/catalog observations and CCR42 consumer binding/application. Mint, delivery and first live verification are follow-through, not structural prerequisites.",
|
||||
"blocked_on": "message-from:railiance-platform",
|
||||
"repo": "ops-mason",
|
||||
"pending_dependencies": []
|
||||
},
|
||||
{
|
||||
"id": "WARDEN-WP-0044-T03",
|
||||
"status": "progress",
|
||||
"priority": "high",
|
||||
"reason": "Local durable execution kernel and injected failure harness implemented. Native acceptance still requires authenticated owner machine descriptors/adapters, actual Forgejo materialization receipt, contained session integration, ordered verification and first native run.",
|
||||
"blocked_on": "",
|
||||
"repo": "ops-warden",
|
||||
"pending_dependencies": [
|
||||
"MASON-WP-0010-T03"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "FLEX-WP-0036-T07",
|
||||
"status": "todo",
|
||||
"priority": "medium",
|
||||
"reason": "",
|
||||
"blocked_on": "",
|
||||
"repo": "access-engine",
|
||||
"pending_dependencies": [
|
||||
"FLEX-WP-0036-T06"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "MASON-WP-0009-T05",
|
||||
"status": "progress",
|
||||
"priority": "medium",
|
||||
"reason": "ops-mason checks complete (policies, roles, invariant ii, login proof). Owner-run: live operator/ESO token capability checks and a non-member refusal; ops-warden activates the proposed pointer.",
|
||||
"blocked_on": "human",
|
||||
"repo": "ops-mason",
|
||||
"pending_dependencies": []
|
||||
},
|
||||
{
|
||||
"id": "FLEX-WP-0036-T08",
|
||||
"status": "todo",
|
||||
"priority": "low",
|
||||
"reason": "",
|
||||
"blocked_on": "",
|
||||
"repo": "access-engine",
|
||||
"pending_dependencies": [
|
||||
"FLEX-WP-0036-T06"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
{
|
||||
"observed_at": "2026-10-16T21:15:42.219146+00:00",
|
||||
"scope": "Read-only selected public source/work-record snapshot, not publication or runtime acceptance",
|
||||
"repos": [
|
||||
{
|
||||
"repo": "the-custodian",
|
||||
"head": "3607d3e08ffdbc473d19e9baf7225843c4075ae0",
|
||||
"files": [
|
||||
{
|
||||
"path": "docs/netkingdom-target-security-architecture.md",
|
||||
"sha256": "3a74cbd71dc22514033906f6029de1d3e113640bc5faab94f8d58c62e0c86203"
|
||||
},
|
||||
{
|
||||
"path": "docs/netkingdom-target-implementation.md",
|
||||
"sha256": "a59b906b7fa7dee5f11d4de7fa38c08b974ee2cafa260c44515238ac42e71176"
|
||||
},
|
||||
{
|
||||
"path": "workplans/CUST-WP-0079-deterministic-security-flow.md",
|
||||
"sha256": "f4ad3299c90f84f49e5867b3d358582c3faea65db4a2a4f595cf1a7f2b806097"
|
||||
},
|
||||
{
|
||||
"path": "scripts/security_flow_daily.py",
|
||||
"sha256": "4a4e84702b3fb38d086695bb6d601430d8914bd79286b4f683794206f110c353"
|
||||
},
|
||||
{
|
||||
"path": "scripts/sync_resolved_decisions.py",
|
||||
"sha256": "182433ca7cd31e2b2773246ec1be02569ba6f089ac009add6543e5e48777b5ec"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"repo": "repo-manager",
|
||||
"head": "97ea2489257de08911cc5da1e1b84d8e15780dc3",
|
||||
"files": [
|
||||
{
|
||||
"path": "src/repo_manager/secret_scan.py",
|
||||
"sha256": "4559d7714308eaa70ce5f8fa91dfcc6b10fe74b607e9f2a94269d31ff76bce9d"
|
||||
},
|
||||
{
|
||||
"path": "workplans/RMGR-WP-0021-secret-detection-controls.md",
|
||||
"sha256": "fa649c2f3da205627f98a1dfc87ea6cf44dd78fd34e601f8c0597ec55e179d96"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"repo": "railiance-forge",
|
||||
"head": "d322b9c9e8a1caa2b3479b270fff8a0e9f6c1f59",
|
||||
"files": [
|
||||
{
|
||||
"path": "tools/receive-secret-scan/main.go",
|
||||
"sha256": "5eee28fbe830ebec7abbdfc93275c34d126f92d1b144df93e72aec0dfb821bc6"
|
||||
},
|
||||
{
|
||||
"path": "workplans/FORGE-WP-0006-independent-secret-detection.md",
|
||||
"sha256": "fbcf3f75e884f6c90554c05525d787f61cfc2b915a206e6010bed447e7a720eb"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"repo": "activity-core",
|
||||
"head": "f9f8df039258a0d8b6fc29dc1f7c4bcc75fdc8c0",
|
||||
"files": [
|
||||
{
|
||||
"path": "activity-definitions/daily-security-flow.md",
|
||||
"sha256": "0281f0cd818b02d1cbd3a732dfb72145e8cb5e9881c4d80c6f6eb3d664da9375"
|
||||
},
|
||||
{
|
||||
"path": "workplans/ACTIVITY-WP-0047-daily-security-flow-adoption.md",
|
||||
"sha256": "e0b83b570da688abb405bed165d81db7cf49bc7ff11ef9a8ad86cbee2dc651db"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"repo": "rein-aharness",
|
||||
"head": "9b88cadaa817e5abea7950b4a264d4e2739a5ddb",
|
||||
"files": [
|
||||
{
|
||||
"path": "rein_aharness/claim_loop.py",
|
||||
"sha256": "5bb6ae75374858d9f515576d0cb8d25e9e555a35f3a89dff9dbe8cd4985f5974"
|
||||
},
|
||||
{
|
||||
"path": "rein_aharness/approaches.py",
|
||||
"sha256": "d095ed3bd817fee1fede545342e32f7ddbc9e20dad6f90fd7807906d26a031cd"
|
||||
},
|
||||
{
|
||||
"path": "rein_aharness/readiness.py",
|
||||
"sha256": "9d6945a0ef597c84846e378df8109c88c1d33a4f16c3650da2b110b8501729b1"
|
||||
},
|
||||
{
|
||||
"path": "rein_aharness/glas_execution.py",
|
||||
"sha256": "860bec6d2225cefb8e6abbab092f2e8521e015fd3a2e5cd76a42569cc8026f6b"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"repo": "glas-harness",
|
||||
"head": "7357115c8311e55ee3d495f5266f501a17ec3c89",
|
||||
"files": [
|
||||
{
|
||||
"path": "profiles/harness.agent-dev-local-1.1.1.yaml",
|
||||
"sha256": "382c64531a92254c0bcc0d0d6ab059cf190f1dabb7ee2e2ec445974de9ef7edb"
|
||||
},
|
||||
{
|
||||
"path": "src/glas_harness/gateway.py",
|
||||
"sha256": "5e998bfa53ade5dafbae799dc04b77031b6414e2af49797f45727d59d7397d42"
|
||||
},
|
||||
{
|
||||
"path": "src/glas_harness/profiles.py",
|
||||
"sha256": "40fba829dfb2a9b3acac4d3bf0539025e4cb5315a2fdbedb8e2ec76e16b29b5b"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"repo": "secrets-engine",
|
||||
"head": "535681052cd55ca613706ecc60c39d299929b3c5",
|
||||
"files": [
|
||||
{
|
||||
"path": "workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md",
|
||||
"sha256": "6cb4ec3500cddd5156235f1a493d7a979227a6b04eb037bacd23073fd1328efe"
|
||||
},
|
||||
{
|
||||
"path": "src/secrets_engine/lifecycle.py",
|
||||
"sha256": "c17644356524cd83e96f58a1022638e71fa373cd338c1bf38980ed6cd45a757c"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"repo": "access-engine",
|
||||
"head": "d328a2601095686f74376d87791bff01e2a3a2a3",
|
||||
"files": [
|
||||
{
|
||||
"path": "workplans/FLEX-WP-0024-decision-envelope-authenticity.md",
|
||||
"sha256": "cc94a493c23c16a3baa718b75ef5d48a596c6386c11534d50fd4839a6f0448f2"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"repo": "audit-core",
|
||||
"head": "64509d2c4039ca1249f9e8aabf2bae030e3640cf",
|
||||
"files": [
|
||||
{
|
||||
"path": "docs/flex-auth-secrets-engine-admission.md",
|
||||
"sha256": "0825d33b4ed33a0c10eda778a2a7d23764f34086756607a4f5d8a12018ec9a38"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
<!-- custodian-brief: generated by fix-consistency — do not edit manually -->
|
||||
# Custodian Brief — the-custodian
|
||||
|
||||
**Domain:** infotech
|
||||
**Last synced:** 2026-10-15 23:45 UTC
|
||||
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
|
||||
|
||||
## Active Workstreams
|
||||
|
||||
### NetKingdom target security architecture and SEC-DSM implementation
|
||||
Progress: 2/6 done | workplan_id: `dfadb19b-445e-59c6-bcd6-48e87b912c52`
|
||||
|
||||
**Open tasks:**
|
||||
- ► Close source defects and reconcile uncovered lifecycle gaps `87637402`
|
||||
- ► Coordinate self-service Playwright identity delivery `ed8b909a`
|
||||
- ► Establish the target architecture and drive the restructuring `6aca534c`
|
||||
- · Verify native closure against every requirement `82378b0f`
|
||||
|
||||
### Separate agent credentials and establish supervised privileged execution
|
||||
Progress: 2/5 done | workplan_id: `a98a9f34-83b4-5c8c-8107-e05f7806d50d`
|
||||
|
||||
**Open tasks:**
|
||||
- ! Build and hand out the agent identity `4b88b0b7`
|
||||
*(wait: Await GLAS-WP-0012 (glas-harness) production acceptance of the local supervised profile; then railiance-platform RBAC, railiance-enablement k3s config, railiance-infra principal mapping and ops-warden certificate issuance.)*
|
||||
- ! Fleet guidance and harness guards `90725511`
|
||||
*(wait: Await CUST-WP-0073-T02: the verified agent identity and execution path must exist before it can be documented; Codex/Grok read-denial guard needs glas-harness or harness-owner delivery.)*
|
||||
- ! Rotate what was exposed `1b41b532`
|
||||
*(wait: Deferred by founder decision 2026-09-24; reopened only by the founder on one of the three triggers below, in one attended rotation window.)*
|
||||
|
||||
### Size Railiance workloads from actual demand and establish a weekly allocation review
|
||||
Progress: 1/5 done | workplan_id: `2249bddb-7524-5add-bd5c-c4163a6ca0f3`
|
||||
|
||||
**Open tasks:**
|
||||
- ! Measure the Vergabe prototype under representative pilot use `82192370`
|
||||
*(wait: Await RAPPS-WP-0014-T03 (blocked, needs_human): populated two-user/document workflow run by Bernd and the company contact, giving representative load for p95 <= 2s / zero failures.)*
|
||||
- ! Review ecosystem allocations and reserve operating room `6dc67558`
|
||||
*(wait: Await CUST-WP-0071-T02 representative sample; Forgejo/runner demand and zero-request workload review need owner input (railiance-cluster, railiance-forge, resource-control).)*
|
||||
- ! Apply accepted sizing changes and verify their effects `944f98e2`
|
||||
*(wait: Await reviewed allocation proposals and applicable workload-owner authority.)*
|
||||
- ! Establish the regular weekly workload-demand and allocation assessment `20ddd4d7`
|
||||
*(wait: Await the repeatable observation and accepted sizing/review procedure.)*
|
||||
|
||||
### Deterministic security flow: standing authorizations, flow report, and agent re-engagement
|
||||
Progress: 3/7 done | workplan_id: `ab288f92-d364-5c24-9362-aee451f266c3`
|
||||
|
||||
**Open tasks:**
|
||||
- ! Schedule the flow report and decision sync `1be0569c`
|
||||
*(wait: Activity Core accepted the schedule in ACTIVITY-WP-0047. Await claiming rein workstation/hub acceptance, owner activation and the first unassisted fire with report/checkpoint evidence.)*
|
||||
- ! Encode the standing change rule as a policy-nexus guardrail `5dd52156`
|
||||
*(wait: policy-nexus owns guardrail publication; handoff written as PNEX-WP-0006.)*
|
||||
- ! Stop agent-authored index and sync commits `8a3dc219`
|
||||
*(wait: Local guard and repeatable shared-hook installation are implemented. Fleet generator adoption and the required full week of clean history remain unverified; repo-manager owner acceptance is needed.)*
|
||||
- ! Attended window #1 `e3da807f`
|
||||
*(wait: Partly done 2026-10-11: list cut from 14 to 3; U05-U07 accepted, U08 waits on KEY-WP-0040-T01. Remaining founder acts: USER-WP-0026-T03 readback, RPF-WP-0044-T08 coulombcore check, the U08 re-run, and Sol's added RPF-WP-0044-T04 issuer login.)*
|
||||
|
||||
---
|
||||
## MCP Orientation (when available)
|
||||
|
||||
If the state-hub MCP server is reachable, call:
|
||||
`get_domain_summary("infotech")`
|
||||
This provides richer cross-domain context.
|
||||
If the MCP call fails, use this file as your orientation source.
|
||||
|
|
@ -0,0 +1 @@
|
|||
100644 6aad3a5788fad3abd60c0f1270f160062dc0168b 0 .custodian-brief.md
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
<!-- custodian-brief: generated by fix-consistency — do not edit manually -->
|
||||
# Custodian Brief — the-custodian
|
||||
|
||||
**Domain:** infotech
|
||||
**Last synced:** 2026-10-15 23:45 UTC
|
||||
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
|
||||
|
||||
## Active Workstreams
|
||||
|
||||
### NetKingdom target security architecture and SEC-DSM implementation
|
||||
Progress: 2/6 done | workplan_id: `dfadb19b-445e-59c6-bcd6-48e87b912c52`
|
||||
|
||||
**Open tasks:**
|
||||
- ► Close source defects and reconcile uncovered lifecycle gaps `87637402`
|
||||
- ► Coordinate self-service Playwright identity delivery `ed8b909a`
|
||||
- ► Establish the target architecture and drive the restructuring `6aca534c`
|
||||
- · Verify native closure against every requirement `82378b0f`
|
||||
|
||||
### Separate agent credentials and establish supervised privileged execution
|
||||
Progress: 2/5 done | workplan_id: `a98a9f34-83b4-5c8c-8107-e05f7806d50d`
|
||||
|
||||
**Open tasks:**
|
||||
- ! Build and hand out the agent identity `4b88b0b7`
|
||||
*(wait: Await GLAS-WP-0012 (glas-harness) production acceptance of the local supervised profile; then railiance-platform RBAC, railiance-enablement k3s config, railiance-infra principal mapping and ops-warden certificate issuance.)*
|
||||
- ! Fleet guidance and harness guards `90725511`
|
||||
*(wait: Await CUST-WP-0073-T02: the verified agent identity and execution path must exist before it can be documented; Codex/Grok read-denial guard needs glas-harness or harness-owner delivery.)*
|
||||
- ! Rotate what was exposed `1b41b532`
|
||||
*(wait: Deferred by founder decision 2026-09-24; reopened only by the founder on one of the three triggers below, in one attended rotation window.)*
|
||||
|
||||
### Size Railiance workloads from actual demand and establish a weekly allocation review
|
||||
Progress: 1/5 done | workplan_id: `2249bddb-7524-5add-bd5c-c4163a6ca0f3`
|
||||
|
||||
**Open tasks:**
|
||||
- ! Measure the Vergabe prototype under representative pilot use `82192370`
|
||||
*(wait: Await RAPPS-WP-0014-T03 (blocked, needs_human): populated two-user/document workflow run by Bernd and the company contact, giving representative load for p95 <= 2s / zero failures.)*
|
||||
- ! Review ecosystem allocations and reserve operating room `6dc67558`
|
||||
*(wait: Await CUST-WP-0071-T02 representative sample; Forgejo/runner demand and zero-request workload review need owner input (railiance-cluster, railiance-forge, resource-control).)*
|
||||
- ! Apply accepted sizing changes and verify their effects `944f98e2`
|
||||
*(wait: Await reviewed allocation proposals and applicable workload-owner authority.)*
|
||||
- ! Establish the regular weekly workload-demand and allocation assessment `20ddd4d7`
|
||||
*(wait: Await the repeatable observation and accepted sizing/review procedure.)*
|
||||
|
||||
### Deterministic security flow: standing authorizations, flow report, and agent re-engagement
|
||||
Progress: 3/7 done | workplan_id: `ab288f92-d364-5c24-9362-aee451f266c3`
|
||||
|
||||
**Open tasks:**
|
||||
- ! Schedule the flow report and decision sync `1be0569c`
|
||||
*(wait: Activity Core accepted the schedule in ACTIVITY-WP-0047. Await claiming rein workstation/hub acceptance, owner activation and the first unassisted fire with report/checkpoint evidence.)*
|
||||
- ! Encode the standing change rule as a policy-nexus guardrail `5dd52156`
|
||||
*(wait: policy-nexus owns guardrail publication; handoff written as PNEX-WP-0006.)*
|
||||
- ! Stop agent-authored index and sync commits `8a3dc219`
|
||||
*(wait: Local guard and repeatable shared-hook installation are implemented. Fleet generator adoption and the required full week of clean history remain unverified; repo-manager owner acceptance is needed.)*
|
||||
- ! Attended window #1 `e3da807f`
|
||||
*(wait: Partly done 2026-10-11: list cut from 14 to 3; U05-U07 accepted, U08 waits on KEY-WP-0040-T01. Remaining founder acts: USER-WP-0026-T03 readback, RPF-WP-0044-T08 coulombcore check, the U08 re-run, and Sol's added RPF-WP-0044-T04 issuer login.)*
|
||||
|
||||
---
|
||||
## MCP Orientation (when available)
|
||||
|
||||
If the state-hub MCP server is reachable, call:
|
||||
`get_domain_summary("infotech")`
|
||||
This provides richer cross-domain context.
|
||||
If the MCP call fails, use this file as your orientation source.
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
{
|
||||
"repo_slug": "the-custodian",
|
||||
"repo_path": "/home/worsch/the-custodian",
|
||||
"source_revision": "fee99c24bd7dedef98d16035996ba0ea902c624a",
|
||||
"source_revision_reason": null,
|
||||
"issues": [
|
||||
{
|
||||
"severity": "WARN",
|
||||
"check_id": "C-35",
|
||||
"message": "Repo Manager warning: workplans/: repository uses more than one workplan prefix: ['CUST-WP', 'THE-WP']",
|
||||
"file_path": "workplans/",
|
||||
"db_id": "",
|
||||
"file_value": "",
|
||||
"db_value": "",
|
||||
"fixable": false
|
||||
},
|
||||
{
|
||||
"severity": "WARN",
|
||||
"check_id": "C-35",
|
||||
"message": "Repo Manager contradictory: workplans/ADHOC-2026-08-25-retired.md: workplan id 'CUST-WP-ADHOC-2026-08-25-RETIRED' is not accepted by the canon kind registry",
|
||||
"file_path": "workplans/ADHOC-2026-08-25-retired.md",
|
||||
"db_id": "",
|
||||
"file_value": "",
|
||||
"db_value": "",
|
||||
"fixable": false
|
||||
},
|
||||
{
|
||||
"severity": "WARN",
|
||||
"check_id": "C-20",
|
||||
"message": "Workplan dependency target 'STATE-WP-0092' is not linked to State Hub",
|
||||
"file_path": "workplans/CUST-WP-0072-fleet-flavor-and-depends-on-backfill.md",
|
||||
"db_id": "",
|
||||
"file_value": "STATE-WP-0092",
|
||||
"db_value": "",
|
||||
"fixable": false
|
||||
},
|
||||
{
|
||||
"severity": "WARN",
|
||||
"check_id": "C-40",
|
||||
"message": "blocker satisfied, task still wait: 'CUST-WP-0073-T05' in workplan 'CUST-WP-0073' \u2014 decision 1591daa1-6e85-4dfd-aadd-5a9fe1ef47fa is resolved",
|
||||
"file_path": "workplans/CUST-WP-0073-agent-credential-separation.md#CUST-WP-0073-T05",
|
||||
"db_id": "",
|
||||
"file_value": "1591daa1-6e85-4dfd-aadd-5a9fe1ef47fa",
|
||||
"db_value": "",
|
||||
"fixable": false
|
||||
},
|
||||
{
|
||||
"severity": "WARN",
|
||||
"check_id": "C-39",
|
||||
"message": "Human wait task 'CUST-WP-0079-T06' in workplan 'CUST-WP-0079' has needs_human: true without decision_id \u2014 unrouted human gate",
|
||||
"file_path": "workplans/CUST-WP-0079-deterministic-security-flow.md#CUST-WP-0079-T06",
|
||||
"db_id": "",
|
||||
"file_value": "needs_human: true",
|
||||
"db_value": "",
|
||||
"fixable": false
|
||||
},
|
||||
{
|
||||
"severity": "WARN",
|
||||
"check_id": "C-22",
|
||||
"message": "Task description drift 'CUST-WP-0080-T06': file content differs from DB (file wins)",
|
||||
"file_path": "workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md#CUST-WP-0080-T06",
|
||||
"db_id": "6aca534c-5c7a-556f-b6b6-406433670c29",
|
||||
"file_value": "Founder implementation request, 2026-10-16. Document the optimized target and\ncoordinate GPT-6.1-Sol medium agents again",
|
||||
"db_value": "Founder implementation request, 2026-10-16. Document the optimized target and\ncoordinate GPT-6.1-Sol medium agents again",
|
||||
"fixable": true
|
||||
},
|
||||
{
|
||||
"severity": "INFO",
|
||||
"check_id": "C-08",
|
||||
"message": "Closed DB workstream 'cust-wp-0010b' (id=416ff571\u2026, status=finished) has no backing workplan file",
|
||||
"file_path": "",
|
||||
"db_id": "416ff571-18cf-5cb2-9c8b-836f68a301b3",
|
||||
"file_value": "",
|
||||
"db_value": "",
|
||||
"fixable": false
|
||||
},
|
||||
{
|
||||
"severity": "INFO",
|
||||
"check_id": "C-08",
|
||||
"message": "Retired DB workstream 'interactive-the-custodian@retired-20260828' (id=370c2481\u2026, status=finished) has no backing workplan file \u2014 projection tombstone, not live work",
|
||||
"file_path": "",
|
||||
"db_id": "370c2481-6806-41eb-a917-f8874f03184f",
|
||||
"file_value": "",
|
||||
"db_value": "",
|
||||
"fixable": false
|
||||
},
|
||||
{
|
||||
"severity": "INFO",
|
||||
"check_id": "C-08",
|
||||
"message": "Retired DB workstream 'adhoc-2026-05-02@retired-20260827' (id=3f54ce9c\u2026, status=finished) has no backing workplan file \u2014 projection tombstone, not live work",
|
||||
"file_path": "",
|
||||
"db_id": "3f54ce9c-1f95-42de-894f-0f81a52ba2e8",
|
||||
"file_value": "",
|
||||
"db_value": "",
|
||||
"fixable": false
|
||||
},
|
||||
{
|
||||
"severity": "INFO",
|
||||
"check_id": "C-08",
|
||||
"message": "Retired DB workstream 'the-custodian-adhoc-2026-07-02@retired-20260827' (id=5c53733b\u2026, status=finished) has no backing workplan file \u2014 projection tombstone, not live work",
|
||||
"file_path": "",
|
||||
"db_id": "5c53733b-c077-4a93-be66-9c3af8f9b6fd",
|
||||
"file_value": "",
|
||||
"db_value": "",
|
||||
"fixable": false
|
||||
},
|
||||
{
|
||||
"severity": "WARN",
|
||||
"check_id": "C-26",
|
||||
"message": "Workplan id 'THE-WP-0001' uses prefix 'THE-WP' but repo canonical prefix is 'CUST-WP' \u2014 new plans must conform",
|
||||
"file_path": "workplans/THE-WP-0001-federation-interface.md",
|
||||
"db_id": "",
|
||||
"file_value": "THE-WP-0001",
|
||||
"db_value": "CUST-WP",
|
||||
"fixable": false
|
||||
}
|
||||
],
|
||||
"fixes_applied": [
|
||||
"bindings: synced 89/89",
|
||||
"C-17 cleared: pushed 2 backlogged commit(s)",
|
||||
"C-22 fixed: task 6aca534c\u2026 description updated",
|
||||
"push: pushed"
|
||||
],
|
||||
"summary": {
|
||||
"fail": 0,
|
||||
"automation_error": 0,
|
||||
"warn": 7,
|
||||
"info": 4
|
||||
},
|
||||
"result": "warn"
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
WARN: working-memory commit failed: fatal: Unable to create '/home/worsch/the-custodian/.git/index.lock': Read-only file system
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"head": "fee99c24bd7dedef98d16035996ba0ea902c624a",
|
||||
"remote_main": "fee99c24bd7dedef98d16035996ba0ea902c624a",
|
||||
"sync_summary": {
|
||||
"fail": 0,
|
||||
"automation_error": 0,
|
||||
"warn": 7,
|
||||
"info": 4
|
||||
},
|
||||
"task_count": 6,
|
||||
"all_statuses_match": true,
|
||||
"original_staged_and_working_brief_restored": true,
|
||||
"retained_refinement": "Initial index restore hit sandbox read-only filesystem; exact update-index rerun under required escalation succeeded.",
|
||||
"goal_status": "active",
|
||||
"all_16_target_gates_remain_open": true
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"owner_head": "878abc93588e12b6da1bac055cfbb3abb8d7adf1",
|
||||
"remote_main": "878abc93588e12b6da1bac055cfbb3abb8d7adf1",
|
||||
"both_source_commits_in_published_history": true,
|
||||
"workplans": [
|
||||
{
|
||||
"workplan": "NK-WP-0048",
|
||||
"task_count": 19,
|
||||
"all_statuses_match": true
|
||||
},
|
||||
{
|
||||
"workplan": "NK-WP-0049",
|
||||
"task_count": 33,
|
||||
"all_statuses_match": true
|
||||
}
|
||||
],
|
||||
"final_sync_summary": {
|
||||
"fail": 0,
|
||||
"automation_error": 0,
|
||||
"warn": 27,
|
||||
"info": 5
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"workplan_id": "dfadb19b-445e-59c6-bcd6-48e87b912c52",
|
||||
"id": "578371e4-ffc2-4303-a8b6-86b0a0717b6a",
|
||||
"topic_id": null,
|
||||
"task_id": "6aca534c-5c7a-556f-b6b6-406433670c29",
|
||||
"decision_id": null,
|
||||
"event_type": "note",
|
||||
"summary": "Resumed NetKingdom target architecture with GPT-6.1-Sol medium delegates. Published browser checker immutable snapshot source8790dd18 and recovery source5fcd5952. Independent49 source+12 real checker scenarios,73 recovery/source checks and four actual synthetic Docker cases passed. Host-bound exact cleanup and three reaper receipts retained; uncertain Docker response remains uncertain. Normal source scanner false-positive public hashes resolved with strictly typed SHA256 metadata, no exemptions. NK-WP0048 has19 projected tasks; NK-WP0049 has33, T32 source proof done/T33 actual-attempt preparation todo/T18 real recovery wait. Scanner baseline and host/rein schedule readiness measured. All16 TSA gates remain open; full goal active. Two delegates reached model usage limit after completed commands; coordinator verified and finished source publication. Custodian commits a3e98b1 and fee99c2 await normal sync.",
|
||||
"detail": null,
|
||||
"author": "codex",
|
||||
"session_id": null,
|
||||
"created_at": "2026-10-16T21:39:32.450075Z",
|
||||
"workstream_id": "dfadb19b-445e-59c6-bcd6-48e87b912c52"
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
"""Apply only independently reviewed local source patches; no stage or publish."""
|
||||
from pathlib import Path
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
|
||||
BASE = Path(__file__).resolve().parent.parent
|
||||
LANES = {
|
||||
'groups': ('net-kingdom', '878abc93588e12b6da1bac055cfbb3abb8d7adf1', 'group-provenance-r1/source.patch', '1f3f3ca94c46dadac8f24896053d4d23179de795e50a24298a0cfbcc46cca30e'),
|
||||
'recovery': ('net-kingdom', '878abc93588e12b6da1bac055cfbb3abb8d7adf1', 'recovery-attempt-r1/candidate.patch', '99cc938d80d64049f927049a5e09a698641e121cf0af2bfa1cbb54ebe7528266'),
|
||||
'scanner': ('repo-manager', '97ea2489257de08911cc5da1e1b84d8e15780dc3', 'scanner-batch-r1/candidate.patch', 'd2f65f2af5444b241ddcb85a3e71e66d1bbe071bd9a8a7be415be84b5e2f8fdb'),
|
||||
}
|
||||
|
||||
def sha(path): return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('lane', choices=LANES)
|
||||
parser.add_argument('--apply', action='store_true')
|
||||
args = parser.parse_args()
|
||||
slug, revision, relative, digest = LANES[args.lane]
|
||||
owner = Path('/home/worsch') / slug
|
||||
patch = BASE / relative
|
||||
def git(*argv):
|
||||
return subprocess.check_output(['git', '-C', str(owner), *argv], stderr=subprocess.PIPE)
|
||||
assert git('rev-parse', 'HEAD').decode().strip() == revision, 'owner revision changed'
|
||||
assert sha(patch) == digest, 'reviewed patch changed'
|
||||
paths = [line[6:] for line in patch.read_text().splitlines() if line.startswith('+++ b/')]
|
||||
assert paths and len(paths) == len(set(paths))
|
||||
for path in paths:
|
||||
assert not Path(path).is_absolute() and '..' not in Path(path).parts
|
||||
assert not git('status', '--porcelain', '--', path).strip(), 'owner source is dirty: ' + path
|
||||
if args.lane == 'groups':
|
||||
entries = json.loads((BASE / 'group-provenance-r1/source-manifest.json').read_text())['files']
|
||||
expected = {e['path']: e['postimage_sha256'] for e in entries}
|
||||
for e in entries:
|
||||
target = owner / e['path']
|
||||
assert (sha(target) if target.exists() else None) == e['preimage_sha256']
|
||||
elif args.lane == 'scanner':
|
||||
entries = json.loads((BASE / 'scanner-batch-r1/manifest.json').read_text())['files']
|
||||
expected = {e['path']: e['candidate_sha256'] for e in entries}
|
||||
for e in entries: assert sha(owner / e['path']) == e['base_sha256']
|
||||
else:
|
||||
candidate = BASE / 'recovery-attempt-r1/test-root'
|
||||
expected = {p: sha(candidate / p) for p in paths}
|
||||
closure = json.loads((BASE / 'recovery-attempt-r1/source-closure.json').read_text())
|
||||
for path, typed in closure.items():
|
||||
target = candidate / path if path in paths else owner / path
|
||||
assert 'sha256:' + sha(target) == typed, 'source closure changed: ' + path
|
||||
assert set(paths) == set(expected)
|
||||
git('apply', '--check', str(patch))
|
||||
before_index = git('ls-files', '--stage', '--', '.custodian-brief.md')
|
||||
before_brief = (owner / '.custodian-brief.md').read_bytes()
|
||||
if args.apply:
|
||||
git('apply', str(patch))
|
||||
for path, wanted in expected.items(): assert sha(owner / path) == wanted, 'postimage changed: ' + path
|
||||
assert before_index == git('ls-files', '--stage', '--', '.custodian-brief.md')
|
||||
assert before_brief == (owner / '.custodian-brief.md').read_bytes()
|
||||
receipt = {'observed_at': datetime.now(timezone.utc).isoformat(), 'lane': args.lane,
|
||||
'owner': str(owner), 'base_revision': revision, 'patch_sha256': 'sha256:' + digest,
|
||||
'applied': args.apply, 'source_postimages': {p: 'sha256:' + h for p, h in expected.items()},
|
||||
'preexisting_brief_preserved': True, 'staged': False, 'published': False,
|
||||
'native_acceptance': False}
|
||||
out = Path(__file__).resolve().parent / (args.lane + ('-integration.json' if args.apply else '-preflight.json'))
|
||||
out.write_text(json.dumps(receipt, indent=2) + '\n')
|
||||
print(json.dumps({'lane': args.lane, 'applied': args.apply, 'files': len(paths)}))
|
||||
|
||||
if __name__ == '__main__': main()
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
import hashlib, json, re
|
||||
base = Path(__file__).resolve().parent
|
||||
packets = base.parent
|
||||
def load(path): return json.loads(path.read_text())
|
||||
def sha(path): return 'sha256:' + hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
def typed(value):
|
||||
if isinstance(value, dict): return {k: typed(v) for k,v in value.items()}
|
||||
if isinstance(value, list): return [typed(v) for v in value]
|
||||
if isinstance(value,str) and re.fullmatch('[0-9a-f]{64}',value): return 'sha256:' + value
|
||||
return value
|
||||
native_path = packets / 'group-provenance-r1/native-fixture/execution-result.json'
|
||||
native = load(native_path)
|
||||
assert native['status'] == 'synthetic_native_compatibility_passed'
|
||||
assert len(native['checks']) == 14 and all(native['checks'].values())
|
||||
assert native['container_cleanup_verified'] and all(x['status']=='absence_verified' for x in native['container_cleanup_obligations'])
|
||||
hook_path = packets / 'scanner-batch-r1/configured-hook-results.json'
|
||||
hooks = load(hook_path)
|
||||
assert hooks['status'] == 'verified' and hooks['fixture_removed'] and hooks['new_scan_storage_absent']
|
||||
assert sum(len(x['cases']) for x in hooks['cases']) == 4
|
||||
benchmark_path = packets / 'scanner-batch-r1/scanner-candidate.json'
|
||||
benchmark = load(benchmark_path)
|
||||
measurements = [{k:v for k,v in item.items() if k != 'samples'} for item in benchmark['observations']]
|
||||
result = {
|
||||
'schema':'custodian.target-source-results.v1','observed_at':datetime.now(timezone.utc).isoformat(),
|
||||
'scope':'Reviewed owner source plus generated local/disposable compatibility fixtures; no target gate closed',
|
||||
'canonical_tests':{'group_provisioner':{'passed':65,'seconds':8.55},
|
||||
'recovery_preparation':{'passed':111,'seconds':14.50},
|
||||
'staged_scanner':{'passed':42,'seconds':31.66}},
|
||||
'recovery_limitations':{'legacy_custody_tests_passed':94,'legacy_real_sops_skipped':61,
|
||||
'actual_owner_held_recovery':False,'private_admission_complete':False,
|
||||
'named_file_mode_parent_loss_fixed':False,'sealed_child_fd_lives_until_child_exit':True},
|
||||
'group_native_fixture':native,'original_group_receipt_sha256':sha(native_path),
|
||||
'independent_group_cleanup':{'engine':'74f93783-6cef-43a8-a3c4-f62586cd3bc1',
|
||||
'exact_container_names_absent':True,'exact_run_label_inventory_empty':True,
|
||||
'hard_parent_loss_claim':False},
|
||||
'configured_hooks':hooks,'original_hook_receipt_sha256':sha(hook_path),
|
||||
'scanner_parity':{'exact_old_new_cases':13,'fixture_removed':True},
|
||||
'scanner_performance':{'observations':measurements,'original_receipt_sha256':sha(benchmark_path),
|
||||
'warm_repetitions':7,'percentiles':'nearest rank; descriptive small sample',
|
||||
'baseline_p50_ms':{'clean-1x1KiB':702.358,'clean-16x1KiB':12065.334,'clean-64x1KiB':49171.849,'clean-1x1MiB':2548.887},
|
||||
'single_blob_slowdown_retained':True,'receive_ci_or_workload_claim':False},
|
||||
'review_refinements_retained':['Group exactly-one intent row update and database identity checks',
|
||||
'Definite before-write refusal preserves evidence and releases active fence',
|
||||
'Scanner batches and reports moved to verified private tmpfs with parent-death cleanup',
|
||||
'SQLite-on-PVC production guidance corrected before publication against accepted ADR-0015'],
|
||||
'production_deployments':False,'actual_credentials_used':False,'native_target_acceptance':False,
|
||||
'all_sixteen_target_gates_remain_open':True,
|
||||
'encoding':'Published checksum strings carry explicit sha256: type prefixes; original receipt bytes remain workspace-local and separately hashed.'
|
||||
}
|
||||
(base / 'verified-results.json').write_text(json.dumps(typed(result),indent=2)+'\n')
|
||||
print('Retained typed results for 65/111/42 canonical checks, 14 LLDAP oracles and four actual-hook cases.')
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
from pathlib import Path
|
||||
import json, subprocess
|
||||
base=Path(__file__).resolve().parent;root=Path('/home/worsch/the-custodian')
|
||||
backup=base/'custodian-publication-backup'
|
||||
try:
|
||||
result=subprocess.run(['statehub','fix-consistency','--repo','the-custodian','--json'],cwd=root,capture_output=True,text=True,timeout=300)
|
||||
(base/'custodian-consistency.json').write_text(result.stdout)
|
||||
(base/'custodian-consistency.stderr.txt').write_text(result.stderr)
|
||||
(base/'custodian-consistency.exit.json').write_text(json.dumps({'exit_code':result.returncode})+'\n')
|
||||
finally:
|
||||
for name,index in json.loads((backup/'originals.json').read_text()).items():
|
||||
(root/name).write_bytes((backup/(name+'.worktree')).read_bytes())
|
||||
mode,oid,stage,path=index.split();assert stage=='0'
|
||||
subprocess.run(['git','-C',str(root),'update-index','--cacheinfo',mode,oid,path],check=True,capture_output=True)
|
||||
assert subprocess.check_output(['git','-C',str(root),'ls-files','--stage','--',name]).decode()==index
|
||||
print(json.dumps({'consistency_exit':result.returncode,'original_other_work_restored':True}))
|
||||
raise SystemExit(result.returncode)
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
"""Verify owner source, pushed ancestry and exact file-backed task projection."""
|
||||
from pathlib import Path
|
||||
from collections import Counter
|
||||
from datetime import datetime, timezone
|
||||
import hashlib, json, re, subprocess, urllib.request
|
||||
import yaml
|
||||
base = Path(__file__).resolve().parent
|
||||
def load(path): return json.loads(path.read_text())
|
||||
def get(path):
|
||||
with urllib.request.urlopen('http://127.0.0.1:8000'+path,timeout=30) as response: return json.load(response)
|
||||
def git(root,*args): return subprocess.check_output(['git','-C',str(root),*args],stderr=subprocess.PIPE)
|
||||
out = {'schema':'custodian.owner-publication-verification.v1','observed_at':datetime.now(timezone.utc).isoformat(),
|
||||
'native_target_acceptance':False,'all_sixteen_target_gates_remain_open':True,'owners':[]}
|
||||
health=get('/state/health')
|
||||
assert health['instance_role']=='primary' and health['instance_label']=='railiance01'
|
||||
out['projection_instance']={k:health[k] for k in ['instance_role','instance_label']}
|
||||
for owner in ['net-kingdom','repo-manager']:
|
||||
root=Path('/home/worsch')/owner
|
||||
original=load(base/(owner+'-commit.json')); final=load(base/(owner+'-followthrough-commit.json'))
|
||||
assert original['exit_code']==0 and final['exit_code']==0
|
||||
head=git(root,'rev-parse','HEAD').decode().strip()
|
||||
assert head==final['head']
|
||||
remote=git(root,'ls-remote','origin','refs/heads/main').decode().split()[0]
|
||||
assert head==remote
|
||||
git(root,'merge-base','--is-ancestor',original['head'],head)
|
||||
source_count=0
|
||||
for path,expected in original['files'].items():
|
||||
if path.startswith('workplans/'):continue
|
||||
assert 'sha256:'+hashlib.sha256((root/path).read_bytes()).hexdigest()==expected
|
||||
source_count+=1
|
||||
backups=base/(owner+'-publication-backup')
|
||||
for name,index in load(backups/'originals.json').items():
|
||||
assert (root/name).read_bytes()==(backups/(name+'.worktree')).read_bytes()
|
||||
assert git(root,'ls-files','--stage','--',name).decode()==index
|
||||
assert load(base/(owner+'-sync.exit.json'))['exit_code']==0
|
||||
sync=load(base/(owner+'-sync.stdout.json'))
|
||||
if owner=='repo-manager':
|
||||
assert sync['ok'] and sync['status']=='applied' and sync['commit']==head
|
||||
assert sync['receipt']['derived_commit']==head
|
||||
sync_summary={'status':sync['status'],'commit':head,'refused':sync['receipt']['outcome']['counts']['refused']}
|
||||
assert sync_summary['refused']==0
|
||||
else:
|
||||
assert sync['source_revision']==head
|
||||
assert sync['summary']['fail']==0 and sync['summary']['automation_error']==0
|
||||
central=load(base/'net-kingdom-central-projection.stdout.json')
|
||||
assert central['ok'] and central['status']=='applied' and central['commit']==head
|
||||
assert central['receipt']['derived_commit']==head
|
||||
assert central['receipt']['outcome']['counts']['refused']==0
|
||||
sync_summary={'legacy_consistency':sync['summary'],'central_projection':central['status'],
|
||||
'retained_audit_finding':'Legacy sync matched UUID/status but omitted task record IDs after T09; forge-derived reconciliation repaired projection.'}
|
||||
workplans=[]
|
||||
for path in [p for p in original['files'] if p.startswith('workplans/')]:
|
||||
text=(root/path).read_text();front=yaml.safe_load(text.split('---',2)[1])
|
||||
source=[yaml.safe_load(block) for block in re.findall(r'```task\s*\n(.*?)```',text,re.S)]
|
||||
projected=get('/tasks/?workplan_id='+front['state_hub_workstream_id'])
|
||||
assert isinstance(projected,list)
|
||||
by_id={item['id']:item for item in projected if item.get('workplan_id')==front['state_hub_workstream_id']}
|
||||
assert len(by_id)==len(source), (path,len(source),len(by_id))
|
||||
for task in source:
|
||||
found=by_id[task['state_hub_task_id']]
|
||||
assert found['status']==task['status'], task['id']
|
||||
assert found['record_id']==task['id'], task['id']
|
||||
workplans.append({'id':front['id'],'tasks':len(source),'source_counts':dict(Counter(t['status'] for t in source)),
|
||||
'all_ids_uuids_statuses_match':True})
|
||||
out['owners'].append({'repo':owner,'source_commit':original['head'],'published_head':head,
|
||||
'remote_main_exact':True,'source_ancestor_verified':True,'source_files_unchanged':source_count,
|
||||
'normal_source_and_record_hooks_passed':True,'preexisting_other_work_preserved':True,
|
||||
'sync':sync_summary,'workplans':workplans})
|
||||
(base/'publication-verification.json').write_text(json.dumps(out,indent=2)+'\n')
|
||||
print(json.dumps({'verified':True,'owners':out['owners']},indent=2))
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
from pathlib import Path
|
||||
import hashlib, json, subprocess
|
||||
base = Path(__file__).resolve().parent
|
||||
group = json.loads((base.parent / 'group-provenance-r1/native-storage-proposal-r1/source-evidence.json').read_text())['sources']
|
||||
recovery = json.loads((base.parent / 'recovery-attempt-r1/t35-t36-source-next/source-review.json').read_text())['source_refs']
|
||||
out = []
|
||||
for lane, entries in [('group_storage',group), ('tls_registration',recovery)]:
|
||||
for entry in entries:
|
||||
repo, path = entry['repo'], entry['path']
|
||||
revision = entry.get('revision', entry.get('head'))
|
||||
expected = entry.get('head_sha256', entry.get('sha256')).removeprefix('sha256:')
|
||||
raw = subprocess.check_output(['git','-C','/home/worsch/'+repo,'show',revision+':'+path],stderr=subprocess.PIPE)
|
||||
assert hashlib.sha256(raw).hexdigest() == expected, 'source changed: ' + repo + '/' + path
|
||||
out.append({'lane':lane,'repo':repo,'path':path,'revision':revision,'sha256':'sha256:'+expected,
|
||||
'source':entry.get('public_source',entry.get('public_source_ref'))})
|
||||
assert len(group)==23 and len(recovery)==17
|
||||
(base / 'source-references.json').write_text(json.dumps({'scope':'exact public Git sources; no live topology or owner assent','all_40_hashes_independently_verified':True,'references':out},indent=2)+'\n')
|
||||
print('Independently verified 40 public-source references.')
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
from pathlib import Path
|
||||
import hashlib, json, subprocess
|
||||
base = Path(__file__).resolve().parent.parent
|
||||
root = Path('/home/worsch/net-kingdom')
|
||||
packet = base / 'group-provenance-r1/storage-policy-correction-r1'
|
||||
manifest = json.loads((packet / 'manifest.json').read_text())
|
||||
patch = packet / 'source.patch'
|
||||
assert hashlib.sha256(patch.read_bytes()).hexdigest() == 'c3e5944a1dba097ddd16d9bfa0021a81002e30242df43129b24746bcdf9f6c2b'
|
||||
for entry in manifest['files']:
|
||||
assert hashlib.sha256((root / entry['path']).read_bytes()).hexdigest() == entry['preimage_sha256']
|
||||
subprocess.run(['git', '-C', str(root), 'apply', '--check', str(patch)], check=True)
|
||||
subprocess.run(['git', '-C', str(root), 'apply', str(patch)], check=True)
|
||||
for entry in manifest['files']:
|
||||
assert hashlib.sha256((root / entry['path']).read_bytes()).hexdigest() == entry['postimage_sha256']
|
||||
receipt = {'patch_sha256': 'sha256:' + manifest['patch_sha256'], 'applied': True,
|
||||
'source_code_changed': False, 'production_storage_admitted': False,
|
||||
'files': {e['path']: 'sha256:' + e['postimage_sha256'] for e in manifest['files']}}
|
||||
(base / 'resume-integration-r1/storage-guidance-correction.json').write_text(json.dumps(receipt, indent=2) + '\n')
|
||||
print('Applied three exact documentation corrections; local source proof retained and native admission open.')
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"exit_code": 0}
|
||||
|
|
@ -0,0 +1,293 @@
|
|||
{
|
||||
"ok": true,
|
||||
"status": "applied",
|
||||
"repo_slug": "the-custodian",
|
||||
"commit": "d834d0c4b71ea7ddf81317d5c2746ca514a810c8",
|
||||
"requests": 2,
|
||||
"elapsed_ms": 9184.2,
|
||||
"git": {
|
||||
"upstream": "origin/main",
|
||||
"behind": 0,
|
||||
"ahead": 0
|
||||
},
|
||||
"identifiers": {
|
||||
"schema": "repo-manager.identifier-assignment.v1",
|
||||
"ok": true,
|
||||
"executed": true,
|
||||
"repo": "the-custodian",
|
||||
"namespace": "helixforge",
|
||||
"assignments": [],
|
||||
"historical_invalid_identifiers": [
|
||||
{
|
||||
"kind": "workplan",
|
||||
"id": "CUST-WP-ADHOC-2026-08-25-RETIRED",
|
||||
"source": "workplans/ADHOC-2026-08-25-retired.md"
|
||||
}
|
||||
],
|
||||
"files_changed": []
|
||||
},
|
||||
"receipt": {
|
||||
"schema": "state-hub.repository-projection-reconcile.v1",
|
||||
"instance_role": "primary",
|
||||
"instance_label": "railiance01",
|
||||
"expected_commit": "d834d0c4b71ea7ddf81317d5c2746ca514a810c8",
|
||||
"derived_commit": "d834d0c4b71ea7ddf81317d5c2746ca514a810c8",
|
||||
"outcome": {
|
||||
"schema": "state-hub.projection-reset.v1",
|
||||
"repo_slug": "the-custodian",
|
||||
"commit": "d834d0c4b71ea7ddf81317d5c2746ca514a810c8",
|
||||
"status": "applied",
|
||||
"counts": {
|
||||
"created": 0,
|
||||
"updated": 88,
|
||||
"retired": 0,
|
||||
"refused": 0,
|
||||
"released": 0,
|
||||
"created_tasks": 0,
|
||||
"updated_tasks": 1,
|
||||
"cancelled_tasks": 0,
|
||||
"created_edges": 0,
|
||||
"removed_edges": 0,
|
||||
"unresolved_edges": 0,
|
||||
"external_edges": 13
|
||||
},
|
||||
"created": [],
|
||||
"updated": [
|
||||
"CUST-WP-0000",
|
||||
"CUST-WP-0000b",
|
||||
"CUST-WP-0000c",
|
||||
"CUST-WP-0001",
|
||||
"CUST-WP-0002",
|
||||
"CUST-WP-0004",
|
||||
"CUST-WP-0005",
|
||||
"CUST-WP-0006",
|
||||
"CUST-WP-0007",
|
||||
"CUST-WP-0008",
|
||||
"CUST-WP-0009",
|
||||
"CUST-WP-0010",
|
||||
"CUST-WP-0010b",
|
||||
"CUST-WP-0013",
|
||||
"CUST-WP-0014",
|
||||
"CUST-WP-0015",
|
||||
"CUST-WP-0016",
|
||||
"CUST-WP-0017",
|
||||
"CUST-WP-0017-LEGACY",
|
||||
"CUST-WP-0018",
|
||||
"CUST-WP-0019",
|
||||
"CUST-WP-0020",
|
||||
"CUST-WP-0021",
|
||||
"CUST-WP-0022",
|
||||
"CUST-WP-0023",
|
||||
"CUST-WP-0024",
|
||||
"CUST-WP-0025",
|
||||
"CUST-WP-0026",
|
||||
"CUST-WP-0027",
|
||||
"CUST-WP-0028",
|
||||
"CUST-WP-0029",
|
||||
"CUST-WP-0030",
|
||||
"CUST-WP-0031",
|
||||
"CUST-WP-0032",
|
||||
"CUST-WP-0033",
|
||||
"CUST-WP-0034",
|
||||
"CUST-WP-0035",
|
||||
"CUST-WP-0036",
|
||||
"CUST-WP-0037",
|
||||
"CUST-WP-0039",
|
||||
"CUST-WP-0040",
|
||||
"CUST-WP-0041",
|
||||
"CUST-WP-0043",
|
||||
"CUST-WP-0044",
|
||||
"CUST-WP-0045",
|
||||
"CUST-WP-0046",
|
||||
"CUST-WP-0047",
|
||||
"CUST-WP-0048",
|
||||
"CUST-WP-0049",
|
||||
"CUST-WP-0050",
|
||||
"CUST-WP-0051",
|
||||
"CUST-WP-0052",
|
||||
"CUST-WP-0053",
|
||||
"CUST-WP-0053-LEGACY",
|
||||
"CUST-WP-0054",
|
||||
"CUST-WP-0055",
|
||||
"CUST-WP-0056",
|
||||
"CUST-WP-0057",
|
||||
"CUST-WP-0058",
|
||||
"CUST-WP-0059",
|
||||
"CUST-WP-0060",
|
||||
"CUST-WP-0061",
|
||||
"CUST-WP-0062",
|
||||
"CUST-WP-0063",
|
||||
"CUST-WP-0064",
|
||||
"CUST-WP-0065",
|
||||
"CUST-WP-0066",
|
||||
"CUST-WP-0067",
|
||||
"CUST-WP-0068",
|
||||
"CUST-WP-0069",
|
||||
"CUST-WP-0070",
|
||||
"CUST-WP-0071",
|
||||
"CUST-WP-0072",
|
||||
"CUST-WP-0073",
|
||||
"CUST-WP-0074",
|
||||
"CUST-WP-0075",
|
||||
"CUST-WP-0076",
|
||||
"CUST-WP-0077",
|
||||
"CUST-WP-0078",
|
||||
"CUST-WP-0079",
|
||||
"CUST-WP-0080",
|
||||
"CUST-WP-0081",
|
||||
"CUST-WP-ADHOC-2026-03-29",
|
||||
"CUST-WP-ADHOC-2026-05-02",
|
||||
"CUST-WP-ADHOC-2026-07-02",
|
||||
"CUST-WP-ADHOC-2026-08-25",
|
||||
"CUST-WP-ADHOC-2026-09-06",
|
||||
"THE-WP-0001"
|
||||
],
|
||||
"retired": [],
|
||||
"released": [],
|
||||
"created_tasks": [],
|
||||
"updated_tasks": [
|
||||
"CUST-WP-0080-T06"
|
||||
],
|
||||
"cancelled_tasks": [],
|
||||
"created_edges": [],
|
||||
"removed_edges": [],
|
||||
"unresolved_edges": [],
|
||||
"external_edges": [
|
||||
{
|
||||
"workplan": "CUST-WP-0071",
|
||||
"from": "CUST-WP-0071-T05",
|
||||
"target": "CUST-WP-0071-T04",
|
||||
"relationship_type": "starts_after",
|
||||
"from_workplan_id": "2249bddb-7524-5add-bd5c-c4163a6ca0f3",
|
||||
"from_task_id": "20ddd4d7-d85d-5b95-bb0a-7b0ca8af76ca",
|
||||
"to_workplan_id": null,
|
||||
"to_task_id": "944f98e2-7e42-5894-925b-752423f3a1cc"
|
||||
},
|
||||
{
|
||||
"workplan": "CUST-WP-0071",
|
||||
"from": "CUST-WP-0071-T03",
|
||||
"target": "CUST-WP-0071-T01",
|
||||
"relationship_type": "starts_after",
|
||||
"from_workplan_id": "2249bddb-7524-5add-bd5c-c4163a6ca0f3",
|
||||
"from_task_id": "6dc67558-eb1e-5bb6-a667-986f884dd495",
|
||||
"to_workplan_id": null,
|
||||
"to_task_id": "4ae8eb8d-f8c5-5256-ae50-c323cfd73d7a"
|
||||
},
|
||||
{
|
||||
"workplan": "CUST-WP-0071",
|
||||
"from": "CUST-WP-0071-T03",
|
||||
"target": "CUST-WP-0071-T02",
|
||||
"relationship_type": "starts_after",
|
||||
"from_workplan_id": "2249bddb-7524-5add-bd5c-c4163a6ca0f3",
|
||||
"from_task_id": "6dc67558-eb1e-5bb6-a667-986f884dd495",
|
||||
"to_workplan_id": null,
|
||||
"to_task_id": "82192370-2fd7-5363-88d2-3c67889d3d68"
|
||||
},
|
||||
{
|
||||
"workplan": "CUST-WP-0071",
|
||||
"from": "CUST-WP-0071-T02",
|
||||
"target": "CUST-WP-0071-T01",
|
||||
"relationship_type": "starts_after",
|
||||
"from_workplan_id": "2249bddb-7524-5add-bd5c-c4163a6ca0f3",
|
||||
"from_task_id": "82192370-2fd7-5363-88d2-3c67889d3d68",
|
||||
"to_workplan_id": null,
|
||||
"to_task_id": "4ae8eb8d-f8c5-5256-ae50-c323cfd73d7a"
|
||||
},
|
||||
{
|
||||
"workplan": "CUST-WP-0071",
|
||||
"from": "CUST-WP-0071-T02",
|
||||
"target": "RAPPS-WP-0014-T03",
|
||||
"relationship_type": "starts_after",
|
||||
"from_workplan_id": "2249bddb-7524-5add-bd5c-c4163a6ca0f3",
|
||||
"from_task_id": "82192370-2fd7-5363-88d2-3c67889d3d68",
|
||||
"to_workplan_id": null,
|
||||
"to_task_id": "dd069c6d-fcc1-5bac-b233-976f2f0d5cd1"
|
||||
},
|
||||
{
|
||||
"workplan": "CUST-WP-0071",
|
||||
"from": "CUST-WP-0071-T04",
|
||||
"target": "CUST-WP-0071-T03",
|
||||
"relationship_type": "starts_after",
|
||||
"from_workplan_id": "2249bddb-7524-5add-bd5c-c4163a6ca0f3",
|
||||
"from_task_id": "944f98e2-7e42-5894-925b-752423f3a1cc",
|
||||
"to_workplan_id": null,
|
||||
"to_task_id": "6dc67558-eb1e-5bb6-a667-986f884dd495"
|
||||
},
|
||||
{
|
||||
"workplan": "CUST-WP-0073",
|
||||
"from": "CUST-WP-0073-T02",
|
||||
"target": "GLAS-WP-0012",
|
||||
"relationship_type": "blocks",
|
||||
"from_workplan_id": "a98a9f34-83b4-5c8c-8107-e05f7806d50d",
|
||||
"from_task_id": "4b88b0b7-dc7e-5612-aa5d-1a08f0530c35",
|
||||
"to_workplan_id": "170bf1ae-337f-5553-8d1e-03b07100e08f",
|
||||
"to_task_id": null
|
||||
},
|
||||
{
|
||||
"workplan": "CUST-WP-0073",
|
||||
"from": "CUST-WP-0073-T04",
|
||||
"target": "CUST-WP-0073-T02",
|
||||
"relationship_type": "starts_after",
|
||||
"from_workplan_id": "a98a9f34-83b4-5c8c-8107-e05f7806d50d",
|
||||
"from_task_id": "90725511-4e31-549f-b567-47feff1a9ca4",
|
||||
"to_workplan_id": null,
|
||||
"to_task_id": "4b88b0b7-dc7e-5612-aa5d-1a08f0530c35"
|
||||
},
|
||||
{
|
||||
"workplan": "CUST-WP-0079",
|
||||
"from": "CUST-WP-0079-T03",
|
||||
"target": "ACTIVITY-WP-0047",
|
||||
"relationship_type": "blocks",
|
||||
"from_workplan_id": "ab288f92-d364-5c24-9362-aee451f266c3",
|
||||
"from_task_id": "1be0569c-221d-5925-a0ff-fd2af88e52b1",
|
||||
"to_workplan_id": "ee97869b-dca2-54c9-aa18-0b479c244fb3",
|
||||
"to_task_id": null
|
||||
},
|
||||
{
|
||||
"workplan": "CUST-WP-0079",
|
||||
"from": "CUST-WP-0079-T04",
|
||||
"target": "PNEX-WP-0006",
|
||||
"relationship_type": "blocks",
|
||||
"from_workplan_id": "ab288f92-d364-5c24-9362-aee451f266c3",
|
||||
"from_task_id": "5dd52156-09a8-5781-b288-99ae822f59a6",
|
||||
"to_workplan_id": "774c5037-a3d0-5e65-893c-d18970acd66a",
|
||||
"to_task_id": null
|
||||
},
|
||||
{
|
||||
"workplan": "CUST-WP-0080",
|
||||
"from": "CUST-WP-0080-T04",
|
||||
"target": "CUST-WP-0080-T03",
|
||||
"relationship_type": "starts_after",
|
||||
"from_workplan_id": "dfadb19b-445e-59c6-bcd6-48e87b912c52",
|
||||
"from_task_id": "82378b0f-7004-5c2f-9683-b356a1227576",
|
||||
"to_workplan_id": null,
|
||||
"to_task_id": "87637402-02f1-57c1-8fbc-4141598af8e3"
|
||||
},
|
||||
{
|
||||
"workplan": "CUST-WP-0080",
|
||||
"from": "CUST-WP-0080-T05",
|
||||
"target": "RPF-WP-0056",
|
||||
"relationship_type": "blocks",
|
||||
"from_workplan_id": "dfadb19b-445e-59c6-bcd6-48e87b912c52",
|
||||
"from_task_id": "ed8b909a-23bf-5e5f-b600-e477fbae136b",
|
||||
"to_workplan_id": "31933d08-c877-5fda-aff9-6220c3705c92",
|
||||
"to_task_id": null
|
||||
},
|
||||
{
|
||||
"workplan": "CUST-WP-0080",
|
||||
"from": "CUST-WP-0080-T05",
|
||||
"target": "RAPP-PLAYWRIGHT-WP-0004",
|
||||
"relationship_type": "blocks",
|
||||
"from_workplan_id": "dfadb19b-445e-59c6-bcd6-48e87b912c52",
|
||||
"from_task_id": "ed8b909a-23bf-5e5f-b600-e477fbae136b",
|
||||
"to_workplan_id": "f9280c40-201e-59b5-a6f0-1aace8e0e2e2",
|
||||
"to_task_id": null
|
||||
}
|
||||
],
|
||||
"refused": [],
|
||||
"notes": [
|
||||
"Preserved bound historical row and task UUIDs: CUST-WP-ADHOC-2026-08-25-RETIRED"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"files": {
|
||||
"docs/netkingdom-target-implementation.md": "sha256:a49de3368eb6f0b5429909797602be392c6e4982559358143180ab1f168e44a5",
|
||||
"workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md": "sha256:b45d908e47d411e3fb56ec7099b5b646a9fbd6806422bf9a744d960217c9d01c",
|
||||
"docs/changes/netkingdom-target-2026-10-16/resume-integration-r1/README.md": "sha256:d4710b067289d8fa643beec6dab847c48ad276bd039aa9ad54192ebbe356fe44",
|
||||
"docs/changes/netkingdom-target-2026-10-16/resume-integration-r1/publication-verification.json": "sha256:4893515106b3cf92fdabda701713a3276884cda16c84049d9bebbdf92294d944",
|
||||
"docs/changes/netkingdom-target-2026-10-16/resume-integration-r1/verified-results.json": "sha256:2e840481872de8784eb0351ecb0aa09ad307339218f05e86b8222e22cd0afd44",
|
||||
"docs/changes/netkingdom-target-2026-10-16/resume-integration-r1/native-next-steps.md": "sha256:c468533e1bc0970e35409c3c71d30bc31e67c17a99b8db16d2e7046f06faf5d1",
|
||||
"docs/changes/netkingdom-target-2026-10-16/resume-integration-r1/source-references.json": "sha256:6e12ae4931e41f0ff8162fd8226fc49dfad4a21a2f42677fbd7149f4dd086faa"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"exit_code": 0}
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
{
|
||||
"repo_slug": "the-custodian",
|
||||
"repo_path": "/home/worsch/the-custodian",
|
||||
"source_revision": "d834d0c4b71ea7ddf81317d5c2746ca514a810c8",
|
||||
"source_revision_reason": null,
|
||||
"issues": [
|
||||
{
|
||||
"severity": "WARN",
|
||||
"check_id": "C-35",
|
||||
"message": "Repo Manager warning: workplans/: repository uses more than one workplan prefix: ['CUST-WP', 'THE-WP']",
|
||||
"file_path": "workplans/",
|
||||
"db_id": "",
|
||||
"file_value": "",
|
||||
"db_value": "",
|
||||
"fixable": false
|
||||
},
|
||||
{
|
||||
"severity": "WARN",
|
||||
"check_id": "C-35",
|
||||
"message": "Repo Manager contradictory: workplans/ADHOC-2026-08-25-retired.md: workplan id 'CUST-WP-ADHOC-2026-08-25-RETIRED' is not accepted by the canon kind registry",
|
||||
"file_path": "workplans/ADHOC-2026-08-25-retired.md",
|
||||
"db_id": "",
|
||||
"file_value": "",
|
||||
"db_value": "",
|
||||
"fixable": false
|
||||
},
|
||||
{
|
||||
"severity": "WARN",
|
||||
"check_id": "C-20",
|
||||
"message": "Workplan dependency target 'STATE-WP-0092' is not linked to State Hub",
|
||||
"file_path": "workplans/CUST-WP-0072-fleet-flavor-and-depends-on-backfill.md",
|
||||
"db_id": "",
|
||||
"file_value": "STATE-WP-0092",
|
||||
"db_value": "",
|
||||
"fixable": false
|
||||
},
|
||||
{
|
||||
"severity": "WARN",
|
||||
"check_id": "C-40",
|
||||
"message": "blocker satisfied, task still wait: 'CUST-WP-0073-T05' in workplan 'CUST-WP-0073' \u2014 decision 1591daa1-6e85-4dfd-aadd-5a9fe1ef47fa is resolved",
|
||||
"file_path": "workplans/CUST-WP-0073-agent-credential-separation.md#CUST-WP-0073-T05",
|
||||
"db_id": "",
|
||||
"file_value": "1591daa1-6e85-4dfd-aadd-5a9fe1ef47fa",
|
||||
"db_value": "",
|
||||
"fixable": false
|
||||
},
|
||||
{
|
||||
"severity": "WARN",
|
||||
"check_id": "C-39",
|
||||
"message": "Human wait task 'CUST-WP-0079-T06' in workplan 'CUST-WP-0079' has needs_human: true without decision_id \u2014 unrouted human gate",
|
||||
"file_path": "workplans/CUST-WP-0079-deterministic-security-flow.md#CUST-WP-0079-T06",
|
||||
"db_id": "",
|
||||
"file_value": "needs_human: true",
|
||||
"db_value": "",
|
||||
"fixable": false
|
||||
},
|
||||
{
|
||||
"severity": "WARN",
|
||||
"check_id": "C-22",
|
||||
"message": "Task description drift 'CUST-WP-0080-T06': file content differs from DB (file wins)",
|
||||
"file_path": "workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md#CUST-WP-0080-T06",
|
||||
"db_id": "6aca534c-5c7a-556f-b6b6-406433670c29",
|
||||
"file_value": "Founder implementation request, 2026-10-16. Document the optimized target and\ncoordinate GPT-6.1-Sol medium agents again",
|
||||
"db_value": "Founder implementation request, 2026-10-16. Document the optimized target and\ncoordinate GPT-6.1-Sol medium agents again",
|
||||
"fixable": true
|
||||
},
|
||||
{
|
||||
"severity": "INFO",
|
||||
"check_id": "C-08",
|
||||
"message": "Closed DB workstream 'cust-wp-0010b' (id=416ff571\u2026, status=finished) has no backing workplan file",
|
||||
"file_path": "",
|
||||
"db_id": "416ff571-18cf-5cb2-9c8b-836f68a301b3",
|
||||
"file_value": "",
|
||||
"db_value": "",
|
||||
"fixable": false
|
||||
},
|
||||
{
|
||||
"severity": "INFO",
|
||||
"check_id": "C-08",
|
||||
"message": "Retired DB workstream 'interactive-the-custodian@retired-20260828' (id=370c2481\u2026, status=finished) has no backing workplan file \u2014 projection tombstone, not live work",
|
||||
"file_path": "",
|
||||
"db_id": "370c2481-6806-41eb-a917-f8874f03184f",
|
||||
"file_value": "",
|
||||
"db_value": "",
|
||||
"fixable": false
|
||||
},
|
||||
{
|
||||
"severity": "INFO",
|
||||
"check_id": "C-08",
|
||||
"message": "Retired DB workstream 'adhoc-2026-05-02@retired-20260827' (id=3f54ce9c\u2026, status=finished) has no backing workplan file \u2014 projection tombstone, not live work",
|
||||
"file_path": "",
|
||||
"db_id": "3f54ce9c-1f95-42de-894f-0f81a52ba2e8",
|
||||
"file_value": "",
|
||||
"db_value": "",
|
||||
"fixable": false
|
||||
},
|
||||
{
|
||||
"severity": "INFO",
|
||||
"check_id": "C-08",
|
||||
"message": "Retired DB workstream 'the-custodian-adhoc-2026-07-02@retired-20260827' (id=5c53733b\u2026, status=finished) has no backing workplan file \u2014 projection tombstone, not live work",
|
||||
"file_path": "",
|
||||
"db_id": "5c53733b-c077-4a93-be66-9c3af8f9b6fd",
|
||||
"file_value": "",
|
||||
"db_value": "",
|
||||
"fixable": false
|
||||
},
|
||||
{
|
||||
"severity": "WARN",
|
||||
"check_id": "C-26",
|
||||
"message": "Workplan id 'THE-WP-0001' uses prefix 'THE-WP' but repo canonical prefix is 'CUST-WP' \u2014 new plans must conform",
|
||||
"file_path": "workplans/THE-WP-0001-federation-interface.md",
|
||||
"db_id": "",
|
||||
"file_value": "THE-WP-0001",
|
||||
"db_value": "CUST-WP",
|
||||
"fixable": false
|
||||
}
|
||||
],
|
||||
"fixes_applied": [
|
||||
"bindings: synced 89/89",
|
||||
"C-17 cleared: pushed 1 backlogged commit(s)",
|
||||
"C-22 fixed: task 6aca534c\u2026 description updated",
|
||||
"push: pushed"
|
||||
],
|
||||
"summary": {
|
||||
"fail": 0,
|
||||
"automation_error": 0,
|
||||
"warn": 7,
|
||||
"info": 4
|
||||
},
|
||||
"result": "warn"
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
WARN: working-memory commit failed: Generated-only commits rejected: staged
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"head": "d834d0c4b71ea7ddf81317d5c2746ca514a810c8",
|
||||
"remote_main_exact": true,
|
||||
"all_seven_committed_files_unchanged": true,
|
||||
"all_six_custodian_task_bindings_match": true,
|
||||
"task_counts": {
|
||||
"done": 2,
|
||||
"progress": 3,
|
||||
"todo": 1
|
||||
},
|
||||
"sync_summary": {
|
||||
"fail": 0,
|
||||
"automation_error": 0,
|
||||
"warn": 7,
|
||||
"info": 4
|
||||
},
|
||||
"central_projection": "applied",
|
||||
"retained_finding": "Legacy Custodian consistency also omitted some task record IDs; final audit failed until existing forge-derived reconciliation repaired them.",
|
||||
"preexisting_staged_brief_and_work_records_preserved": true,
|
||||
"goal_complete": false
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
<!-- custodian-brief: generated by fix-consistency — do not edit manually -->
|
||||
# Custodian Brief — the-custodian
|
||||
|
||||
**Domain:** infotech
|
||||
**Last synced:** 2026-10-15 23:45 UTC
|
||||
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
|
||||
|
||||
## Active Workstreams
|
||||
|
||||
### NetKingdom target security architecture and SEC-DSM implementation
|
||||
Progress: 2/6 done | workplan_id: `dfadb19b-445e-59c6-bcd6-48e87b912c52`
|
||||
|
||||
**Open tasks:**
|
||||
- ► Close source defects and reconcile uncovered lifecycle gaps `87637402`
|
||||
- ► Coordinate self-service Playwright identity delivery `ed8b909a`
|
||||
- ► Establish the target architecture and drive the restructuring `6aca534c`
|
||||
- · Verify native closure against every requirement `82378b0f`
|
||||
|
||||
### Separate agent credentials and establish supervised privileged execution
|
||||
Progress: 2/5 done | workplan_id: `a98a9f34-83b4-5c8c-8107-e05f7806d50d`
|
||||
|
||||
**Open tasks:**
|
||||
- ! Build and hand out the agent identity `4b88b0b7`
|
||||
*(wait: Await GLAS-WP-0012 (glas-harness) production acceptance of the local supervised profile; then railiance-platform RBAC, railiance-enablement k3s config, railiance-infra principal mapping and ops-warden certificate issuance.)*
|
||||
- ! Fleet guidance and harness guards `90725511`
|
||||
*(wait: Await CUST-WP-0073-T02: the verified agent identity and execution path must exist before it can be documented; Codex/Grok read-denial guard needs glas-harness or harness-owner delivery.)*
|
||||
- ! Rotate what was exposed `1b41b532`
|
||||
*(wait: Deferred by founder decision 2026-09-24; reopened only by the founder on one of the three triggers below, in one attended rotation window.)*
|
||||
|
||||
### Size Railiance workloads from actual demand and establish a weekly allocation review
|
||||
Progress: 1/5 done | workplan_id: `2249bddb-7524-5add-bd5c-c4163a6ca0f3`
|
||||
|
||||
**Open tasks:**
|
||||
- ! Measure the Vergabe prototype under representative pilot use `82192370`
|
||||
*(wait: Await RAPPS-WP-0014-T03 (blocked, needs_human): populated two-user/document workflow run by Bernd and the company contact, giving representative load for p95 <= 2s / zero failures.)*
|
||||
- ! Review ecosystem allocations and reserve operating room `6dc67558`
|
||||
*(wait: Await CUST-WP-0071-T02 representative sample; Forgejo/runner demand and zero-request workload review need owner input (railiance-cluster, railiance-forge, resource-control).)*
|
||||
- ! Apply accepted sizing changes and verify their effects `944f98e2`
|
||||
*(wait: Await reviewed allocation proposals and applicable workload-owner authority.)*
|
||||
- ! Establish the regular weekly workload-demand and allocation assessment `20ddd4d7`
|
||||
*(wait: Await the repeatable observation and accepted sizing/review procedure.)*
|
||||
|
||||
### Deterministic security flow: standing authorizations, flow report, and agent re-engagement
|
||||
Progress: 3/7 done | workplan_id: `ab288f92-d364-5c24-9362-aee451f266c3`
|
||||
|
||||
**Open tasks:**
|
||||
- ! Schedule the flow report and decision sync `1be0569c`
|
||||
*(wait: Activity Core accepted the schedule in ACTIVITY-WP-0047. Await claiming rein workstation/hub acceptance, owner activation and the first unassisted fire with report/checkpoint evidence.)*
|
||||
- ! Encode the standing change rule as a policy-nexus guardrail `5dd52156`
|
||||
*(wait: policy-nexus owns guardrail publication; handoff written as PNEX-WP-0006.)*
|
||||
- ! Stop agent-authored index and sync commits `8a3dc219`
|
||||
*(wait: Local guard and repeatable shared-hook installation are implemented. Fleet generator adoption and the required full week of clean history remain unverified; repo-manager owner acceptance is needed.)*
|
||||
- ! Attended window #1 `e3da807f`
|
||||
*(wait: Partly done 2026-10-11: list cut from 14 to 3; U05-U07 accepted, U08 waits on KEY-WP-0040-T01. Remaining founder acts: USER-WP-0026-T03 readback, RPF-WP-0044-T08 coulombcore check, the U08 re-run, and Sol's added RPF-WP-0044-T04 issuer login.)*
|
||||
|
||||
---
|
||||
## MCP Orientation (when available)
|
||||
|
||||
If the state-hub MCP server is reachable, call:
|
||||
`get_domain_summary("infotech")`
|
||||
This provides richer cross-domain context.
|
||||
If the MCP call fails, use this file as your orientation source.
|
||||
|
|
@ -0,0 +1,536 @@
|
|||
# Work Records — the-custodian
|
||||
|
||||
> Generated by `statehub fix-consistency` (CUST-WP-0061-T04, work-record
|
||||
> stage 3). Do not edit by hand — edit the source file/block listed for
|
||||
> each record and re-run fix-consistency to refresh this index. Archived
|
||||
> workplans are omitted; closed decisions/intakes/engagements stay listed
|
||||
> so recently-resolved work is still visible. [auto]
|
||||
|
||||
| Kind | ID | Status | Lane | Source |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| workplan | CUST-WP-ADHOC-2026-05-02 | done | — | workplans/ADHOC-2026-05-02.md |
|
||||
| workplan | CUST-WP-ADHOC-2026-07-02 | finished | — | workplans/ADHOC-2026-07-02.md |
|
||||
| workplan | CUST-WP-ADHOC-2026-08-25-RETIRED | finished | — | workplans/ADHOC-2026-08-25-retired.md |
|
||||
| workplan | CUST-WP-ADHOC-2026-08-25 | finished | — | workplans/ADHOC-2026-08-25.md |
|
||||
| workplan | CUST-WP-ADHOC-2026-09-06 | finished | — | workplans/ADHOC-2026-09-06.md |
|
||||
| workplan | CUST-WP-0000 | finished | — | workplans/CUST-WP-0000-state-hub-v0.1-build-deploy.md |
|
||||
| workplan | CUST-WP-0000b | finished | — | workplans/CUST-WP-0000b-state-hub-v0.2-decisions-dependencies.md |
|
||||
| workplan | CUST-WP-0001 | done | — | workplans/CUST-WP-0001-custodian-agent-runtime.md |
|
||||
| workplan | CUST-WP-0002 | finished | — | workplans/CUST-WP-0002-contribution-tracking-sbom.md |
|
||||
| workplan | CUST-WP-0004 | finished | — | workplans/CUST-WP-0004-ep-td-tracking.md |
|
||||
| workplan | CUST-WP-0005 | finished | — | workplans/CUST-WP-0005-dynamic-domains.md |
|
||||
| workplan | CUST-WP-0006 | done | — | workplans/CUST-WP-0006-gems-state-hub.md |
|
||||
| workplan | CUST-WP-0007 | finished | — | workplans/CUST-WP-0007-gems-migration.md |
|
||||
| workplan | CUST-WP-0008 | done | — | workplans/CUST-WP-0008-consistency-engine.md |
|
||||
| workplan | CUST-WP-0009 | finished | — | workplans/CUST-WP-0009-human-interventions.md |
|
||||
| workplan | CUST-WP-0010 | finished | — | workplans/CUST-WP-0010-workstream-lifecycle-docs.md |
|
||||
| workplan | CUST-WP-0010b | finished | — | workplans/CUST-WP-0010b-domain-and-repo-goals.md |
|
||||
| workplan | CUST-WP-0013 | finished | — | workplans/CUST-WP-0013-sbom-infra-expansion.md |
|
||||
| workplan | CUST-WP-0015 | done | — | workplans/CUST-WP-0015-agent-inbox.md |
|
||||
| workplan | CUST-WP-0016 | done | — | workplans/CUST-WP-0016-kaizen-agentic-integration.md |
|
||||
| workplan | CUST-WP-0017 | done | — | workplans/CUST-WP-0017-scope-md-agent-and-coverage.md |
|
||||
| workplan | CUST-WP-0018 | done | — | workplans/CUST-WP-0018-api-hardening-code-quality.md |
|
||||
| workplan | CUST-WP-0019 | done | — | workplans/CUST-WP-0019-dashboard-ux-polish.md |
|
||||
| workplan | CUST-WP-0020 | done | — | workplans/CUST-WP-0020-test-suite.md |
|
||||
| workplan | CUST-WP-0021 | done | — | workplans/CUST-WP-0021-multi-host-repo-paths.md |
|
||||
| workplan | CUST-WP-0022 | done | — | workplans/CUST-WP-0022-capability-requests.md |
|
||||
| workplan | CUST-WP-0023 | done | — | workplans/CUST-WP-0023-tpsc.md |
|
||||
| workplan | CUST-WP-0024 | done | — | workplans/CUST-WP-0024-repo-doi-gate.md |
|
||||
| workplan | CUST-WP-0026 | done | — | workplans/CUST-WP-0026-distributed-consistency.md |
|
||||
| workplan | CUST-WP-0027 | done | — | workplans/CUST-WP-0027-capability-request-dispute.md |
|
||||
| workplan | CUST-WP-0029 | done | — | workplans/CUST-WP-0029-token-consumption-tracking.md |
|
||||
| workplan | CUST-WP-0030 | done | — | workplans/CUST-WP-0030-dashboard-entity-list-ux.md |
|
||||
| workplan | CUST-WP-0031 | done | — | workplans/CUST-WP-0031-capability-registry.md |
|
||||
| workplan | CUST-WP-0032 | done | — | workplans/CUST-WP-0032-haskell-build-machine.md |
|
||||
| workplan | CUST-WP-0033 | done | — | workplans/CUST-WP-0033-interface-change-registry.md |
|
||||
| workplan | CUST-WP-0039 | done | — | workplans/CUST-WP-0039-dashboard-poll-optimization.md |
|
||||
| workplan | CUST-WP-0040 | finished | — | workplans/CUST-WP-0040-state-hub-nats-activity-core-integration.md |
|
||||
| workplan | CUST-WP-0041 | done | — | workplans/CUST-WP-0041-api-performance.md |
|
||||
| workplan | CUST-WP-0043 | finished | — | workplans/CUST-WP-0043-state-hub-repo-extraction.md |
|
||||
| workplan | CUST-WP-0044 | finished | — | workplans/CUST-WP-0044-daily-statehub-wsjf-triage.md |
|
||||
| workplan | CUST-WP-0045 | finished | — | workplans/CUST-WP-0045-activity-core-daily-triage-runner.md |
|
||||
| workplan | CUST-WP-0069 | finished | — | workplans/CUST-WP-0045-cutover-runbook.md |
|
||||
| workplan | CUST-WP-0046 | finished | — | workplans/CUST-WP-0046-hourly-recently-on-scope-activity-core.md |
|
||||
| workplan | CUST-WP-0047 | finished | — | workplans/CUST-WP-0047-ops-hub-service-inventory-now-view.md |
|
||||
| workplan | CUST-WP-0048 | finished | — | workplans/CUST-WP-0048-hub-core-state-hub-import-refactor.md |
|
||||
| workplan | CUST-WP-0049 | finished | — | workplans/CUST-WP-0049-interhub-bootstrap-access-lane.md |
|
||||
| workplan | CUST-WP-0050 | finished | — | workplans/CUST-WP-0050-repo-classification-registration-redesign.md |
|
||||
| workplan | CUST-WP-0051 | finished | — | workplans/CUST-WP-0051-infrastructure-stabilization-metaplan.md |
|
||||
| workplan | CUST-WP-0052 | finished | — | workplans/CUST-WP-0052-core-hub-fos-reset.md |
|
||||
| workplan | CUST-WP-0053 | finished | — | workplans/CUST-WP-0053-coordination-hygiene-improvements.md |
|
||||
| workplan | CUST-WP-0055 | finished | — | workplans/CUST-WP-0055-workplan-terminology-fleet-refactor.md |
|
||||
| workplan | CUST-WP-0056 | finished | — | workplans/CUST-WP-0056-daily-todo-md-stale-review.md |
|
||||
| workplan | CUST-WP-0059 | finished | — | workplans/CUST-WP-0059-forgejo-ci-publish-cutover-tail.md |
|
||||
| workplan | CUST-WP-0060 | finished | — | workplans/CUST-WP-0060-work-record-canonization.md |
|
||||
| workplan | CUST-WP-0061 | finished | — | workplans/CUST-WP-0061-work-record-stage3-intake-promotion-index.md |
|
||||
| workplan | CUST-WP-0062 | finished | — | workplans/CUST-WP-0062-sbom-nexus-daily-catchup.md |
|
||||
| workplan | CUST-WP-0063 | finished | — | workplans/CUST-WP-0063-inbox-governance-packets.md |
|
||||
| workplan | CUST-WP-0064 | finished | — | workplans/CUST-WP-0064-sbom-controlled-scan-inputs.md |
|
||||
| workplan | CUST-WP-0065 | finished | — | workplans/CUST-WP-0065-reclassify-repos-and-guidance-docs-to-the-new-sector-domain.md |
|
||||
| workplan | CUST-WP-0066 | finished | — | workplans/CUST-WP-0066-repository-qualified-ad-hoc-identifiers.md |
|
||||
| workplan | CUST-WP-0067 | finished | — | workplans/CUST-WP-0067-hub-authority-target-resolution.md |
|
||||
| workplan | CUST-WP-0068 | finished | — | workplans/CUST-WP-0068-cache-only-work-record-recovery.md |
|
||||
| workplan | CUST-WP-0070 | finished | — | workplans/CUST-WP-0070-publication-repo-category.md |
|
||||
| workplan | CUST-WP-0071 | blocked | — | workplans/CUST-WP-0071-measured-workload-sizing-and-weekly-review.md |
|
||||
| workplan | CUST-WP-0072 | finished | — | workplans/CUST-WP-0072-fleet-flavor-and-depends-on-backfill.md |
|
||||
| workplan | CUST-WP-0073 | blocked | — | workplans/CUST-WP-0073-agent-credential-separation.md |
|
||||
| workplan | CUST-WP-0074 | finished | — | workplans/CUST-WP-0074-qualified-wait-states.md |
|
||||
| workplan | CUST-WP-0075 | finished | — | workplans/CUST-WP-0075-dependency-graph-driven-unblocking.md |
|
||||
| workplan | CUST-WP-0076 | finished | — | workplans/CUST-WP-0076-provisioning-window-cadence.md |
|
||||
| workplan | CUST-WP-0077 | finished | — | workplans/CUST-WP-0077-dependency-root-execution-wave.md |
|
||||
| workplan | CUST-WP-0078 | finished | — | workplans/CUST-WP-0078-security-collaboration-consolidation.md |
|
||||
| workplan | CUST-WP-0079 | blocked | — | workplans/CUST-WP-0079-deterministic-security-flow.md |
|
||||
| workplan | CUST-WP-0080 | active | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md |
|
||||
| workplan | CUST-WP-0081 | finished | — | workplans/CUST-WP-0081-netkingdom-architecture-review-and-reuse.md |
|
||||
| workplan | THE-WP-0001 | finished | — | workplans/THE-WP-0001-federation-interface.md |
|
||||
| task | CUST-WP-ADHOC-2026-05-02-T01 | done | — | workplans/ADHOC-2026-05-02.md |
|
||||
| task | CUST-WP-ADHOC-2026-07-02-T01 | done | — | workplans/ADHOC-2026-07-02.md |
|
||||
| task | ADHOC-2026-08-25-T01 | done | — | workplans/ADHOC-2026-08-25-retired.md |
|
||||
| task | CUST-WP-ADHOC-2026-08-25-T01 | done | — | workplans/ADHOC-2026-08-25.md |
|
||||
| task | CUST-WP-ADHOC-2026-09-06-T01 | done | — | workplans/ADHOC-2026-09-06.md |
|
||||
| task | CUST-WP-0001-T01 | done | — | workplans/CUST-WP-0001-custodian-agent-runtime.md |
|
||||
| task | CUST-WP-0001-T02 | done | — | workplans/CUST-WP-0001-custodian-agent-runtime.md |
|
||||
| task | CUST-WP-0002-T01 | done | — | workplans/CUST-WP-0002-contribution-tracking-sbom.md |
|
||||
| task | CUST-WP-0002-T02 | done | — | workplans/CUST-WP-0002-contribution-tracking-sbom.md |
|
||||
| task | CUST-WP-0002-T03 | done | — | workplans/CUST-WP-0002-contribution-tracking-sbom.md |
|
||||
| task | CUST-WP-0002-T04 | done | — | workplans/CUST-WP-0002-contribution-tracking-sbom.md |
|
||||
| task | CUST-WP-0002-T05 | done | — | workplans/CUST-WP-0002-contribution-tracking-sbom.md |
|
||||
| task | CUST-WP-0002-T06 | done | — | workplans/CUST-WP-0002-contribution-tracking-sbom.md |
|
||||
| task | CUST-WP-0002-T07 | done | — | workplans/CUST-WP-0002-contribution-tracking-sbom.md |
|
||||
| task | CUST-WP-0002-T08 | done | — | workplans/CUST-WP-0002-contribution-tracking-sbom.md |
|
||||
| task | CUST-WP-0002-T09 | done | — | workplans/CUST-WP-0002-contribution-tracking-sbom.md |
|
||||
| task | CUST-WP-0002-T10 | done | — | workplans/CUST-WP-0002-contribution-tracking-sbom.md |
|
||||
| task | CUST-WP-0002-T11 | done | — | workplans/CUST-WP-0002-contribution-tracking-sbom.md |
|
||||
| task | CUST-WP-0002-T12 | done | — | workplans/CUST-WP-0002-contribution-tracking-sbom.md |
|
||||
| task | CUST-WP-0002-T13 | done | — | workplans/CUST-WP-0002-contribution-tracking-sbom.md |
|
||||
| task | CUST-WP-0002-T14 | done | — | workplans/CUST-WP-0002-contribution-tracking-sbom.md |
|
||||
| task | CUST-WP-0002-T15 | done | — | workplans/CUST-WP-0002-contribution-tracking-sbom.md |
|
||||
| task | CUST-WP-0004-T01 | done | — | workplans/CUST-WP-0004-ep-td-tracking.md |
|
||||
| task | CUST-WP-0004-T02 | done | — | workplans/CUST-WP-0004-ep-td-tracking.md |
|
||||
| task | CUST-WP-0004-T03 | done | — | workplans/CUST-WP-0004-ep-td-tracking.md |
|
||||
| task | CUST-WP-0004-T04 | done | — | workplans/CUST-WP-0004-ep-td-tracking.md |
|
||||
| task | CUST-WP-0004-T05 | done | — | workplans/CUST-WP-0004-ep-td-tracking.md |
|
||||
| task | CUST-WP-0004-T06 | done | — | workplans/CUST-WP-0004-ep-td-tracking.md |
|
||||
| task | CUST-WP-0004-T07 | done | — | workplans/CUST-WP-0004-ep-td-tracking.md |
|
||||
| task | CUST-WP-0004-T08 | done | — | workplans/CUST-WP-0004-ep-td-tracking.md |
|
||||
| task | CUST-WP-0004-T09 | done | — | workplans/CUST-WP-0004-ep-td-tracking.md |
|
||||
| task | CUST-WP-0004-T10 | done | — | workplans/CUST-WP-0004-ep-td-tracking.md |
|
||||
| task | CUST-WP-0005-T01 | done | — | workplans/CUST-WP-0005-dynamic-domains.md |
|
||||
| task | CUST-WP-0005-T02 | done | — | workplans/CUST-WP-0005-dynamic-domains.md |
|
||||
| task | CUST-WP-0005-T03 | done | — | workplans/CUST-WP-0005-dynamic-domains.md |
|
||||
| task | CUST-WP-0005-T04 | done | — | workplans/CUST-WP-0005-dynamic-domains.md |
|
||||
| task | CUST-WP-0005-T05 | done | — | workplans/CUST-WP-0005-dynamic-domains.md |
|
||||
| task | CUST-WP-0005-T06 | done | — | workplans/CUST-WP-0005-dynamic-domains.md |
|
||||
| task | CUST-WP-0005-T07 | done | — | workplans/CUST-WP-0005-dynamic-domains.md |
|
||||
| task | CUST-WP-0005-T08 | done | — | workplans/CUST-WP-0005-dynamic-domains.md |
|
||||
| task | CUST-WP-0005-T09 | done | — | workplans/CUST-WP-0005-dynamic-domains.md |
|
||||
| task | CUST-WP-0005-T10 | done | — | workplans/CUST-WP-0005-dynamic-domains.md |
|
||||
| task | CUST-WP-0005-T11 | done | — | workplans/CUST-WP-0005-dynamic-domains.md |
|
||||
| task | T01 | done | — | workplans/CUST-WP-0006-gems-state-hub.md |
|
||||
| task | T02 | done | — | workplans/CUST-WP-0006-gems-state-hub.md |
|
||||
| task | T03 | done | — | workplans/CUST-WP-0006-gems-state-hub.md |
|
||||
| task | T04 | done | — | workplans/CUST-WP-0006-gems-state-hub.md |
|
||||
| task | T05 | done | — | workplans/CUST-WP-0006-gems-state-hub.md |
|
||||
| task | T01 | done | — | workplans/CUST-WP-0007-gems-migration.md |
|
||||
| task | T02 | done | — | workplans/CUST-WP-0007-gems-migration.md |
|
||||
| task | T03 | done | — | workplans/CUST-WP-0007-gems-migration.md |
|
||||
| task | T04 | done | — | workplans/CUST-WP-0007-gems-migration.md |
|
||||
| task | T05 | done | — | workplans/CUST-WP-0007-gems-migration.md |
|
||||
| task | T06 | done | — | workplans/CUST-WP-0007-gems-migration.md |
|
||||
| task | T07 | done | — | workplans/CUST-WP-0007-gems-migration.md |
|
||||
| task | T08 | done | — | workplans/CUST-WP-0007-gems-migration.md |
|
||||
| task | T09 | done | — | workplans/CUST-WP-0007-gems-migration.md |
|
||||
| task | T10 | done | — | workplans/CUST-WP-0007-gems-migration.md |
|
||||
| task | T11 | done | — | workplans/CUST-WP-0007-gems-migration.md |
|
||||
| task | T12 | done | — | workplans/CUST-WP-0007-gems-migration.md |
|
||||
| task | T13 | done | — | workplans/CUST-WP-0007-gems-migration.md |
|
||||
| task | T14 | done | — | workplans/CUST-WP-0007-gems-migration.md |
|
||||
| task | T15 | done | — | workplans/CUST-WP-0007-gems-migration.md |
|
||||
| task | T16 | done | — | workplans/CUST-WP-0007-gems-migration.md |
|
||||
| task | T17 | done | — | workplans/CUST-WP-0007-gems-migration.md |
|
||||
| task | CUST-WP-0008-T01 | done | — | workplans/CUST-WP-0008-consistency-engine.md |
|
||||
| task | CUST-WP-0008-T02 | done | — | workplans/CUST-WP-0008-consistency-engine.md |
|
||||
| task | CUST-WP-0008-T03 | done | — | workplans/CUST-WP-0008-consistency-engine.md |
|
||||
| task | CUST-WP-0008-T04 | done | — | workplans/CUST-WP-0008-consistency-engine.md |
|
||||
| task | CUST-WP-0009-T1 | done | — | workplans/CUST-WP-0009-human-interventions.md |
|
||||
| task | CUST-WP-0009-T2 | done | — | workplans/CUST-WP-0009-human-interventions.md |
|
||||
| task | CUST-WP-0009-T3 | done | — | workplans/CUST-WP-0009-human-interventions.md |
|
||||
| task | CUST-WP-0010-T1 | done | — | workplans/CUST-WP-0010-workstream-lifecycle-docs.md |
|
||||
| task | CUST-WP-0010-T2 | done | — | workplans/CUST-WP-0010-workstream-lifecycle-docs.md |
|
||||
| task | CUST-WP-0010-T3 | done | — | workplans/CUST-WP-0010-workstream-lifecycle-docs.md |
|
||||
| task | CUST-WP-0010b-T01 | done | — | workplans/CUST-WP-0010b-domain-and-repo-goals.md |
|
||||
| task | CUST-WP-0010b-T02 | done | — | workplans/CUST-WP-0010b-domain-and-repo-goals.md |
|
||||
| task | CUST-WP-0013-T01 | done | — | workplans/CUST-WP-0013-sbom-infra-expansion.md |
|
||||
| task | CUST-WP-0013-T02 | done | — | workplans/CUST-WP-0013-sbom-infra-expansion.md |
|
||||
| task | CUST-WP-0013-T03 | done | — | workplans/CUST-WP-0013-sbom-infra-expansion.md |
|
||||
| task | CUST-WP-0013-T04 | done | — | workplans/CUST-WP-0013-sbom-infra-expansion.md |
|
||||
| task | CUST-WP-0013-T05 | done | — | workplans/CUST-WP-0013-sbom-infra-expansion.md |
|
||||
| task | CUST-WP-0013-T06 | done | — | workplans/CUST-WP-0013-sbom-infra-expansion.md |
|
||||
| task | CUST-WP-0013-T07 | done | — | workplans/CUST-WP-0013-sbom-infra-expansion.md |
|
||||
| task | CUST-WP-0013-T08 | done | — | workplans/CUST-WP-0013-sbom-infra-expansion.md |
|
||||
| task | CUST-WP-0013-T09 | done | — | workplans/CUST-WP-0013-sbom-infra-expansion.md |
|
||||
| task | CUST-WP-0013-T10 | done | — | workplans/CUST-WP-0013-sbom-infra-expansion.md |
|
||||
| task | CUST-WP-0013-T11 | done | — | workplans/CUST-WP-0013-sbom-infra-expansion.md |
|
||||
| task | CUST-WP-0013-T12 | done | — | workplans/CUST-WP-0013-sbom-infra-expansion.md |
|
||||
| task | CUST-WP-0013-T13 | done | — | workplans/CUST-WP-0013-sbom-infra-expansion.md |
|
||||
| task | CUST-WP-0013-T14 | done | — | workplans/CUST-WP-0013-sbom-infra-expansion.md |
|
||||
| task | CUST-WP-0015-T01 | done | — | workplans/CUST-WP-0015-agent-inbox.md |
|
||||
| task | CUST-WP-0015-T02 | done | — | workplans/CUST-WP-0015-agent-inbox.md |
|
||||
| task | CUST-WP-0015-T03 | done | — | workplans/CUST-WP-0015-agent-inbox.md |
|
||||
| task | CUST-WP-0015-T04 | done | — | workplans/CUST-WP-0015-agent-inbox.md |
|
||||
| task | CUST-WP-0015-T05 | done | — | workplans/CUST-WP-0015-agent-inbox.md |
|
||||
| task | CUST-WP-0016-T01 | done | — | workplans/CUST-WP-0016-kaizen-agentic-integration.md |
|
||||
| task | CUST-WP-0016-T02 | done | — | workplans/CUST-WP-0016-kaizen-agentic-integration.md |
|
||||
| task | CUST-WP-0016-T03 | done | — | workplans/CUST-WP-0016-kaizen-agentic-integration.md |
|
||||
| task | CUST-WP-0016-T04 | done | — | workplans/CUST-WP-0016-kaizen-agentic-integration.md |
|
||||
| task | CUST-WP-0016-T05 | done | — | workplans/CUST-WP-0016-kaizen-agentic-integration.md |
|
||||
| task | CUST-WP-0017-T01 | done | — | workplans/CUST-WP-0017-scope-md-agent-and-coverage.md |
|
||||
| task | CUST-WP-0017-T02 | done | — | workplans/CUST-WP-0017-scope-md-agent-and-coverage.md |
|
||||
| task | CUST-WP-0017-T03 | done | — | workplans/CUST-WP-0017-scope-md-agent-and-coverage.md |
|
||||
| task | CUST-WP-0017-T04 | done | — | workplans/CUST-WP-0017-scope-md-agent-and-coverage.md |
|
||||
| task | CUST-WP-0017-T05 | done | — | workplans/CUST-WP-0017-scope-md-agent-and-coverage.md |
|
||||
| task | CUST-WP-0017-T06 | done | — | workplans/CUST-WP-0017-scope-md-agent-and-coverage.md |
|
||||
| task | CUST-WP-0018-T01 | done | — | workplans/CUST-WP-0018-api-hardening-code-quality.md |
|
||||
| task | CUST-WP-0018-T02 | done | — | workplans/CUST-WP-0018-api-hardening-code-quality.md |
|
||||
| task | CUST-WP-0018-T03 | done | — | workplans/CUST-WP-0018-api-hardening-code-quality.md |
|
||||
| task | CUST-WP-0018-T04 | done | — | workplans/CUST-WP-0018-api-hardening-code-quality.md |
|
||||
| task | CUST-WP-0018-T05 | done | — | workplans/CUST-WP-0018-api-hardening-code-quality.md |
|
||||
| task | CUST-WP-0018-T06 | done | — | workplans/CUST-WP-0018-api-hardening-code-quality.md |
|
||||
| task | CUST-WP-0018-T07 | done | — | workplans/CUST-WP-0018-api-hardening-code-quality.md |
|
||||
| task | CUST-WP-0019-T01 | done | — | workplans/CUST-WP-0019-dashboard-ux-polish.md |
|
||||
| task | CUST-WP-0019-T02 | done | — | workplans/CUST-WP-0019-dashboard-ux-polish.md |
|
||||
| task | CUST-WP-0019-T03 | done | — | workplans/CUST-WP-0019-dashboard-ux-polish.md |
|
||||
| task | CUST-WP-0020-T01 | done | — | workplans/CUST-WP-0020-test-suite.md |
|
||||
| task | CUST-WP-0020-T02 | done | — | workplans/CUST-WP-0020-test-suite.md |
|
||||
| task | CUST-WP-0020-T03 | done | — | workplans/CUST-WP-0020-test-suite.md |
|
||||
| task | CUST-WP-0020-T04 | done | — | workplans/CUST-WP-0020-test-suite.md |
|
||||
| task | CUST-WP-0020-T05 | done | — | workplans/CUST-WP-0020-test-suite.md |
|
||||
| task | CUST-WP-0021-T01 | done | — | workplans/CUST-WP-0021-multi-host-repo-paths.md |
|
||||
| task | CUST-WP-0021-T02 | done | — | workplans/CUST-WP-0021-multi-host-repo-paths.md |
|
||||
| task | CUST-WP-0021-T03 | done | — | workplans/CUST-WP-0021-multi-host-repo-paths.md |
|
||||
| task | CUST-WP-0021-T04 | done | — | workplans/CUST-WP-0021-multi-host-repo-paths.md |
|
||||
| task | CUST-WP-0021-T05 | done | — | workplans/CUST-WP-0021-multi-host-repo-paths.md |
|
||||
| task | CUST-WP-0022-T01 | done | — | workplans/CUST-WP-0022-capability-requests.md |
|
||||
| task | CUST-WP-0022-T02 | done | — | workplans/CUST-WP-0022-capability-requests.md |
|
||||
| task | CUST-WP-0022-T03 | done | — | workplans/CUST-WP-0022-capability-requests.md |
|
||||
| task | CUST-WP-0022-T04 | done | — | workplans/CUST-WP-0022-capability-requests.md |
|
||||
| task | CUST-WP-0022-T05 | done | — | workplans/CUST-WP-0022-capability-requests.md |
|
||||
| task | CUST-WP-0022-T06 | done | — | workplans/CUST-WP-0022-capability-requests.md |
|
||||
| task | CUST-WP-0022-T07 | done | — | workplans/CUST-WP-0022-capability-requests.md |
|
||||
| task | CUST-WP-0022-T08 | done | — | workplans/CUST-WP-0022-capability-requests.md |
|
||||
| task | CUST-WP-0022-T09 | done | — | workplans/CUST-WP-0022-capability-requests.md |
|
||||
| task | CUST-WP-0022-T10 | done | — | workplans/CUST-WP-0022-capability-requests.md |
|
||||
| task | CUST-WP-0023-T01 | done | — | workplans/CUST-WP-0023-tpsc.md |
|
||||
| task | CUST-WP-0023-T02 | done | — | workplans/CUST-WP-0023-tpsc.md |
|
||||
| task | CUST-WP-0023-T03 | done | — | workplans/CUST-WP-0023-tpsc.md |
|
||||
| task | CUST-WP-0023-T04 | done | — | workplans/CUST-WP-0023-tpsc.md |
|
||||
| task | CUST-WP-0023-T05 | done | — | workplans/CUST-WP-0023-tpsc.md |
|
||||
| task | CUST-WP-0023-T06 | done | — | workplans/CUST-WP-0023-tpsc.md |
|
||||
| task | CUST-WP-0023-T07 | done | — | workplans/CUST-WP-0023-tpsc.md |
|
||||
| task | CUST-WP-0023-T08 | done | — | workplans/CUST-WP-0023-tpsc.md |
|
||||
| task | CUST-WP-0023-T09 | done | — | workplans/CUST-WP-0023-tpsc.md |
|
||||
| task | CUST-WP-0023-T10 | done | — | workplans/CUST-WP-0023-tpsc.md |
|
||||
| task | CUST-WP-0024-T01 | done | — | workplans/CUST-WP-0024-repo-doi-gate.md |
|
||||
| task | CUST-WP-0024-T02 | done | — | workplans/CUST-WP-0024-repo-doi-gate.md |
|
||||
| task | CUST-WP-0024-T03 | done | — | workplans/CUST-WP-0024-repo-doi-gate.md |
|
||||
| task | CUST-WP-0024-T04 | done | — | workplans/CUST-WP-0024-repo-doi-gate.md |
|
||||
| task | CUST-WP-0024-T05 | done | — | workplans/CUST-WP-0024-repo-doi-gate.md |
|
||||
| task | CUST-WP-0026-T01 | done | — | workplans/CUST-WP-0026-distributed-consistency.md |
|
||||
| task | CUST-WP-0026-T02 | done | — | workplans/CUST-WP-0026-distributed-consistency.md |
|
||||
| task | CUST-WP-0026-T03 | done | — | workplans/CUST-WP-0026-distributed-consistency.md |
|
||||
| task | CUST-WP-0026-T04 | done | — | workplans/CUST-WP-0026-distributed-consistency.md |
|
||||
| task | CUST-WP-0026-T05 | done | — | workplans/CUST-WP-0026-distributed-consistency.md |
|
||||
| task | CUST-WP-0027-T01 | done | — | workplans/CUST-WP-0027-capability-request-dispute.md |
|
||||
| task | CUST-WP-0027-T02 | done | — | workplans/CUST-WP-0027-capability-request-dispute.md |
|
||||
| task | CUST-WP-0027-T03 | done | — | workplans/CUST-WP-0027-capability-request-dispute.md |
|
||||
| task | CUST-WP-0027-T04 | done | — | workplans/CUST-WP-0027-capability-request-dispute.md |
|
||||
| task | CUST-WP-0027-T05 | done | — | workplans/CUST-WP-0027-capability-request-dispute.md |
|
||||
| task | CUST-WP-0029-T01 | done | — | workplans/CUST-WP-0029-token-consumption-tracking.md |
|
||||
| task | CUST-WP-0029-T02 | done | — | workplans/CUST-WP-0029-token-consumption-tracking.md |
|
||||
| task | CUST-WP-0029-T03 | done | — | workplans/CUST-WP-0029-token-consumption-tracking.md |
|
||||
| task | CUST-WP-0029-T04 | done | — | workplans/CUST-WP-0029-token-consumption-tracking.md |
|
||||
| task | CUST-WP-0029-T05 | done | — | workplans/CUST-WP-0029-token-consumption-tracking.md |
|
||||
| task | CUST-WP-0029-T06 | done | — | workplans/CUST-WP-0029-token-consumption-tracking.md |
|
||||
| task | CUST-WP-0029-T07 | done | — | workplans/CUST-WP-0029-token-consumption-tracking.md |
|
||||
| task | CUST-WP-0029-T08 | done | — | workplans/CUST-WP-0029-token-consumption-tracking.md |
|
||||
| task | T01 | done | — | workplans/CUST-WP-0030-dashboard-entity-list-ux.md |
|
||||
| task | T02 | done | — | workplans/CUST-WP-0030-dashboard-entity-list-ux.md |
|
||||
| task | T03 | done | — | workplans/CUST-WP-0030-dashboard-entity-list-ux.md |
|
||||
| task | T04 | done | — | workplans/CUST-WP-0030-dashboard-entity-list-ux.md |
|
||||
| task | T05 | done | — | workplans/CUST-WP-0030-dashboard-entity-list-ux.md |
|
||||
| task | T06 | done | — | workplans/CUST-WP-0030-dashboard-entity-list-ux.md |
|
||||
| task | T07 | done | — | workplans/CUST-WP-0030-dashboard-entity-list-ux.md |
|
||||
| task | T08 | done | — | workplans/CUST-WP-0030-dashboard-entity-list-ux.md |
|
||||
| task | T09 | done | — | workplans/CUST-WP-0030-dashboard-entity-list-ux.md |
|
||||
| task | T01 | done | — | workplans/CUST-WP-0031-capability-registry.md |
|
||||
| task | T02 | done | — | workplans/CUST-WP-0031-capability-registry.md |
|
||||
| task | T03 | done | — | workplans/CUST-WP-0031-capability-registry.md |
|
||||
| task | T04 | done | — | workplans/CUST-WP-0031-capability-registry.md |
|
||||
| task | T05 | done | — | workplans/CUST-WP-0031-capability-registry.md |
|
||||
| task | T06 | done | — | workplans/CUST-WP-0031-capability-registry.md |
|
||||
| task | T07 | done | — | workplans/CUST-WP-0031-capability-registry.md |
|
||||
| task | CUST-WP-0032-T01 | done | — | workplans/CUST-WP-0032-haskell-build-machine.md |
|
||||
| task | CUST-WP-0032-T02 | done | — | workplans/CUST-WP-0032-haskell-build-machine.md |
|
||||
| task | CUST-WP-0032-T03 | done | — | workplans/CUST-WP-0032-haskell-build-machine.md |
|
||||
| task | CUST-WP-0032-T04 | done | — | workplans/CUST-WP-0032-haskell-build-machine.md |
|
||||
| task | CUST-WP-0032-T05 | done | — | workplans/CUST-WP-0032-haskell-build-machine.md |
|
||||
| task | CUST-WP-0032-T06 | done | — | workplans/CUST-WP-0032-haskell-build-machine.md |
|
||||
| task | CUST-WP-0032-T07 | done | — | workplans/CUST-WP-0032-haskell-build-machine.md |
|
||||
| task | CUST-WP-0032-T08 | done | — | workplans/CUST-WP-0032-haskell-build-machine.md |
|
||||
| task | CUST-WP-0032-T09 | done | — | workplans/CUST-WP-0032-haskell-build-machine.md |
|
||||
| task | CUST-WP-0032-T10 | done | — | workplans/CUST-WP-0032-haskell-build-machine.md |
|
||||
| task | CUST-WP-0032-T11 | done | — | workplans/CUST-WP-0032-haskell-build-machine.md |
|
||||
| task | CUST-WP-0032-T12 | done | — | workplans/CUST-WP-0032-haskell-build-machine.md |
|
||||
| task | CUST-WP-0032-T13 | done | — | workplans/CUST-WP-0032-haskell-build-machine.md |
|
||||
| task | CUST-WP-0032-T14 | done | — | workplans/CUST-WP-0032-haskell-build-machine.md |
|
||||
| task | CUST-WP-0032-T15 | done | — | workplans/CUST-WP-0032-haskell-build-machine.md |
|
||||
| task | CUST-WP-0033-T01 | done | — | workplans/CUST-WP-0033-interface-change-registry.md |
|
||||
| task | CUST-WP-0033-T02 | done | — | workplans/CUST-WP-0033-interface-change-registry.md |
|
||||
| task | CUST-WP-0033-T03 | done | — | workplans/CUST-WP-0033-interface-change-registry.md |
|
||||
| task | CUST-WP-0033-T04 | done | — | workplans/CUST-WP-0033-interface-change-registry.md |
|
||||
| task | CUST-WP-0033-T05 | done | — | workplans/CUST-WP-0033-interface-change-registry.md |
|
||||
| task | CUST-WP-0033-T06 | done | — | workplans/CUST-WP-0033-interface-change-registry.md |
|
||||
| task | CUST-WP-0039-T1 | done | — | workplans/CUST-WP-0039-dashboard-poll-optimization.md |
|
||||
| task | CUST-WP-0039-T2 | done | — | workplans/CUST-WP-0039-dashboard-poll-optimization.md |
|
||||
| task | CUST-WP-0039-T3 | done | — | workplans/CUST-WP-0039-dashboard-poll-optimization.md |
|
||||
| task | CUST-WP-0039-T4 | done | — | workplans/CUST-WP-0039-dashboard-poll-optimization.md |
|
||||
| task | CUST-WP-0039-T5 | done | — | workplans/CUST-WP-0039-dashboard-poll-optimization.md |
|
||||
| task | CUST-WP-0039-T6 | done | — | workplans/CUST-WP-0039-dashboard-poll-optimization.md |
|
||||
| task | T01 | done | — | workplans/CUST-WP-0040-state-hub-nats-activity-core-integration.md |
|
||||
| task | T02 | done | — | workplans/CUST-WP-0040-state-hub-nats-activity-core-integration.md |
|
||||
| task | T03 | done | — | workplans/CUST-WP-0040-state-hub-nats-activity-core-integration.md |
|
||||
| task | T04 | done | — | workplans/CUST-WP-0040-state-hub-nats-activity-core-integration.md |
|
||||
| task | T05 | done | — | workplans/CUST-WP-0040-state-hub-nats-activity-core-integration.md |
|
||||
| task | T06 | done | — | workplans/CUST-WP-0040-state-hub-nats-activity-core-integration.md |
|
||||
| task | CUST-WP-0041-T1 | done | — | workplans/CUST-WP-0041-api-performance.md |
|
||||
| task | CUST-WP-0041-T2 | done | — | workplans/CUST-WP-0041-api-performance.md |
|
||||
| task | CUST-WP-0041-T3 | done | — | workplans/CUST-WP-0041-api-performance.md |
|
||||
| task | CUST-WP-0041-T4 | done | — | workplans/CUST-WP-0041-api-performance.md |
|
||||
| task | CUST-WP-0043-T01 | done | — | workplans/CUST-WP-0043-state-hub-repo-extraction.md |
|
||||
| task | CUST-WP-0043-T02 | done | — | workplans/CUST-WP-0043-state-hub-repo-extraction.md |
|
||||
| task | CUST-WP-0043-T03 | done | — | workplans/CUST-WP-0043-state-hub-repo-extraction.md |
|
||||
| task | CUST-WP-0043-T04 | done | — | workplans/CUST-WP-0043-state-hub-repo-extraction.md |
|
||||
| task | CUST-WP-0043-T05 | done | — | workplans/CUST-WP-0043-state-hub-repo-extraction.md |
|
||||
| task | CUST-WP-0043-T06 | done | — | workplans/CUST-WP-0043-state-hub-repo-extraction.md |
|
||||
| task | CUST-WP-0043-T07 | done | — | workplans/CUST-WP-0043-state-hub-repo-extraction.md |
|
||||
| task | CUST-WP-0043-T08 | done | — | workplans/CUST-WP-0043-state-hub-repo-extraction.md |
|
||||
| task | CUST-WP-0043-T09 | done | — | workplans/CUST-WP-0043-state-hub-repo-extraction.md |
|
||||
| task | CUST-WP-0044-T01 | done | — | workplans/CUST-WP-0044-daily-statehub-wsjf-triage.md |
|
||||
| task | CUST-WP-0044-T02 | done | — | workplans/CUST-WP-0044-daily-statehub-wsjf-triage.md |
|
||||
| task | CUST-WP-0044-T03 | done | — | workplans/CUST-WP-0044-daily-statehub-wsjf-triage.md |
|
||||
| task | CUST-WP-0044-T04 | done | — | workplans/CUST-WP-0044-daily-statehub-wsjf-triage.md |
|
||||
| task | CUST-WP-0044-T05 | done | — | workplans/CUST-WP-0044-daily-statehub-wsjf-triage.md |
|
||||
| task | CUST-WP-0044-T06 | done | — | workplans/CUST-WP-0044-daily-statehub-wsjf-triage.md |
|
||||
| task | CUST-WP-0045-T01 | done | — | workplans/CUST-WP-0045-activity-core-daily-triage-runner.md |
|
||||
| task | CUST-WP-0045-T02 | done | — | workplans/CUST-WP-0045-activity-core-daily-triage-runner.md |
|
||||
| task | CUST-WP-0045-T03 | done | — | workplans/CUST-WP-0045-activity-core-daily-triage-runner.md |
|
||||
| task | CUST-WP-0045-T04 | done | — | workplans/CUST-WP-0045-activity-core-daily-triage-runner.md |
|
||||
| task | CUST-WP-0045-T05 | done | — | workplans/CUST-WP-0045-activity-core-daily-triage-runner.md |
|
||||
| task | CUST-WP-0045-T06 | done | — | workplans/CUST-WP-0045-activity-core-daily-triage-runner.md |
|
||||
| task | CUST-WP-0045-T07 | done | — | workplans/CUST-WP-0045-activity-core-daily-triage-runner.md |
|
||||
| task | CUST-WP-0045-T08 | done | — | workplans/CUST-WP-0045-activity-core-daily-triage-runner.md |
|
||||
| task | CUST-WP-0046-T01 | done | — | workplans/CUST-WP-0046-hourly-recently-on-scope-activity-core.md |
|
||||
| task | CUST-WP-0046-T02 | done | — | workplans/CUST-WP-0046-hourly-recently-on-scope-activity-core.md |
|
||||
| task | CUST-WP-0046-T03 | done | — | workplans/CUST-WP-0046-hourly-recently-on-scope-activity-core.md |
|
||||
| task | CUST-WP-0046-T04 | done | — | workplans/CUST-WP-0046-hourly-recently-on-scope-activity-core.md |
|
||||
| task | CUST-WP-0046-T05 | done | — | workplans/CUST-WP-0046-hourly-recently-on-scope-activity-core.md |
|
||||
| task | CUST-WP-0046-T06 | done | — | workplans/CUST-WP-0046-hourly-recently-on-scope-activity-core.md |
|
||||
| task | CUST-WP-0046-T07 | done | — | workplans/CUST-WP-0046-hourly-recently-on-scope-activity-core.md |
|
||||
| task | CUST-WP-0047-T01 | done | — | workplans/CUST-WP-0047-ops-hub-service-inventory-now-view.md |
|
||||
| task | CUST-WP-0047-T02 | done | — | workplans/CUST-WP-0047-ops-hub-service-inventory-now-view.md |
|
||||
| task | CUST-WP-0047-T03 | done | — | workplans/CUST-WP-0047-ops-hub-service-inventory-now-view.md |
|
||||
| task | CUST-WP-0047-T04 | done | — | workplans/CUST-WP-0047-ops-hub-service-inventory-now-view.md |
|
||||
| task | CUST-WP-0047-T05 | cancel | — | workplans/CUST-WP-0047-ops-hub-service-inventory-now-view.md |
|
||||
| task | CUST-WP-0047-T06 | done | — | workplans/CUST-WP-0047-ops-hub-service-inventory-now-view.md |
|
||||
| task | CUST-WP-0047-T07 | done | — | workplans/CUST-WP-0047-ops-hub-service-inventory-now-view.md |
|
||||
| task | CUST-WP-0048-T01 | done | — | workplans/CUST-WP-0048-hub-core-state-hub-import-refactor.md |
|
||||
| task | CUST-WP-0048-T02 | done | — | workplans/CUST-WP-0048-hub-core-state-hub-import-refactor.md |
|
||||
| task | CUST-WP-0048-T03 | done | — | workplans/CUST-WP-0048-hub-core-state-hub-import-refactor.md |
|
||||
| task | CUST-WP-0048-T04 | done | — | workplans/CUST-WP-0048-hub-core-state-hub-import-refactor.md |
|
||||
| task | CUST-WP-0048-T05 | done | — | workplans/CUST-WP-0048-hub-core-state-hub-import-refactor.md |
|
||||
| task | CUST-WP-0048-T06 | done | — | workplans/CUST-WP-0048-hub-core-state-hub-import-refactor.md |
|
||||
| task | CUST-WP-0048-T07 | done | — | workplans/CUST-WP-0048-hub-core-state-hub-import-refactor.md |
|
||||
| task | CUST-WP-0048-T08 | done | — | workplans/CUST-WP-0048-hub-core-state-hub-import-refactor.md |
|
||||
| task | CUST-WP-0049-T01 | done | — | workplans/CUST-WP-0049-interhub-bootstrap-access-lane.md |
|
||||
| task | CUST-WP-0049-T02 | done | — | workplans/CUST-WP-0049-interhub-bootstrap-access-lane.md |
|
||||
| task | CUST-WP-0049-T03 | done | — | workplans/CUST-WP-0049-interhub-bootstrap-access-lane.md |
|
||||
| task | CUST-WP-0049-T04 | done | — | workplans/CUST-WP-0049-interhub-bootstrap-access-lane.md |
|
||||
| task | CUST-WP-0049-T05 | done | — | workplans/CUST-WP-0049-interhub-bootstrap-access-lane.md |
|
||||
| task | CUST-WP-0049-T06 | cancel | — | workplans/CUST-WP-0049-interhub-bootstrap-access-lane.md |
|
||||
| task | CUST-WP-0050-T01 | done | — | workplans/CUST-WP-0050-repo-classification-registration-redesign.md |
|
||||
| task | CUST-WP-0050-T02 | done | — | workplans/CUST-WP-0050-repo-classification-registration-redesign.md |
|
||||
| task | CUST-WP-0050-T03 | cancel | — | workplans/CUST-WP-0050-repo-classification-registration-redesign.md |
|
||||
| task | CUST-WP-0050-T04 | cancel | — | workplans/CUST-WP-0050-repo-classification-registration-redesign.md |
|
||||
| task | CUST-WP-0050-T05 | cancel | — | workplans/CUST-WP-0050-repo-classification-registration-redesign.md |
|
||||
| task | CUST-WP-0050-T06 | cancel | — | workplans/CUST-WP-0050-repo-classification-registration-redesign.md |
|
||||
| task | CUST-WP-0050-T07 | cancel | — | workplans/CUST-WP-0050-repo-classification-registration-redesign.md |
|
||||
| task | CUST-WP-0050-T08 | cancel | — | workplans/CUST-WP-0050-repo-classification-registration-redesign.md |
|
||||
| task | CUST-WP-0050-T09 | cancel | — | workplans/CUST-WP-0050-repo-classification-registration-redesign.md |
|
||||
| task | CUST-WP-0050-T10 | cancel | — | workplans/CUST-WP-0050-repo-classification-registration-redesign.md |
|
||||
| task | CUST-WP-0050-T11 | done | — | workplans/CUST-WP-0050-repo-classification-registration-redesign.md |
|
||||
| task | CUST-WP-0051-T01 | done | — | workplans/CUST-WP-0051-infrastructure-stabilization-metaplan.md |
|
||||
| task | CUST-WP-0051-T02 | done | — | workplans/CUST-WP-0051-infrastructure-stabilization-metaplan.md |
|
||||
| task | CUST-WP-0051-T03 | done | — | workplans/CUST-WP-0051-infrastructure-stabilization-metaplan.md |
|
||||
| task | CUST-WP-0051-T04 | done | — | workplans/CUST-WP-0051-infrastructure-stabilization-metaplan.md |
|
||||
| task | CUST-WP-0051-T05 | done | — | workplans/CUST-WP-0051-infrastructure-stabilization-metaplan.md |
|
||||
| task | CUST-WP-0051-T06 | done | — | workplans/CUST-WP-0051-infrastructure-stabilization-metaplan.md |
|
||||
| task | CUST-WP-0051-T07 | done | — | workplans/CUST-WP-0051-infrastructure-stabilization-metaplan.md |
|
||||
| task | CUST-WP-0051-T08 | done | — | workplans/CUST-WP-0051-infrastructure-stabilization-metaplan.md |
|
||||
| task | CUST-WP-0052-T01 | done | — | workplans/CUST-WP-0052-core-hub-fos-reset.md |
|
||||
| task | CUST-WP-0052-T02 | done | — | workplans/CUST-WP-0052-core-hub-fos-reset.md |
|
||||
| task | CUST-WP-0052-T03 | done | — | workplans/CUST-WP-0052-core-hub-fos-reset.md |
|
||||
| task | CUST-WP-0052-T04 | done | — | workplans/CUST-WP-0052-core-hub-fos-reset.md |
|
||||
| task | CUST-WP-0052-T05 | done | — | workplans/CUST-WP-0052-core-hub-fos-reset.md |
|
||||
| task | CUST-WP-0052-T06 | done | — | workplans/CUST-WP-0052-core-hub-fos-reset.md |
|
||||
| task | CUST-WP-0052-T07 | done | — | workplans/CUST-WP-0052-core-hub-fos-reset.md |
|
||||
| task | CUST-WP-0053-T01 | done | — | workplans/CUST-WP-0053-coordination-hygiene-improvements.md |
|
||||
| task | CUST-WP-0053-T02 | done | — | workplans/CUST-WP-0053-coordination-hygiene-improvements.md |
|
||||
| task | CUST-WP-0053-T03 | done | — | workplans/CUST-WP-0053-coordination-hygiene-improvements.md |
|
||||
| task | CUST-WP-0053-T04 | done | — | workplans/CUST-WP-0053-coordination-hygiene-improvements.md |
|
||||
| task | CUST-WP-0053-T05 | done | — | workplans/CUST-WP-0053-coordination-hygiene-improvements.md |
|
||||
| task | CUST-WP-0055-T01 | done | — | workplans/CUST-WP-0055-workplan-terminology-fleet-refactor.md |
|
||||
| task | CUST-WP-0055-T02 | done | — | workplans/CUST-WP-0055-workplan-terminology-fleet-refactor.md |
|
||||
| task | CUST-WP-0055-T03 | done | — | workplans/CUST-WP-0055-workplan-terminology-fleet-refactor.md |
|
||||
| task | CUST-WP-0055-T04 | done | — | workplans/CUST-WP-0055-workplan-terminology-fleet-refactor.md |
|
||||
| task | CUST-WP-0055-T05 | done | — | workplans/CUST-WP-0055-workplan-terminology-fleet-refactor.md |
|
||||
| task | CUST-WP-0055-T06 | done | — | workplans/CUST-WP-0055-workplan-terminology-fleet-refactor.md |
|
||||
| task | CUST-WP-0055-T07 | done | — | workplans/CUST-WP-0055-workplan-terminology-fleet-refactor.md |
|
||||
| task | CUST-WP-0055-T08 | done | — | workplans/CUST-WP-0055-workplan-terminology-fleet-refactor.md |
|
||||
| task | CUST-WP-0056-T01 | done | — | workplans/CUST-WP-0056-daily-todo-md-stale-review.md |
|
||||
| task | CUST-WP-0056-T02 | done | — | workplans/CUST-WP-0056-daily-todo-md-stale-review.md |
|
||||
| task | CUST-WP-0056-T03 | done | — | workplans/CUST-WP-0056-daily-todo-md-stale-review.md |
|
||||
| task | CUST-WP-0059-T01 | done | — | workplans/CUST-WP-0059-forgejo-ci-publish-cutover-tail.md |
|
||||
| task | CUST-WP-0059-T02 | done | — | workplans/CUST-WP-0059-forgejo-ci-publish-cutover-tail.md |
|
||||
| task | CUST-WP-0059-T03 | done | — | workplans/CUST-WP-0059-forgejo-ci-publish-cutover-tail.md |
|
||||
| task | CUST-WP-0059-T04 | done | — | workplans/CUST-WP-0059-forgejo-ci-publish-cutover-tail.md |
|
||||
| task | CUST-WP-0059-T05 | done | — | workplans/CUST-WP-0059-forgejo-ci-publish-cutover-tail.md |
|
||||
| task | CUST-WP-0059-T06 | done | — | workplans/CUST-WP-0059-forgejo-ci-publish-cutover-tail.md |
|
||||
| task | CUST-WP-0060-T01 | done | — | workplans/CUST-WP-0060-work-record-canonization.md |
|
||||
| task | CUST-WP-0060-T02 | done | — | workplans/CUST-WP-0060-work-record-canonization.md |
|
||||
| task | CUST-WP-0060-T03 | done | — | workplans/CUST-WP-0060-work-record-canonization.md |
|
||||
| task | CUST-WP-0060-T04 | done | — | workplans/CUST-WP-0060-work-record-canonization.md |
|
||||
| task | CUST-WP-0060-T05 | done | — | workplans/CUST-WP-0060-work-record-canonization.md |
|
||||
| task | CUST-WP-0060-T06 | done | — | workplans/CUST-WP-0060-work-record-canonization.md |
|
||||
| task | CUST-WP-0060-T07 | done | — | workplans/CUST-WP-0060-work-record-canonization.md |
|
||||
| task | CUST-WP-0061-T01 | done | — | workplans/CUST-WP-0061-work-record-stage3-intake-promotion-index.md |
|
||||
| task | CUST-WP-0061-T02 | done | — | workplans/CUST-WP-0061-work-record-stage3-intake-promotion-index.md |
|
||||
| task | CUST-WP-0061-T03 | done | — | workplans/CUST-WP-0061-work-record-stage3-intake-promotion-index.md |
|
||||
| task | CUST-WP-0061-T04 | done | — | workplans/CUST-WP-0061-work-record-stage3-intake-promotion-index.md |
|
||||
| task | CUST-WP-0061-T05 | done | — | workplans/CUST-WP-0061-work-record-stage3-intake-promotion-index.md |
|
||||
| task | CUST-WP-0061-T06 | done | — | workplans/CUST-WP-0061-work-record-stage3-intake-promotion-index.md |
|
||||
| task | CUST-WP-0062-T01 | done | — | workplans/CUST-WP-0062-sbom-nexus-daily-catchup.md |
|
||||
| task | CUST-WP-0062-T02 | done | — | workplans/CUST-WP-0062-sbom-nexus-daily-catchup.md |
|
||||
| task | CUST-WP-0062-T03 | done | — | workplans/CUST-WP-0062-sbom-nexus-daily-catchup.md |
|
||||
| task | CUST-WP-0062-T04 | done | — | workplans/CUST-WP-0062-sbom-nexus-daily-catchup.md |
|
||||
| task | CUST-WP-0062-T05 | done | — | workplans/CUST-WP-0062-sbom-nexus-daily-catchup.md |
|
||||
| task | CUST-WP-0062-T06 | done | — | workplans/CUST-WP-0062-sbom-nexus-daily-catchup.md |
|
||||
| task | CUST-WP-0063-T01 | done | — | workplans/CUST-WP-0063-inbox-governance-packets.md |
|
||||
| task | CUST-WP-0063-T02 | done | — | workplans/CUST-WP-0063-inbox-governance-packets.md |
|
||||
| task | CUST-WP-0063-T03 | done | — | workplans/CUST-WP-0063-inbox-governance-packets.md |
|
||||
| task | CUST-WP-0063-T04 | done | — | workplans/CUST-WP-0063-inbox-governance-packets.md |
|
||||
| task | CUST-WP-0063-T05 | done | — | workplans/CUST-WP-0063-inbox-governance-packets.md |
|
||||
| task | CUST-WP-0064-T01 | done | — | workplans/CUST-WP-0064-sbom-controlled-scan-inputs.md |
|
||||
| task | CUST-WP-0064-T02 | done | — | workplans/CUST-WP-0064-sbom-controlled-scan-inputs.md |
|
||||
| task | CUST-WP-0064-T03 | done | — | workplans/CUST-WP-0064-sbom-controlled-scan-inputs.md |
|
||||
| task | CUST-WP-0064-T04 | done | — | workplans/CUST-WP-0064-sbom-controlled-scan-inputs.md |
|
||||
| task | CUST-WP-0065-T01 | done | — | workplans/CUST-WP-0065-reclassify-repos-and-guidance-docs-to-the-new-sector-domain.md |
|
||||
| task | CUST-WP-0065-T02 | done | — | workplans/CUST-WP-0065-reclassify-repos-and-guidance-docs-to-the-new-sector-domain.md |
|
||||
| task | CUST-WP-0065-T03 | done | — | workplans/CUST-WP-0065-reclassify-repos-and-guidance-docs-to-the-new-sector-domain.md |
|
||||
| task | CUST-WP-0065-T04 | done | — | workplans/CUST-WP-0065-reclassify-repos-and-guidance-docs-to-the-new-sector-domain.md |
|
||||
| task | CUST-WP-0066-T01 | done | — | workplans/CUST-WP-0066-repository-qualified-ad-hoc-identifiers.md |
|
||||
| task | CUST-WP-0066-T02 | done | — | workplans/CUST-WP-0066-repository-qualified-ad-hoc-identifiers.md |
|
||||
| task | CUST-WP-0066-T03 | done | — | workplans/CUST-WP-0066-repository-qualified-ad-hoc-identifiers.md |
|
||||
| task | CUST-WP-0067-T01 | done | — | workplans/CUST-WP-0067-hub-authority-target-resolution.md |
|
||||
| task | CUST-WP-0067-T02 | done | — | workplans/CUST-WP-0067-hub-authority-target-resolution.md |
|
||||
| task | CUST-WP-0067-T03 | done | — | workplans/CUST-WP-0067-hub-authority-target-resolution.md |
|
||||
| task | CUST-WP-0067-T04 | done | — | workplans/CUST-WP-0067-hub-authority-target-resolution.md |
|
||||
| task | CUST-WP-0067-T05 | done | — | workplans/CUST-WP-0067-hub-authority-target-resolution.md |
|
||||
| task | CUST-WP-0067-T06 | done | — | workplans/CUST-WP-0067-hub-authority-target-resolution.md |
|
||||
| task | CUST-WP-0067-T07 | done | — | workplans/CUST-WP-0067-hub-authority-target-resolution.md |
|
||||
| task | CUST-WP-0067-T08 | done | — | workplans/CUST-WP-0067-hub-authority-target-resolution.md |
|
||||
| task | CUST-WP-0067-T09 | done | — | workplans/CUST-WP-0067-hub-authority-target-resolution.md |
|
||||
| task | CUST-WP-0067-T10 | done | — | workplans/CUST-WP-0067-hub-authority-target-resolution.md |
|
||||
| task | CUST-WP-0068-T01 | done | — | workplans/CUST-WP-0068-cache-only-work-record-recovery.md |
|
||||
| task | CUST-WP-0068-T02 | done | — | workplans/CUST-WP-0068-cache-only-work-record-recovery.md |
|
||||
| task | CUST-WP-0068-T03 | done | — | workplans/CUST-WP-0068-cache-only-work-record-recovery.md |
|
||||
| task | CUST-WP-0068-T04 | done | — | workplans/CUST-WP-0068-cache-only-work-record-recovery.md |
|
||||
| task | CUST-WP-0068-T05 | done | — | workplans/CUST-WP-0068-cache-only-work-record-recovery.md |
|
||||
| task | CUST-WP-0068-T06 | done | — | workplans/CUST-WP-0068-cache-only-work-record-recovery.md |
|
||||
| task | CUST-WP-0068-T07 | done | — | workplans/CUST-WP-0068-cache-only-work-record-recovery.md |
|
||||
| task | CUST-WP-0068-T08 | done | — | workplans/CUST-WP-0068-cache-only-work-record-recovery.md |
|
||||
| task | CUST-WP-0068-T09 | done | — | workplans/CUST-WP-0068-cache-only-work-record-recovery.md |
|
||||
| task | CUST-WP-0070-T01 | done | — | workplans/CUST-WP-0070-publication-repo-category.md |
|
||||
| task | CUST-WP-0070-T02 | done | — | workplans/CUST-WP-0070-publication-repo-category.md |
|
||||
| task | CUST-WP-0070-T03 | done | — | workplans/CUST-WP-0070-publication-repo-category.md |
|
||||
| task | CUST-WP-0071-T01 | done | — | workplans/CUST-WP-0071-measured-workload-sizing-and-weekly-review.md |
|
||||
| task | CUST-WP-0071-T02 | wait | — | workplans/CUST-WP-0071-measured-workload-sizing-and-weekly-review.md |
|
||||
| task | CUST-WP-0071-T03 | wait | — | workplans/CUST-WP-0071-measured-workload-sizing-and-weekly-review.md |
|
||||
| task | CUST-WP-0071-T04 | wait | — | workplans/CUST-WP-0071-measured-workload-sizing-and-weekly-review.md |
|
||||
| task | CUST-WP-0071-T05 | wait | — | workplans/CUST-WP-0071-measured-workload-sizing-and-weekly-review.md |
|
||||
| task | CUST-WP-0072-T01 | done | — | workplans/CUST-WP-0072-fleet-flavor-and-depends-on-backfill.md |
|
||||
| task | CUST-WP-0072-T02 | done | — | workplans/CUST-WP-0072-fleet-flavor-and-depends-on-backfill.md |
|
||||
| task | CUST-WP-0072-T03 | done | — | workplans/CUST-WP-0072-fleet-flavor-and-depends-on-backfill.md |
|
||||
| task | CUST-WP-0072-T04 | done | — | workplans/CUST-WP-0072-fleet-flavor-and-depends-on-backfill.md |
|
||||
| task | CUST-WP-0073-T01 | done | — | workplans/CUST-WP-0073-agent-credential-separation.md |
|
||||
| task | CUST-WP-0073-T02 | wait | — | workplans/CUST-WP-0073-agent-credential-separation.md |
|
||||
| task | CUST-WP-0073-T03 | done | — | workplans/CUST-WP-0073-agent-credential-separation.md |
|
||||
| task | CUST-WP-0073-T04 | wait | — | workplans/CUST-WP-0073-agent-credential-separation.md |
|
||||
| task | CUST-WP-0073-T05 | wait | — | workplans/CUST-WP-0073-agent-credential-separation.md |
|
||||
| task | CUST-WP-0074-T01 | done | — | workplans/CUST-WP-0074-qualified-wait-states.md |
|
||||
| task | CUST-WP-0074-T02 | done | — | workplans/CUST-WP-0074-qualified-wait-states.md |
|
||||
| task | CUST-WP-0074-T03 | done | — | workplans/CUST-WP-0074-qualified-wait-states.md |
|
||||
| task | CUST-WP-0074-T04 | done | — | workplans/CUST-WP-0074-qualified-wait-states.md |
|
||||
| task | CUST-WP-0074-T05 | done | — | workplans/CUST-WP-0074-qualified-wait-states.md |
|
||||
| task | CUST-WP-0074-T06 | done | — | workplans/CUST-WP-0074-qualified-wait-states.md |
|
||||
| task | CUST-WP-0075-T01 | done | — | workplans/CUST-WP-0075-dependency-graph-driven-unblocking.md |
|
||||
| task | CUST-WP-0075-T02 | done | — | workplans/CUST-WP-0075-dependency-graph-driven-unblocking.md |
|
||||
| task | CUST-WP-0075-T03 | done | — | workplans/CUST-WP-0075-dependency-graph-driven-unblocking.md |
|
||||
| task | CUST-WP-0075-T04 | done | — | workplans/CUST-WP-0075-dependency-graph-driven-unblocking.md |
|
||||
| task | CUST-WP-0075-T05 | done | — | workplans/CUST-WP-0075-dependency-graph-driven-unblocking.md |
|
||||
| task | CUST-WP-0075-T06 | done | — | workplans/CUST-WP-0075-dependency-graph-driven-unblocking.md |
|
||||
| task | CUST-WP-0076-T01 | done | — | workplans/CUST-WP-0076-provisioning-window-cadence.md |
|
||||
| task | CUST-WP-0077-T01 | done | — | workplans/CUST-WP-0077-dependency-root-execution-wave.md |
|
||||
| task | CUST-WP-0077-T02 | done | — | workplans/CUST-WP-0077-dependency-root-execution-wave.md |
|
||||
| task | CUST-WP-0077-T03 | done | — | workplans/CUST-WP-0077-dependency-root-execution-wave.md |
|
||||
| task | CUST-WP-0077-T04 | done | — | workplans/CUST-WP-0077-dependency-root-execution-wave.md |
|
||||
| task | CUST-WP-0077-T05 | done | — | workplans/CUST-WP-0077-dependency-root-execution-wave.md |
|
||||
| task | CUST-WP-0078-T01 | done | — | workplans/CUST-WP-0078-security-collaboration-consolidation.md |
|
||||
| task | CUST-WP-0078-T02 | done | — | workplans/CUST-WP-0078-security-collaboration-consolidation.md |
|
||||
| task | CUST-WP-0078-T03 | done | — | workplans/CUST-WP-0078-security-collaboration-consolidation.md |
|
||||
| task | CUST-WP-0078-T04 | done | — | workplans/CUST-WP-0078-security-collaboration-consolidation.md |
|
||||
| task | CUST-WP-0079-T01 | done | — | workplans/CUST-WP-0079-deterministic-security-flow.md |
|
||||
| task | CUST-WP-0079-T02 | done | — | workplans/CUST-WP-0079-deterministic-security-flow.md |
|
||||
| task | CUST-WP-0079-T03 | wait | — | workplans/CUST-WP-0079-deterministic-security-flow.md |
|
||||
| task | CUST-WP-0079-T04 | wait | — | workplans/CUST-WP-0079-deterministic-security-flow.md |
|
||||
| task | CUST-WP-0079-T05 | wait | — | workplans/CUST-WP-0079-deterministic-security-flow.md |
|
||||
| task | CUST-WP-0079-T06 | wait | — | workplans/CUST-WP-0079-deterministic-security-flow.md |
|
||||
| task | CUST-WP-0079-T07 | done | — | workplans/CUST-WP-0079-deterministic-security-flow.md |
|
||||
| task | CUST-WP-0080-T01 | done | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md |
|
||||
| task | CUST-WP-0080-T02 | done | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md |
|
||||
| task | CUST-WP-0080-T03 | progress | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md |
|
||||
| task | CUST-WP-0080-T04 | todo | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md |
|
||||
| task | CUST-WP-0080-T05 | progress | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md |
|
||||
| task | CUST-WP-0080-T06 | progress | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md |
|
||||
| task | CUST-WP-0081-T01 | done | — | workplans/CUST-WP-0081-netkingdom-architecture-review-and-reuse.md |
|
||||
| task | CUST-WP-0081-T02 | done | — | workplans/CUST-WP-0081-netkingdom-architecture-review-and-reuse.md |
|
||||
| task | CUST-WP-0081-T03 | done | — | workplans/CUST-WP-0081-netkingdom-architecture-review-and-reuse.md |
|
||||
| task | THE-WP-0001-T01 | done | — | workplans/THE-WP-0001-federation-interface.md |
|
||||
| task | THE-WP-0001-T02 | done | — | workplans/THE-WP-0001-federation-interface.md |
|
||||
| intake | CUST-IN-0001 | closed | green | intake-legacy-suggestions-migration.md |
|
||||
| intake | CUST-IN-0002 | closed | green | intake-legacy-suggestions-migration.md |
|
||||
| intake | CUST-IN-0003 | closed | green | intake-legacy-suggestions-migration.md |
|
||||
| intake | CUST-IN-0004 | routed | green | intake-legacy-suggestions-migration.md |
|
||||
| intake | CUST-IN-0005 | closed | green | intake-legacy-suggestions-migration.md |
|
||||
| intake | CUST-IN-0006 | closed | green | intake-legacy-suggestions-migration.md |
|
||||
| intake | CUST-IN-0007 | closed | green | intake-legacy-suggestions-migration.md |
|
||||
| intake | CUST-IN-0008 | closed | blue | intake-legacy-suggestions-migration.md |
|
||||
| intake | CUST-IN-0009 | closed | blue | intake-legacy-suggestions-migration.md |
|
||||
| intake | CUST-IN-0010 | closed | green | intake-legacy-suggestions-migration.md |
|
||||
| intake | CUST-IN-0011 | routed | red | intakes.md |
|
||||
| intake | CUST-IN-0012 | closed | green | intakes.md |
|
||||
| intake | CUST-IN-0013 | closed | blue | intakes.md |
|
||||
| intake | CUST-IN-0014 | closed | blue | intakes.md |
|
||||
| intake | CUST-IN-0015 | open | blue | intakes.md |
|
||||
| intake | CUST-IN-0016 | open | green | intakes.md |
|
||||
| intake | CUST-IN-0017 | open | green | intakes.md |
|
||||
| intake | CUST-IN-0018 | open | green | intakes.md |
|
||||
|
|
@ -0,0 +1 @@
|
|||
{".custodian-brief.md": "100644 6aad3a5788fad3abd60c0f1270f160062dc0168b 0\t.custodian-brief.md\n", "WORK-RECORDS.md": "100644 d535e0f2dcc3d454e041a8f1b547b5dea4493380 0\tWORK-RECORDS.md\n"}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"workplan_id": "dfadb19b-445e-59c6-bcd6-48e87b912c52",
|
||||
"id": "27a691e0-dab7-41ec-9d3b-b0deebe2535e",
|
||||
"topic_id": null,
|
||||
"task_id": "6aca534c-5c7a-556f-b6b6-406433670c29",
|
||||
"decision_id": null,
|
||||
"event_type": "note",
|
||||
"summary": "Final source checkpoint d834d0c is published; all7 reviewed Custodian files match and67 source task ID/UUID/status bindings agree (NK-WP-0048:20, NK-WP-0049:36, RMGR-WP-0021:5, CUST-WP-0080:6). Custodian consistency completed fail0/automation_error0/warn7/info4; existing forge-derived reconciliation repaired missing legacy task record IDs. Original staged brief, working records and unrelated changes preserved. Delivered bounded source: NK-WP-0048-T20 and NK-WP-0049-T34. Live next work: NK-WP-0048-T14, NK-WP-0049-T35/T36, readiness NK-WP-0049-T33, real recovery/MFA NK-WP-0049-T18/T19, scanner runtime gap RMGR-WP-0021-T05. All16 target gates remain open. Native storage, writer isolation and original TLS/trust recovery require their recorded owner boundaries.",
|
||||
"detail": null,
|
||||
"author": "codex",
|
||||
"session_id": null,
|
||||
"created_at": "2026-10-16T22:49:48.009494Z",
|
||||
"workstream_id": "dfadb19b-445e-59c6-bcd6-48e87b912c52"
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"observed_at": "2026-10-16T22:10:25.662202+00:00",
|
||||
"lane": "groups",
|
||||
"owner": "/home/worsch/net-kingdom",
|
||||
"base_revision": "878abc93588e12b6da1bac055cfbb3abb8d7adf1",
|
||||
"patch_sha256": "sha256:1f3f3ca94c46dadac8f24896053d4d23179de795e50a24298a0cfbcc46cca30e",
|
||||
"applied": true,
|
||||
"source_postimages": {
|
||||
"docs/service-group-provenance.md": "sha256:9cb866c1ba4f92d239ff37ea6be57bafe04cf5e9e811fda5842878ab9677678c",
|
||||
"identity-provisioner/Containerfile": "sha256:c744f39ffb4c0dc2686c3e94d8233bc772b55faae9581557bee17f91c19d9ca6",
|
||||
"identity-provisioner/group_provenance.py": "sha256:fbd4c88d7682ba3d46c5b325dad1bc4f4a7faac2cd3f6ce9e0f7a89283bf46d4",
|
||||
"identity-provisioner/provisioner.py": "sha256:2f8e4368e7155465bbc4070b1019c7105edf43bdc2ad3bbc5aa9bc19dc6ed7a5",
|
||||
"identity-provisioner/server.py": "sha256:b61c83da6c7ce20fa2790d5bb00aeca6a91dccd133526bc559ed26562f90eb29",
|
||||
"identity-provisioner/tests/test_directory_contract.py": "sha256:250395dcf2e3dbeca863d1829a0ae14b86ee17fd4107605df409bf56fa48c836",
|
||||
"identity-provisioner/tests/test_group_provenance.py": "sha256:0809300a8c0d20070dc9806c011d0b5ca3695468bd02521fb386aebc4dde499a",
|
||||
"identity-provisioner/tests/test_service_groups.py": "sha256:815571fd92c0001dacbe250640acd99b2f2872b4171519ed54f8f469ee90aafe",
|
||||
"sso-mfa/k8s/README.md": "sha256:0aa87dc25bef245eadc184ae58ef1e25b748e2541086d4de2ada15d38b17340f"
|
||||
},
|
||||
"preexisting_brief_preserved": true,
|
||||
"staged": false,
|
||||
"published": false,
|
||||
"native_acceptance": false
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"observed_at": "2026-10-16T22:09:04.829846+00:00",
|
||||
"lane": "groups",
|
||||
"owner": "/home/worsch/net-kingdom",
|
||||
"base_revision": "878abc93588e12b6da1bac055cfbb3abb8d7adf1",
|
||||
"patch_sha256": "sha256:1f3f3ca94c46dadac8f24896053d4d23179de795e50a24298a0cfbcc46cca30e",
|
||||
"applied": false,
|
||||
"source_postimages": {
|
||||
"docs/service-group-provenance.md": "sha256:9cb866c1ba4f92d239ff37ea6be57bafe04cf5e9e811fda5842878ab9677678c",
|
||||
"identity-provisioner/Containerfile": "sha256:c744f39ffb4c0dc2686c3e94d8233bc772b55faae9581557bee17f91c19d9ca6",
|
||||
"identity-provisioner/group_provenance.py": "sha256:fbd4c88d7682ba3d46c5b325dad1bc4f4a7faac2cd3f6ce9e0f7a89283bf46d4",
|
||||
"identity-provisioner/provisioner.py": "sha256:2f8e4368e7155465bbc4070b1019c7105edf43bdc2ad3bbc5aa9bc19dc6ed7a5",
|
||||
"identity-provisioner/server.py": "sha256:b61c83da6c7ce20fa2790d5bb00aeca6a91dccd133526bc559ed26562f90eb29",
|
||||
"identity-provisioner/tests/test_directory_contract.py": "sha256:250395dcf2e3dbeca863d1829a0ae14b86ee17fd4107605df409bf56fa48c836",
|
||||
"identity-provisioner/tests/test_group_provenance.py": "sha256:0809300a8c0d20070dc9806c011d0b5ca3695468bd02521fb386aebc4dde499a",
|
||||
"identity-provisioner/tests/test_service_groups.py": "sha256:815571fd92c0001dacbe250640acd99b2f2872b4171519ed54f8f469ee90aafe",
|
||||
"sso-mfa/k8s/README.md": "sha256:0aa87dc25bef245eadc184ae58ef1e25b748e2541086d4de2ada15d38b17340f"
|
||||
},
|
||||
"preexisting_brief_preserved": true,
|
||||
"staged": false,
|
||||
"published": false,
|
||||
"native_acceptance": false
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
from pathlib import Path
|
||||
import hashlib, json, subprocess
|
||||
|
||||
base = Path(__file__).resolve().parent.parent
|
||||
lanes = [
|
||||
('net-kingdom', 'group-provenance-r1/workplan-refinement/source.patch', '8251911aa8d6ebf9be916ece22ae4cd8ddb179110c3f9348aaee67db4c3acf74'),
|
||||
('net-kingdom', 'recovery-attempt-r1/workplan-refinement.patch', 'aa11ac3b3cbb36febc436736e7eaa611f8769fbae70b0f026fa9534954aa604c'),
|
||||
('repo-manager', 'scanner-batch-r1/owner-workplan.patch', 'e0d08a2e571664a6ec8c8c22b67c3788d248878c1644f9b758ba57c30a321488'),
|
||||
]
|
||||
receipts = []
|
||||
for slug, rel, expected in lanes:
|
||||
root = Path('/home/worsch') / slug
|
||||
patch = base / rel
|
||||
assert hashlib.sha256(patch.read_bytes()).hexdigest() == expected
|
||||
def git(*args): return subprocess.check_output(['git', '-C', str(root), *args], stderr=subprocess.PIPE)
|
||||
paths = [s[6:] for s in patch.read_text().splitlines() if s.startswith('+++ b/')]
|
||||
assert len(paths) == 1
|
||||
assert not git('status', '--porcelain', '--', paths[0]).strip()
|
||||
git('apply', '--check', str(patch))
|
||||
git('apply', str(patch))
|
||||
if slug == 'repo-manager':
|
||||
path = root / paths[0]
|
||||
text = path.read_text().replace('id: RMGR-WP-0021-T05\nstatus: todo', 'id: RMGR-WP-0021-T05\nstatus: progress')
|
||||
text += '\n2026-10-17 integration started: exact reviewed source is applied in the owner\ncheckout. Canonical tests, configured-hook fixtures and publication are being\nverified separately; actual-hook scanner-unavailability remains pending.\n'
|
||||
path.write_text(text)
|
||||
receipts.append({'owner': slug, 'path': paths[0], 'reviewed_patch_sha256': 'sha256:' + expected,
|
||||
'integrated_sha256': 'sha256:' + hashlib.sha256((root / paths[0]).read_bytes()).hexdigest()})
|
||||
(base / 'resume-integration-r1/workplan-integration.json').write_text(json.dumps(receipts, indent=2) + '\n')
|
||||
print(json.dumps(receipts, indent=2))
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue