Multi-user servers: email and invite codes at sign-up and sign-in
ci / build (ubuntu-22.04) (push) Canceled after 0s
ci / build (windows-2025) (push) Canceled after 0s
ci / deny (push) Canceled after 0s

Create account takes an email and an invite or setup code (none with open
sign-up), joins servers that already have accounts, and Settings shows the
account email (admins manage people from the web vault).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-24 07:52:08 +07:00
co-authored by Claude Opus 5.5
parent 9dd43b76e9
commit b1c98f6376
4 changed files with 62 additions and 23 deletions
+5 -4
View File
@@ -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. // 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] #[tauri::command]
async fn create_account( async fn create_account(
app: State<'_, App>, app: State<'_, App>,
server_url: String, server_url: String,
pins: String, pins: String,
setup_code: String, setup_code: String,
email: String,
master_password: String, master_password: String,
) -> CmdResult<String> { ) -> CmdResult<String> {
let master_password = Zeroizing::new(master_password); 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(); app.activity.touch();
sk.map(|s| s.to_string()) sk.map(|s| s.to_string())
} }
@@ -378,8 +379,8 @@ async fn server_setup_open(app: State<'_, App>) -> CmdResult<bool> {
} }
#[tauri::command] #[tauri::command]
async fn register_server(app: State<'_, App>, setup_code: String) -> CmdResult<()> { async fn register_server(app: State<'_, App>, setup_code: String, email: String) -> CmdResult<()> {
app.sync.register(&setup_code).await app.sync.register(&setup_code, &email).await
} }
#[tauri::command] #[tauri::command]
+43 -11
View File
@@ -139,8 +139,22 @@ fn now_secs() -> u64 {
} }
/// User-facing text for a core error. /// User-facing text for a core error.
/// Deliberately loose ([email protected]); 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 { pub fn describe(e: &CoreError) -> String {
match e { 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::Network { msg } => format!("Can't reach the server: {msg}"),
CoreError::PinMismatch => { CoreError::PinMismatch => {
"The server's certificate doesn't match the pinned keys. Someone may be intercepting \ "The server's certificate doesn't match the pinned keys. Someone may be intercepting \
@@ -344,33 +358,43 @@ impl SyncService {
Ok(()) Ok(())
} }
/// "Create a new account": creates the vault here and registers it with a fresh server using the setup code /// "Create a new account": creates the vault here and registers it with the server, using its setup code
/// (`KV_SETUP_CODE`). Returns the Secret Key for the Emergency Kit. /// (`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( pub async fn create_account(
&self, &self,
server_url: &str, server_url: &str,
pins: Vec<String>, pins: Vec<String>,
setup_code: &str, setup_code: &str,
email: &str,
master_password: Zeroizing<String>, master_password: Zeroizing<String>,
) -> Result<Zeroizing<String>, String> { ) -> Result<Zeroizing<String>, String> {
if self.core.has_account() { if self.core.has_account() {
return Err("This computer already has a vault.".into()); return Err("This computer already has a vault.".into());
} }
if setup_code.trim().is_empty() { let email = email.trim();
return Err("Enter the server's setup code.".into()); 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)?; self.configure(server_url, pins)?;
let info = self.core.server_info().await.map_err(|e| describe(&e))?; 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( return Err(
"This server already has an account. Choose “Use my phone” or “Paste setup code” instead.".into() "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))?); let sk = Zeroizing::new(self.core.create_account(master_password.to_string()).map_err(|e| describe(&e))?);
self.remember_secret_key(&sk); 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!( 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.", Settings → Sync and try again.",
describe(&e) describe(&e)
)); ));
@@ -379,9 +403,17 @@ impl SyncService {
Ok(sk) Ok(sk)
} }
/// For a vault created before sync was set up: registers it with a fresh server. /// For a vault created before sync was set up: registers it with the server (setup code, invite, or nothing
pub async fn register(&self, setup_code: &str) -> Result<(), String> { /// with open sign-up).
self.core.register_account(setup_code.trim().to_string()).await.map_err(|e| describe(&e))?; 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(|_| ()) self.sync_now().await.map(|_| ())
} }
@@ -678,7 +710,7 @@ mod tests {
let phone_key: Arc<dyn kv_ffi::DeviceSigner> = let phone_key: Arc<dyn kv_ffi::DeviceSigner> =
Arc::new(DeviceKey::load_or_create_file(phone_dir.path()).unwrap()); Arc::new(DeviceKey::load_or_create_file(phone_dir.path()).unwrap());
phone.set_device_signer(phone_key, "phone".into(), PlatformDto::Ios).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("[email protected]".into())).await.unwrap();
server.state().require_approval = true; server.state().require_approval = true;
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
+2 -1
View File
@@ -73,7 +73,8 @@ async fn create_join_approve_and_sync() {
// A: "Create a new account". // A: "Create a new account".
let a = instance(da.path()); 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, "[email protected]", Zeroizing::new(PASSWORD.into())).await.unwrap();
assert!(sk.starts_with("K1")); assert!(sk.starts_with("K1"));
assert!(a.status().logged_in); assert!(a.status().logged_in);
assert!(a.status().last_sync_at.is_some(), "first sync after registration: {:?}", a.status().last_error); assert!(a.status().last_sync_at.is_some(), "first sync after registration: {:?}", a.status().last_error);
+12 -7
View File
@@ -349,6 +349,7 @@ function finishSetup(body: HTMLElement, view: SetupView) {
function createPanel(body: HTMLElement, status: SyncStatus) { function createPanel(body: HTMLElement, status: SyncStatus) {
const url = serverInput(status); const url = serverInput(status);
const email = input({ type: "email", autocomplete: "email", spellcheck: false });
const code = input({ spellcheck: false }); const code = input({ spellcheck: false });
const pins = input({ placeholder: "base64, comma-separated", spellcheck: false }, true); const pins = input({ placeholder: "base64, comma-separated", spellcheck: false }, true);
const pw = input({ type: "password", autocomplete: "new-password" }); const pw = input({ type: "password", autocomplete: "new-password" });
@@ -366,7 +367,7 @@ function createPanel(body: HTMLElement, status: SyncStatus) {
btn.disabled = true; btn.disabled = true;
btn.textContent = "Creating…"; btn.textContent = "Creating…";
try { try {
const secretKey = await invoke<string>("create_account", { serverUrl: url.value, pins: pins.value, setupCode: code.value, masterPassword: pw.value }); const secretKey = await invoke<string>("create_account", { serverUrl: url.value, pins: pins.value, setupCode: code.value, email: email.value, masterPassword: pw.value });
pw.value = pw2.value = ""; pw.value = pw2.value = "";
await showSecretKey(secretKey); await showSecretKey(secretKey);
} catch (e) { } catch (e) {
@@ -380,9 +381,10 @@ function createPanel(body: HTMLElement, status: SyncStatus) {
} }
}; };
body.append( 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("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)), h("details", "", h("summary", "kv-small", "Certificate pins (advanced)"), field("SPKI pins", pins)),
field("Master password", pw, "10 or more characters."), field("Master password", pw, "10 or more characters."),
field("Repeat master password", pw2), 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. // 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 url = input({ type: "url", placeholder: "https://kestrove.example.com", value: s.server_url ?? "" });
const pins = input({ value: s.pins.join(", ") }, true); const pins = input({ value: s.pins.join(", ") }, true);
const email = input({ type: "email", autocomplete: "email", spellcheck: false });
const code = input({}); const code = input({});
const err = errBox(); const err = errBox();
const btn = button("Connect", "primary"); const btn = button("Connect", "primary");
@@ -745,9 +748,10 @@ function renderSyncSettings(box: HTMLElement, s: SyncStatus, reload: () => Promi
err.replaceChildren(); err.replaceChildren();
try { try {
await invoke("configure_server", { serverUrl: url.value, pins: pins.value }); await invoke("configure_server", { serverUrl: url.value, pins: pins.value });
if (await invoke<boolean>("server_setup_open")) { const setupOpen = await invoke<boolean>("server_setup_open");
if (!code.value.trim()) throw new Error("This server has no account yet: enter its setup code to register this vault."); if (setupOpen && !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 }); if (setupOpen || email.value.trim()) {
await invoke("register_server", { setupCode: code.value, email: email.value });
} else { } else {
await invoke("sync_now"); 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."), 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) { async function renderDevices(box: HTMLElement) {