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_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
|
||||||
|
|||||||
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
|
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`,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user