generated from coulomb/repo-seed
Clarify bootstrap custody UI flow
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user