From 0ab7c14ec9b38b0310685bd3e83d4ce451ed9ad6 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 2 Jun 2026 01:11:42 +0200 Subject: [PATCH] Add signed custody roster workflow --- Makefile | 35 ++- .../custody-roster.example.json | 51 ++++ tools/security-bootstrap-console/README.md | 25 +- .../security_bootstrap_console.py | 289 +++++++++++++++++- ...-security-readiness-for-user-onboarding.md | 18 ++ 5 files changed, 406 insertions(+), 12 deletions(-) create mode 100644 examples/security-bootstrap/custody-roster.example.json diff --git a/Makefile b/Makefile index 1779064..0405873 100644 --- a/Makefile +++ b/Makefile @@ -9,6 +9,11 @@ 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 +CUSTODY_ROSTER ?= .local/custody-roster.json +CUSTODY_ROSTER_SIGNATURE ?= .local/custody-roster.json.sig +CUSTODY_ROSTER_ALLOWED_SIGNERS ?= .local/custody-roster.allowed_signers +CUSTODY_ROSTER_SIGNING_KEY ?= $(HOME)/.ssh/id_custodian_agent +CUSTODY_ROSTER_SIGNING_PRINCIPAL ?= platform-custodian # ── Help ────────────────────────────────────────────────────────────────────── help: ## Show this help @@ -181,7 +186,32 @@ security-bootstrap-validate-t02: ## Validate NET-WP-0017-T02 OpenBao audit/recov validate-t02 \ --railiance-path "$(RAILIANCE_PLATFORM_PATH)" \ --restore-evidence "$(OPENBAO_RESTORE_EVIDENCE)" \ - --emergency-evidence "$(OPENBAO_EMERGENCY_EVIDENCE)" + --emergency-evidence "$(OPENBAO_EMERGENCY_EVIDENCE)" \ + --custody-roster "$(CUSTODY_ROSTER)" \ + --custody-roster-signature "$(CUSTODY_ROSTER_SIGNATURE)" \ + --custody-roster-allowed-signers "$(CUSTODY_ROSTER_ALLOWED_SIGNERS)" + +security-bootstrap-custody-roster-template: ## Print a non-secret two-of-three custody roster template + python3 tools/security-bootstrap-console/security_bootstrap_console.py custody-roster-template + +security-bootstrap-validate-custody-roster: ## Validate and verify the signed local custody roster + python3 tools/security-bootstrap-console/security_bootstrap_console.py \ + validate-custody-roster \ + --roster "$(CUSTODY_ROSTER)" \ + --signature "$(CUSTODY_ROSTER_SIGNATURE)" \ + --allowed-signers "$(CUSTODY_ROSTER_ALLOWED_SIGNERS)" + +security-bootstrap-sign-custody-roster: ## Sign the ignored local custody roster with an SSH signing key + @mkdir -p "$$(dirname "$(CUSTODY_ROSTER_ALLOWED_SIGNERS)")" + @printf '%s ' "$(CUSTODY_ROSTER_SIGNING_PRINCIPAL)" > "$(CUSTODY_ROSTER_ALLOWED_SIGNERS)" + @cat "$(CUSTODY_ROSTER_SIGNING_KEY).pub" >> "$(CUSTODY_ROSTER_ALLOWED_SIGNERS)" + ssh-keygen -Y sign \ + -f "$(CUSTODY_ROSTER_SIGNING_KEY)" \ + -n netkingdom-custody-roster \ + "$(CUSTODY_ROSTER)" + @if [[ "$(CUSTODY_ROSTER_SIGNATURE)" != "$(CUSTODY_ROSTER).sig" ]]; then \ + cp "$(CUSTODY_ROSTER).sig" "$(CUSTODY_ROSTER_SIGNATURE)"; \ + fi 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 \ @@ -224,6 +254,9 @@ security-bootstrap-ui: security-bootstrap-metadata-init ## Serve local custody a iam-profile-conformance-test playbook-contract-test \ security-bootstrap-console security-bootstrap-king-kit \ security-bootstrap-validate-kit security-bootstrap-validate-t02 \ + security-bootstrap-custody-roster-template \ + security-bootstrap-validate-custody-roster \ + security-bootstrap-sign-custody-roster \ security-bootstrap-approve-custody \ security-bootstrap-custody-packet security-bootstrap-openbao-preflight \ security-bootstrap-metadata-init security-bootstrap-ui diff --git a/examples/security-bootstrap/custody-roster.example.json b/examples/security-bootstrap/custody-roster.example.json new file mode 100644 index 0000000..ffe9c86 --- /dev/null +++ b/examples/security-bootstrap/custody-roster.example.json @@ -0,0 +1,51 @@ +{ + "schema": "netkingdom.custody-roster.v1", + "roster_id": "netkingdom-openbao-custody-2of3-20260602-example", + "custody_model": "two-of-three-planned", + "status": "planned", + "scope": "OpenBao platform recovery, emergency unseal, and custody migration", + "created_at": "2026-06-02T00:00:00Z", + "review_date": "2026-07-02", + "approved_by": { + "role": "platform-custodian", + "signing_principal": "platform-custodian", + "public_key_reference": "~/.ssh/id_custodian_agent.pub" + }, + "holders": [ + { + "holder_id": "holder-1", + "role": "king-holder", + "contact": { + "email": "king@example.test", + "phone": "+49-000-0000000" + }, + "identity_reference": "planned:lldap/platform-root", + "admin_user": true, + "custody_material": "future share slot 1" + }, + { + "holder_id": "holder-2", + "role": "escrow-holder-1", + "contact": { + "email": "escrow-one@example.test", + "phone": "+49-000-0000001" + }, + "identity_reference": "planned:lldap/custody-escrow-1", + "admin_user": false, + "custody_material": "future share slot 2" + }, + { + "holder_id": "holder-3", + "role": "escrow-holder-2", + "contact": { + "email": "escrow-two@example.test", + "phone": "+49-000-0000002" + }, + "identity_reference": "planned:lldap/custody-escrow-2", + "admin_user": false, + "custody_material": "future share slot 3" + } + ], + "secret_material_recorded": false, + "notes": "Real contact data belongs only in .local/ or an encrypted custody store, never in Git or State Hub." +} diff --git a/tools/security-bootstrap-console/README.md b/tools/security-bootstrap-console/README.md index a1078e0..bc50cb8 100644 --- a/tools/security-bootstrap-console/README.md +++ b/tools/security-bootstrap-console/README.md @@ -223,9 +223,28 @@ 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. +roster, the Audit Core retention/risk decision, and the Railiance restore and +emergency-drill evidence validators. It fails until real evidence files exist, +the signed custody roster exists, and the remaining T02 metadata gates are +recorded. + +Create and validate the local two-of-three custody roster: + +```bash +make security-bootstrap-custody-roster-template \ + > .local/custody-roster.json + +# Edit .local/custody-roster.json locally. It may contain real contact data, +# so it is ignored by Git and must not be copied into State Hub or workplans. + +make security-bootstrap-sign-custody-roster +make security-bootstrap-validate-custody-roster +``` + +The roster is tamper-evident through an SSH detached signature with namespace +`netkingdom-custody-roster`. The default signer is +`~/.ssh/id_custodian_agent`; the local allowed-signers file is written to +`.local/custody-roster.allowed_signers`. OpenBao itself is operated from the Railiance runbook. Public ingress is disabled, so the live ceremony uses Railiance `make` targets, `kubectl exec`, diff --git a/tools/security-bootstrap-console/security_bootstrap_console.py b/tools/security-bootstrap-console/security_bootstrap_console.py index 5ac79c8..2615614 100755 --- a/tools/security-bootstrap-console/security_bootstrap_console.py +++ b/tools/security-bootstrap-console/security_bootstrap_console.py @@ -31,6 +31,9 @@ DEFAULT_STAGE = "S1 - Low-trust assembly" STAGE_ORDER = ("S1", "S2", "S3", "S4", "S5", "S6") REPO_ROOT = Path(__file__).resolve().parents[2] DEFAULT_METADATA_PATH = REPO_ROOT / ".local/security-bootstrap.json" +DEFAULT_CUSTODY_ROSTER_PATH = REPO_ROOT / ".local/custody-roster.json" +DEFAULT_CUSTODY_ROSTER_SIGNATURE_PATH = REPO_ROOT / ".local/custody-roster.json.sig" +DEFAULT_CUSTODY_ROSTER_ALLOWED_SIGNERS_PATH = REPO_ROOT / ".local/custody-roster.allowed_signers" APPROVAL_PHRASE = "approve custody mode" VALID_STORAGE_CLASSES = {"password-safe", "offline-packet", "hardware-token"} VALID_MFA_CLASSES = {"totp", "webauthn", "hardware-token"} @@ -55,6 +58,9 @@ KEYCAPE_OPENBAO_CLIENT_REDIRECTS = ( ) AGE_PUBLIC_PREFIX = "age1" AGE_PRIVATE_MARKER = "AGE-SECRET-KEY-1" +CUSTODY_ROSTER_SCHEMA = "netkingdom.custody-roster.v1" +CUSTODY_ROSTER_SIGNATURE_NAMESPACE = "netkingdom-custody-roster" +CUSTODY_ROSTER_HOLDER_ROLES = {"king-holder", "escrow-holder-1", "escrow-holder-2"} @dataclass(frozen=True) @@ -648,9 +654,11 @@ def print_status(data: dict[str, Any]) -> None: print("3. openbao-preflight") print("4. handover-checklist") print("5. validate-t02") - print("6. metadata-template") - print("7. approve-custody-mode") - print("8. web-ui") + print("6. custody-roster-template") + print("7. validate-custody-roster") + print("8. metadata-template") + print("9. approve-custody-mode") + print("10. web-ui") print("") print("Refusal boundary") print("This console will not run bao operator init or collect secret values.") @@ -759,6 +767,235 @@ def audit_core_posture_reason(data: dict[str, Any]) -> str: return "Temporary bootstrap audit-retention risk exception is recorded with owner and review date." +def custody_roster_template() -> dict[str, Any]: + return { + "schema": CUSTODY_ROSTER_SCHEMA, + "roster_id": "netkingdom-openbao-custody-2of3-YYYYMMDD", + "custody_model": "two-of-three-planned", + "status": "planned", + "scope": "OpenBao platform recovery, emergency unseal, and custody migration", + "created_at": "YYYY-MM-DDTHH:MM:SSZ", + "review_date": "YYYY-MM-DD", + "approved_by": { + "role": "platform-custodian", + "signing_principal": "platform-custodian", + "public_key_reference": "~/.ssh/id_custodian_agent.pub", + }, + "holders": [ + { + "holder_id": "holder-1", + "role": "king-holder", + "contact": { + "email": "king@example.test", + "phone": "+49-000-0000000", + }, + "identity_reference": "planned:lldap/platform-root", + "admin_user": True, + "custody_material": "future share slot 1", + }, + { + "holder_id": "holder-2", + "role": "escrow-holder-1", + "contact": { + "email": "escrow-one@example.test", + "phone": "+49-000-0000001", + }, + "identity_reference": "planned:lldap/custody-escrow-1", + "admin_user": False, + "custody_material": "future share slot 2", + }, + { + "holder_id": "holder-3", + "role": "escrow-holder-2", + "contact": { + "email": "escrow-two@example.test", + "phone": "+49-000-0000002", + }, + "identity_reference": "planned:lldap/custody-escrow-2", + "admin_user": False, + "custody_material": "future share slot 3", + }, + ], + "secret_material_recorded": False, + "notes": "Real contact data belongs only in .local/ or an encrypted custody store, never in Git or State Hub.", + } + + +def load_json_object(path: Path) -> tuple[dict[str, Any] | None, str | None]: + if not path.exists(): + return None, f"file missing: {path}" + try: + data = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + return None, f"invalid JSON: {exc}" + if not isinstance(data, dict): + return None, "JSON root must be an object" + return data, None + + +def looks_like_email(value: Any) -> bool: + text = str(value or "").strip() + return "@" in text and "." in text.rsplit("@", 1)[-1] + + +def looks_like_phone(value: Any) -> bool: + text = str(value or "").strip() + return text.startswith("+") and len([ch for ch in text if ch.isdigit()]) >= 8 + + +def custody_roster_secret_issue(data: dict[str, Any]) -> str: + encoded = json.dumps(data, sort_keys=True) + secret_markers = [ + "OPENBAO_ROOT_TOKEN", + "VAULT_TOKEN", + "BEGIN PRIVATE KEY", + "BEGIN OPENSSH PRIVATE KEY", + "AGE-SECRET-KEY-1", + "-----BEGIN", + "hvs.", + ] + for marker in secret_markers: + if marker in encoded: + return f"secret-looking marker present: {marker}" + return "" + + +def validate_custody_roster_data(data: dict[str, Any]) -> list[str]: + errors: list[str] = [] + if data.get("schema") != CUSTODY_ROSTER_SCHEMA: + errors.append(f"schema must be {CUSTODY_ROSTER_SCHEMA}") + if data.get("custody_model") not in {"two-of-three-planned", "two-of-three-active"}: + errors.append("custody_model must be two-of-three-planned or two-of-three-active") + if data.get("status") not in {"planned", "active"}: + errors.append("status must be planned or active") + if not valid_review_date(data.get("review_date")): + errors.append("review_date must use YYYY-MM-DD") + if data.get("secret_material_recorded") is not False: + errors.append("secret_material_recorded must be false") + approved_by = data.get("approved_by") + if not isinstance(approved_by, dict): + errors.append("approved_by must be an object") + elif not str(approved_by.get("signing_principal") or "").strip(): + errors.append("approved_by.signing_principal is required") + holders = data.get("holders") + if not isinstance(holders, list) or len(holders) != 3: + errors.append("holders must contain exactly three entries for two-of-three custody") + return errors + roles: set[str] = set() + emails: set[str] = set() + phones: set[str] = set() + for index, holder in enumerate(holders, start=1): + prefix = f"holders[{index}]" + if not isinstance(holder, dict): + errors.append(f"{prefix} must be an object") + continue + role = str(holder.get("role") or "").strip() + roles.add(role) + if role not in CUSTODY_ROSTER_HOLDER_ROLES: + errors.append(f"{prefix}.role must be one of {sorted(CUSTODY_ROSTER_HOLDER_ROLES)}") + if not str(holder.get("holder_id") or "").strip(): + errors.append(f"{prefix}.holder_id is required") + contact = holder.get("contact") + if not isinstance(contact, dict): + errors.append(f"{prefix}.contact must be an object") + continue + email = str(contact.get("email") or "").strip().lower() + phone = str(contact.get("phone") or "").strip() + if not looks_like_email(email): + errors.append(f"{prefix}.contact.email must look like an email address") + if not looks_like_phone(phone): + errors.append(f"{prefix}.contact.phone must be an international phone reference") + if email: + emails.add(email) + if phone: + phones.add(phone) + if role.startswith("escrow-holder") and holder.get("admin_user") is True: + errors.append(f"{prefix}.admin_user must not be true for escrow holders") + if roles != CUSTODY_ROSTER_HOLDER_ROLES: + errors.append("holders must include king-holder, escrow-holder-1, and escrow-holder-2") + if len(emails) != 3: + errors.append("holder email contacts must be distinct") + if len(phones) != 3: + errors.append("holder phone contacts must be distinct") + secret_issue = custody_roster_secret_issue(data) + if secret_issue: + errors.append(secret_issue) + return errors + + +def verify_custody_roster_signature( + roster_path: Path, + signature_path: Path, + allowed_signers_path: Path, + data: dict[str, Any], +) -> tuple[bool, str]: + if not signature_path.exists(): + return False, f"signature file missing: {signature_path}" + if not allowed_signers_path.exists(): + return False, f"allowed signers file missing: {allowed_signers_path}" + approved_by = data.get("approved_by") if isinstance(data.get("approved_by"), dict) else {} + principal = str(approved_by.get("signing_principal") or "").strip() + if not principal: + return False, "approved_by.signing_principal is required for signature verification" + result = subprocess.run( + [ + "ssh-keygen", + "-Y", + "verify", + "-f", + str(allowed_signers_path), + "-I", + principal, + "-n", + CUSTODY_ROSTER_SIGNATURE_NAMESPACE, + "-s", + str(signature_path), + ], + input=roster_path.read_bytes(), + capture_output=True, + check=False, + ) + output = compact_command_output( + result.stdout.decode("utf-8", errors="replace") + + "\n" + + result.stderr.decode("utf-8", errors="replace") + ) + if result.returncode == 0: + return True, output or "signature verified" + return False, output + + +def custody_roster_gate(roster_path: Path, signature_path: Path, allowed_signers_path: Path) -> Gate: + data, error = load_json_object(roster_path) + if error: + return Gate("Signed custody roster", "blocked", error) + assert data is not None + errors = validate_custody_roster_data(data) + if errors: + return Gate("Signed custody roster", "blocked", "; ".join(errors)) + verified, reason = verify_custody_roster_signature(roster_path, signature_path, allowed_signers_path, data) + if verified: + return Gate("Signed custody roster", "done", reason) + return Gate("Signed custody roster", "blocked", reason) + + +def print_custody_roster_template() -> None: + print(json.dumps(custody_roster_template(), indent=2)) + + +def print_validate_custody_roster(args: argparse.Namespace) -> int: + roster_path = resolve_cli_path(args.roster) + signature_path = resolve_cli_path(args.signature) + allowed_signers_path = resolve_cli_path(args.allowed_signers) + gate = custody_roster_gate(roster_path, signature_path, allowed_signers_path) + print("CUSTODY ROSTER VALIDATION") + print("") + print(f"- {gate.status}: {gate.name} - {gate.reason}") + if gate.status == "done": + return 0 + return 1 + + 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." @@ -807,11 +1044,6 @@ def t02_metadata_gates(data: dict[str, Any]) -> list[Gate]: "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", @@ -826,9 +1058,13 @@ def print_validate_t02(args: argparse.Namespace, data: dict[str, Any]) -> int: railiance_path = resolve_cli_path(args.railiance_path) restore_evidence = resolve_cli_path(args.restore_evidence) emergency_evidence = resolve_cli_path(args.emergency_evidence) + custody_roster = resolve_cli_path(args.custody_roster) + custody_roster_signature = resolve_cli_path(args.custody_roster_signature) + custody_roster_allowed_signers = resolve_cli_path(args.custody_roster_allowed_signers) gates = t02_metadata_gates(merged) gates.extend( [ + custody_roster_gate(custody_roster, custody_roster_signature, custody_roster_allowed_signers), evidence_validator_gate( "Restore drill evidence file", railiance_path, @@ -3994,6 +4230,37 @@ def build_parser() -> argparse.ArgumentParser: default="/tmp/netkingdom-openbao-emergency-drill/evidence.json", help="Path to non-secret emergency seal/unseal drill evidence JSON.", ) + validate_t02.add_argument( + "--custody-roster", + default=str(DEFAULT_CUSTODY_ROSTER_PATH), + help="Path to local custody roster JSON.", + ) + validate_t02.add_argument( + "--custody-roster-signature", + default=str(DEFAULT_CUSTODY_ROSTER_SIGNATURE_PATH), + help="Path to detached SSH signature for the custody roster.", + ) + validate_t02.add_argument( + "--custody-roster-allowed-signers", + default=str(DEFAULT_CUSTODY_ROSTER_ALLOWED_SIGNERS_PATH), + help="Path to SSH allowed_signers file for custody roster verification.", + ) + validate_roster = sub.add_parser("validate-custody-roster", help="Validate and verify the signed local custody roster.") + validate_roster.add_argument( + "--roster", + default=str(DEFAULT_CUSTODY_ROSTER_PATH), + help="Path to local custody roster JSON.", + ) + validate_roster.add_argument( + "--signature", + default=str(DEFAULT_CUSTODY_ROSTER_SIGNATURE_PATH), + help="Path to detached SSH signature.", + ) + validate_roster.add_argument( + "--allowed-signers", + default=str(DEFAULT_CUSTODY_ROSTER_ALLOWED_SIGNERS_PATH), + help="Path to SSH allowed_signers file.", + ) approve = sub.add_parser("approve-custody-mode", help="Approve a live-init-ready custody mode.") approve.add_argument( "--mode", @@ -4025,6 +4292,7 @@ def build_parser() -> argparse.ArgumentParser: help=f'Use the required approval phrase "{APPROVAL_PHRASE}" non-interactively.', ) sub.add_parser("custody-packet", help="Print blank offline custody packet template.") + sub.add_parser("custody-roster-template", help="Print non-secret custody roster JSON template.") sub.add_parser("handover-checklist", help="Print handover and cleanup checklist.") sub.add_parser("metadata-template", help="Print non-secret metadata JSON template.") sub.add_parser("refuse-live-init", help="Explain why live OpenBao init is refused.") @@ -4063,11 +4331,16 @@ def main(argv: list[str] | None = None) -> int: return print_validate_king_kit(data) if args.command == "validate-t02": return print_validate_t02(args, data) + if args.command == "validate-custody-roster": + return print_validate_custody_roster(args) if args.command == "approve-custody-mode": return print_approve_custody_mode(args, data) if args.command == "custody-packet": print_custody_packet() return 0 + if args.command == "custody-roster-template": + print_custody_roster_template() + return 0 if args.command == "handover-checklist": print_handover_checklist() return 0 diff --git a/workplans/NET-WP-0017-it-security-readiness-for-user-onboarding.md b/workplans/NET-WP-0017-it-security-readiness-for-user-onboarding.md index 278f110..b1343ff 100644 --- a/workplans/NET-WP-0017-it-security-readiness-for-user-onboarding.md +++ b/workplans/NET-WP-0017-it-security-readiness-for-user-onboarding.md @@ -239,6 +239,24 @@ are missing, the emergency drill is not recorded, no independent future quorum holder is recorded, and the temporary Audit Core risk posture has not yet been accepted or replaced by a production sink. +**2026-06-02:** Replaced the loose single escrow-holder planning gate with a +signed two-of-three custody roster. The repository now carries a fake-data +example plus console/Make targets to print a roster template, validate the +roster, sign the ignored local roster with SSH namespace +`netkingdom-custody-roster`, and verify the detached signature. Real holder +contact records belong only in `.local/custody-roster.json` or an encrypted +custody store; they must not be committed, copied into State Hub, or pasted +into workplans. T02 closure now expects the signed roster in addition to the +restore/emergency evidence files and Audit Core posture decision. + +**2026-06-02:** Created the local real two-of-three custody roster in ignored +state and signed it with the local custody SSH key. `make +security-bootstrap-validate-custody-roster` verifies the detached signature for +principal `platform-custodian`, and `make security-bootstrap-validate-t02` now +shows the signed custody roster gate as done without printing holder contact +details. T02 remains open for emergency seal/unseal drill metadata, the Audit +Core retention/risk decision, and the real restore/emergency evidence files. + ### T03 - Close Trial Taint And Retire Bootstrap Admin Paths ```task