Clarify bootstrap custody UI flow

This commit is contained in:
2026-05-25 01:25:47 +02:00
parent 711c451d43
commit 83cf2111c1
3 changed files with 160 additions and 38 deletions

View File

@@ -141,6 +141,61 @@ def identity_login_ready(data: dict[str, Any]) -> bool:
return yes(data, "oidc_login_verified")
def recovery_material_ready(data: dict[str, Any]) -> bool:
return yes(data, "recovery_confirmed")
def recovery_material_reason(data: dict[str, Any]) -> str:
if recovery_material_ready(data):
return "Recovery references are prepared outside this UI."
return (
"Prepare password recovery, MFA recovery/re-enrollment, custodian age-key "
"recovery, and encrypted bootstrap bundle recovery references."
)
def custody_packet_ready(data: dict[str, Any]) -> bool:
return yes(data, "custody_packet_prepared")
def custody_packet_reason(data: dict[str, Any]) -> str:
if custody_packet_ready(data):
return "The offline ceremony packet is ready without recording secret values here."
return (
"Prepare the OpenBao ceremony packet: selected custody mode, recovery references, "
"share assignment slots, root-token disposition plan, and signature/date."
)
def metadata_secret_boundary_issue(data: dict[str, Any]) -> str:
state = bootstrap_secret_state()
if state["plaintext_secrets_present"]:
return "Plaintext bootstrap secrets directory is present; remove it before custody approval."
encoded = json.dumps(data, sort_keys=True)
secret_markers = (
AGE_PRIVATE_MARKER,
"-----BEGIN PRIVATE KEY-----",
"-----BEGIN OPENSSH PRIVATE KEY-----",
"OPENBAO_ROOT_TOKEN",
"VAULT_TOKEN",
)
for marker in secret_markers:
if marker in encoded:
return f"Metadata contains a secret-looking marker: {marker}."
return ""
def secret_boundary_ready(data: dict[str, Any]) -> bool:
return metadata_secret_boundary_issue(data) == ""
def secret_boundary_reason(data: dict[str, Any]) -> str:
issue = metadata_secret_boundary_issue(data)
if issue:
return issue
return "The control surface stores only non-secret references; no user attestation is required."
def extract_age_public_key(value: Any) -> str:
if value is None:
return ""
@@ -232,7 +287,7 @@ def kit_validation(data: dict[str, Any]) -> list[Gate]:
Gate(
"Storage class",
"done" if storage_values & VALID_STORAGE_CLASSES else "blocked",
"Select password-safe, offline-packet, hardware-token, or a combination.",
"Select where the credential is held; hardware is optional policy, not a default requirement.",
),
Gate(
"Password safe storage",
@@ -251,30 +306,30 @@ def kit_validation(data: dict[str, Any]) -> list[Gate]:
),
Gate(
"Recovery material",
"done" if yes(data, "recovery_confirmed") else "blocked",
"Confirm recovery material exists without recording values.",
"done" if recovery_material_ready(data) else "blocked",
recovery_material_reason(data),
),
Gate(
"Custody packet",
"done" if yes(data, "custody_packet_prepared") else "blocked",
"Prepare the offline custody packet.",
"done" if custody_packet_ready(data) else "blocked",
custody_packet_reason(data),
),
Gate(
"No secret capture",
"done" if yes(data, "no_secret_capture_confirmed") else "blocked",
"Confirm no secret values were stored in metadata, Git, State Hub, chat, tickets, or email.",
"Control-surface secret boundary",
"done" if secret_boundary_ready(data) else "blocked",
secret_boundary_reason(data),
),
Gate(
"Custody mode",
"Custody strategy selected",
"done" if custody_mode in VALID_CUSTODY_MODES else "blocked",
"Approve temporary-single-king, two-of-three-planned, or two-of-three-ready.",
"Choose how the OpenBao init ceremony will be controlled.",
),
]
def king_kit_ready(data: dict[str, Any]) -> bool:
gates = kit_validation(data)
required = [gate for gate in gates if gate.name != "Custody mode"]
required = [gate for gate in gates if gate.name != "Custody strategy selected"]
return all(gate.status == "done" for gate in required)
@@ -289,7 +344,7 @@ def custody_mode_reason(data: dict[str, Any]) -> str:
if mode == "two-of-three-planned":
return "Two-of-three is recorded as the target, but live init stays blocked until it is ready."
if mode in CUSTODY_APPROVAL_MODES and not yes(data, "custody_mode_approved"):
return "Mode is selected but not yet explicitly approved."
return "Strategy is selected; explicit approval is still pending."
return "Choose temporary-single-king or two-of-three-ready for live OpenBao custody."
@@ -313,7 +368,7 @@ def build_gates(data: dict[str, Any]) -> list[Gate]:
"Dedicated king credential, second factor, and recovery storage.",
),
Gate(
"Custody mode",
"Custody strategy approval",
"done" if custody_mode_approved(data) else "blocked",
custody_mode_reason(data),
),
@@ -350,8 +405,8 @@ def next_action(gates: list[Gate]) -> str:
if gate.status == "blocked":
if gate.name == "King credential kit":
return "Define king credential kit"
if gate.name == "Custody mode":
return "Choose custody mode"
if gate.name == "Custody strategy approval":
return "Approve custody strategy"
if gate.name == "OpenBao preflight":
return "Run OpenBao preflight"
if gate.name == "Root-token disposition":
@@ -501,7 +556,7 @@ def validate_custody_approval(
errors: list[str] = []
mode = data.get("custody_mode")
if approval_phrase.strip().lower() != APPROVAL_PHRASE:
errors.append(f'Type "{APPROVAL_PHRASE}" to approve custody mode.')
errors.append(f'Type "{APPROVAL_PHRASE}" to approve the selected custody strategy.')
if mode not in VALID_CUSTODY_MODES:
errors.append("Select a custody mode.")
elif mode not in CUSTODY_APPROVAL_MODES:
@@ -510,7 +565,7 @@ def validate_custody_approval(
"Use temporary-single-king now or two-of-three-ready when shares exist."
)
for gate in kit_validation(data):
if gate.name == "Custody mode":
if gate.name == "Custody strategy selected":
continue
if gate.status != "done":
errors.append(f"{gate.name}: {gate.reason}")
@@ -1071,6 +1126,23 @@ def ui_html() -> str:
line-height: 1.35;
margin: 0 0 16px;
}
.spec-list {
margin: 0 0 16px;
padding-left: 20px;
color: var(--muted);
font-size: 13px;
line-height: 1.45;
}
.spec-list li { margin: 5px 0; }
.system-note {
border: 1px solid var(--soft-line);
background: #ffffff;
padding: 12px 14px;
line-height: 1.35;
margin: 0 0 16px;
}
.conditional { display: none; }
.conditional.visible { display: grid; }
.actions {
display: flex;
flex-wrap: wrap;
@@ -1194,7 +1266,7 @@ def ui_html() -> str:
<body>
<header>
<div class="eyebrow">NetKingdom control surface</div>
<h1>Security bootstrap custody approval</h1>
<h1>Guided security bootstrap</h1>
</header>
<main>
<section class="topline" aria-label="Bootstrap status">
@@ -1215,7 +1287,7 @@ def ui_html() -> str:
<div class="layout">
<form id="approval-form">
<section class="panel">
<h2>Bootstrap key custody</h2>
<h2>1. Bootstrap key envelope</h2>
<p class="notice">The custodian age public key encrypts bootstrap bundles. The private key is ceremony material and must not be pasted into this UI.</p>
<div class="choice-list">
<div class="choice"><span class="step-number">1</span><span><strong>Register public recipient</strong><span>Paste only the custodian public age recipient, for example <code>age1...</code>. This value is safe to store and lets tools encrypt new bootstrap bundles.</span></span></div>
@@ -1250,7 +1322,7 @@ def ui_html() -> str:
</section>
<section class="panel">
<h2>Credential home</h2>
<h2>2. Platform-root identity</h2>
<p class="notice">Guide mode. OpenBao stores and audits secrets after the ceremony; it does not create the king account.</p>
<div class="choice-list">
<div class="choice"><span class="step-number">1</span><span><strong>Open LLDAP as bootstrap admin</strong><span>LLDAP has no public registration. Log in as <code>admin</code> using <code>LLDAP_LDAP_USER_PASS</code> from your password safe entry <code>net-kingdom/LLDAP/admin</code>. That value was generated during installation and injected into the <code>lldap-secrets</code> Kubernetes Secret.</span><span class="inline-actions"><a class="button-link" href="https://lldap.coulomb.social" target="_blank" rel="noreferrer" title="Open the LLDAP admin UI. This path uses password auth only and must be restricted before production.">Open LLDAP</a></span></span></div>
@@ -1282,7 +1354,7 @@ def ui_html() -> str:
</section>
<section class="panel">
<h2>King credential</h2>
<h2>3. Credential record</h2>
<p class="notice">Local non-secret metadata only. OpenBao initialization stays manual.</p>
<div class="grid">
<label class="field">
@@ -1303,14 +1375,14 @@ def ui_html() -> str:
<option value="">Select</option>
<option value="totp">TOTP</option>
<option value="webauthn">WebAuthn</option>
<option value="hardware-token">Hardware token</option>
<option value="hardware-token">Hardware token (policy only)</option>
</select>
</label>
</div>
</section>
<section class="panel">
<h2>Second factor enrollment</h2>
<h2>4. MFA and login proof</h2>
<p class="notice">The QR code or setup key belongs to the authority that verifies login. This UI records confirmation only.</p>
<div class="grid">
<label class="field">
@@ -1333,31 +1405,50 @@ def ui_html() -> str:
</section>
<section class="panel">
<h2>Storage and recovery</h2>
<h2>5. Recovery material</h2>
<p class="notice">Recovery material is the ability to regain control of the platform-root credential and encrypted bootstrap bundle. It is not the OpenBao ceremony packet, and this UI stores only references.</p>
<ul class="spec-list">
<li>Platform-root password entry exists in the password safe and its label is known.</li>
<li>MFA recovery or re-enrollment path is known, such as privacyIDEA admin repair or a stored recovery-code location if that authority issues codes.</li>
<li>Custodian age private-key location is known and separate from the public recipient stored here.</li>
<li>Encrypted bootstrap bundle location is known; plaintext bootstrap secrets are absent before custody approval.</li>
<li>Notification contact and setup operator are recorded for lockout handling.</li>
</ul>
<div class="choice-list">
<label class="choice"><input name="storage_classes" value="password-safe" type="checkbox"><span><strong>Password safe</strong><span>Credential held in a dedicated vault entry.</span></span></label>
<label class="choice"><input name="storage_classes" value="offline-packet" type="checkbox"><span><strong>Offline packet</strong><span>Recovery material exists outside live systems.</span></span></label>
<label class="choice"><input name="storage_classes" value="hardware-token" type="checkbox"><span><strong>Hardware token</strong><span>Custody includes hardware-backed access.</span></span></label>
<label class="choice"><input id="recovery_confirmed" type="checkbox"><span><strong>Recovery material confirmed</strong><span>No values recorded here.</span></span></label>
<label class="choice"><input id="custody_packet_prepared" type="checkbox"><span><strong>Custody packet prepared</strong><span>Offline packet is ready for the ceremony.</span></span></label>
<label class="choice"><input id="no_secret_capture_confirmed" type="checkbox"><span><strong>No secret capture</strong><span>No secrets in Git, State Hub, chat, tickets, email, or screenshots.</span></span></label>
<label id="hardware-storage-choice" class="choice conditional"><input name="storage_classes" value="hardware-token" type="checkbox"><span><strong>Hardware token storage</strong><span>Shown only when the selected credential policy uses a hardware token.</span></span></label>
<label class="choice"><input id="recovery_confirmed" type="checkbox"><span><strong>Recovery material prepared</strong><span>The items above exist outside this UI. Do not paste passwords, OTP seeds, recovery codes, or private keys here.</span></span></label>
</div>
</section>
<section class="panel">
<h2>Custody mode</h2>
<h2>6. Custody packet and approval</h2>
<p class="notice">The custody packet is the offline ceremony envelope for OpenBao init. Recovery material proves access can be restored; the custody packet governs how the first real secrets are created, split, sealed, and signed off.</p>
<ul class="spec-list">
<li>Credential label, setup operator, notification contact, and selected custody strategy.</li>
<li>References to recovery material, not the recovery values themselves.</li>
<li>OpenBao init checklist, unseal-share assignment slots, and quorum plan.</li>
<li>Root-token disposition plan: revoke immediately or seal offline after scoped admin access works.</li>
<li>Signature/date line for the attended ceremony.</li>
</ul>
<div class="system-note">Secret capture is enforced by architecture: the control surface does not request secrets, and the gate checks local metadata plus plaintext bootstrap-secret presence. There is no user checkbox for this contract.</div>
<div class="choice-list" style="margin-bottom: 14px;">
<label class="choice"><input id="custody_packet_prepared" type="checkbox"><span><strong>Custody packet prepared</strong><span>The offline ceremony packet above is ready. No OpenBao root token or unseal share is recorded here.</span></span></label>
</div>
<p class="notice">Selecting a strategy records intent. Approval is a separate operator action that unlocks the next gate; it still does not run OpenBao init.</p>
<div class="choice-list">
<label class="choice"><input name="custody_mode" value="temporary-single-king" type="radio"><span><strong>Temporary single king</strong><span>Recommended while Railiance is still pre-production.</span></span></label>
<label class="choice"><input name="custody_mode" value="two-of-three-ready" type="radio"><span><strong>Two of three ready</strong><span>Use when independent shares already exist.</span></span></label>
<label class="choice"><input name="custody_mode" value="two-of-three-planned" type="radio"><span><strong>Two of three planned</strong><span>Records intent but does not approve live init.</span></span></label>
</div>
<div class="field" style="margin-top: 14px;">
<span class="label">Approval phrase</span>
<span class="label">Approval phrase for selected strategy</span>
<input id="approval_phrase" type="text" autocomplete="off" placeholder="approve custody mode">
</div>
<div class="actions">
<button class="secondary" id="save-button" type="button" title="Save the visible non-secret progress fields to local metadata.">Save progress</button>
<button id="approve-button" type="submit" title="Approve the selected custody mode only after all kit gates are satisfied.">Approve custody mode</button>
<button id="approve-button" type="submit" title="Approve the selected custody strategy only after all kit gates are satisfied.">Approve selected strategy</button>
<button class="secondary" id="refresh-button" type="button" title="Reload the local metadata and gate status from disk.">Refresh</button>
</div>
<div id="message" class="message" role="status">Waiting for local approval.</div>
@@ -1403,8 +1494,7 @@ def ui_html() -> str:
"mfa_enrollment_reference",
"mfa_enrolled_confirmed",
"recovery_confirmed",
"custody_packet_prepared",
"no_secret_capture_confirmed"
"custody_packet_prepared"
];
function setMessage(text, kind) {
@@ -1452,6 +1542,16 @@ def ui_html() -> str:
const mode = metadata.custody_mode || "temporary-single-king";
const selected = document.querySelector(`[name='custody_mode'][value='${mode}']`);
if (selected) selected.checked = true;
syncConditionalHardware();
}
function syncConditionalHardware() {
const source = document.getElementById("mfa_class");
const row = document.getElementById("hardware-storage-choice");
if (!source || !row) return;
const input = row.querySelector("input");
const visible = source.value === "hardware-token" || (input && input.checked);
row.classList.toggle("visible", visible);
}
async function loadStatus() {
@@ -1493,13 +1593,14 @@ def ui_html() -> str:
storage_classes: storage,
recovery_confirmed: document.getElementById("recovery_confirmed").checked,
custody_packet_prepared: document.getElementById("custody_packet_prepared").checked,
no_secret_capture_confirmed: document.getElementById("no_secret_capture_confirmed").checked,
custody_mode: mode ? mode.value : "",
approval_phrase: document.getElementById("approval_phrase").value,
approved_by: document.getElementById("setup_operator").value.trim()
};
}
document.getElementById("mfa_class").addEventListener("change", syncConditionalHardware);
document.getElementById("approval-form").addEventListener("submit", async (event) => {
event.preventDefault();
const button = document.getElementById("approve-button");
@@ -1518,7 +1619,7 @@ def ui_html() -> str:
}
document.getElementById("approval_phrase").value = "";
await loadStatus();
setMessage("Custody mode approved. OpenBao init remains a separate human ceremony.", "ok");
setMessage("Selected custody strategy approved. OpenBao init remains a separate human ceremony.", "ok");
} catch (error) {
setMessage("Request failed: " + error.message, "error");
} finally {
@@ -1647,7 +1748,7 @@ def make_ui_handler(metadata_path: Path) -> type[BaseHTTPRequestHandler]:
return
write_metadata(metadata_path, approved)
response = status_payload(approved, metadata_path)
response["message"] = "Custody mode approved."
response["message"] = "Selected custody strategy approved."
self.send_json(HTTPStatus.OK, response)
return SecurityBootstrapUIHandler