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"));
}
+55 -6
View File
@@ -70,7 +70,7 @@ async fn two_devices_against_local_server() {
let sk = a.create_account(pw.into()).unwrap();
connect(&a, &url, "device A");
assert!(a.server_info().await.unwrap().setup_open, "server must be fresh (setup open)");
a.register_account(token).await.unwrap();
a.register_account(token, None).await.unwrap();
let id = a.save_item(item("Bank", "pw-a")).unwrap();
let r = a.sync_now().await.unwrap();
assert_eq!(r.pushed, 1);
@@ -79,13 +79,13 @@ async fn two_devices_against_local_server() {
tokio::time::sleep(std::time::Duration::from_secs(61)).await;
let b = VaultCore::new_in_memory();
connect(&b, &url, "device B");
if b.login(pw.into(), sk.clone(), None).await.unwrap() == LoginResult::PendingApproval {
if b.login(pw.into(), sk.clone(), None, None).await.unwrap() == LoginResult::PendingApproval {
let devices = a.list_devices().await.unwrap();
let pending = devices.iter().find(|d| !d.approved).expect("B listed as pending");
assert_eq!(pending.key_fingerprint, b.device_key_fingerprint().unwrap());
a.approve_device(pending.device_id.clone()).await.unwrap();
tokio::time::sleep(std::time::Duration::from_secs(61)).await; // rate limit again
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();
assert_eq!(b.get_item(id.clone()).unwrap().password.as_deref(), Some("pw-a"));
@@ -167,7 +167,7 @@ async fn removed_device_reenrols_against_local_server() {
let web = VaultCore::new_in_memory();
web.create_account(pw.into()).unwrap();
connect(&web, &url, "web vault");
web.register_account(token).await.unwrap();
web.register_account(token, None).await.unwrap();
let id = web.save_item(item("Bank", "pw-a")).unwrap();
web.sync_now().await.unwrap();
@@ -206,7 +206,7 @@ async fn delete_account_against_local_server() {
let app = VaultCore::new_in_memory();
app.create_account(pw.into()).unwrap();
connect(&app, &url, "phone");
app.register_account(token.clone()).await.unwrap();
app.register_account(token.clone(), None).await.unwrap();
app.save_item(item("Bank", "pw-a")).unwrap();
app.sync_now().await.unwrap();
@@ -217,6 +217,55 @@ async fn delete_account_against_local_server() {
let fresh = VaultCore::new_in_memory();
fresh.create_account(pw.into()).unwrap();
connect(&fresh, &url, "new phone");
fresh.register_account(token).await.unwrap();
fresh.register_account(token, None).await.unwrap();
println!("account deleted and server reusable OK against {url}");
}
#[tokio::test]
#[ignore = "needs a fresh local kestrove-server; see the module docs"]
async fn two_users_on_one_server() {
let url = std::env::var("KESTROVE_SERVER_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".into());
let token =
std::env::var("KESTROVE_SETUP_TOKEN").expect("set KESTROVE_SETUP_TOKEN to the token the server printed");
let (pw_a, pw_b) = ("the admin's long master password", "the second user's long master password");
// The admin: first account, with the setup code.
let admin = VaultCore::new_in_memory();
admin.create_account(pw_a.into()).unwrap();
connect(&admin, &url, "admin laptop");
admin.register_account(token, Some("[email protected]".into())).await.unwrap();
let details = admin.account_details().await.unwrap();
assert_eq!((details.email.as_deref(), details.is_admin), (Some("[email protected]"), true));
admin.save_item(item("Admin bank", "admin-secret")).unwrap();
admin.sync_now().await.unwrap();
// A second user, invited by the admin.
let invite = admin.admin_create_invite(Some("[email protected]".into()), None).await.unwrap();
let b = VaultCore::new_in_memory();
let sk_b = b.create_account(pw_b.into()).unwrap();
connect(&b, &url, "b phone");
b.register_account(invite.code.clone(), Some("[email protected]".into())).await.unwrap();
assert!(!b.account_details().await.unwrap().is_admin);
let b_item = b.save_item(item("B mail", "b-secret")).unwrap();
b.sync_now().await.unwrap();
// The invite is used up.
let c = VaultCore::new_in_memory();
c.create_account("someone else's password!!".into()).unwrap();
connect(&c, &url, "c");
assert!(c.register_account(invite.code, Some("[email protected]".into())).await.is_err());
// Neither sees the other's items after syncing.
admin.sync_now().await.unwrap();
let titles = |core: &VaultCore| core.list_items().unwrap().into_iter().map(|i| i.title).collect::<Vec<_>>();
assert_eq!(titles(&admin), vec!["Admin bank".to_string()]);
assert_eq!(titles(&b), vec!["B mail".to_string()]);
// B's new device signs in with email + password + Secret Key and gets only B's vault (after approval).
let laptop = VaultCore::new_in_memory();
connect(&laptop, &url, "b laptop");
let r = laptop.login(pw_b.into(), sk_b, None, Some("[email protected]".into())).await.unwrap();
assert_eq!(r, LoginResult::PendingApproval);
let pending = b.list_devices().await.unwrap().into_iter().find(|d| !d.approved).unwrap();
b.approve_device(pending.device_id).await.unwrap();
println!("two users on {url}: isolated OK ({b_item})");
}
+99
View File
@@ -112,8 +112,22 @@ pub struct ServerInfo {
pub version: String,
pub api: u32,
pub single_tenant: bool,
/// No account exists yet: the first one is created with the server's setup code and becomes its admin.
pub setup_open: bool,
pub min_client: String,
/// How new accounts join once the server has an admin (docs/03 "Accounts"). Absent on older servers.
#[serde(default)]
pub registration: Option<Registration>,
}
/// Who may create an account on a server that already has its admin.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Registration {
/// Only with an invite code from the admin.
Invite,
/// Anyone who can reach the server (optionally limited to some email domains), or with an invite.
Open,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -129,7 +143,11 @@ pub struct DeviceEnroll {
/// `POST /v1/setup/start`: OPAQUE registration, first message. Needs the setup token.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetupStart {
/// The server's setup code (first account), an invite code, or empty with open sign-up.
#[serde(default)]
pub setup_token: String,
#[serde(default)]
pub email: Option<String>,
pub account_id: Uuid,
/// Serialized opaque-ke `RegistrationRequest`.
#[serde(with = "b64")]
@@ -150,7 +168,12 @@ pub type SetupFinish = SetupRequest;
/// Body of `POST /v1/setup/finish` (alias [`SetupFinish`]).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetupRequest {
/// The server's setup code (first account), an invite code, or empty with open sign-up.
#[serde(default)]
pub setup_token: String,
/// The address this account signs in with. Required on a server that already has an account.
#[serde(default)]
pub email: Option<String>,
pub account_id: Uuid,
pub kdf: KdfParams,
/// Serialized opaque-ke `RegistrationUpload`.
@@ -235,6 +258,82 @@ pub struct AccountInfo {
#[serde(with = "b64")]
pub sig_pk: Vec<u8>,
pub wrapped_keyset: Envelope,
#[serde(default)]
pub email: Option<String>,
#[serde(default)]
pub is_admin: bool,
}
/// `PUT /v1/account/email`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountEmail {
pub email: String,
}
/// `GET /v1/admin/users`: what an admin may know about an account. Never any ciphertext or key material.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AdminUser {
pub account_id: Uuid,
pub email: Option<String>,
pub is_admin: bool,
pub disabled: bool,
/// RFC 3339.
pub created_at: String,
/// Unix seconds of the latest session activity, if any.
pub last_seen: Option<i64>,
pub devices: u32,
pub items: u32,
/// Bytes of encrypted vault data stored for the account.
pub storage_bytes: u64,
}
/// `PATCH /v1/admin/users/{id}`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AdminUserUpdate {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub is_admin: Option<bool>,
}
/// `POST /v1/admin/invites`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct InviteCreate {
/// Only this address can use the invite.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
/// Days until it expires (1 to 30, default 7).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub days: Option<u32>,
}
/// Reply to `POST /v1/admin/invites`. `code` is shown once; the server keeps only its hash.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InviteCreated {
pub invite_id: Uuid,
pub code: String,
/// Unix seconds.
pub expires_at: i64,
}
/// `GET /v1/admin/invites`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InviteInfo {
pub invite_id: Uuid,
pub email: Option<String>,
pub created_at: i64,
pub expires_at: i64,
pub used: bool,
pub revoked: bool,
}
/// `GET|PUT /v1/admin/settings`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AdminSettings {
pub registration: Registration,
/// With open sign-up, only these email domains may register (empty: any).
#[serde(default)]
pub allowed_domains: Vec<String>,
}
/// `POST /v1/account/credentials/start`: OPAQUE re-registration for a password change.
+75 -9
View File
@@ -3,8 +3,9 @@
use std::sync::{Arc, Mutex};
use kv_proto::{
AccountInfo, ApiError, Changes, DeviceEnroll, DeviceInfo, Envelope, ItemPut, ItemRecord, JoinBundle, JoinCreate,
JoinCreated, JoinHello, JoinHelloResponse, JoinPoll, KdfParams, LoginFinish, LoginFinishResponse, LoginStart,
AccountEmail, AccountInfo, AdminSettings, AdminUser, AdminUserUpdate, ApiError, Changes, DeviceEnroll, DeviceInfo,
Envelope, InviteCreate, InviteCreated, InviteInfo, ItemPut, ItemRecord, JoinBundle, JoinCreate, JoinCreated,
JoinHello, JoinHelloResponse, JoinPoll, KdfParams, LoginFinish, LoginFinishResponse, LoginStart,
LoginStartResponse, LoginStatus, Manifest, PreloginResponse, RegistrationStartResponse, ServerInfo, SetupFinish,
SetupStart, TicketCreate, TicketCreated, TicketInfo, Vault, HEADER_JOIN_TOKEN,
};
@@ -32,6 +33,16 @@ pub enum LoginOutcome {
PendingApproval,
}
/// How a new device names its account at prelogin.
#[derive(Debug, Clone, Copy)]
pub enum PreloginBy<'a> {
/// The server's only account (servers and clients from before multi-user).
OnlyAccount,
Email(&'a str),
/// From a setup QR or Emergency Kit.
Account(Uuid),
}
pub struct ApiClient {
base: String,
http: reqwest::Client,
@@ -166,17 +177,27 @@ impl ApiClient {
self.json(Method::GET, PATH_SERVER_INFO, None::<&()>, None, false).await
}
/// Account id and KDF parameters of the (single) account: what a new device needs to derive
/// `AUTH_INPUT` before it can build KE1.
pub async fn prelogin(&self) -> Result<PreloginResponse, SyncError> {
self.json(Method::GET, PATH_PRELOGIN, None::<&()>, None, false).await
/// Account id and KDF parameters: what a new device needs to derive `AUTH_INPUT` before it can build KE1.
/// An unknown email or id gets a plausible stand-in, so this never tells whether an account exists.
pub async fn prelogin(&self, by: PreloginBy<'_>) -> Result<PreloginResponse, SyncError> {
let path = match by {
PreloginBy::OnlyAccount => PATH_PRELOGIN.to_string(),
PreloginBy::Email(email) => {
let q: String = url::form_urlencoded::byte_serialize(email.trim().as_bytes()).collect();
format!("{PATH_PRELOGIN}?email={q}")
}
PreloginBy::Account(id) => format!("{PATH_PRELOGIN}?account_id={id}"),
};
self.json(Method::GET, &path, None::<&()>, None, false).await
}
/// OPAQUE registration plus account creation (first device only, needs the setup token).
/// OPAQUE registration plus account creation. `setup_token` is the server's setup code (first account), an
/// invite code, or empty with open sign-up; `email` is required once the server has an account.
#[allow(clippy::too_many_arguments)]
pub async fn register(
&self,
setup_token: &str,
email: Option<&str>,
account_id: Uuid,
kdf: &KdfParams,
auth_input: &[u8],
@@ -186,13 +207,19 @@ impl ApiClient {
device: DeviceEnroll,
) -> Result<(), SyncError> {
let reg = opaque::registration_start(auth_input)?;
let start =
SetupStart { setup_token: setup_token.to_string(), account_id, registration_request: reg.request.clone() };
let email = email.map(|e| e.trim().to_string()).filter(|e| !e.is_empty());
let start = SetupStart {
setup_token: setup_token.to_string(),
email: email.clone(),
account_id,
registration_request: reg.request.clone(),
};
let resp: RegistrationStartResponse =
self.json(Method::POST, PATH_SETUP_START, Some(&start), None, false).await?;
let upload = reg.finish(auth_input, &resp.registration_response)?;
let req = SetupFinish {
setup_token: setup_token.to_string(),
email,
account_id,
kdf: kdf.clone(),
opaque_registration_upload: upload,
@@ -299,6 +326,45 @@ impl ApiClient {
r
}
pub async fn set_email(&self, email: &str) -> Result<(), SyncError> {
self.no_content(Method::PUT, PATH_ACCOUNT_EMAIL, Some(&AccountEmail { email: email.trim().to_string() }), true)
.await
}
// ---------------------------------------------------------------- admin (docs/03 "Accounts")
pub async fn admin_users(&self) -> Result<Vec<AdminUser>, SyncError> {
self.json(Method::GET, PATH_ADMIN_USERS, None::<&()>, None, true).await
}
pub async fn admin_update_user(&self, account: Uuid, update: &AdminUserUpdate) -> Result<(), SyncError> {
self.no_content(Method::PATCH, &format!("{PATH_ADMIN_USERS}/{account}"), Some(update), true).await
}
pub async fn admin_delete_user(&self, account: Uuid) -> Result<(), SyncError> {
self.no_content(Method::DELETE, &format!("{PATH_ADMIN_USERS}/{account}"), None::<&()>, true).await
}
pub async fn admin_invites(&self) -> Result<Vec<InviteInfo>, SyncError> {
self.json(Method::GET, PATH_ADMIN_INVITES, None::<&()>, None, true).await
}
pub async fn admin_create_invite(&self, req: &InviteCreate) -> Result<InviteCreated, SyncError> {
self.json(Method::POST, PATH_ADMIN_INVITES, Some(req), None, true).await
}
pub async fn admin_revoke_invite(&self, invite: Uuid) -> Result<(), SyncError> {
self.no_content(Method::DELETE, &format!("{PATH_ADMIN_INVITES}/{invite}"), None::<&()>, true).await
}
pub async fn admin_settings(&self) -> Result<AdminSettings, SyncError> {
self.json(Method::GET, PATH_ADMIN_SETTINGS, None::<&()>, None, true).await
}
pub async fn admin_put_settings(&self, settings: &AdminSettings) -> Result<AdminSettings, SyncError> {
self.json(Method::PUT, PATH_ADMIN_SETTINGS, Some(settings), None, true).await
}
pub async fn account(&self) -> Result<AccountInfo, SyncError> {
self.json(Method::GET, PATH_ACCOUNT, None::<&()>, None, true).await
}
+1 -1
View File
@@ -25,7 +25,7 @@ pub mod test_server;
pub mod transport;
pub mod wire;
pub use client::{ApiClient, LoginOutcome};
pub use client::{ApiClient, LoginOutcome, PreloginBy};
pub use engine::{SyncContext, SyncReport};
use std::{future::Future, pin::Pin};
+3
View File
@@ -229,6 +229,7 @@ fn handle(st: &mut State, req: &Request) -> Result<ResponseTemplate, ResponseTem
single_tenant: true,
setup_open: st.setup_token.is_some(),
min_client: "0.1.0".into(),
registration: None,
},
)),
("POST", ["v1", "setup", "start"]) => {
@@ -459,6 +460,8 @@ fn authed(
enc_pk: a.enc_pk.clone(),
sig_pk: a.sig_pk.clone(),
wrapped_keyset: a.wrapped_keyset.clone(),
email: None,
is_admin: true,
},
))
}
+4
View File
@@ -8,6 +8,10 @@ pub const PATH_LOGIN_START: &str = "/v1/auth/login/start";
pub const PATH_LOGIN_FINISH: &str = "/v1/auth/login/finish";
pub const PATH_LOGOUT: &str = "/v1/auth/logout";
pub const PATH_ACCOUNT: &str = "/v1/account";
pub const PATH_ACCOUNT_EMAIL: &str = "/v1/account/email";
pub const PATH_ADMIN_USERS: &str = "/v1/admin/users";
pub const PATH_ADMIN_INVITES: &str = "/v1/admin/invites";
pub const PATH_ADMIN_SETTINGS: &str = "/v1/admin/settings";
pub const PATH_VAULTS: &str = "/v1/vaults";
pub const PATH_DEVICES: &str = "/v1/devices";
pub const PATH_TICKETS: &str = "/v1/devices/tickets";
+2
View File
@@ -61,6 +61,7 @@ impl Device {
d.api
.register(
SETUP_TOKEN,
None,
d.keyset.account_id,
&test_kdf(),
&AUTH,
@@ -410,6 +411,7 @@ async fn auth_errors() {
.api
.register(
SETUP_TOKEN,
None,
Uuid::new_v4(),
&test_kdf(),
&AUTH,
+120 -7
View File
@@ -490,17 +490,110 @@ impl WebCore {
api: info.api,
setup_open: info.setup_open,
min_client: info.min_client,
registration: info.registration.map(registration_name),
})
}
/// First device only: registers the account with a fresh server using its one-time setup token.
pub async fn register_account(&self, setup_token: &str) -> Result<()> {
pub async fn account_details(&self) -> Result<AccountDetailsDto> {
let info = self.api()?.account().await?;
Ok(AccountDetailsDto { email: info.email, is_admin: info.is_admin })
}
pub async fn set_account_email(&self, email: &str) -> Result<()> {
Ok(self.api()?.set_email(email).await?)
}
// ------------------------------------------------------------ admin (docs/03 "Accounts")
pub async fn admin_users(&self) -> Result<Vec<AdminUserDto>> {
Ok(self
.api()?
.admin_users()
.await?
.into_iter()
.map(|u| AdminUserDto {
account_id: u.account_id.to_string(),
email: u.email,
is_admin: u.is_admin,
disabled: u.disabled,
created_at: u.created_at,
last_seen: u.last_seen,
devices: u.devices,
items: u.items,
storage_bytes: u.storage_bytes,
})
.collect())
}
pub async fn admin_update_user(
&self,
account_id: &str,
disabled: Option<bool>,
is_admin: Option<bool>,
) -> Result<()> {
let update = kv_proto::AdminUserUpdate { disabled, is_admin };
Ok(self.api()?.admin_update_user(parse_id(account_id)?, &update).await?)
}
pub async fn admin_delete_user(&self, account_id: &str) -> Result<()> {
Ok(self.api()?.admin_delete_user(parse_id(account_id)?).await?)
}
pub async fn admin_invites(&self) -> Result<Vec<InviteDto>> {
Ok(self
.api()?
.admin_invites()
.await?
.into_iter()
.map(|i| InviteDto {
invite_id: i.invite_id.to_string(),
email: i.email,
created_at: i.created_at,
expires_at: i.expires_at,
used: i.used,
revoked: i.revoked,
})
.collect())
}
pub async fn admin_create_invite(&self, email: Option<String>, days: Option<u32>) -> Result<InviteCreatedDto> {
let c = self.api()?.admin_create_invite(&kv_proto::InviteCreate { email, days }).await?;
Ok(InviteCreatedDto { invite_id: c.invite_id.to_string(), code: c.code, expires_at: c.expires_at })
}
pub async fn admin_revoke_invite(&self, invite_id: &str) -> Result<()> {
Ok(self.api()?.admin_revoke_invite(parse_id(invite_id)?).await?)
}
pub async fn admin_settings(&self) -> Result<AdminSettingsDto> {
let s = self.api()?.admin_settings().await?;
Ok(AdminSettingsDto { registration: registration_name(s.registration), allowed_domains: s.allowed_domains })
}
pub async fn admin_put_settings(
&self,
registration: &str,
allowed_domains: Vec<String>,
) -> Result<AdminSettingsDto> {
let registration = match registration {
"open" => kv_proto::Registration::Open,
"invite" => kv_proto::Registration::Invite,
other => return Err(CoreError::Invalid { msg: format!("unknown sign-up mode {other}") }),
};
let s = self.api()?.admin_put_settings(&kv_proto::AdminSettings { registration, allowed_domains }).await?;
Ok(AdminSettingsDto { registration: registration_name(s.registration), allowed_domains: s.allowed_domains })
}
/// Registers the account: `setup_token` is the server's setup code (first account), an invite code, or empty
/// with open sign-up; `email` is required once the server has an account.
pub async fn register_account(&self, setup_token: &str, 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(),
@@ -515,9 +608,15 @@ impl WebCore {
/// Logs in with the master password and Secret Key. A browser with no account fetches it from the
/// server; call `sync_now` afterwards to download the vault.
pub async fn login(&self, master_password: &str, secret_key: &str, totp: Option<String>) -> Result<LoginResult> {
pub async fn login(
&self,
master_password: &str,
secret_key: &str,
totp: Option<String>,
email: Option<String>,
) -> Result<LoginResult> {
let sk = SecretKey::parse(secret_key).inspect_err(|_| self.forget_secret_key())?;
self.login_inner(master_password, sk, totp, None, None).await
self.login_inner(master_password, sk, totp, None, None, email).await
}
/// `login`, optionally presenting an enrollment ticket and checking the server holds `expect_account`.
@@ -528,8 +627,9 @@ impl WebCore {
totp: Option<String>,
ticket: Option<String>,
expect_account: Option<Uuid>,
email: Option<String>,
) -> Result<LoginResult> {
let r = self.login_unchecked(master_password, sk, totp, ticket, expect_account).await;
let r = self.login_unchecked(master_password, sk, totp, ticket, expect_account, email).await;
r.inspect_err(|_| self.forget_secret_key())
}
@@ -540,13 +640,19 @@ impl WebCore {
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();
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)
}
};
@@ -720,7 +826,7 @@ impl WebCore {
}
self.configure_server(&p.server_url, 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 browser: opens a rendezvous on `server_url` and returns the QR to show. Then call
@@ -1169,3 +1275,10 @@ pub fn device_key_fingerprint(p256_pk: &[u8]) -> String {
fn parse_id(s: &str) -> Result<Uuid> {
Uuid::parse_str(s).map_err(|_| CoreError::Invalid { msg: "id must be a UUID".into() })
}
fn registration_name(r: kv_proto::Registration) -> String {
match r {
kv_proto::Registration::Invite => "invite".into(),
kv_proto::Registration::Open => "open".into(),
}
}
+50
View File
@@ -88,6 +88,56 @@ pub struct ServerInfoDto {
pub api: u32,
pub setup_open: bool,
pub min_client: String,
/// "invite" or "open" (absent on servers from before multi-user).
pub registration: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountDetailsDto {
pub email: Option<String>,
pub is_admin: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminUserDto {
pub account_id: String,
pub email: Option<String>,
pub is_admin: bool,
pub disabled: bool,
pub created_at: String,
pub last_seen: Option<i64>,
pub devices: u32,
pub items: u32,
pub storage_bytes: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InviteDto {
pub invite_id: String,
pub email: Option<String>,
pub created_at: i64,
pub expires_at: i64,
pub used: bool,
pub revoked: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InviteCreatedDto {
pub invite_id: String,
pub code: String,
pub expires_at: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminSettingsDto {
/// "invite" or "open".
pub registration: String,
pub allowed_domains: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+73 -4
View File
@@ -62,7 +62,12 @@ export interface ItemDto {
export interface TotpCode { code: string; secondsRemaining: number; period: number; }
export interface ServerInfoDto { version: string; api: number; setupOpen: boolean; minClient: string; }
export interface ServerInfoDto { version: string; api: number; setupOpen: boolean; minClient: string; registration: "invite" | "open" | null; }
export interface AccountDetailsDto { email: string | null; isAdmin: boolean; }
export interface AdminUserDto { accountId: string; email: string | null; isAdmin: boolean; disabled: boolean; createdAt: string; lastSeen: number | null; devices: number; items: number; storageBytes: number; }
export interface InviteDto { inviteId: string; email: string | null; createdAt: number; expiresAt: number; used: boolean; revoked: boolean; }
export interface InviteCreatedDto { inviteId: string; code: string; expiresAt: number; }
export interface AdminSettingsDto { registration: "invite" | "open"; allowedDomains: string[]; }
export interface SyncReport {
pulled: number;
@@ -648,8 +653,12 @@ impl VaultCore {
/// First device only (the web vault is rarely that): registers the account with a fresh server.
#[wasm_bindgen(js_name = registerAccount, unchecked_return_type = "Promise<void>")]
pub fn register_account(&self, #[wasm_bindgen(js_name = "setupToken")] setup_token: String) -> Promise {
self.write(move |s| async move { s.core.register_account(&setup_token).await })
pub fn register_account(
&self,
#[wasm_bindgen(js_name = "setupToken")] setup_token: String,
email: Option<String>,
) -> Promise {
self.write(move |s| async move { s.core.register_account(&setup_token, email).await })
}
/// Logs in (and unlocks). Resolves `"pendingApproval"` until an enrolled device approves this
@@ -660,9 +669,69 @@ impl VaultCore {
#[wasm_bindgen(js_name = "masterPassword")] master_password: String,
#[wasm_bindgen(js_name = "secretKey")] secret_key: String,
totp: Option<String>,
email: Option<String>,
) -> Promise {
let (pw, sk) = (zeroize::Zeroizing::new(master_password), zeroize::Zeroizing::new(secret_key));
self.write(move |s| async move { s.core.login(&pw, &sk, totp).await })
self.write(move |s| async move { s.core.login(&pw, &sk, totp, email).await })
}
#[wasm_bindgen(js_name = accountDetails, unchecked_return_type = "Promise<AccountDetailsDto>")]
pub fn account_details(&self) -> Promise {
self.run(|s| async move { s.core.account_details().await })
}
#[wasm_bindgen(js_name = setAccountEmail, unchecked_return_type = "Promise<void>")]
pub fn set_account_email(&self, email: String) -> Promise {
self.run(move |s| async move { s.core.set_account_email(&email).await })
}
#[wasm_bindgen(js_name = adminUsers, unchecked_return_type = "Promise<AdminUserDto[]>")]
pub fn admin_users(&self) -> Promise {
self.run(|s| async move { s.core.admin_users().await })
}
#[wasm_bindgen(js_name = adminUpdateUser, unchecked_return_type = "Promise<void>")]
pub fn admin_update_user(
&self,
#[wasm_bindgen(js_name = "accountId")] account_id: String,
disabled: Option<bool>,
#[wasm_bindgen(js_name = "isAdmin")] is_admin: Option<bool>,
) -> Promise {
self.run(move |s| async move { s.core.admin_update_user(&account_id, disabled, is_admin).await })
}
#[wasm_bindgen(js_name = adminDeleteUser, unchecked_return_type = "Promise<void>")]
pub fn admin_delete_user(&self, #[wasm_bindgen(js_name = "accountId")] account_id: String) -> Promise {
self.run(move |s| async move { s.core.admin_delete_user(&account_id).await })
}
#[wasm_bindgen(js_name = adminInvites, unchecked_return_type = "Promise<InviteDto[]>")]
pub fn admin_invites(&self) -> Promise {
self.run(|s| async move { s.core.admin_invites().await })
}
#[wasm_bindgen(js_name = adminCreateInvite, unchecked_return_type = "Promise<InviteCreatedDto>")]
pub fn admin_create_invite(&self, email: Option<String>, days: Option<u32>) -> Promise {
self.run(move |s| async move { s.core.admin_create_invite(email, days).await })
}
#[wasm_bindgen(js_name = adminRevokeInvite, unchecked_return_type = "Promise<void>")]
pub fn admin_revoke_invite(&self, #[wasm_bindgen(js_name = "inviteId")] invite_id: String) -> Promise {
self.run(move |s| async move { s.core.admin_revoke_invite(&invite_id).await })
}
#[wasm_bindgen(js_name = adminSettings, unchecked_return_type = "Promise<AdminSettingsDto>")]
pub fn admin_settings(&self) -> Promise {
self.run(|s| async move { s.core.admin_settings().await })
}
#[wasm_bindgen(js_name = adminPutSettings, unchecked_return_type = "Promise<AdminSettingsDto>")]
pub fn admin_put_settings(
&self,
registration: String,
#[wasm_bindgen(js_name = "allowedDomains")] allowed_domains: Vec<String>,
) -> Promise {
self.run(move |s| async move { s.core.admin_put_settings(&registration, allowed_domains).await })
}
#[wasm_bindgen(js_name = syncNow, unchecked_return_type = "Promise<SyncReport>")]
+6 -6
View File
@@ -70,7 +70,7 @@ async fn web_vault_enrolls_and_syncs_with_an_app_device() {
app.configure_server(server.url(), vec![]).unwrap();
app.set_device_signer(Arc::new(Signer(SoftwareSigner::generate(Uuid::new_v4()))), "phone".into(), PlatformDto::Ios)
.unwrap();
app.register_account(SETUP_TOKEN.into()).await.unwrap();
app.register_account(SETUP_TOKEN.into(), None).await.unwrap();
let mut on_app = kv_ffi::ItemDto {
id: String::new(),
kind: kv_ffi::ItemKindDto::Login,
@@ -93,12 +93,12 @@ async fn web_vault_enrolls_and_syncs_with_an_app_device() {
let signer = Arc::new(SoftwareSigner::generate(Uuid::new_v4()));
let web = web_core(store.clone(), &server.url(), signer.clone());
assert!(matches!(web.sync_now().await, Err(CoreError::NoAccount | CoreError::Locked)));
assert_eq!(web.login(PW, &sk, None).await.unwrap(), LoginResult::PendingApproval);
assert_eq!(web.login(PW, &sk, None, None).await.unwrap(), LoginResult::PendingApproval);
assert!(!web.has_account());
let pending = app.list_devices().await.unwrap().into_iter().find(|d| !d.approved).unwrap();
assert_eq!(pending.key_fingerprint, web.device_key_fingerprint().unwrap());
app.approve_device(pending.device_id).await.unwrap();
assert_eq!(web.login(PW, &sk, None).await.unwrap(), LoginResult::Ok);
assert_eq!(web.login(PW, &sk, None, None).await.unwrap(), LoginResult::Ok);
let r = web.sync_now().await.unwrap();
assert_eq!((r.pulled, r.vaults_synced), (1, 1));
assert_eq!(web.get_item(&bank).unwrap().password.as_deref(), Some("from the app"));
@@ -158,7 +158,7 @@ async fn concurrent_sync_is_refused_not_interleaved() {
let server = TestServer::start().await;
let web = web_core(Arc::new(WebStore::new()), &server.url(), Arc::new(SoftwareSigner::generate(Uuid::new_v4())));
let sk = web.create_account(PW).unwrap();
web.register_account(SETUP_TOKEN).await.unwrap();
web.register_account(SETUP_TOKEN, None).await.unwrap();
web.save_item(web_item("A", "1")).unwrap();
let (a, b) = tokio::join!(web.sync_now(), web.sync_now());
assert!(matches!((&a, &b), (Ok(_), Err(CoreError::Busy)) | (Err(CoreError::Busy), Ok(_))), "{a:?} {b:?}");
@@ -176,7 +176,7 @@ async fn app_with_item(server: &TestServer) -> (Arc<kv_ffi::VaultCore>, String,
app.configure_server(server.url(), vec![]).unwrap();
app.set_device_signer(Arc::new(Signer(SoftwareSigner::generate(Uuid::new_v4()))), "phone".into(), PlatformDto::Ios)
.unwrap();
app.register_account(SETUP_TOKEN.into()).await.unwrap();
app.register_account(SETUP_TOKEN.into(), None).await.unwrap();
let id = app
.save_item(kv_ffi::ItemDto {
id: String::new(),
@@ -254,7 +254,7 @@ async fn web_vault_enrolls_by_ticket_both_ways() {
assert!(matches!(web.unlock("wrong password!!", &sk), Err(CoreError::BadCredentials)));
assert!(matches!(web.setup_payload_for_kit(), Err(CoreError::Locked)));
web.unlock(PW, &sk).unwrap();
assert!(web.login("wrong password!!", &sk, None).await.is_err());
assert!(web.login("wrong password!!", &sk, None, None).await.is_err());
assert!(matches!(web.setup_payload_for_kit(), Err(CoreError::Locked)));
web.unlock(PW, &sk).unwrap();