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:
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(®, 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
@@ -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"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user