Multi-user: email sign-in, invite/open registration, admin calls
- kv-proto: email on setup, AccountInfo.email/is_admin, ServerInfo.registration, admin DTOs. - kv-sync: prelogin by email or account id (setup QR / Emergency Kit), register with email, set_email, /v1/admin/* client calls. - kv-ffi: register_account(code, email), login(.., email), account_details, set_account_email, admin_create_invite. kv-wasm: the same plus the full admin API for the web vault. - Live test: two users on one server stay isolated; a new device signs in by email. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -333,13 +333,13 @@ mod tests {
|
|||||||
let a = VaultCore::open(dir.path().join("a.sqlite").to_string_lossy().into_owned()).unwrap();
|
let a = VaultCore::open(dir.path().join("a.sqlite").to_string_lossy().into_owned()).unwrap();
|
||||||
let sk = a.create_account(PW.into()).unwrap();
|
let sk = a.create_account(PW.into()).unwrap();
|
||||||
connect(&a, &server);
|
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();
|
let r = a.import_commit(onepux(), ImportFormat::OnePux, skip()).unwrap();
|
||||||
assert_eq!(a.sync_now().await.unwrap().pushed, r.imported);
|
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();
|
let b = VaultCore::open(dir.path().join("b.sqlite").to_string_lossy().into_owned()).unwrap();
|
||||||
connect(&b, &server);
|
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();
|
b.sync_now().await.unwrap();
|
||||||
assert_eq!(b.list_items().unwrap().len(), 8);
|
assert_eq!(b.list_items().unwrap().len(), 8);
|
||||||
let id = r.item_ids[0].clone();
|
let id = r.item_ids[0].clone();
|
||||||
|
|||||||
@@ -340,6 +340,22 @@ struct Inner {
|
|||||||
api: Option<Arc<ApiClient>>,
|
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.
|
/// One account's vault on this device.
|
||||||
#[derive(uniffi::Object)]
|
#[derive(uniffi::Object)]
|
||||||
pub struct VaultCore {
|
pub struct VaultCore {
|
||||||
@@ -750,16 +766,18 @@ impl VaultCore {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// First device only: registers the account created by `create_account` with a fresh server,
|
/// Registers the account created by `create_account` with the server. `setup_token` is the server's setup code
|
||||||
/// using the one-time setup token the server printed on first boot. Must be unlocked with the
|
/// (its first account, which becomes the admin), an invite code from the admin, or empty with open sign-up;
|
||||||
/// master password (not biometrics) in this session. Logs in afterwards.
|
/// `email` is what the account signs in with (required once the server has an account; docs/03 "Accounts").
|
||||||
pub async fn register_account(&self, setup_token: String) -> Result<()> {
|
/// 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 api = self.api()?;
|
||||||
let (acct, u) = self.unlocked_snapshot()?;
|
let (acct, u) = self.unlocked_snapshot()?;
|
||||||
let auth = u.auth_input.clone().ok_or(CoreError::NotAuthenticated)?;
|
let auth = u.auth_input.clone().ok_or(CoreError::NotAuthenticated)?;
|
||||||
let device = self.enroll()?;
|
let device = self.enroll()?;
|
||||||
api.register(
|
api.register(
|
||||||
&setup_token,
|
&setup_token,
|
||||||
|
email.as_deref(),
|
||||||
acct.account_id,
|
acct.account_id,
|
||||||
&acct.kdf,
|
&acct.kdf,
|
||||||
auth.as_ref(),
|
auth.as_ref(),
|
||||||
@@ -772,7 +790,8 @@ impl VaultCore {
|
|||||||
self.do_login(&api, acct.account_id, auth.as_ref(), None).await.map(|_| ())
|
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.
|
/// * New device (no local account): fetches the account from the server, stores it and unlocks.
|
||||||
/// Then call `sync_now` to download the vault.
|
/// Then call `sync_now` to download the vault.
|
||||||
/// * Existing account: renews the session and unlocks.
|
/// * Existing account: renews the session and unlocks.
|
||||||
@@ -781,9 +800,28 @@ impl VaultCore {
|
|||||||
master_password: String,
|
master_password: String,
|
||||||
secret_key: String,
|
secret_key: String,
|
||||||
totp: Option<String>,
|
totp: Option<String>,
|
||||||
|
email: Option<String>,
|
||||||
) -> Result<LoginResult> {
|
) -> Result<LoginResult> {
|
||||||
let sk = SecretKey::parse(&secret_key)?;
|
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
|
/// Pull, merge, push and verify every vault. Renews an expired session automatically when the
|
||||||
@@ -802,6 +840,7 @@ impl VaultCore {
|
|||||||
totp: Option<String>,
|
totp: Option<String>,
|
||||||
ticket: Option<String>,
|
ticket: Option<String>,
|
||||||
expect_account: Option<Uuid>,
|
expect_account: Option<Uuid>,
|
||||||
|
email: Option<String>,
|
||||||
) -> Result<LoginResult> {
|
) -> Result<LoginResult> {
|
||||||
let api = self.api()?;
|
let api = self.api()?;
|
||||||
let local = self.lock_inner().account.clone();
|
let local = self.lock_inner().account.clone();
|
||||||
@@ -810,7 +849,12 @@ impl VaultCore {
|
|||||||
let (account_id, kdf_params) = match &local {
|
let (account_id, kdf_params) = match &local {
|
||||||
Some(a) => (a.account_id, a.kdf.clone()),
|
Some(a) => (a.account_id, a.kdf.clone()),
|
||||||
None => {
|
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)
|
(pre.account_id, pre.kdf)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ impl VaultCore {
|
|||||||
}
|
}
|
||||||
self.configure_server(p.server_url.clone(), p.spki_pins.clone())?;
|
self.configure_server(p.server_url.clone(), p.spki_pins.clone())?;
|
||||||
let sk = SecretKey::parse(&p.secret_key)?;
|
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.
|
/// Joining device (no camera): opens a rendezvous on `server_url` and returns the QR to show.
|
||||||
|
|||||||
@@ -194,14 +194,14 @@ async fn passkey_created_on_one_device_signs_on_another() {
|
|||||||
let a = VaultCore::new_in_memory();
|
let a = VaultCore::new_in_memory();
|
||||||
let sk = a.create_account(PW.into()).unwrap();
|
let sk = a.create_account(PW.into()).unwrap();
|
||||||
connect(&a, &server);
|
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 reg = a.create_passkey(RP.into(), b"u".to_vec(), "me".into(), cdh(b"r"), None).unwrap();
|
||||||
let vk = rp_verify_registration(®, RP);
|
let vk = rp_verify_registration(®, RP);
|
||||||
assert_eq!(a.sync_now().await.unwrap().pushed, 1);
|
assert_eq!(a.sync_now().await.unwrap().pushed, 1);
|
||||||
|
|
||||||
let b = VaultCore::new_in_memory();
|
let b = VaultCore::new_in_memory();
|
||||||
connect(&b, &server);
|
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();
|
b.sync_now().await.unwrap();
|
||||||
let on_b = b.passkeys_for_rp(RP.into(), vec![]).unwrap();
|
let on_b = b.passkeys_for_rp(RP.into(), vec![]).unwrap();
|
||||||
assert_eq!(on_b.len(), 1);
|
assert_eq!(on_b.len(), 1);
|
||||||
|
|||||||
+19
-19
@@ -172,7 +172,7 @@ async fn two_devices_sync_through_server() {
|
|||||||
let sk = a.create_account(PW.into()).unwrap();
|
let sk = a.create_account(PW.into()).unwrap();
|
||||||
connect(&a, &server);
|
connect(&a, &server);
|
||||||
assert!(a.server_info().await.unwrap().setup_open);
|
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());
|
assert!(a.is_logged_in());
|
||||||
let id = a.save_item(login_item("Bank", "pw-a")).unwrap();
|
let id = a.save_item(login_item("Bank", "pw-a")).unwrap();
|
||||||
let r = a.sync_now().await.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_path = dir.path().join("b.sqlite").to_string_lossy().into_owned();
|
||||||
let b = VaultCore::open(b_path.clone()).unwrap();
|
let b = VaultCore::open(b_path.clone()).unwrap();
|
||||||
connect(&b, &server);
|
connect(&b, &server);
|
||||||
assert!(matches!(b.login("wrong password!!".into(), sk.clone(), None).await, Err(CoreError::BadCredentials)));
|
assert!(matches!(b.login("wrong password!!".into(), sk.clone(), None, None).await, Err(CoreError::BadCredentials)));
|
||||||
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);
|
||||||
assert!(b.has_account() && !b.is_locked());
|
assert!(b.has_account() && !b.is_locked());
|
||||||
b.sync_now().await.unwrap();
|
b.sync_now().await.unwrap();
|
||||||
assert_eq!(b.get_item(id.clone()).unwrap().password.as_deref(), Some("pw-a"));
|
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();
|
let a = VaultCore::new_in_memory();
|
||||||
a.create_account(PW.into()).unwrap();
|
a.create_account(PW.into()).unwrap();
|
||||||
connect(&a, &server);
|
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 {
|
for i in 0..2 {
|
||||||
a.save_item(login_item(&format!("item {i}"), "p")).unwrap();
|
a.save_item(login_item(&format!("item {i}"), "p")).unwrap();
|
||||||
a.sync_now().await.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 a = VaultCore::new_in_memory();
|
||||||
let sk = a.create_account(PW.into()).unwrap();
|
let sk = a.create_account(PW.into()).unwrap();
|
||||||
connect(&a, &server);
|
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();
|
a.sync_now().await.unwrap();
|
||||||
|
|
||||||
server.state().require_approval = true;
|
server.state().require_approval = true;
|
||||||
let b = VaultCore::new_in_memory();
|
let b = VaultCore::new_in_memory();
|
||||||
connect(&b, &server);
|
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());
|
assert!(!b.has_account());
|
||||||
// A approves B after comparing fingerprints, then B's next login succeeds.
|
// A approves B after comparing fingerprints, then B's next login succeeds.
|
||||||
let devices = a.list_devices().await.unwrap();
|
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_eq!(pending.key_fingerprint, b.device_key_fingerprint().unwrap());
|
||||||
assert!(devices.iter().any(|d| d.current && d.approved));
|
assert!(devices.iter().any(|d| d.current && d.approved));
|
||||||
a.approve_device(pending.device_id.clone()).await.unwrap();
|
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();
|
b.sync_now().await.unwrap();
|
||||||
// Revoking B kills its session; B learns it was removed and resets its identity.
|
// Revoking B kills its session; B learns it was removed and resets its identity.
|
||||||
a.revoke_device(pending.device_id.clone()).await.unwrap();
|
a.revoke_device(pending.device_id.clone()).await.unwrap();
|
||||||
@@ -294,7 +294,7 @@ async fn pending_approval_and_biometric_session_expiry() {
|
|||||||
c.lock();
|
c.lock();
|
||||||
c.unlock_with_local_key(luk).unwrap();
|
c.unlock_with_local_key(luk).unwrap();
|
||||||
connect(&c, &server);
|
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]
|
#[tokio::test]
|
||||||
@@ -303,7 +303,7 @@ async fn save_item_bumps_seq_and_replays_are_refused() {
|
|||||||
let a = VaultCore::new_in_memory();
|
let a = VaultCore::new_in_memory();
|
||||||
let sk = a.create_account(PW.into()).unwrap();
|
let sk = a.create_account(PW.into()).unwrap();
|
||||||
connect(&a, &server);
|
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();
|
let id = a.save_item(login_item("Bank", "pw1")).unwrap();
|
||||||
a.sync_now().await.unwrap();
|
a.sync_now().await.unwrap();
|
||||||
let vid = *server.state().vaults.keys().next().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();
|
let b = VaultCore::new_in_memory();
|
||||||
connect(&b, &server);
|
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();
|
b.sync_now().await.unwrap();
|
||||||
assert_eq!(seq_of(&b), 3);
|
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 a = VaultCore::new_in_memory();
|
||||||
let sk = a.create_account(PW.into()).unwrap();
|
let sk = a.create_account(PW.into()).unwrap();
|
||||||
connect(&a, &server);
|
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 gone = a.save_item(login_item("Old", "pw0")).unwrap();
|
||||||
let kept = a.save_item(login_item("Bank", "pw1")).unwrap();
|
let kept = a.save_item(login_item("Bank", "pw1")).unwrap();
|
||||||
a.sync_now().await.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();
|
let b = VaultCore::new_in_memory();
|
||||||
connect(&b, &server);
|
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();
|
b.sync_now().await.unwrap();
|
||||||
assert_eq!(b.list_items().unwrap().len(), 1);
|
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 a = VaultCore::new_in_memory();
|
||||||
let sk = a.create_account(PW.into()).unwrap();
|
let sk = a.create_account(PW.into()).unwrap();
|
||||||
connect(&a, server);
|
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();
|
let id = a.save_item(login_item("Bank", "pw-a")).unwrap();
|
||||||
a.sync_now().await.unwrap();
|
a.sync_now().await.unwrap();
|
||||||
server.state().require_approval = true;
|
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.configure_server(server.url(), vec![]).unwrap();
|
||||||
phone.set_device_signer(signer.clone(), "iPhone".into(), PlatformDto::Ios).unwrap();
|
phone.set_device_signer(signer.clone(), "iPhone".into(), PlatformDto::Ios).unwrap();
|
||||||
let old_id = signer.device_id();
|
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();
|
a.revoke_device(old_id.clone()).await.unwrap();
|
||||||
|
|
||||||
// Reinstall: a new core, the same platform identity. A wrong password reveals nothing and resets nothing.
|
// 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();
|
let b = VaultCore::new_in_memory();
|
||||||
b.configure_server(server.url(), vec![]).unwrap();
|
b.configure_server(server.url(), vec![]).unwrap();
|
||||||
b.set_device_signer(signer.clone(), "laptop".into(), PlatformDto::Linux).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();
|
b.sync_now().await.unwrap();
|
||||||
let old_id = signer.device_id();
|
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);
|
assert_eq!(signer.resets.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||||
|
|
||||||
server.state().require_approval = true;
|
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();
|
let new_id = signer.device_id();
|
||||||
assert_ne!(new_id, old_id);
|
assert_ne!(new_id, old_id);
|
||||||
a.approve_device(new_id).await.unwrap();
|
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();
|
b.sync_now().await.unwrap();
|
||||||
assert_eq!(b.get_item(id).unwrap().password.as_deref(), Some("pw-a"));
|
assert_eq!(b.get_item(id).unwrap().password.as_deref(), Some("pw-a"));
|
||||||
// The old id can never come back.
|
// 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);
|
assert_eq!(a.server_url(), None);
|
||||||
connect(&a, &server);
|
connect(&a, &server);
|
||||||
assert_eq!(a.server_url(), Some(server.url()));
|
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
|
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();
|
a.sign_out_device(false).await.unwrap();
|
||||||
assert!(!a.has_account() && !a.is_logged_in());
|
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.
|
// Same device, signed in again: the item came back from the server.
|
||||||
connect(&a, &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();
|
a.sync_now().await.unwrap();
|
||||||
assert_eq!(a.get_item(id).unwrap().password.as_deref(), Some("pw-a"));
|
assert_eq!(a.get_item(id).unwrap().password.as_deref(), Some("pw-a"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ async fn two_devices_against_local_server() {
|
|||||||
let sk = a.create_account(pw.into()).unwrap();
|
let sk = a.create_account(pw.into()).unwrap();
|
||||||
connect(&a, &url, "device A");
|
connect(&a, &url, "device A");
|
||||||
assert!(a.server_info().await.unwrap().setup_open, "server must be fresh (setup open)");
|
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 id = a.save_item(item("Bank", "pw-a")).unwrap();
|
||||||
let r = a.sync_now().await.unwrap();
|
let r = a.sync_now().await.unwrap();
|
||||||
assert_eq!(r.pushed, 1);
|
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;
|
tokio::time::sleep(std::time::Duration::from_secs(61)).await;
|
||||||
let b = VaultCore::new_in_memory();
|
let b = VaultCore::new_in_memory();
|
||||||
connect(&b, &url, "device B");
|
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 devices = a.list_devices().await.unwrap();
|
||||||
let pending = devices.iter().find(|d| !d.approved).expect("B listed as pending");
|
let pending = devices.iter().find(|d| !d.approved).expect("B listed as pending");
|
||||||
assert_eq!(pending.key_fingerprint, b.device_key_fingerprint().unwrap());
|
assert_eq!(pending.key_fingerprint, b.device_key_fingerprint().unwrap());
|
||||||
a.approve_device(pending.device_id.clone()).await.unwrap();
|
a.approve_device(pending.device_id.clone()).await.unwrap();
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(61)).await; // rate limit again
|
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();
|
b.sync_now().await.unwrap();
|
||||||
assert_eq!(b.get_item(id.clone()).unwrap().password.as_deref(), Some("pw-a"));
|
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();
|
let web = VaultCore::new_in_memory();
|
||||||
web.create_account(pw.into()).unwrap();
|
web.create_account(pw.into()).unwrap();
|
||||||
connect(&web, &url, "web vault");
|
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();
|
let id = web.save_item(item("Bank", "pw-a")).unwrap();
|
||||||
web.sync_now().await.unwrap();
|
web.sync_now().await.unwrap();
|
||||||
|
|
||||||
@@ -206,7 +206,7 @@ async fn delete_account_against_local_server() {
|
|||||||
let app = VaultCore::new_in_memory();
|
let app = VaultCore::new_in_memory();
|
||||||
app.create_account(pw.into()).unwrap();
|
app.create_account(pw.into()).unwrap();
|
||||||
connect(&app, &url, "phone");
|
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.save_item(item("Bank", "pw-a")).unwrap();
|
||||||
app.sync_now().await.unwrap();
|
app.sync_now().await.unwrap();
|
||||||
|
|
||||||
@@ -217,6 +217,55 @@ async fn delete_account_against_local_server() {
|
|||||||
let fresh = VaultCore::new_in_memory();
|
let fresh = VaultCore::new_in_memory();
|
||||||
fresh.create_account(pw.into()).unwrap();
|
fresh.create_account(pw.into()).unwrap();
|
||||||
connect(&fresh, &url, "new phone");
|
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}");
|
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})");
|
||||||
|
}
|
||||||
|
|||||||
@@ -112,8 +112,22 @@ pub struct ServerInfo {
|
|||||||
pub version: String,
|
pub version: String,
|
||||||
pub api: u32,
|
pub api: u32,
|
||||||
pub single_tenant: bool,
|
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 setup_open: bool,
|
||||||
pub min_client: String,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -129,7 +143,11 @@ pub struct DeviceEnroll {
|
|||||||
/// `POST /v1/setup/start`: OPAQUE registration, first message. Needs the setup token.
|
/// `POST /v1/setup/start`: OPAQUE registration, first message. Needs the setup token.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct SetupStart {
|
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,
|
pub setup_token: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub email: Option<String>,
|
||||||
pub account_id: Uuid,
|
pub account_id: Uuid,
|
||||||
/// Serialized opaque-ke `RegistrationRequest`.
|
/// Serialized opaque-ke `RegistrationRequest`.
|
||||||
#[serde(with = "b64")]
|
#[serde(with = "b64")]
|
||||||
@@ -150,7 +168,12 @@ pub type SetupFinish = SetupRequest;
|
|||||||
/// Body of `POST /v1/setup/finish` (alias [`SetupFinish`]).
|
/// Body of `POST /v1/setup/finish` (alias [`SetupFinish`]).
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct SetupRequest {
|
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,
|
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 account_id: Uuid,
|
||||||
pub kdf: KdfParams,
|
pub kdf: KdfParams,
|
||||||
/// Serialized opaque-ke `RegistrationUpload`.
|
/// Serialized opaque-ke `RegistrationUpload`.
|
||||||
@@ -235,6 +258,82 @@ pub struct AccountInfo {
|
|||||||
#[serde(with = "b64")]
|
#[serde(with = "b64")]
|
||||||
pub sig_pk: Vec<u8>,
|
pub sig_pk: Vec<u8>,
|
||||||
pub wrapped_keyset: Envelope,
|
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.
|
/// `POST /v1/account/credentials/start`: OPAQUE re-registration for a password change.
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use kv_proto::{
|
use kv_proto::{
|
||||||
AccountInfo, ApiError, Changes, DeviceEnroll, DeviceInfo, Envelope, ItemPut, ItemRecord, JoinBundle, JoinCreate,
|
AccountEmail, AccountInfo, AdminSettings, AdminUser, AdminUserUpdate, ApiError, Changes, DeviceEnroll, DeviceInfo,
|
||||||
JoinCreated, JoinHello, JoinHelloResponse, JoinPoll, KdfParams, LoginFinish, LoginFinishResponse, LoginStart,
|
Envelope, InviteCreate, InviteCreated, InviteInfo, ItemPut, ItemRecord, JoinBundle, JoinCreate, JoinCreated,
|
||||||
|
JoinHello, JoinHelloResponse, JoinPoll, KdfParams, LoginFinish, LoginFinishResponse, LoginStart,
|
||||||
LoginStartResponse, LoginStatus, Manifest, PreloginResponse, RegistrationStartResponse, ServerInfo, SetupFinish,
|
LoginStartResponse, LoginStatus, Manifest, PreloginResponse, RegistrationStartResponse, ServerInfo, SetupFinish,
|
||||||
SetupStart, TicketCreate, TicketCreated, TicketInfo, Vault, HEADER_JOIN_TOKEN,
|
SetupStart, TicketCreate, TicketCreated, TicketInfo, Vault, HEADER_JOIN_TOKEN,
|
||||||
};
|
};
|
||||||
@@ -32,6 +33,16 @@ pub enum LoginOutcome {
|
|||||||
PendingApproval,
|
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 {
|
pub struct ApiClient {
|
||||||
base: String,
|
base: String,
|
||||||
http: reqwest::Client,
|
http: reqwest::Client,
|
||||||
@@ -166,17 +177,27 @@ impl ApiClient {
|
|||||||
self.json(Method::GET, PATH_SERVER_INFO, None::<&()>, None, false).await
|
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
|
/// Account id and KDF parameters: what a new device needs to derive `AUTH_INPUT` before it can build KE1.
|
||||||
/// `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) -> Result<PreloginResponse, SyncError> {
|
pub async fn prelogin(&self, by: PreloginBy<'_>) -> Result<PreloginResponse, SyncError> {
|
||||||
self.json(Method::GET, PATH_PRELOGIN, None::<&()>, None, false).await
|
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)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn register(
|
pub async fn register(
|
||||||
&self,
|
&self,
|
||||||
setup_token: &str,
|
setup_token: &str,
|
||||||
|
email: Option<&str>,
|
||||||
account_id: Uuid,
|
account_id: Uuid,
|
||||||
kdf: &KdfParams,
|
kdf: &KdfParams,
|
||||||
auth_input: &[u8],
|
auth_input: &[u8],
|
||||||
@@ -186,13 +207,19 @@ impl ApiClient {
|
|||||||
device: DeviceEnroll,
|
device: DeviceEnroll,
|
||||||
) -> Result<(), SyncError> {
|
) -> Result<(), SyncError> {
|
||||||
let reg = opaque::registration_start(auth_input)?;
|
let reg = opaque::registration_start(auth_input)?;
|
||||||
let start =
|
let email = email.map(|e| e.trim().to_string()).filter(|e| !e.is_empty());
|
||||||
SetupStart { setup_token: setup_token.to_string(), account_id, registration_request: reg.request.clone() };
|
let start = SetupStart {
|
||||||
|
setup_token: setup_token.to_string(),
|
||||||
|
email: email.clone(),
|
||||||
|
account_id,
|
||||||
|
registration_request: reg.request.clone(),
|
||||||
|
};
|
||||||
let resp: RegistrationStartResponse =
|
let resp: RegistrationStartResponse =
|
||||||
self.json(Method::POST, PATH_SETUP_START, Some(&start), None, false).await?;
|
self.json(Method::POST, PATH_SETUP_START, Some(&start), None, false).await?;
|
||||||
let upload = reg.finish(auth_input, &resp.registration_response)?;
|
let upload = reg.finish(auth_input, &resp.registration_response)?;
|
||||||
let req = SetupFinish {
|
let req = SetupFinish {
|
||||||
setup_token: setup_token.to_string(),
|
setup_token: setup_token.to_string(),
|
||||||
|
email,
|
||||||
account_id,
|
account_id,
|
||||||
kdf: kdf.clone(),
|
kdf: kdf.clone(),
|
||||||
opaque_registration_upload: upload,
|
opaque_registration_upload: upload,
|
||||||
@@ -299,6 +326,45 @@ impl ApiClient {
|
|||||||
r
|
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> {
|
pub async fn account(&self) -> Result<AccountInfo, SyncError> {
|
||||||
self.json(Method::GET, PATH_ACCOUNT, None::<&()>, None, true).await
|
self.json(Method::GET, PATH_ACCOUNT, None::<&()>, None, true).await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ pub mod test_server;
|
|||||||
pub mod transport;
|
pub mod transport;
|
||||||
pub mod wire;
|
pub mod wire;
|
||||||
|
|
||||||
pub use client::{ApiClient, LoginOutcome};
|
pub use client::{ApiClient, LoginOutcome, PreloginBy};
|
||||||
pub use engine::{SyncContext, SyncReport};
|
pub use engine::{SyncContext, SyncReport};
|
||||||
use std::{future::Future, pin::Pin};
|
use std::{future::Future, pin::Pin};
|
||||||
|
|
||||||
|
|||||||
@@ -229,6 +229,7 @@ fn handle(st: &mut State, req: &Request) -> Result<ResponseTemplate, ResponseTem
|
|||||||
single_tenant: true,
|
single_tenant: true,
|
||||||
setup_open: st.setup_token.is_some(),
|
setup_open: st.setup_token.is_some(),
|
||||||
min_client: "0.1.0".into(),
|
min_client: "0.1.0".into(),
|
||||||
|
registration: None,
|
||||||
},
|
},
|
||||||
)),
|
)),
|
||||||
("POST", ["v1", "setup", "start"]) => {
|
("POST", ["v1", "setup", "start"]) => {
|
||||||
@@ -459,6 +460,8 @@ fn authed(
|
|||||||
enc_pk: a.enc_pk.clone(),
|
enc_pk: a.enc_pk.clone(),
|
||||||
sig_pk: a.sig_pk.clone(),
|
sig_pk: a.sig_pk.clone(),
|
||||||
wrapped_keyset: a.wrapped_keyset.clone(),
|
wrapped_keyset: a.wrapped_keyset.clone(),
|
||||||
|
email: None,
|
||||||
|
is_admin: true,
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_LOGIN_FINISH: &str = "/v1/auth/login/finish";
|
||||||
pub const PATH_LOGOUT: &str = "/v1/auth/logout";
|
pub const PATH_LOGOUT: &str = "/v1/auth/logout";
|
||||||
pub const PATH_ACCOUNT: &str = "/v1/account";
|
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_VAULTS: &str = "/v1/vaults";
|
||||||
pub const PATH_DEVICES: &str = "/v1/devices";
|
pub const PATH_DEVICES: &str = "/v1/devices";
|
||||||
pub const PATH_TICKETS: &str = "/v1/devices/tickets";
|
pub const PATH_TICKETS: &str = "/v1/devices/tickets";
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ impl Device {
|
|||||||
d.api
|
d.api
|
||||||
.register(
|
.register(
|
||||||
SETUP_TOKEN,
|
SETUP_TOKEN,
|
||||||
|
None,
|
||||||
d.keyset.account_id,
|
d.keyset.account_id,
|
||||||
&test_kdf(),
|
&test_kdf(),
|
||||||
&AUTH,
|
&AUTH,
|
||||||
@@ -410,6 +411,7 @@ async fn auth_errors() {
|
|||||||
.api
|
.api
|
||||||
.register(
|
.register(
|
||||||
SETUP_TOKEN,
|
SETUP_TOKEN,
|
||||||
|
None,
|
||||||
Uuid::new_v4(),
|
Uuid::new_v4(),
|
||||||
&test_kdf(),
|
&test_kdf(),
|
||||||
&AUTH,
|
&AUTH,
|
||||||
|
|||||||
+120
-7
@@ -490,17 +490,110 @@ impl WebCore {
|
|||||||
api: info.api,
|
api: info.api,
|
||||||
setup_open: info.setup_open,
|
setup_open: info.setup_open,
|
||||||
min_client: info.min_client,
|
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 account_details(&self) -> Result<AccountDetailsDto> {
|
||||||
pub async fn register_account(&self, setup_token: &str) -> 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<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 api = self.api()?;
|
||||||
let (acct, u) = self.unlocked_snapshot()?;
|
let (acct, u) = self.unlocked_snapshot()?;
|
||||||
let auth = u.auth_input.clone().ok_or(CoreError::NotAuthenticated)?;
|
let auth = u.auth_input.clone().ok_or(CoreError::NotAuthenticated)?;
|
||||||
let device = self.enroll()?;
|
let device = self.enroll()?;
|
||||||
api.register(
|
api.register(
|
||||||
setup_token,
|
setup_token,
|
||||||
|
email.as_deref(),
|
||||||
acct.account_id,
|
acct.account_id,
|
||||||
&acct.kdf,
|
&acct.kdf,
|
||||||
auth.as_ref(),
|
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
|
/// 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.
|
/// 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())?;
|
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`.
|
/// `login`, optionally presenting an enrollment ticket and checking the server holds `expect_account`.
|
||||||
@@ -528,8 +627,9 @@ impl WebCore {
|
|||||||
totp: Option<String>,
|
totp: Option<String>,
|
||||||
ticket: Option<String>,
|
ticket: Option<String>,
|
||||||
expect_account: Option<Uuid>,
|
expect_account: Option<Uuid>,
|
||||||
|
email: Option<String>,
|
||||||
) -> Result<LoginResult> {
|
) -> 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())
|
r.inspect_err(|_| self.forget_secret_key())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,13 +640,19 @@ impl WebCore {
|
|||||||
totp: Option<String>,
|
totp: Option<String>,
|
||||||
ticket: Option<String>,
|
ticket: Option<String>,
|
||||||
expect_account: Option<Uuid>,
|
expect_account: Option<Uuid>,
|
||||||
|
email: Option<String>,
|
||||||
) -> Result<LoginResult> {
|
) -> Result<LoginResult> {
|
||||||
let api = self.api()?;
|
let api = self.api()?;
|
||||||
let local = self.lock_inner().account.clone();
|
let local = self.lock_inner().account.clone();
|
||||||
let (account_id, kdf_params) = match &local {
|
let (account_id, kdf_params) = match &local {
|
||||||
Some(a) => (a.account_id, a.kdf.clone()),
|
Some(a) => (a.account_id, a.kdf.clone()),
|
||||||
None => {
|
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)
|
(pre.account_id, pre.kdf)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -720,7 +826,7 @@ impl WebCore {
|
|||||||
}
|
}
|
||||||
self.configure_server(&p.server_url, p.spki_pins.clone())?;
|
self.configure_server(&p.server_url, p.spki_pins.clone())?;
|
||||||
let sk = SecretKey::parse(&p.secret_key)?;
|
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
|
/// 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> {
|
fn parse_id(s: &str) -> Result<Uuid> {
|
||||||
Uuid::parse_str(s).map_err(|_| CoreError::Invalid { msg: "id must be a UUID".into() })
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -88,6 +88,56 @@ pub struct ServerInfoDto {
|
|||||||
pub api: u32,
|
pub api: u32,
|
||||||
pub setup_open: bool,
|
pub setup_open: bool,
|
||||||
pub min_client: String,
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -62,7 +62,12 @@ export interface ItemDto {
|
|||||||
|
|
||||||
export interface TotpCode { code: string; secondsRemaining: number; period: number; }
|
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 {
|
export interface SyncReport {
|
||||||
pulled: number;
|
pulled: number;
|
||||||
@@ -648,8 +653,12 @@ impl VaultCore {
|
|||||||
|
|
||||||
/// First device only (the web vault is rarely that): registers the account with a fresh server.
|
/// 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>")]
|
#[wasm_bindgen(js_name = registerAccount, unchecked_return_type = "Promise<void>")]
|
||||||
pub fn register_account(&self, #[wasm_bindgen(js_name = "setupToken")] setup_token: String) -> Promise {
|
pub fn register_account(
|
||||||
self.write(move |s| async move { s.core.register_account(&setup_token).await })
|
&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
|
/// 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 = "masterPassword")] master_password: String,
|
||||||
#[wasm_bindgen(js_name = "secretKey")] secret_key: String,
|
#[wasm_bindgen(js_name = "secretKey")] secret_key: String,
|
||||||
totp: Option<String>,
|
totp: Option<String>,
|
||||||
|
email: Option<String>,
|
||||||
) -> Promise {
|
) -> Promise {
|
||||||
let (pw, sk) = (zeroize::Zeroizing::new(master_password), zeroize::Zeroizing::new(secret_key));
|
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(®istration, allowed_domains).await })
|
||||||
}
|
}
|
||||||
|
|
||||||
#[wasm_bindgen(js_name = syncNow, unchecked_return_type = "Promise<SyncReport>")]
|
#[wasm_bindgen(js_name = syncNow, unchecked_return_type = "Promise<SyncReport>")]
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ async fn web_vault_enrolls_and_syncs_with_an_app_device() {
|
|||||||
app.configure_server(server.url(), vec![]).unwrap();
|
app.configure_server(server.url(), vec![]).unwrap();
|
||||||
app.set_device_signer(Arc::new(Signer(SoftwareSigner::generate(Uuid::new_v4()))), "phone".into(), PlatformDto::Ios)
|
app.set_device_signer(Arc::new(Signer(SoftwareSigner::generate(Uuid::new_v4()))), "phone".into(), PlatformDto::Ios)
|
||||||
.unwrap();
|
.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 {
|
let mut on_app = kv_ffi::ItemDto {
|
||||||
id: String::new(),
|
id: String::new(),
|
||||||
kind: kv_ffi::ItemKindDto::Login,
|
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 signer = Arc::new(SoftwareSigner::generate(Uuid::new_v4()));
|
||||||
let web = web_core(store.clone(), &server.url(), signer.clone());
|
let web = web_core(store.clone(), &server.url(), signer.clone());
|
||||||
assert!(matches!(web.sync_now().await, Err(CoreError::NoAccount | CoreError::Locked)));
|
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());
|
assert!(!web.has_account());
|
||||||
let pending = app.list_devices().await.unwrap().into_iter().find(|d| !d.approved).unwrap();
|
let pending = app.list_devices().await.unwrap().into_iter().find(|d| !d.approved).unwrap();
|
||||||
assert_eq!(pending.key_fingerprint, web.device_key_fingerprint().unwrap());
|
assert_eq!(pending.key_fingerprint, web.device_key_fingerprint().unwrap());
|
||||||
app.approve_device(pending.device_id).await.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();
|
let r = web.sync_now().await.unwrap();
|
||||||
assert_eq!((r.pulled, r.vaults_synced), (1, 1));
|
assert_eq!((r.pulled, r.vaults_synced), (1, 1));
|
||||||
assert_eq!(web.get_item(&bank).unwrap().password.as_deref(), Some("from the app"));
|
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 server = TestServer::start().await;
|
||||||
let web = web_core(Arc::new(WebStore::new()), &server.url(), Arc::new(SoftwareSigner::generate(Uuid::new_v4())));
|
let web = web_core(Arc::new(WebStore::new()), &server.url(), Arc::new(SoftwareSigner::generate(Uuid::new_v4())));
|
||||||
let sk = web.create_account(PW).unwrap();
|
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();
|
web.save_item(web_item("A", "1")).unwrap();
|
||||||
let (a, b) = tokio::join!(web.sync_now(), web.sync_now());
|
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:?}");
|
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.configure_server(server.url(), vec![]).unwrap();
|
||||||
app.set_device_signer(Arc::new(Signer(SoftwareSigner::generate(Uuid::new_v4()))), "phone".into(), PlatformDto::Ios)
|
app.set_device_signer(Arc::new(Signer(SoftwareSigner::generate(Uuid::new_v4()))), "phone".into(), PlatformDto::Ios)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
app.register_account(SETUP_TOKEN.into()).await.unwrap();
|
app.register_account(SETUP_TOKEN.into(), None).await.unwrap();
|
||||||
let id = app
|
let id = app
|
||||||
.save_item(kv_ffi::ItemDto {
|
.save_item(kv_ffi::ItemDto {
|
||||||
id: String::new(),
|
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.unlock("wrong password!!", &sk), Err(CoreError::BadCredentials)));
|
||||||
assert!(matches!(web.setup_payload_for_kit(), Err(CoreError::Locked)));
|
assert!(matches!(web.setup_payload_for_kit(), Err(CoreError::Locked)));
|
||||||
web.unlock(PW, &sk).unwrap();
|
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)));
|
assert!(matches!(web.setup_payload_for_kit(), Err(CoreError::Locked)));
|
||||||
web.unlock(PW, &sk).unwrap();
|
web.unlock(PW, &sk).unwrap();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user