From 586ea2c831a3735e9488c382a9186f3112eb2731 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentino=20Me=C4=91imorec?= Date: Wed, 21 Oct 2026 18:51:12 +0200 Subject: [PATCH 1/6] Teams: say when a team waits for two-step verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A team that requires two-step verification hides its vaults from a member without it, and the app said nothing: the vault just never came. The sync round now notes it (Hold::Totp): from 403 totp_setup_required, or, for a member who never had the vaults, from the team's policy and the account when none of its vaults is listed. The sidebar, the phone's hosts screen and the team's page say so and open the authenticator setup; turning it on or off starts a round. Signed-off-by: Valentino Međimorec --- apps/desktop/e2e/flows.mjs | 56 +++- apps/desktop/src-tauri/src/sync.rs | 23 +- apps/desktop/src/App.tsx | 9 +- apps/desktop/src/Sidebar.tsx | 11 + apps/desktop/src/SyncPanel.tsx | 32 +- apps/desktop/src/TeamsPanel.tsx | 37 ++- apps/desktop/src/api.ts | 9 + apps/desktop/src/mobile/MobileWorkspace.tsx | 14 + apps/desktop/src/styles.css | 6 + crates/mg-store/src/sharing.rs | 24 +- crates/mg-store/src/sync.rs | 2 + crates/mg-store/src/sync_http.rs | 3 + crates/mg-sync/src/client.rs | 9 + crates/mg-sync/src/types.rs | 3 +- docs/features/account-and-devices.md | 13 +- docs/features/teams.md | 2 +- docs/sync-crypto-spec.md | 5 +- server/tests/totp_policy.rs | 310 ++++++++++++++++++++ spec/openapi.yaml | 10 +- 19 files changed, 548 insertions(+), 30 deletions(-) create mode 100644 server/tests/totp_policy.rs diff --git a/apps/desktop/e2e/flows.mjs b/apps/desktop/e2e/flows.mjs index 50953d5..1fe3bb9 100644 --- a/apps/desktop/e2e/flows.mjs +++ b/apps/desktop/e2e/flows.mjs @@ -7,6 +7,7 @@ // mails sign-up codes (Mailpit's web address), for servers that verify addresses. import assert from "node:assert/strict"; +import { createHmac } from "node:crypto"; export const PASSWORD = "correct horse battery staple"; @@ -495,17 +496,64 @@ export async function rotateDueKeys(s, org) { } } -/** J7 (an owner or admin, in the organization's page): requires approval of new devices. */ -export async function requireDeviceApproval(s) { +/** An owner or admin, in the organization's page: turns on the policy whose label has `text`. */ +async function turnOnPolicy(s, text) { await s.wait( - () => s.exec(`return [...document.querySelectorAll(".page-aside label.check")].some((l) => l.innerText.includes("approve new devices")) || null`), + () => s.exec(`return [...document.querySelectorAll(".page-aside label.check")].some((l) => l.innerText.includes(arguments[0])) || null`, text), "the policies", ); - await s.exec(`[...document.querySelectorAll(".page-aside label.check")].find((l) => l.innerText.includes("approve new devices")).querySelector("input").click(); return true`); + await s.exec(`[...document.querySelectorAll(".page-aside label.check")].find((l) => l.innerText.includes(arguments[0])).querySelector("input").click(); return true`, text); await s.clickButton("Save policies"); await s.wait(() => s.exec(`return [...document.querySelectorAll(".page-aside button")].some((b) => b.innerText.includes("Save policies")) ? null : true`), "the policies to save"); } +/** J7 (an owner or admin, in the organization's page): requires approval of new devices. */ +export const requireDeviceApproval = (s) => turnOnPolicy(s, "approve new devices"); + +/** T14 (an owner or admin, in the organization's page): requires two-step verification. */ +export const requireTwoStep = (s) => turnOnPolicy(s, "Require two-step verification"); + +/** The sidebar's "Turn on two-step verification" (a team requires it), with its tooltip, or null. */ +export const twoStepDue = (s) => s.exec(`const b = document.querySelector("button.sidebar-foot.totp"); return b ? b.title : null`); + +/** The current code of an authenticator app holding `secret` (RFC 6238: SHA-1, 6 digits, 30 s). */ +export function totpCode(secret, at = Date.now()) { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + const bits = [...secret.replace(/[\s=]/g, "").toUpperCase()].map((c) => alphabet.indexOf(c).toString(2).padStart(5, "0")).join(""); + const key = Buffer.from(bits.match(/.{8}/g).map((b) => parseInt(b, 2))); + const counter = Buffer.alloc(8); + counter.writeBigUInt64BE(BigInt(Math.floor(at / 30000))); + const mac = createHmac("sha1", key).update(counter).digest(); + const at4 = mac[mac.length - 1] & 15; + return String((mac.readUInt32BE(at4) & 0x7fffffff) % 1e6).padStart(6, "0"); +} + +/** T14: turns on two-step verification with a made-up authenticator, from the sidebar's notice + * when a team asks for it, else from Sync; returns its secret. */ +export async function turnOnTwoStep(s, password = PASSWORD) { + const press = (text) => + s.exec(`const b = [...document.querySelectorAll(".panel button")].find((b) => b.innerText === arguments[0] && !b.disabled); + if (!b) throw new Error("no enabled button " + arguments[0]); b.click(); return true`, text); + if (await twoStepDue(s)) await s.click("button.sidebar-foot.totp"); + else { + await s.click("button.sidebar-foot.sync"); + await s.wait(() => s.exec(`return [...document.querySelectorAll(".panel button")].some((b) => b.innerText === "Turn on two-step verification") || null`), "the account"); + await press("Turn on two-step verification"); + } + await s.waitText("Confirm with your master password", ".panel"); + await s.typeLabelled("Master password", password); + await press("Continue"); + const secret = await s.wait( + () => s.exec(`const k = [...document.querySelectorAll(".panel .hint")].find((p) => p.innerText.includes("Enter this key instead")); return k ? k.querySelector(".mono").innerText : null`), + "the authenticator key", + 30000, + ); + await s.typeLabelled("Authenticator code", totpCode(secret)); + await press("Turn on"); + await s.waitText("Turn off two-step verification", ".panel", 30000); + return secret.replace(/\s/g, ""); +} + /** The device-approval state on this device's sidebar ("Waiting for approval", "Denied"), or null. */ export const approvalState = (s) => s.exec(`const b = document.querySelector("button.sidebar-foot.approval"); return b ? b.innerText : null`); diff --git a/apps/desktop/src-tauri/src/sync.rs b/apps/desktop/src-tauri/src/sync.rs index 3ef31b7..0b53920 100644 --- a/apps/desktop/src-tauri/src/sync.rs +++ b/apps/desktop/src-tauri/src/sync.rs @@ -121,6 +121,8 @@ pub struct SyncStatusView { syncing: bool, /// Sync is on but the server ended this device's session: sign in again. signed_out: bool, + /// Organizations whose vaults wait for this account to turn on two-step verification. + totp_required: Vec, /// Organizations whose vaults wait for this device to sign in at their identity provider. sso_required: Vec, /// Organizations whose vaults wait for this device to be approved (or that denied it). @@ -323,6 +325,14 @@ fn status(vault: &VaultState, sync: &SyncState, syncing: bool) -> SyncStatusView .map_or_else(|| "Your organization".into(), |o| o.name.clone()) }; let held = sync.held.lock().map(|h| h.clone()).unwrap_or_default(); + let totp_required = held + .iter() + .filter(|(_, why)| *why == Hold::Totp) + .map(|(id, _)| SsoNeed { + org_id: id.clone(), + org_name: name(id), + }) + .collect(); let sso_required = held .iter() .filter(|(_, why)| *why == Hold::Sso) @@ -333,7 +343,7 @@ fn status(vault: &VaultState, sync: &SyncState, syncing: bool) -> SyncStatusView .collect(); let approval_required = held .iter() - .filter(|(_, why)| *why != Hold::Sso) + .filter(|(_, why)| matches!(why, Hold::Approval | Hold::Denied)) .map(|(id, why)| ApprovalNeed { org_id: id.clone(), org_name: name(id), @@ -383,6 +393,7 @@ fn status(vault: &VaultState, sync: &SyncState, syncing: bool) -> SyncStatusView last_sync_at: info.as_ref().and_then(|i| i.last_sync_at), last_error: info.and_then(|i| i.last_error), syncing, + totp_required, sso_required, approval_required, approvals_waiting, @@ -1271,7 +1282,10 @@ pub async fn sync_totp_enable( let client = signed_in(&state, &vault).await?; account::enable_totp(&vault.0, client, code) .await - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string())?; + // Teams that require it open their vaults: the next round brings them. + state.poke.notify_one(); + Ok(()) } /// Turns two-step verification off (master password and a current code). @@ -1286,7 +1300,10 @@ pub async fn sync_totp_disable( let client = signed_in(&state, &vault).await?; account::disable_totp(&vault.0, client, Zeroizing::new(password), code) .await - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string())?; + // Teams that require it hold their vaults back from now on: the next round says so. + state.poke.notify_one(); + Ok(()) } /// Mails a code to the address the account should move to. diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 12afc0f..2f84552 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -154,7 +154,8 @@ type View = | { kind: "key"; id?: string } | { kind: "snippet"; id?: string } | { kind: "forward"; id?: string } - | { kind: "sync" } + /** `totp`: straight to turning on two-step verification (teams wait for it). */ + | { kind: "sync"; totp?: boolean } | { kind: "security" } | { kind: "about" } | { kind: "teams" } @@ -834,12 +835,16 @@ function Workspace({ onLock, termSettings, onSettingsChanged: loadTermSettings, onChanged={refresh} onClose={close} onSso={(orgId, orgName) => setSsoFor({ orgId, orgName })} + totpRequired={syncStatus?.totpRequired.map((o) => o.orgId) ?? []} + onTotp={() => open({ kind: "sync", totp: true })} invite={linked?.invite} openOrg={linked?.org} /> )} {view.kind === "sync" && syncStatus && ( void syncApi.status().then(setSyncStatus).then(refresh)} @@ -1069,6 +1074,7 @@ function Workspace({ onLock, termSettings, onSettingsChanged: loadTermSettings, open(p === "quick" ? { kind: "connect" } : p === "forwards" ? { kind: "forward" } : p === "logs" ? { kind: "logs" } : { kind: p }) } onLock={onLock} + onTotp={() => open({ kind: "sync", totp: true })} onSso={setSsoFor} onApproval={setApprovalFor} onSend={sendTo} @@ -1107,6 +1113,7 @@ function Workspace({ onLock, termSettings, onSettingsChanged: loadTermSettings, onOpenSync={() => open({ kind: "sync" })} vaults={vaults} onOpenTeams={() => open({ kind: "teams" })} + onTotp={() => open({ kind: "sync", totp: true })} onSso={setSsoFor} onApproval={setApprovalFor} onOpenAppearance={() => open({ kind: "appearance" })} diff --git a/apps/desktop/src/Sidebar.tsx b/apps/desktop/src/Sidebar.tsx index 6e8e192..72f5b5c 100644 --- a/apps/desktop/src/Sidebar.tsx +++ b/apps/desktop/src/Sidebar.tsx @@ -1,6 +1,7 @@ import { useState, type ReactNode } from "react"; import { ago, + totpTitle, type ForwardView, type GroupView, type HostView, @@ -56,6 +57,8 @@ interface Props { onLock: () => void; update: UpdateInfo | null; onOpenAbout: () => void; + /** Turn on two-step verification: organizations' vaults wait for it. */ + onTotp: () => void; /** Sign in at an organization's identity provider. */ onSso: (need: SsoNeed) => void; /** An organization's vaults wait for this device's approval. */ @@ -104,6 +107,7 @@ export function Sidebar({ onLock, update, onOpenAbout, + onTotp, onSso, onApproval, }: Props) { @@ -284,6 +288,13 @@ export function Sidebar({ Update to {update.version} )} + {!!syncStatus?.totpRequired.length && ( +