Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/first_run_setup_wizard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
default: minor
---

# First-run setup wizard

A fresh install now asks where your vault should come from instead of only
offering to create one: start a new vault, import a vault file you exported
elsewhere, or — in the extension — use the vault in the desktop app. Importing
on first run adopts the file, so the passphrase that opens it unlocks this
device too. After creating a vault the desktop app offers to turn on the browser
bridge and show a pairing code.
13 changes: 10 additions & 3 deletions .github/workflows/changeset.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ name: Changeset
# The job keeps a single sticky comment on the PR (found by a hidden marker):
# - no changeset -> "changeset required" + how to add one, and the check fails
# - changeset(s) -> "changeset detected" + each file's contents in <pre>
# Escape hatch: label the PR "skip-changeset" for chore/docs/ci work that
# doesn't warrant a release entry. The labeled/unlabeled triggers re-run when
# the label is toggled.
# Escape hatches: label the PR "skip-changeset" for chore/docs/ci work that
# doesn't warrant a release entry (the labeled/unlabeled triggers re-run when
# the label is toggled), and Knope's own release PR — head branch `release` —
# is exempt, since it consumes the change files instead of adding one.
on:
pull_request:
branches: [main]
Expand All @@ -35,9 +36,15 @@ jobs:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
HEAD_REF: ${{ github.event.pull_request.head.ref }}
run: |
set -euo pipefail

if [ "$HEAD_REF" = "release" ]; then
echo "::notice title=Changeset skipped::Knope release PR — it consumes change files rather than adding one."
exit 0
fi

case ",$LABELS," in
*,skip-changeset,*)
echo "::notice title=Changeset skipped::PR is labeled 'skip-changeset'."
Expand Down
28 changes: 28 additions & 0 deletions apps/twofau-app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,33 @@ async fn import_vault(
Ok(Some(count))
}

/// First run only: adopt a picked vault file as this device's vault, keeping
/// the passphrase it was sealed with. Returns the account count, or None if the
/// user cancels the picker. Errors are the codes `AppVault::adopt_blob` returns,
/// which the setup wizard turns into its own messages.
#[tauri::command]
async fn adopt_vault(
app: AppHandle,
vault: State<'_, Arc<AppVault>>,
dialog_open: State<'_, DialogOpen>,
passphrase: String,
) -> Result<Option<usize>, String> {
let _guard = DialogGuard::new(&dialog_open);
let Some(path) = app
.dialog()
.file()
.add_filter("2FAU vault", &["dat"])
.blocking_pick_file()
else {
return Ok(None);
};
let path = path.into_path().map_err(|e| e.to_string())?;
let blob = std::fs::read(&path).map_err(|_| "bad-file".to_string())?;
let count = vault.adopt_blob(&blob, &passphrase)?;
refresh_tray(&app);
Ok(Some(count))
}

#[tauri::command]
async fn change_passphrase(
vault: State<'_, Arc<AppVault>>,
Expand Down Expand Up @@ -576,6 +603,7 @@ pub fn run() {
list_accounts,
export_vault,
import_vault,
adopt_vault,
change_passphrase,
parse_migration,
code,
Expand Down
87 changes: 87 additions & 0 deletions apps/twofau-app/src-tauri/src/vault.rs
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,29 @@ impl AppVault {
Ok(count)
}

/// Adopt an exported blob as *this* device's vault, before one exists. The
/// file's own bytes are written verbatim, so its salt, nonce and KDF id
/// carry over and the passphrase that opens the file becomes this device's.
/// Unlike `import_blob` (which merges into an unlocked vault) this replaces,
/// so it refuses once a vault is there. Leaves the vault locked: the caller
/// unlocks through the ordinary path, which also caches the passphrase.
///
/// Errors are machine-readable codes — the first-run UI tells "wrong
/// passphrase" (retry) from "not a vault file" (pick another).
pub fn adopt_blob(&self, blob: &[u8], passphrase: &str) -> Result<usize, String> {
if self.has_vault() {
return Err("vault-exists".to_string());
}
let doc = open_with_passphrase(blob, passphrase).map_err(|e| match e {
twofau_core::VaultError::DecryptFailed => "wrong-passphrase".to_string(),
_ => "bad-file".to_string(),
})?;
let count = doc.entries.len();
let mut rev = self.revision.lock().expect("revision mutex");
self.write_blob_locked(&mut rev, blob)?;
Ok(count)
}

/// Re-seal the vault under a new passphrase after checking the current one.
/// Also updates the remembered passphrase so silent auto-unlock keeps working.
pub fn change_passphrase(&self, current: &str, next: &str) -> Result<(), String> {
Expand Down Expand Up @@ -554,6 +577,70 @@ mod tests {
assert_eq!(reopened.list().unwrap().len(), 2);
}

#[test]
fn adopt_takes_over_an_exported_vault_with_its_own_passphrase() {
let (a, _da) = fresh();
a.unlock(PASS.into(), false).unwrap();
a.add_uri("otpauth://totp/Acme:me?secret=JBSWY3DPEHPK3PXP&issuer=Acme")
.unwrap();
a.add_uri("otpauth://totp/Beta:you?secret=JBSWY3DPEHPK3PXP&issuer=Beta")
.unwrap();
let blob = a.export_blob().unwrap();

// A device with no vault of its own adopts the file, then unlocks with
// the passphrase the file was sealed under.
let dir = tempdir().unwrap();
let path = dir.path().join("vault.dat");
let b = AppVault::new(path.clone());
assert_eq!(b.adopt_blob(&blob, PASS).unwrap(), 2);
assert!(b.is_locked(), "adopting does not unlock");
b.unlock(PASS.into(), false).unwrap();
assert_eq!(b.list().unwrap().len(), 2);

// And it survives a restart under that same passphrase.
let reopened = AppVault::new(path);
reopened.unlock(PASS.into(), false).unwrap();
assert_eq!(reopened.list().unwrap().len(), 2);
}

#[test]
fn adopt_reports_a_wrong_passphrase_apart_from_a_foreign_file() {
let (a, _da) = fresh();
a.unlock(PASS.into(), false).unwrap();
let blob = a.export_blob().unwrap();

let dir = tempdir().unwrap();
let b = AppVault::new(dir.path().join("vault.dat"));
assert_eq!(
b.adopt_blob(&blob, "not-the-passphrase"),
Err("wrong-passphrase".into())
);
assert_eq!(
b.adopt_blob(b"not a vault at all", PASS),
Err("bad-file".into())
);
assert!(!b.has_vault(), "a failed adopt writes nothing");
}

#[test]
fn adopt_refuses_once_a_vault_exists() {
let (a, _da) = fresh();
a.unlock(PASS.into(), false).unwrap();
a.add_uri("otpauth://totp/Acme:me?secret=JBSWY3DPEHPK3PXP&issuer=Acme")
.unwrap();
let blob = a.export_blob().unwrap();

// B already has its own vault: adopting is refused and B is untouched.
let (b, _db) = fresh();
b.unlock("a-different-passphrase".into(), false).unwrap();
b.add_uri("otpauth://totp/Beta:you?secret=JBSWY3DPEHPK3PXP&issuer=Beta")
.unwrap();
assert_eq!(b.adopt_blob(&blob, PASS), Err("vault-exists".into()));
let accounts = b.list().unwrap();
assert_eq!(accounts.len(), 1);
assert_eq!(accounts[0].issuer, "Beta");
}

#[test]
fn import_rejects_a_wrong_file_passphrase() {
let (a, _da) = fresh();
Expand Down
3 changes: 3 additions & 0 deletions apps/twofau-app/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { initAutoLock } from "./auto-lock";
import { getQuickCopy } from "./hotkeys";
import { getLocale } from "./locale";
import { tauriSettingsBackend } from "./settings-backend";
import { tauriSetupBackend } from "./setup-backend";
import { TauriVaultService } from "./tauri-vault-service";
import "./index.css";

Expand All @@ -29,6 +30,7 @@ function Root({
const containerRef = useRef<HTMLDivElement>(null);
const service = useRef(new TauriVaultService(startUnlocked, needsSetup)).current;
const settingsBackend = useMemo(() => tauriSettingsBackend(version), [version]);
const setupBackend = useMemo(() => tauriSetupBackend(), []);
const quickCopy = useMemo(() => getQuickCopy(), []);

// Keep the OS window's height matched to the panel content (like the Swift
Expand Down Expand Up @@ -73,6 +75,7 @@ function Root({
messages={messages}
onQuit={() => void invoke("quit")}
settingsBackend={settingsBackend}
setupBackend={setupBackend}
parseMigration={(uri) => invoke<ParsedOtp[]>("parse_migration", { uri })}
readClipboard={async () => (await readText()) ?? ""}
writeClipboard={(text) => writeText(text)}
Expand Down
102 changes: 102 additions & 0 deletions apps/twofau-app/src/setup-backend.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { invoke } from "@tauri-apps/api/core";
import { useState } from "react";
import { useT } from "@twofau/ui";
import type { AdoptError, AdoptFailure, SetupBackend } from "@twofau/ui";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";

/** `adopt_vault` rejects with a bare code; hand the wizard a tagged Error so it
* can say which of the two went wrong. */
function adoptError(err: unknown): AdoptError {
const raw = String(err);
const code: AdoptFailure | undefined =
raw === "wrong-passphrase" || raw === "bad-file" ? raw : undefined;
const tagged: AdoptError = new Error(raw);
tagged.code = code;
return tagged;
}

/** The desktop's first-run extras: adopt a vault file, then (optionally) turn on
* the bridge so a browser can use this vault. */
export function tauriSetupBackend(): SetupBackend {
return {
adopt: {
kind: "native",
run: async (passphrase) => {
try {
return await invoke<number | null>("adopt_vault", { passphrase });
} catch (err) {
throw adoptError(err);
}
},
},
connect: {
placement: "after-vault",
title: "Connect your browser",
description: "Let the 2FAU extension use this vault.",
screen: ({ onDone, onSkip }) => <ConnectBrowserStep onDone={onDone} onSkip={onSkip} />,
},
};
}

function ConnectBrowserStep({ onDone, onSkip }: { onDone: () => void; onSkip: () => void }) {
const { t } = useT();
const [code, setCode] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);

// Enabling and minting the code are one action here: the only reason to be on
// this screen is to pair a browser. Skipping leaves the bridge off.
async function enable() {
setBusy(true);
setError(null);
try {
const status = await invoke<{ port: number }>("bridge_status");
await invoke("bridge_enable", { on: true, port: status.port });
setCode(await invoke<string>("bridge_pairing_code"));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}

return (
<div className="flex flex-col items-center gap-3 px-6 py-8">
<p className="text-[15px] font-semibold">{t("Connect your browser")}</p>
<p className="text-center text-[11px] text-muted-foreground">
{t(
"The 2FAU extension can use this vault instead of keeping its own. You can also do this later in Settings.",
)}
</p>

{code === null ? (
<Button className="w-full" disabled={busy} onClick={() => void enable()}>
{busy ? t("Starting…") : t("Turn on the bridge")}
</Button>
) : (
<>
<Input
readOnly
value={code}
aria-label={t("Pairing code")}
onFocus={(e) => e.currentTarget.select()}
className="text-center font-mono text-[16px] tracking-[0.35em] tabular-nums"
/>
<p className="text-center text-[11px] text-muted-foreground">
{t("Enter this code in the extension. It expires in about two minutes.")}
</p>
<Button className="w-full" onClick={onDone}>
{t("Done")}
</Button>
</>
)}

{error && <p className="text-[11px] text-destructive">{error}</p>}

<Button variant="ghost" className="w-full" disabled={busy} onClick={onSkip}>
{code === null ? t("Skip for now") : t("Close")}
</Button>
</div>
);
}
11 changes: 11 additions & 0 deletions apps/twofau-extension/src/popup/main.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { VaultService } from "@twofau/ui";
import {
createTranslator,
I18nProvider,
loadMessages,
modsFromToken,
Expand All @@ -15,6 +16,8 @@ import ReactDOM from "react-dom/client";
import { BridgeUnreachableError } from "../bridge/connection";
import { SCAN_MESSAGE } from "../shared/messages";
import { createVaultService } from "../vault/backend";
import { extensionSetupBackend } from "./setup-backend";
import type { ExtensionVaultService } from "../vault/extension-vault-service";
import { readSettings } from "../vault/settings";
import { accountMatchesSite, hostOf } from "../vault/site-match";
import { initWasm } from "../wasm";
Expand Down Expand Up @@ -93,9 +96,17 @@ async function bootstrap() {
mods: modsFromToken(settings?.quickCopyMods ?? "mod"),
};

// First run offers more than "create a passphrase": adopt an exported vault,
// or hand the whole thing to the desktop app. The service is the local one
// either way until a mode is chosen — only `client` mode needs a reload.
const setupBackend = service.needsSetup()
? extensionSetupBackend(service as ExtensionVaultService, createTranslator(locale, messages))
: undefined;

root.render(
<TwoFAUApp
service={service}
setupBackend={setupBackend}
locale={locale}
messages={messages}
quickCopy={quickCopy}
Expand Down
Loading
Loading