Add signed custody roster workflow

This commit is contained in:
2026-06-02 01:11:42 +02:00
parent 31e6d6660f
commit 0ab7c14ec9
5 changed files with 406 additions and 12 deletions

View File

@@ -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

View File

@@ -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."
}

View File

@@ -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`,

View File

@@ -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

View File

@@ -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