onboarding: verify element recovery key via hash
This commit is contained in:
parent
a63bb3b048
commit
665f0d0c02
@ -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
|
||||
|
||||
@ -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},
|
||||
}
|
||||
)
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user