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

@@ -37,8 +37,7 @@ python3 tools/security-bootstrap-console/security_bootstrap_console.py \
--mfa-enrolled-confirmed \
--mfa-enrollment-source identity-provider \
--recovery-confirmed \
--custody-packet-prepared \
--no-secret-capture-confirmed
--custody-packet-prepared
```
The command asks for the phrase `approve custody mode` unless `--yes` is passed.
@@ -49,6 +48,21 @@ For TOTP, use the QR code or setup key from the identity provider or other
authority that will verify the login. This tool records only the non-secret
enrollment confirmation and source.
Recovery material means the operator can regain control of the platform-root
credential and encrypted bootstrap bundle without this UI storing any values:
the platform-root password-safe entry, MFA recovery or re-enrollment path,
custodian age private-key location, encrypted bootstrap bundle location, and
notification/setup contact are all known.
The custody packet is separate. It is the offline OpenBao ceremony envelope:
selected custody strategy, recovery-material references, init checklist,
unseal-share assignment slots, root-token disposition plan, and signature/date.
Secret capture is an architecture gate, not a user checkbox. The control
surface must not request or store passwords, OTP seeds, recovery codes, private
keys, OpenBao root tokens, or unseal shares. The UI reports this automatically
from local metadata and plaintext bootstrap-secret presence.
Serve the local approval UI:
```bash

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

View File

@@ -216,6 +216,13 @@ showed issuer `https://kc.coulomb.social`, audience
bootstrap progress now records both MFA enrollment confirmation and OIDC login
verification.
**2026-05-25:** Reworked the bootstrap-console flow after operator review. The
UI now follows the use case top to bottom, hides hardware-token storage unless
the selected policy uses hardware tokens, specifies the exact recovery material
contents, distinguishes recovery material from the OpenBao custody packet, and
turns "no secret capture" into an automatic control-surface boundary gate
rather than a user checkbox.
**2026-05-24:** Stepped back from ad hoc secret rollout and added the
custodian age-key bootstrap model to the control surface. The UI now records
the custodian public age recipient, a derived fingerprint, and a non-secret