generated from coulomb/repo-seed
Add signed custody roster workflow
This commit is contained in:
35
Makefile
35
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
|
||||
|
||||
51
examples/security-bootstrap/custody-roster.example.json
Normal file
51
examples/security-bootstrap/custody-roster.example.json
Normal 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."
|
||||
}
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user