Multi-user servers: email and invite codes at sign-up and sign-in
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:
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user