onboarding: verify element recovery key via hash

This commit is contained in:
Brad Stein 2026-01-04 13:00:42 -03:00
parent a63bb3b048
commit 665f0d0c02
3 changed files with 302 additions and 22 deletions

View File

@ -73,6 +73,17 @@ def ensure_schema() -> None:
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS access_request_onboarding_artifacts (
request_code TEXT NOT NULL REFERENCES access_requests(request_code) ON DELETE CASCADE,
artifact TEXT NOT NULL,
value_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (request_code, artifact)
)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS access_requests_status_created_at
@ -91,6 +102,12 @@ def ensure_schema() -> None:
ON access_request_onboarding_steps (request_code)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS access_request_onboarding_artifacts_request_code
ON access_request_onboarding_artifacts (request_code)
"""
)
conn.execute(
"""
CREATE UNIQUE INDEX IF NOT EXISTS access_requests_username_pending

View File

@ -61,19 +61,38 @@ ONBOARDING_STEPS: tuple[str, ...] = (
"vaultwarden_master_password",
"element_recovery_key",
"element_recovery_key_stored",
"vaultwarden_browser_extension",
"vaultwarden_mobile_app",
"elementx_setup",
"jellyfin_login",
"mail_client_setup",
)
KEYCLOAK_MANAGED_STEPS: set[str] = {"keycloak_password_changed"}
ONBOARDING_STEP_PREREQUISITES: dict[str, set[str]] = {
"vaultwarden_master_password": {"keycloak_password_changed"},
"element_recovery_key": {"keycloak_password_changed", "vaultwarden_master_password"},
"element_recovery_key_stored": {
"keycloak_password_changed",
"vaultwarden_master_password",
"element_recovery_key",
},
}
def _sequential_prerequisites(
steps: tuple[str, ...],
keycloak_managed_steps: set[str],
) -> dict[str, set[str]]:
completed: list[str] = []
prerequisites: dict[str, set[str]] = {}
for step in steps:
if step in keycloak_managed_steps:
completed.append(step)
continue
prerequisites[step] = set(completed)
completed.append(step)
return prerequisites
ONBOARDING_STEP_PREREQUISITES: dict[str, set[str]] = _sequential_prerequisites(
ONBOARDING_STEPS,
KEYCLOAK_MANAGED_STEPS,
)
_ELEMENT_RECOVERY_ARTIFACT = "element_recovery_key_sha256"
_SHA256_HEX_RE = re.compile(r"^[0-9a-f]{64}$")
def _normalize_status(status: str) -> str:
@ -572,6 +591,11 @@ def register(app) -> None:
mark_done = completed
if mark_done:
if step == "element_recovery_key":
return (
jsonify({"error": "step requires verification"}),
400,
)
prerequisites = ONBOARDING_STEP_PREREQUISITES.get(step, set())
if prerequisites:
current_completed = _completed_onboarding_steps(conn, code, username)
@ -591,6 +615,11 @@ def register(app) -> None:
"DELETE FROM access_request_onboarding_steps WHERE request_code = %s AND step = %s",
(code, step),
)
if step == "element_recovery_key":
conn.execute(
"DELETE FROM access_request_onboarding_artifacts WHERE request_code = %s AND artifact = %s",
(code, _ELEMENT_RECOVERY_ARTIFACT),
)
# Re-evaluate completion to update request status to ready if applicable.
status = _advance_status(conn, code, username, status)
@ -605,3 +634,78 @@ def register(app) -> None:
"onboarding": {"required_steps": list(ONBOARDING_STEPS), "completed_steps": completed_steps},
}
)
@app.route("/api/access/request/onboarding/element-recovery", methods=["POST"])
@require_auth
def request_access_onboarding_element_recovery() -> Any:
if not configured():
return jsonify({"error": "server not configured"}), 503
payload = request.get_json(silent=True) or {}
code = (payload.get("request_code") or payload.get("code") or "").strip()
sha256_hex = (payload.get("sha256") or payload.get("sha256_hex") or "").strip().lower()
if not code:
return jsonify({"error": "request_code is required"}), 400
if not sha256_hex:
return jsonify({"error": "sha256 is required"}), 400
if not _SHA256_HEX_RE.fullmatch(sha256_hex):
return jsonify({"error": "invalid sha256"}), 400
username = getattr(g, "keycloak_username", "") or ""
if not username:
return jsonify({"error": "invalid token"}), 401
try:
with connect() as conn:
row = conn.execute(
"SELECT username, status FROM access_requests WHERE request_code = %s",
(code,),
).fetchone()
if not row:
return jsonify({"error": "not found"}), 404
if (row.get("username") or "") != username:
return jsonify({"error": "forbidden"}), 403
status = _normalize_status(row.get("status") or "")
if status not in {"awaiting_onboarding", "ready"}:
return jsonify({"error": "onboarding not available"}), 409
prerequisites = ONBOARDING_STEP_PREREQUISITES.get("element_recovery_key", set())
if prerequisites:
current_completed = _completed_onboarding_steps(conn, code, username)
missing = sorted(prerequisites - current_completed)
if missing:
return jsonify({"error": "step is blocked", "blocked_by": missing}), 409
conn.execute(
"""
INSERT INTO access_request_onboarding_artifacts (request_code, artifact, value_hash)
VALUES (%s, %s, %s)
ON CONFLICT (request_code, artifact) DO UPDATE
SET value_hash = EXCLUDED.value_hash,
created_at = NOW()
""",
(code, _ELEMENT_RECOVERY_ARTIFACT, sha256_hex),
)
conn.execute(
"""
INSERT INTO access_request_onboarding_steps (request_code, step)
VALUES (%s, %s)
ON CONFLICT (request_code, step) DO NOTHING
""",
(code, "element_recovery_key"),
)
status = _advance_status(conn, code, username, status)
completed_steps = sorted(_completed_onboarding_steps(conn, code, username))
except Exception:
return jsonify({"error": "failed to verify element recovery key"}), 502
return jsonify(
{
"ok": True,
"status": status,
"onboarding": {"required_steps": list(ONBOARDING_STEPS), "completed_steps": completed_steps},
}
)

View File

@ -156,19 +156,40 @@
<li class="check-item">
<label>
<input
type="checkbox"
:checked="isStepDone('element_recovery_key')"
:disabled="!auth.authenticated || loading || isStepBlocked('element_recovery_key')"
@change="toggleStep('element_recovery_key', $event)"
/>
<input type="checkbox" :checked="isStepDone('element_recovery_key')" disabled />
<span>Create an Element recovery key</span>
<span class="pill mono auto-pill" :class="stepPillClass('element_recovery_key')">
{{ stepPillLabel("element_recovery_key") }}
</span>
</label>
<div class="recovery-verify">
<input
v-model="elementRecoveryKey"
class="input mono"
type="text"
placeholder="Paste recovery key (hashed locally)"
:disabled="
!auth.authenticated || loading || isStepDone('element_recovery_key') || isStepBlocked('element_recovery_key')
"
/>
<button
class="primary verify"
type="button"
@click="verifyElementRecoveryKey"
:disabled="
!auth.authenticated ||
loading ||
isStepDone('element_recovery_key') ||
isStepBlocked('element_recovery_key') ||
!elementRecoveryKey.trim()
"
>
Verify
</button>
</div>
<p class="muted">
In Element, create a recovery key so you can restore encrypted history if you lose a device.
In Element, create a recovery key so you can restore encrypted history if you lose a device. Atlas stores only a SHA-256 hash so the
recovery key itself is never saved server-side.
</p>
</li>
@ -187,6 +208,31 @@
</label>
<p class="muted">Save the recovery key in Vaultwarden so it doesn't get lost.</p>
</li>
<li v-for="step in extraSteps" :key="step.id" class="check-item">
<label>
<input
type="checkbox"
:checked="isStepDone(step.id)"
:disabled="!auth.authenticated || loading || isStepBlocked(step.id)"
@change="toggleStep(step.id, $event)"
/>
<span>{{ step.title }}</span>
<span class="pill mono auto-pill" :class="stepPillClass(step.id)">{{ stepPillLabel(step.id) }}</span>
</label>
<p class="muted">
{{ step.description }}
<template v-if="step.primaryLink">
<a :href="step.primaryLink.href" target="_blank" rel="noreferrer">{{ step.primaryLink.text }}</a
>.
</template>
<template v-if="step.secondaryLink">
<span> </span>
<a :href="step.secondaryLink.href" target="_blank" rel="noreferrer">{{ step.secondaryLink.text }}</a
>.
</template>
</p>
</li>
</ul>
<div v-if="status === 'ready'" class="ready-box">
@ -228,6 +274,41 @@ const copied = ref(false);
const usernameCopied = ref(false);
const tasks = ref([]);
const blocked = ref(false);
const elementRecoveryKey = ref("");
const extraSteps = [
{
id: "vaultwarden_browser_extension",
title: "Install the Vaultwarden browser extension",
description: "Install Bitwarden in your browser and point it at your Atlas vault for autofill.",
primaryLink: { href: "https://bitwarden.com/download", text: "Bitwarden downloads" },
secondaryLink: { href: "https://vault.bstein.dev", text: "Passwords" },
},
{
id: "vaultwarden_mobile_app",
title: "Install Bitwarden on your phone",
description: "Install the mobile app, set the server to vault.bstein.dev, and enable biometrics for fast unlock.",
primaryLink: { href: "https://bitwarden.com/download", text: "Bitwarden downloads" },
secondaryLink: { href: "https://vault.bstein.dev", text: "Passwords" },
},
{
id: "elementx_setup",
title: "Install Element X and sign in",
description: "Install Element X on mobile and sign in with your Atlas username/password to join rooms and calls.",
primaryLink: { href: "https://live.bstein.dev", text: "Element" },
},
{
id: "jellyfin_login",
title: "Sign in to Jellyfin",
description: "Sign in with your Atlas username/password (LDAP-backed).",
primaryLink: { href: "https://stream.bstein.dev", text: "Jellyfin" },
},
{
id: "mail_client_setup",
title: "Set up mail on a device",
description: "Use the IMAP/SMTP details on your Account page to add mail to your phone or desktop client.",
primaryLink: { href: "/account", text: "Account" },
},
];
function statusLabel(value) {
const key = (value || "").trim();
@ -257,12 +338,20 @@ function isStepDone(step) {
}
function isStepBlocked(step) {
const order = [
"keycloak_password_changed",
"vaultwarden_master_password",
"element_recovery_key",
"element_recovery_key_stored",
];
const order =
Array.isArray(onboarding.value?.required_steps) && onboarding.value.required_steps.length
? onboarding.value.required_steps
: [
"keycloak_password_changed",
"vaultwarden_master_password",
"element_recovery_key",
"element_recovery_key_stored",
"vaultwarden_browser_extension",
"vaultwarden_mobile_app",
"elementx_setup",
"jellyfin_login",
"mail_client_setup",
];
const idx = order.indexOf(step);
if (idx <= 0) return false;
for (let i = 0; i < idx; i += 1) {
@ -386,6 +475,10 @@ async function toggleStep(step, event) {
event?.preventDefault?.();
return;
}
if (step === "element_recovery_key") {
event?.preventDefault?.();
return;
}
error.value = "";
loading.value = true;
try {
@ -405,6 +498,47 @@ async function toggleStep(step, event) {
}
}
async function sha256Hex(text) {
const encoder = new TextEncoder();
const data = encoder.encode(text);
const digest = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
async function verifyElementRecoveryKey() {
if (!auth.authenticated) {
error.value = "Log in to verify your recovery key.";
return;
}
if (isStepBlocked("element_recovery_key")) {
error.value = "Complete earlier onboarding steps first.";
return;
}
const raw = elementRecoveryKey.value.trim();
if (!raw) return;
error.value = "";
loading.value = true;
try {
const hash = await sha256Hex(raw);
const resp = await authFetch("/api/access/request/onboarding/element-recovery", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ request_code: requestCode.value.trim(), sha256: hash }),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`);
status.value = data.status || status.value;
onboarding.value = data.onboarding || onboarding.value;
elementRecoveryKey.value = "";
} catch (err) {
error.value = err.message || "Failed to verify recovery key";
} finally {
loading.value = false;
}
}
onMounted(async () => {
const code = route.query.code || route.query.request_code || "";
if (typeof code === "string" && code.trim()) {
@ -567,6 +701,31 @@ button.primary {
height: 18px;
}
.recovery-verify {
display: flex;
gap: 10px;
margin-top: 10px;
align-items: stretch;
}
.recovery-verify .input {
flex: 1;
}
.recovery-verify .verify {
min-width: 96px;
}
@media (max-width: 560px) {
.recovery-verify {
flex-direction: column;
}
.recovery-verify .verify {
width: 100%;
}
}
.ready-box {
margin-top: 14px;
padding: 14px;