diff --git a/crates/kv-ffi/src/import.rs b/crates/kv-ffi/src/import.rs index 80dbe86..e9e99b9 100644 --- a/crates/kv-ffi/src/import.rs +++ b/crates/kv-ffi/src/import.rs @@ -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(); diff --git a/crates/kv-ffi/src/lib.rs b/crates/kv-ffi/src/lib.rs index 62b3b75..ff3e86c 100644 --- a/crates/kv-ffi/src/lib.rs +++ b/crates/kv-ffi/src/lib.rs @@ -340,6 +340,22 @@ struct Inner { api: Option>, } +/// 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, + /// 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) -> 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, + email: Option, ) -> Result { 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 { + 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, days: Option) -> Result { + 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, ticket: Option, expect_account: Option, + email: Option, ) -> Result { 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) } }; diff --git a/crates/kv-ffi/src/onboarding.rs b/crates/kv-ffi/src/onboarding.rs index 1d017ef..0499720 100644 --- a/crates/kv-ffi/src/onboarding.rs +++ b/crates/kv-ffi/src/onboarding.rs @@ -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. diff --git a/crates/kv-ffi/src/passkeys/tests.rs b/crates/kv-ffi/src/passkeys/tests.rs index 189fe17..85380d3 100644 --- a/crates/kv-ffi/src/passkeys/tests.rs +++ b/crates/kv-ffi/src/passkeys/tests.rs @@ -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); diff --git a/crates/kv-ffi/src/tests.rs b/crates/kv-ffi/src/tests.rs index b2085b0..2071f77 100644 --- a/crates/kv-ffi/src/tests.rs +++ b/crates/kv-ffi/src/tests.rs @@ -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, 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")); } diff --git a/crates/kv-ffi/tests/live_server.rs b/crates/kv-ffi/tests/live_server.rs index b52e3a4..babfc92 100644 --- a/crates/kv-ffi/tests/live_server.rs +++ b/crates/kv-ffi/tests/live_server.rs @@ -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("admin@example.com".into())).await.unwrap(); + let details = admin.account_details().await.unwrap(); + assert_eq!((details.email.as_deref(), details.is_admin), (Some("admin@example.com"), 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("b@example.com".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("b@example.com".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("c@example.com".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::>(); + 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("b@example.com".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})"); +} diff --git a/crates/kv-proto/src/lib.rs b/crates/kv-proto/src/lib.rs index 0cc65a3..6394404 100644 --- a/crates/kv-proto/src/lib.rs +++ b/crates/kv-proto/src/lib.rs @@ -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, +} + +/// 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, 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, 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, pub wrapped_keyset: Envelope, + #[serde(default)] + pub email: Option, + #[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, + 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, + 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub is_admin: Option, +} + +/// `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, + /// Days until it expires (1 to 30, default 7). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub days: Option, +} + +/// 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, + 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, } /// `POST /v1/account/credentials/start`: OPAQUE re-registration for a password change. diff --git a/crates/kv-sync/src/client.rs b/crates/kv-sync/src/client.rs index 9089029..00a16ec 100644 --- a/crates/kv-sync/src/client.rs +++ b/crates/kv-sync/src/client.rs @@ -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 { - 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 { + 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, 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, SyncError> { + self.json(Method::GET, PATH_ADMIN_INVITES, None::<&()>, None, true).await + } + + pub async fn admin_create_invite(&self, req: &InviteCreate) -> Result { + 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 { + self.json(Method::GET, PATH_ADMIN_SETTINGS, None::<&()>, None, true).await + } + + pub async fn admin_put_settings(&self, settings: &AdminSettings) -> Result { + self.json(Method::PUT, PATH_ADMIN_SETTINGS, Some(settings), None, true).await + } + pub async fn account(&self) -> Result { self.json(Method::GET, PATH_ACCOUNT, None::<&()>, None, true).await } diff --git a/crates/kv-sync/src/lib.rs b/crates/kv-sync/src/lib.rs index eb999ca..6dad425 100644 --- a/crates/kv-sync/src/lib.rs +++ b/crates/kv-sync/src/lib.rs @@ -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}; diff --git a/crates/kv-sync/src/test_server.rs b/crates/kv-sync/src/test_server.rs index e3aa889..55e2e1d 100644 --- a/crates/kv-sync/src/test_server.rs +++ b/crates/kv-sync/src/test_server.rs @@ -229,6 +229,7 @@ fn handle(st: &mut State, req: &Request) -> Result { @@ -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, }, )) } diff --git a/crates/kv-sync/src/wire.rs b/crates/kv-sync/src/wire.rs index efa9cd2..03462fd 100644 --- a/crates/kv-sync/src/wire.rs +++ b/crates/kv-sync/src/wire.rs @@ -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"; diff --git a/crates/kv-sync/tests/sync.rs b/crates/kv-sync/tests/sync.rs index e2b14e0..07e24e0 100644 --- a/crates/kv-sync/tests/sync.rs +++ b/crates/kv-sync/tests/sync.rs @@ -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, diff --git a/crates/kv-wasm/src/core.rs b/crates/kv-wasm/src/core.rs index 504426e..1cf20be 100644 --- a/crates/kv-wasm/src/core.rs +++ b/crates/kv-wasm/src/core.rs @@ -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 { + 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> { + 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, + is_admin: Option, + ) -> 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> { + 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, days: Option) -> Result { + 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 { + 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, + ) -> Result { + 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) -> 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) -> Result { + pub async fn login( + &self, + master_password: &str, + secret_key: &str, + totp: Option, + email: Option, + ) -> Result { 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, ticket: Option, expect_account: Option, + email: Option, ) -> Result { - 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, ticket: Option, expect_account: Option, + email: Option, ) -> Result { 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::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(), + } +} diff --git a/crates/kv-wasm/src/dto.rs b/crates/kv-wasm/src/dto.rs index 6687dbb..b2bd29d 100644 --- a/crates/kv-wasm/src/dto.rs +++ b/crates/kv-wasm/src/dto.rs @@ -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, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountDetailsDto { + pub email: Option, + 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, + pub is_admin: bool, + pub disabled: bool, + pub created_at: String, + pub last_seen: Option, + 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, + 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, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/kv-wasm/src/wasm/mod.rs b/crates/kv-wasm/src/wasm/mod.rs index 199c248..5ab0d81 100644 --- a/crates/kv-wasm/src/wasm/mod.rs +++ b/crates/kv-wasm/src/wasm/mod.rs @@ -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")] - 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, + ) -> 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, + email: Option, ) -> 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")] + 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")] + 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")] + 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")] + pub fn admin_update_user( + &self, + #[wasm_bindgen(js_name = "accountId")] account_id: String, + disabled: Option, + #[wasm_bindgen(js_name = "isAdmin")] is_admin: Option, + ) -> 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")] + 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")] + 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")] + pub fn admin_create_invite(&self, email: Option, days: Option) -> Promise { + self.run(move |s| async move { s.core.admin_create_invite(email, days).await }) + } + + #[wasm_bindgen(js_name = adminRevokeInvite, unchecked_return_type = "Promise")] + 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")] + 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")] + pub fn admin_put_settings( + &self, + registration: String, + #[wasm_bindgen(js_name = "allowedDomains")] allowed_domains: Vec, + ) -> Promise { + self.run(move |s| async move { s.core.admin_put_settings(®istration, allowed_domains).await }) } #[wasm_bindgen(js_name = syncNow, unchecked_return_type = "Promise")] diff --git a/crates/kv-wasm/tests/sync.rs b/crates/kv-wasm/tests/sync.rs index 72f2406..f2ddefe 100644 --- a/crates/kv-wasm/tests/sync.rs +++ b/crates/kv-wasm/tests/sync.rs @@ -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, 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();