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_RESTORE_EVIDENCE ?= /tmp/netkingdom-openbao-restore-drill/evidence.json
OPENBAO_EMERGENCY_EVIDENCE ?= /tmp/netkingdom-openbao-emergency-drill/evidence.json OPENBAO_EMERGENCY_EVIDENCE ?= /tmp/netkingdom-openbao-emergency-drill/evidence.json
RAILIANCE_PLATFORM_PATH ?= ../railiance-platform 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 ──────────────────────────────────────────────────────────────────────
help: ## Show this help help: ## Show this help
@@ -181,7 +186,32 @@ security-bootstrap-validate-t02: ## Validate NET-WP-0017-T02 OpenBao audit/recov
validate-t02 \ validate-t02 \
--railiance-path "$(RAILIANCE_PLATFORM_PATH)" \ --railiance-path "$(RAILIANCE_PLATFORM_PATH)" \
--restore-evidence "$(OPENBAO_RESTORE_EVIDENCE)" \ --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" 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 \ 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 \ iam-profile-conformance-test playbook-contract-test \
security-bootstrap-console security-bootstrap-king-kit \ security-bootstrap-console security-bootstrap-king-kit \
security-bootstrap-validate-kit security-bootstrap-validate-t02 \ 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-approve-custody \
security-bootstrap-custody-packet security-bootstrap-openbao-preflight \ security-bootstrap-custody-packet security-bootstrap-openbao-preflight \
security-bootstrap-metadata-init security-bootstrap-ui 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 The validator checks local non-secret metadata, the next independent quorum
holder, the Audit Core retention/risk decision, and the Railiance restore and roster, the Audit Core retention/risk decision, and the Railiance restore and
emergency-drill evidence validators. It fails until real evidence files exist emergency-drill evidence validators. It fails until real evidence files exist,
and the remaining T02 metadata gates are recorded. 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 OpenBao itself is operated from the Railiance runbook. Public ingress is
disabled, so the live ceremony uses Railiance `make` targets, `kubectl exec`, 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") STAGE_ORDER = ("S1", "S2", "S3", "S4", "S5", "S6")
REPO_ROOT = Path(__file__).resolve().parents[2] REPO_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_METADATA_PATH = REPO_ROOT / ".local/security-bootstrap.json" 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" APPROVAL_PHRASE = "approve custody mode"
VALID_STORAGE_CLASSES = {"password-safe", "offline-packet", "hardware-token"} VALID_STORAGE_CLASSES = {"password-safe", "offline-packet", "hardware-token"}
VALID_MFA_CLASSES = {"totp", "webauthn", "hardware-token"} VALID_MFA_CLASSES = {"totp", "webauthn", "hardware-token"}
@@ -55,6 +58,9 @@ KEYCAPE_OPENBAO_CLIENT_REDIRECTS = (
) )
AGE_PUBLIC_PREFIX = "age1" AGE_PUBLIC_PREFIX = "age1"
AGE_PRIVATE_MARKER = "AGE-SECRET-KEY-1" 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) @dataclass(frozen=True)
@@ -648,9 +654,11 @@ def print_status(data: dict[str, Any]) -> None:
print("3. openbao-preflight") print("3. openbao-preflight")
print("4. handover-checklist") print("4. handover-checklist")
print("5. validate-t02") print("5. validate-t02")
print("6. metadata-template") print("6. custody-roster-template")
print("7. approve-custody-mode") print("7. validate-custody-roster")
print("8. web-ui") print("8. metadata-template")
print("9. approve-custody-mode")
print("10. web-ui")
print("") print("")
print("Refusal boundary") print("Refusal boundary")
print("This console will not run bao operator init or collect secret values.") 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." 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: def compact_command_output(text: str) -> str:
lines = [line.strip() for line in text.splitlines() if line.strip()] lines = [line.strip() for line in text.splitlines() if line.strip()]
return lines[-1] if lines else "No validator output captured." 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", "done" if yes(data, "openbao_emergency_lockdown_drilled") else "blocked",
"Record the emergency drill completion flag only after an attended drill and validated evidence.", "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( Gate(
"Audit Core retention posture", "Audit Core retention posture",
"done" if audit_core_posture_ready(data) else "blocked", "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) railiance_path = resolve_cli_path(args.railiance_path)
restore_evidence = resolve_cli_path(args.restore_evidence) restore_evidence = resolve_cli_path(args.restore_evidence)
emergency_evidence = resolve_cli_path(args.emergency_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 = t02_metadata_gates(merged)
gates.extend( gates.extend(
[ [
custody_roster_gate(custody_roster, custody_roster_signature, custody_roster_allowed_signers),
evidence_validator_gate( evidence_validator_gate(
"Restore drill evidence file", "Restore drill evidence file",
railiance_path, railiance_path,
@@ -3994,6 +4230,37 @@ def build_parser() -> argparse.ArgumentParser:
default="/tmp/netkingdom-openbao-emergency-drill/evidence.json", default="/tmp/netkingdom-openbao-emergency-drill/evidence.json",
help="Path to non-secret emergency seal/unseal 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 = sub.add_parser("approve-custody-mode", help="Approve a live-init-ready custody mode.")
approve.add_argument( approve.add_argument(
"--mode", "--mode",
@@ -4025,6 +4292,7 @@ def build_parser() -> argparse.ArgumentParser:
help=f'Use the required approval phrase "{APPROVAL_PHRASE}" non-interactively.', 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-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("handover-checklist", help="Print handover and cleanup checklist.")
sub.add_parser("metadata-template", help="Print non-secret metadata JSON template.") 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.") 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) return print_validate_king_kit(data)
if args.command == "validate-t02": if args.command == "validate-t02":
return print_validate_t02(args, data) return print_validate_t02(args, data)
if args.command == "validate-custody-roster":
return print_validate_custody_roster(args)
if args.command == "approve-custody-mode": if args.command == "approve-custody-mode":
return print_approve_custody_mode(args, data) return print_approve_custody_mode(args, data)
if args.command == "custody-packet": if args.command == "custody-packet":
print_custody_packet() print_custody_packet()
return 0 return 0
if args.command == "custody-roster-template":
print_custody_roster_template()
return 0
if args.command == "handover-checklist": if args.command == "handover-checklist":
print_handover_checklist() print_handover_checklist()
return 0 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 holder is recorded, and the temporary Audit Core risk posture has not yet been
accepted or replaced by a production sink. 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 ### T03 - Close Trial Taint And Retire Bootstrap Admin Paths
```task ```task