Multi-user: email sign-in, invite/open registration, admin calls

- kv-proto: email on setup, AccountInfo.email/is_admin, ServerInfo.registration, admin DTOs.
- kv-sync: prelogin by email or account id (setup QR / Emergency Kit), register with email,
  set_email, /v1/admin/* client calls.
- kv-ffi: register_account(code, email), login(.., email), account_details, set_account_email,
  admin_create_invite. kv-wasm: the same plus the full admin API for the web vault.
- Live test: two users on one server stay isolated; a new device signs in by email.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-24 07:35:23 +07:00
co-authored by Claude Opus 5.5
parent ead95bb605
commit c84015f67b
16 changed files with 563 additions and 64 deletions
+2 -2
View File
@@ -333,13 +333,13 @@ mod tests {
let a = VaultCore::open(dir.path().join("a.sqlite").to_string_lossy().into_owned()).unwrap();
let sk = a.create_account(PW.into()).unwrap();
connect(&a, &server);
a.register_account(SETUP_TOKEN.into()).await.unwrap();
a.register_account(SETUP_TOKEN.into(), None).await.unwrap();
let r = a.import_commit(onepux(), ImportFormat::OnePux, skip()).unwrap();
assert_eq!(a.sync_now().await.unwrap().pushed, r.imported);
let b = VaultCore::open(dir.path().join("b.sqlite").to_string_lossy().into_owned()).unwrap();
connect(&b, &server);
assert_eq!(b.login(PW.into(), sk, None).await.unwrap(), LoginResult::Ok);
assert_eq!(b.login(PW.into(), sk, None, None).await.unwrap(), LoginResult::Ok);
b.sync_now().await.unwrap();
assert_eq!(b.list_items().unwrap().len(), 8);
let id = r.item_ids[0].clone();
+51 -7
View File
@@ -340,6 +340,22 @@ struct Inner {
api: Option<Arc<ApiClient>>,
}
/// A new invite, shown once.
#[derive(Debug, Clone, uniffi::Record)]
pub struct InviteCode {
pub code: String,
/// Unix seconds.
pub expires_at: i64,
}
/// What `account_details` returns: never anything secret.
#[derive(Debug, Clone, uniffi::Record)]
pub struct AccountDetails {
pub email: Option<String>,
/// This account administers the server (invites, users, sign-up): it still can't read anyone's vault.
pub is_admin: bool,
}
/// One account's vault on this device.
#[derive(uniffi::Object)]
pub struct VaultCore {
@@ -750,16 +766,18 @@ impl VaultCore {
})
}
/// First device only: registers the account created by `create_account` with a fresh server,
/// using the one-time setup token the server printed on first boot. Must be unlocked with the
/// master password (not biometrics) in this session. Logs in afterwards.
pub async fn register_account(&self, setup_token: String) -> Result<()> {
/// Registers the account created by `create_account` with the server. `setup_token` is the server's setup code
/// (its first account, which becomes the admin), an invite code from the admin, or empty with open sign-up;
/// `email` is what the account signs in with (required once the server has an account; docs/03 "Accounts").
/// Must be unlocked with the master password (not biometrics) in this session. Logs in afterwards.
pub async fn register_account(&self, setup_token: String, email: Option<String>) -> Result<()> {
let api = self.api()?;
let (acct, u) = self.unlocked_snapshot()?;
let auth = u.auth_input.clone().ok_or(CoreError::NotAuthenticated)?;
let device = self.enroll()?;
api.register(
&setup_token,
email.as_deref(),
acct.account_id,
&acct.kdf,
auth.as_ref(),
@@ -772,7 +790,8 @@ impl VaultCore {
self.do_login(&api, acct.account_id, auth.as_ref(), None).await.map(|_| ())
}
/// Logs in with the master password and Secret Key (the server is single-tenant, so no account id is needed).
/// Logs in with the master password, Secret Key and the account's `email` (may be omitted on a server with
/// a single account, or when this device already has the account).
/// * New device (no local account): fetches the account from the server, stores it and unlocks.
/// Then call `sync_now` to download the vault.
/// * Existing account: renews the session and unlocks.
@@ -781,9 +800,28 @@ impl VaultCore {
master_password: String,
secret_key: String,
totp: Option<String>,
email: Option<String>,
) -> Result<LoginResult> {
let sk = SecretKey::parse(&secret_key)?;
self.login_inner(master_password, sk, totp, None, None).await
self.login_inner(master_password, sk, totp, None, None, email).await
}
/// The account's email and whether it administers the server (`GET /v1/account`).
pub async fn account_details(&self) -> Result<AccountDetails> {
let info = self.revoked(self.api()?.account().await)?;
Ok(AccountDetails { email: info.email, is_admin: info.is_admin })
}
/// Sets the address this account signs in with (accounts from before multi-user have none).
pub async fn set_account_email(&self, email: String) -> Result<()> {
self.revoked(self.api()?.set_email(&email).await)
}
/// Admins only: a one-time invite code for a new account (optionally only for `email`, valid `days`, default 7).
/// The full admin page is in the web vault.
pub async fn admin_create_invite(&self, email: Option<String>, days: Option<u32>) -> Result<InviteCode> {
let c = self.revoked(self.api()?.admin_create_invite(&kv_proto::InviteCreate { email, days }).await)?;
Ok(InviteCode { code: c.code, expires_at: c.expires_at })
}
/// Pull, merge, push and verify every vault. Renews an expired session automatically when the
@@ -802,6 +840,7 @@ impl VaultCore {
totp: Option<String>,
ticket: Option<String>,
expect_account: Option<Uuid>,
email: Option<String>,
) -> Result<LoginResult> {
let api = self.api()?;
let local = self.lock_inner().account.clone();
@@ -810,7 +849,12 @@ impl VaultCore {
let (account_id, kdf_params) = match &local {
Some(a) => (a.account_id, a.kdf.clone()),
None => {
let pre = api.prelogin().await?;
let by = match (expect_account, email.as_deref().map(str::trim).filter(|e| !e.is_empty())) {
(Some(id), _) => kv_sync::PreloginBy::Account(id),
(None, Some(e)) => kv_sync::PreloginBy::Email(e),
(None, None) => kv_sync::PreloginBy::OnlyAccount,
};
let pre = api.prelogin(by).await?;
(pre.account_id, pre.kdf)
}
};
+1 -1
View File
@@ -155,7 +155,7 @@ impl VaultCore {
}
self.configure_server(p.server_url.clone(), p.spki_pins.clone())?;
let sk = SecretKey::parse(&p.secret_key)?;
self.login_inner(master_password, sk, None, p.ticket.clone(), Some(p.account_id)).await
self.login_inner(master_password, sk, None, p.ticket.clone(), Some(p.account_id), None).await
}
/// Joining device (no camera): opens a rendezvous on `server_url` and returns the QR to show.
+2 -2
View File
@@ -194,14 +194,14 @@ async fn passkey_created_on_one_device_signs_on_another() {
let a = VaultCore::new_in_memory();
let sk = a.create_account(PW.into()).unwrap();
connect(&a, &server);
a.register_account(SETUP_TOKEN.into()).await.unwrap();
a.register_account(SETUP_TOKEN.into(), None).await.unwrap();
let reg = a.create_passkey(RP.into(), b"u".to_vec(), "me".into(), cdh(b"r"), None).unwrap();
let vk = rp_verify_registration(&reg, RP);
assert_eq!(a.sync_now().await.unwrap().pushed, 1);
let b = VaultCore::new_in_memory();
connect(&b, &server);
b.login(PW.into(), sk, None).await.unwrap();
b.login(PW.into(), sk, None, None).await.unwrap();
b.sync_now().await.unwrap();
let on_b = b.passkeys_for_rp(RP.into(), vec![]).unwrap();
assert_eq!(on_b.len(), 1);
+19 -19
View File
@@ -172,7 +172,7 @@ async fn two_devices_sync_through_server() {
let sk = a.create_account(PW.into()).unwrap();
connect(&a, &server);
assert!(a.server_info().await.unwrap().setup_open);
a.register_account(SETUP_TOKEN.into()).await.unwrap();
a.register_account(SETUP_TOKEN.into(), None).await.unwrap();
assert!(a.is_logged_in());
let id = a.save_item(login_item("Bank", "pw-a")).unwrap();
let r = a.sync_now().await.unwrap();
@@ -182,8 +182,8 @@ async fn two_devices_sync_through_server() {
let b_path = dir.path().join("b.sqlite").to_string_lossy().into_owned();
let b = VaultCore::open(b_path.clone()).unwrap();
connect(&b, &server);
assert!(matches!(b.login("wrong password!!".into(), sk.clone(), None).await, Err(CoreError::BadCredentials)));
assert_eq!(b.login(PW.into(), sk.clone(), None).await.unwrap(), LoginResult::Ok);
assert!(matches!(b.login("wrong password!!".into(), sk.clone(), None, None).await, Err(CoreError::BadCredentials)));
assert_eq!(b.login(PW.into(), sk.clone(), None, None).await.unwrap(), LoginResult::Ok);
assert!(b.has_account() && !b.is_locked());
b.sync_now().await.unwrap();
assert_eq!(b.get_item(id.clone()).unwrap().password.as_deref(), Some("pw-a"));
@@ -233,7 +233,7 @@ async fn integrity_alarm_reaches_the_app() {
let a = VaultCore::new_in_memory();
a.create_account(PW.into()).unwrap();
connect(&a, &server);
a.register_account(SETUP_TOKEN.into()).await.unwrap();
a.register_account(SETUP_TOKEN.into(), None).await.unwrap();
for i in 0..2 {
a.save_item(login_item(&format!("item {i}"), "p")).unwrap();
a.sync_now().await.unwrap();
@@ -258,13 +258,13 @@ async fn pending_approval_and_biometric_session_expiry() {
let a = VaultCore::new_in_memory();
let sk = a.create_account(PW.into()).unwrap();
connect(&a, &server);
a.register_account(SETUP_TOKEN.into()).await.unwrap();
a.register_account(SETUP_TOKEN.into(), None).await.unwrap();
a.sync_now().await.unwrap();
server.state().require_approval = true;
let b = VaultCore::new_in_memory();
connect(&b, &server);
assert_eq!(b.login(PW.into(), sk.clone(), None).await.unwrap(), LoginResult::PendingApproval);
assert_eq!(b.login(PW.into(), sk.clone(), None, None).await.unwrap(), LoginResult::PendingApproval);
assert!(!b.has_account());
// A approves B after comparing fingerprints, then B's next login succeeds.
let devices = a.list_devices().await.unwrap();
@@ -272,7 +272,7 @@ async fn pending_approval_and_biometric_session_expiry() {
assert_eq!(pending.key_fingerprint, b.device_key_fingerprint().unwrap());
assert!(devices.iter().any(|d| d.current && d.approved));
a.approve_device(pending.device_id.clone()).await.unwrap();
assert_eq!(b.login(PW.into(), sk, None).await.unwrap(), LoginResult::Ok);
assert_eq!(b.login(PW.into(), sk, None, None).await.unwrap(), LoginResult::Ok);
b.sync_now().await.unwrap();
// Revoking B kills its session; B learns it was removed and resets its identity.
a.revoke_device(pending.device_id.clone()).await.unwrap();
@@ -294,7 +294,7 @@ async fn pending_approval_and_biometric_session_expiry() {
c.lock();
c.unlock_with_local_key(luk).unwrap();
connect(&c, &server);
assert!(matches!(c.register_account("x".into()).await, Err(CoreError::NotAuthenticated)));
assert!(matches!(c.register_account("x".into(), None).await, Err(CoreError::NotAuthenticated)));
}
#[tokio::test]
@@ -303,7 +303,7 @@ async fn save_item_bumps_seq_and_replays_are_refused() {
let a = VaultCore::new_in_memory();
let sk = a.create_account(PW.into()).unwrap();
connect(&a, &server);
a.register_account(SETUP_TOKEN.into()).await.unwrap();
a.register_account(SETUP_TOKEN.into(), None).await.unwrap();
let id = a.save_item(login_item("Bank", "pw1")).unwrap();
a.sync_now().await.unwrap();
let vid = *server.state().vaults.keys().next().unwrap();
@@ -326,7 +326,7 @@ async fn save_item_bumps_seq_and_replays_are_refused() {
let b = VaultCore::new_in_memory();
connect(&b, &server);
b.login(PW.into(), sk, None).await.unwrap();
b.login(PW.into(), sk, None, None).await.unwrap();
b.sync_now().await.unwrap();
assert_eq!(seq_of(&b), 3);
@@ -344,7 +344,7 @@ async fn delete_item_seals_the_tombstone_and_forged_deletes_are_refused() {
let a = VaultCore::new_in_memory();
let sk = a.create_account(PW.into()).unwrap();
connect(&a, &server);
a.register_account(SETUP_TOKEN.into()).await.unwrap();
a.register_account(SETUP_TOKEN.into(), None).await.unwrap();
let gone = a.save_item(login_item("Old", "pw0")).unwrap();
let kept = a.save_item(login_item("Bank", "pw1")).unwrap();
a.sync_now().await.unwrap();
@@ -357,7 +357,7 @@ async fn delete_item_seals_the_tombstone_and_forged_deletes_are_refused() {
let b = VaultCore::new_in_memory();
connect(&b, &server);
b.login(PW.into(), sk, None).await.unwrap();
b.login(PW.into(), sk, None, None).await.unwrap();
b.sync_now().await.unwrap();
assert_eq!(b.list_items().unwrap().len(), 1);
@@ -378,7 +378,7 @@ async fn phone_with_item(server: &TestServer) -> (Arc<VaultCore>, String, String
let a = VaultCore::new_in_memory();
let sk = a.create_account(PW.into()).unwrap();
connect(&a, server);
a.register_account(SETUP_TOKEN.into()).await.unwrap();
a.register_account(SETUP_TOKEN.into(), None).await.unwrap();
let id = a.save_item(login_item("Bank", "pw-a")).unwrap();
a.sync_now().await.unwrap();
server.state().require_approval = true;
@@ -574,7 +574,7 @@ async fn revoked_device_resets_and_reenrols_with_the_same_ticket() {
phone.configure_server(server.url(), vec![]).unwrap();
phone.set_device_signer(signer.clone(), "iPhone".into(), PlatformDto::Ios).unwrap();
let old_id = signer.device_id();
assert_eq!(phone.login(PW.into(), sk.clone(), None).await.unwrap(), LoginResult::PendingApproval);
assert_eq!(phone.login(PW.into(), sk.clone(), None, None).await.unwrap(), LoginResult::PendingApproval);
a.revoke_device(old_id.clone()).await.unwrap();
// Reinstall: a new core, the same platform identity. A wrong password reveals nothing and resets nothing.
@@ -615,7 +615,7 @@ async fn revoked_device_without_a_ticket_resets_and_asks_to_set_up_again() {
let b = VaultCore::new_in_memory();
b.configure_server(server.url(), vec![]).unwrap();
b.set_device_signer(signer.clone(), "laptop".into(), PlatformDto::Linux).unwrap();
assert_eq!(b.login(PW.into(), sk.clone(), None).await.unwrap(), LoginResult::Ok);
assert_eq!(b.login(PW.into(), sk.clone(), None, None).await.unwrap(), LoginResult::Ok);
b.sync_now().await.unwrap();
let old_id = signer.device_id();
@@ -629,11 +629,11 @@ async fn revoked_device_without_a_ticket_resets_and_asks_to_set_up_again() {
assert_eq!(signer.resets.load(std::sync::atomic::Ordering::SeqCst), 1);
server.state().require_approval = true;
assert_eq!(b.login(PW.into(), sk.clone(), None).await.unwrap(), LoginResult::PendingApproval);
assert_eq!(b.login(PW.into(), sk.clone(), None, None).await.unwrap(), LoginResult::PendingApproval);
let new_id = signer.device_id();
assert_ne!(new_id, old_id);
a.approve_device(new_id).await.unwrap();
assert_eq!(b.login(PW.into(), sk, None).await.unwrap(), LoginResult::Ok);
assert_eq!(b.login(PW.into(), sk, None, None).await.unwrap(), LoginResult::Ok);
b.sync_now().await.unwrap();
assert_eq!(b.get_item(id).unwrap().password.as_deref(), Some("pw-a"));
// The old id can never come back.
@@ -682,7 +682,7 @@ async fn sign_out_device_keeps_the_account_on_the_server() {
assert_eq!(a.server_url(), None);
connect(&a, &server);
assert_eq!(a.server_url(), Some(server.url()));
a.register_account(SETUP_TOKEN.into()).await.unwrap();
a.register_account(SETUP_TOKEN.into(), None).await.unwrap();
let id = a.save_item(login_item("Bank", "pw-a")).unwrap(); // not synced yet: sign-out syncs it first
a.sign_out_device(false).await.unwrap();
assert!(!a.has_account() && !a.is_logged_in());
@@ -690,7 +690,7 @@ async fn sign_out_device_keeps_the_account_on_the_server() {
// Same device, signed in again: the item came back from the server.
connect(&a, &server);
assert_eq!(a.login(PW.into(), sk, None).await.unwrap(), LoginResult::Ok);
assert_eq!(a.login(PW.into(), sk, None, None).await.unwrap(), LoginResult::Ok);
a.sync_now().await.unwrap();
assert_eq!(a.get_item(id).unwrap().password.as_deref(), Some("pw-a"));
}