diff --git a/Makefile b/Makefile index 3950076..1779064 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,9 @@ OPERATOR_AGE_PUBKEY := $(shell cat keys/age.pub 2>/dev/null | tr -d '[:space:]') SECURITY_BOOTSTRAP_METADATA ?= $(if $(METADATA),$(METADATA),.local/security-bootstrap.json) SECURITY_BOOTSTRAP_HOST ?= $(if $(HOST),$(HOST),127.0.0.1) SECURITY_BOOTSTRAP_PORT ?= $(if $(PORT),$(PORT),8876) +OPENBAO_RESTORE_EVIDENCE ?= /tmp/netkingdom-openbao-restore-drill/evidence.json +OPENBAO_EMERGENCY_EVIDENCE ?= /tmp/netkingdom-openbao-emergency-drill/evidence.json +RAILIANCE_PLATFORM_PATH ?= ../railiance-platform # ── Help ────────────────────────────────────────────────────────────────────── help: ## Show this help @@ -172,6 +175,14 @@ security-bootstrap-validate-kit: ## Validate non-secret king credential metadata --metadata "$(SECURITY_BOOTSTRAP_METADATA)" \ validate-king-kit +security-bootstrap-validate-t02: ## Validate NET-WP-0017-T02 OpenBao audit/recovery gates + python3 tools/security-bootstrap-console/security_bootstrap_console.py \ + --metadata "$(SECURITY_BOOTSTRAP_METADATA)" \ + validate-t02 \ + --railiance-path "$(RAILIANCE_PLATFORM_PATH)" \ + --restore-evidence "$(OPENBAO_RESTORE_EVIDENCE)" \ + --emergency-evidence "$(OPENBAO_EMERGENCY_EVIDENCE)" + security-bootstrap-approve-custody: ## Approve custody mode metadata: make security-bootstrap-approve-custody ARGS="--mfa-enrolled-confirmed --mfa-enrollment-source identity-provider --recovery-confirmed --custody-packet-prepared --no-secret-capture-confirmed" python3 tools/security-bootstrap-console/security_bootstrap_console.py \ --metadata "$(SECURITY_BOOTSTRAP_METADATA)" \ @@ -212,6 +223,7 @@ security-bootstrap-ui: security-bootstrap-metadata-init ## Serve local custody a creds-agent-init creds-agent-status creds-emergency-reprint \ iam-profile-conformance-test playbook-contract-test \ security-bootstrap-console security-bootstrap-king-kit \ - security-bootstrap-validate-kit security-bootstrap-approve-custody \ + security-bootstrap-validate-kit security-bootstrap-validate-t02 \ + security-bootstrap-approve-custody \ security-bootstrap-custody-packet security-bootstrap-openbao-preflight \ security-bootstrap-metadata-init security-bootstrap-ui diff --git a/tools/security-bootstrap-console/README.md b/tools/security-bootstrap-console/README.md index da2fa00..a1078e0 100644 --- a/tools/security-bootstrap-console/README.md +++ b/tools/security-bootstrap-console/README.md @@ -216,6 +216,17 @@ python3 tools/security-bootstrap-console/security_bootstrap_console.py openbao-p This still does not run `bao operator init`. +Validate the current NET-WP-0017-T02 OpenBao audit/recovery gates: + +```bash +make security-bootstrap-validate-t02 +``` + +The validator checks local non-secret metadata, the next independent quorum +holder, the Audit Core retention/risk decision, and the Railiance restore and +emergency-drill evidence validators. It fails until real evidence files exist +and the remaining T02 metadata gates are recorded. + OpenBao itself is operated from the Railiance runbook. Public ingress is disabled, so the live ceremony uses Railiance `make` targets, `kubectl exec`, or an operator port-forward. The local UI can record non-secret milestones diff --git a/tools/security-bootstrap-console/security_bootstrap_console.py b/tools/security-bootstrap-console/security_bootstrap_console.py index 0f5aa73..5ac79c8 100755 --- a/tools/security-bootstrap-console/security_bootstrap_console.py +++ b/tools/security-bootstrap-console/security_bootstrap_console.py @@ -647,9 +647,10 @@ def print_status(data: dict[str, Any]) -> None: print("2. custody-packet") print("3. openbao-preflight") print("4. handover-checklist") - print("5. metadata-template") - print("6. approve-custody-mode") - print("7. web-ui") + print("5. validate-t02") + print("6. metadata-template") + print("7. approve-custody-mode") + print("8. web-ui") print("") print("Refusal boundary") print("This console will not run bao operator init or collect secret values.") @@ -694,6 +695,168 @@ def print_validate_king_kit(data: dict[str, Any]) -> int: return 1 +def valid_review_date(value: Any) -> bool: + try: + datetime.strptime(str(value).strip(), "%Y-%m-%d") + except (TypeError, ValueError): + return False + return True + + +def independent_quorum_holder_ready(data: dict[str, Any]) -> bool: + holder = str(data.get("role_future_quorum_email") or "").strip().lower() + if not holder: + return False + current_holders = { + str(data.get(key) or "").strip().lower() + for key in ( + "role_setup_operator_email", + "role_platform_custodian_email", + "role_identity_admin_email", + "role_openbao_operator_email", + "role_recovery_custodian_email", + "notification_contact", + ) + } + current_holders.discard("") + return holder not in current_holders + + +def independent_quorum_holder_reason(data: dict[str, Any]) -> str: + holder = str(data.get("role_future_quorum_email") or "").strip() + if not holder: + return "Record the next independent escrow/quorum holder email before moving beyond temporary single-king custody." + if not independent_quorum_holder_ready(data): + return "Future quorum holder is recorded but is not independent from the current bootstrap/custody roles." + return "Future quorum holder is recorded as independent from the current bootstrap/custody roles." + + +def audit_core_posture_ready(data: dict[str, Any]) -> bool: + if yes(data, "audit_core_production_sink_ready"): + return True + return ( + yes(data, "audit_core_bootstrap_risk_accepted") + and bool(str(data.get("audit_core_risk_owner") or "").strip()) + and valid_review_date(data.get("audit_core_risk_review_date")) + and bool(str(data.get("audit_core_risk_note") or "").strip()) + ) + + +def audit_core_posture_reason(data: dict[str, Any]) -> str: + if yes(data, "audit_core_production_sink_ready"): + return "Production Audit Core retention is recorded as ready." + missing: list[str] = [] + if not yes(data, "audit_core_bootstrap_risk_accepted"): + missing.append("risk acceptance") + if not str(data.get("audit_core_risk_owner") or "").strip(): + missing.append("owner") + if not valid_review_date(data.get("audit_core_risk_review_date")): + missing.append("review date") + if not str(data.get("audit_core_risk_note") or "").strip(): + missing.append("risk note") + if missing: + return "Production Audit Core retention is deferred; record " + ", ".join(missing) + "." + return "Temporary bootstrap audit-retention risk exception is recorded with owner and review date." + + +def compact_command_output(text: str) -> str: + lines = [line.strip() for line in text.splitlines() if line.strip()] + return lines[-1] if lines else "No validator output captured." + + +def resolve_cli_path(value: str | Path) -> Path: + path = Path(value).expanduser() + if not path.is_absolute(): + path = (Path.cwd() / path).resolve() + return path + + +def evidence_validator_gate( + name: str, + railiance_path: Path, + target: str, + env_key: str, + evidence_path: Path, +) -> Gate: + if not railiance_path.is_dir(): + return Gate(name, "blocked", f"Railiance repo not found: {railiance_path}") + if not evidence_path.exists(): + return Gate(name, "blocked", f"Evidence file missing: {evidence_path}") + result = subprocess.run( + ["make", target, f"{env_key}={evidence_path}"], + cwd=railiance_path, + text=True, + capture_output=True, + check=False, + ) + output = compact_command_output(result.stdout + "\n" + result.stderr) + if result.returncode == 0: + return Gate(name, "done", output) + return Gate(name, "blocked", output) + + +def t02_metadata_gates(data: dict[str, Any]) -> list[Gate]: + return [ + Gate( + "Restore drill metadata", + "done" if yes(data, "restore_drill_passed") else "blocked", + "Record the restore drill completion flag only after evidence exists and validates.", + ), + Gate( + "Emergency seal/unseal metadata", + "done" if yes(data, "openbao_emergency_lockdown_drilled") else "blocked", + "Record the emergency drill completion flag only after an attended drill and validated evidence.", + ), + Gate( + "Next independent escrow holder", + "done" if independent_quorum_holder_ready(data) else "blocked", + independent_quorum_holder_reason(data), + ), + Gate( + "Audit Core retention posture", + "done" if audit_core_posture_ready(data) else "blocked", + audit_core_posture_reason(data), + ), + ] + + +def print_validate_t02(args: argparse.Namespace, data: dict[str, Any]) -> int: + merged = metadata_template() + merged.update(data) + railiance_path = resolve_cli_path(args.railiance_path) + restore_evidence = resolve_cli_path(args.restore_evidence) + emergency_evidence = resolve_cli_path(args.emergency_evidence) + gates = t02_metadata_gates(merged) + gates.extend( + [ + evidence_validator_gate( + "Restore drill evidence file", + railiance_path, + "openbao-validate-restore-evidence", + "OPENBAO_RESTORE_EVIDENCE", + restore_evidence, + ), + evidence_validator_gate( + "Emergency drill evidence file", + railiance_path, + "openbao-validate-emergency-evidence", + "OPENBAO_EMERGENCY_EVIDENCE", + emergency_evidence, + ), + ] + ) + print("NET-WP-0017-T02 VALIDATION") + print("") + for gate in gates: + print(f"- {gate.status}: {gate.name} - {gate.reason}") + print("") + if all(gate.status == "done" for gate in gates): + print("NET-WP-0017-T02 evidence and metadata gates are complete.") + return 0 + print("NET-WP-0017-T02 is still open.") + return 1 + + def merged_approval_metadata( existing: dict[str, Any], payload: dict[str, Any], @@ -720,6 +883,9 @@ def merged_approval_metadata( "mfa_enrollment_reference", "custody_mode", "root_token_disposition", + "audit_core_risk_owner", + "audit_core_risk_review_date", + "audit_core_risk_note", "notes", ) for field in text_fields: @@ -754,6 +920,8 @@ def merged_approval_metadata( "openbao_oidc_auth_configured", "openbao_oidc_admin_login_verified", "restore_drill_passed", + "audit_core_production_sink_ready", + "audit_core_bootstrap_risk_accepted", "cleanup_complete", "platform_reopened", ): @@ -978,6 +1146,11 @@ def metadata_template() -> dict[str, Any]: "openbao_oidc_admin_login_verified": False, "root_token_disposition": "", "restore_drill_passed": False, + "audit_core_production_sink_ready": False, + "audit_core_bootstrap_risk_accepted": False, + "audit_core_risk_owner": "", + "audit_core_risk_review_date": "", + "audit_core_risk_note": "", "cleanup_complete": False, "platform_reopened": False, "review_date": "", @@ -3106,6 +3279,24 @@ def ui_html() -> str: