diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 81fbc03..5ba6d9e 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -154,17 +154,18 @@ fn state(app: State<'_, App>) -> AppState { // Argon2id takes ~0.5 s, so these run as async commands off the main thread. -/// "Create a new account": a new vault here, registered with a fresh server using its setup code. +/// "Create a new account": a new vault here, registered with the server using its setup code or an invite. #[tauri::command] async fn create_account( app: State<'_, App>, server_url: String, pins: String, setup_code: String, + email: String, master_password: String, ) -> CmdResult { let master_password = Zeroizing::new(master_password); - let sk = app.sync.create_account(&server_url, sync::parse_pins(&pins), &setup_code, master_password).await; + let sk = app.sync.create_account(&server_url, sync::parse_pins(&pins), &setup_code, &email, master_password).await; app.activity.touch(); sk.map(|s| s.to_string()) } @@ -378,8 +379,8 @@ async fn server_setup_open(app: State<'_, App>) -> CmdResult { } #[tauri::command] -async fn register_server(app: State<'_, App>, setup_code: String) -> CmdResult<()> { - app.sync.register(&setup_code).await +async fn register_server(app: State<'_, App>, setup_code: String, email: String) -> CmdResult<()> { + app.sync.register(&setup_code, &email).await } #[tauri::command] diff --git a/src-tauri/src/sync.rs b/src-tauri/src/sync.rs index 7daae25..632512c 100644 --- a/src-tauri/src/sync.rs +++ b/src-tauri/src/sync.rs @@ -139,8 +139,22 @@ fn now_secs() -> u64 { } /// User-facing text for a core error. +/// Deliberately loose (something@something.tld); the server normalises and checks it again. +fn looks_like_email(s: &str) -> bool { + match s.split_once('@') { + Some((user, domain)) => { + !user.is_empty() && !domain.contains('@') && domain.contains('.') && !s.contains(char::is_whitespace) + } + None => false, + } +} + pub fn describe(e: &CoreError) -> String { match e { + CoreError::Server { status: 409, .. } => { + "An account with this email already exists on the server. Sign in to it instead, or use another email." + .into() + } CoreError::Network { msg } => format!("Can't reach the server: {msg}"), CoreError::PinMismatch => { "The server's certificate doesn't match the pinned keys. Someone may be intercepting \ @@ -344,33 +358,43 @@ impl SyncService { Ok(()) } - /// "Create a new account": creates the vault here and registers it with a fresh server using the setup code - /// (`KV_SETUP_CODE`). Returns the Secret Key for the Emergency Kit. + /// "Create a new account": creates the vault here and registers it with the server, using its setup code + /// (`KV_SETUP_CODE`, first account), an admin's invite, or nothing with open sign-up. Returns the Secret Key + /// for the Emergency Kit. pub async fn create_account( &self, server_url: &str, pins: Vec, setup_code: &str, + email: &str, master_password: Zeroizing, ) -> Result, String> { if self.core.has_account() { return Err("This computer already has a vault.".into()); } - if setup_code.trim().is_empty() { - return Err("Enter the server's setup code.".into()); + let email = email.trim(); + if !looks_like_email(email) { + return Err("Enter your email address. You sign in with it on new devices.".into()); } + let code = setup_code.trim(); self.configure(server_url, pins)?; let info = self.core.server_info().await.map_err(|e| describe(&e))?; - if !info.setup_open { + if !info.setup_open && !info.multi_user { return Err( "This server already has an account. Choose “Use my phone” or “Paste setup code” instead.".into() ); } + if code.is_empty() && info.setup_open { + return Err("Enter the server's setup code (KV_SETUP_CODE in its .env).".into()); + } + if code.is_empty() && !info.open_signup { + return Err("Enter the invite code your server's admin sent you.".into()); + } let sk = Zeroizing::new(self.core.create_account(master_password.to_string()).map_err(|e| describe(&e))?); self.remember_secret_key(&sk); - if let Err(e) = self.core.register_account(setup_code.trim().to_string()).await { + if let Err(e) = self.core.register_account(code.to_string(), Some(email.to_string())).await { return Err(format!( - "Your vault was created on this computer, but the server refused it: {}. Check the setup code in \ + "Your vault was created on this computer, but the server refused it: {}. Check the code in \ Settings → Sync and try again.", describe(&e) )); @@ -379,9 +403,17 @@ impl SyncService { Ok(sk) } - /// For a vault created before sync was set up: registers it with a fresh server. - pub async fn register(&self, setup_code: &str) -> Result<(), String> { - self.core.register_account(setup_code.trim().to_string()).await.map_err(|e| describe(&e))?; + /// For a vault created before sync was set up: registers it with the server (setup code, invite, or nothing + /// with open sign-up). + pub async fn register(&self, setup_code: &str, email: &str) -> Result<(), String> { + let email = email.trim(); + if !looks_like_email(email) { + return Err("Enter your email address. You sign in with it on new devices.".into()); + } + self.core + .register_account(setup_code.trim().to_string(), Some(email.to_string())) + .await + .map_err(|e| describe(&e))?; self.sync_now().await.map(|_| ()) } @@ -678,7 +710,7 @@ mod tests { let phone_key: Arc = Arc::new(DeviceKey::load_or_create_file(phone_dir.path()).unwrap()); phone.set_device_signer(phone_key, "phone".into(), PlatformDto::Ios).unwrap(); - phone.register_account(SETUP_TOKEN.into()).await.unwrap(); + phone.register_account(SETUP_TOKEN.into(), Some("phone@example.com".into())).await.unwrap(); server.state().require_approval = true; let dir = tempfile::tempdir().unwrap(); diff --git a/src-tauri/tests/live_sync.rs b/src-tauri/tests/live_sync.rs index 59ddf76..957741d 100644 --- a/src-tauri/tests/live_sync.rs +++ b/src-tauri/tests/live_sync.rs @@ -73,7 +73,8 @@ async fn create_join_approve_and_sync() { // A: "Create a new account". let a = instance(da.path()); - let sk = a.create_account(&server, vec![], &code, Zeroizing::new(PASSWORD.into())).await.unwrap(); + let sk = + a.create_account(&server, vec![], &code, "desktop@example.com", Zeroizing::new(PASSWORD.into())).await.unwrap(); assert!(sk.starts_with("K1")); assert!(a.status().logged_in); assert!(a.status().last_sync_at.is_some(), "first sync after registration: {:?}", a.status().last_error); diff --git a/src/main.ts b/src/main.ts index d7e19bb..6eac892 100644 --- a/src/main.ts +++ b/src/main.ts @@ -349,6 +349,7 @@ function finishSetup(body: HTMLElement, view: SetupView) { function createPanel(body: HTMLElement, status: SyncStatus) { const url = serverInput(status); + const email = input({ type: "email", autocomplete: "email", spellcheck: false }); const code = input({ spellcheck: false }); const pins = input({ placeholder: "base64, comma-separated", spellcheck: false }, true); const pw = input({ type: "password", autocomplete: "new-password" }); @@ -366,7 +367,7 @@ function createPanel(body: HTMLElement, status: SyncStatus) { btn.disabled = true; btn.textContent = "Creating…"; try { - const secretKey = await invoke("create_account", { serverUrl: url.value, pins: pins.value, setupCode: code.value, masterPassword: pw.value }); + const secretKey = await invoke("create_account", { serverUrl: url.value, pins: pins.value, setupCode: code.value, email: email.value, masterPassword: pw.value }); pw.value = pw2.value = ""; await showSecretKey(secretKey); } catch (e) { @@ -380,9 +381,10 @@ function createPanel(body: HTMLElement, status: SyncStatus) { } }; body.append( - p("kv-muted", "For the first device on a new server. You'll get a Secret Key: keep it with your Emergency Kit."), + p("kv-muted", "A new account on your Kestrove server. You'll get a Secret Key: keep it with your Emergency Kit."), field("Your Kestrove server", url), - field("Setup code", code, "KV_SETUP_CODE in your server's .env."), + field("Email", email, "You sign in with it on new devices."), + field("Invite or setup code", code, "The invite from your server's admin, or KV_SETUP_CODE from its .env for the first account. Empty with open sign-up."), h("details", "", h("summary", "kv-small", "Certificate pins (advanced)"), field("SPKI pins", pins)), field("Master password", pw, "10 or more characters."), field("Repeat master password", pw2), @@ -737,6 +739,7 @@ function renderSyncSettings(box: HTMLElement, s: SyncStatus, reload: () => Promi // A vault from before sync (or whose registration failed): connect it. const url = input({ type: "url", placeholder: "https://kestrove.example.com", value: s.server_url ?? "" }); const pins = input({ value: s.pins.join(", ") }, true); + const email = input({ type: "email", autocomplete: "email", spellcheck: false }); const code = input({}); const err = errBox(); const btn = button("Connect", "primary"); @@ -745,9 +748,10 @@ function renderSyncSettings(box: HTMLElement, s: SyncStatus, reload: () => Promi err.replaceChildren(); try { await invoke("configure_server", { serverUrl: url.value, pins: pins.value }); - if (await invoke("server_setup_open")) { - if (!code.value.trim()) throw new Error("This server has no account yet: enter its setup code to register this vault."); - await invoke("register_server", { setupCode: code.value }); + const setupOpen = await invoke("server_setup_open"); + if (setupOpen && !code.value.trim()) throw new Error("This server has no account yet: enter its setup code to register this vault."); + if (setupOpen || email.value.trim()) { + await invoke("register_server", { setupCode: code.value, email: email.value }); } else { await invoke("sync_now"); } @@ -758,7 +762,8 @@ function renderSyncSettings(box: HTMLElement, s: SyncStatus, reload: () => Promi } }; box.append(h("div", "kv-card pad stack", field("Your Kestrove server", url), field("SPKI pins", pins, "Optional."), - field("Setup code", code, "Only for a new server."), err, h("div", "actions", btn))); + field("Email", email, "To register this vault as a new account."), + field("Invite or setup code", code, "An admin's invite, or the setup code of a new server."), err, h("div", "actions", btn))); } async function renderDevices(box: HTMLElement) {