Multi-user: email sign-in, invite/open registration, admin calls
- kv-proto: email on setup, AccountInfo.email/is_admin, ServerInfo.registration, admin DTOs. - kv-sync: prelogin by email or account id (setup QR / Emergency Kit), register with email, set_email, /v1/admin/* client calls. - kv-ffi: register_account(code, email), login(.., email), account_details, set_account_email, admin_create_invite. kv-wasm: the same plus the full admin API for the web vault. - Live test: two users on one server stay isolated; a new device signs in by email. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -333,13 +333,13 @@ mod tests {
|
||||
let a = VaultCore::open(dir.path().join("a.sqlite").to_string_lossy().into_owned()).unwrap();
|
||||
let sk = a.create_account(PW.into()).unwrap();
|
||||
connect(&a, &server);
|
||||
a.register_account(SETUP_TOKEN.into()).await.unwrap();
|
||||
a.register_account(SETUP_TOKEN.into(), None).await.unwrap();
|
||||
let r = a.import_commit(onepux(), ImportFormat::OnePux, skip()).unwrap();
|
||||
assert_eq!(a.sync_now().await.unwrap().pushed, r.imported);
|
||||
|
||||
let b = VaultCore::open(dir.path().join("b.sqlite").to_string_lossy().into_owned()).unwrap();
|
||||
connect(&b, &server);
|
||||
assert_eq!(b.login(PW.into(), sk, None).await.unwrap(), LoginResult::Ok);
|
||||
assert_eq!(b.login(PW.into(), sk, None, None).await.unwrap(), LoginResult::Ok);
|
||||
b.sync_now().await.unwrap();
|
||||
assert_eq!(b.list_items().unwrap().len(), 8);
|
||||
let id = r.item_ids[0].clone();
|
||||
|
||||
@@ -340,6 +340,22 @@ struct Inner {
|
||||
api: Option<Arc<ApiClient>>,
|
||||
}
|
||||
|
||||
/// A new invite, shown once.
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct InviteCode {
|
||||
pub code: String,
|
||||
/// Unix seconds.
|
||||
pub expires_at: i64,
|
||||
}
|
||||
|
||||
/// What `account_details` returns: never anything secret.
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct AccountDetails {
|
||||
pub email: Option<String>,
|
||||
/// This account administers the server (invites, users, sign-up): it still can't read anyone's vault.
|
||||
pub is_admin: bool,
|
||||
}
|
||||
|
||||
/// One account's vault on this device.
|
||||
#[derive(uniffi::Object)]
|
||||
pub struct VaultCore {
|
||||
@@ -750,16 +766,18 @@ impl VaultCore {
|
||||
})
|
||||
}
|
||||
|
||||
/// First device only: registers the account created by `create_account` with a fresh server,
|
||||
/// using the one-time setup token the server printed on first boot. Must be unlocked with the
|
||||
/// master password (not biometrics) in this session. Logs in afterwards.
|
||||
pub async fn register_account(&self, setup_token: String) -> Result<()> {
|
||||
/// Registers the account created by `create_account` with the server. `setup_token` is the server's setup code
|
||||
/// (its first account, which becomes the admin), an invite code from the admin, or empty with open sign-up;
|
||||
/// `email` is what the account signs in with (required once the server has an account; docs/03 "Accounts").
|
||||
/// Must be unlocked with the master password (not biometrics) in this session. Logs in afterwards.
|
||||
pub async fn register_account(&self, setup_token: String, email: Option<String>) -> Result<()> {
|
||||
let api = self.api()?;
|
||||
let (acct, u) = self.unlocked_snapshot()?;
|
||||
let auth = u.auth_input.clone().ok_or(CoreError::NotAuthenticated)?;
|
||||
let device = self.enroll()?;
|
||||
api.register(
|
||||
&setup_token,
|
||||
email.as_deref(),
|
||||
acct.account_id,
|
||||
&acct.kdf,
|
||||
auth.as_ref(),
|
||||
@@ -772,7 +790,8 @@ impl VaultCore {
|
||||
self.do_login(&api, acct.account_id, auth.as_ref(), None).await.map(|_| ())
|
||||
}
|
||||
|
||||
/// Logs in with the master password and Secret Key (the server is single-tenant, so no account id is needed).
|
||||
/// Logs in with the master password, Secret Key and the account's `email` (may be omitted on a server with
|
||||
/// a single account, or when this device already has the account).
|
||||
/// * New device (no local account): fetches the account from the server, stores it and unlocks.
|
||||
/// Then call `sync_now` to download the vault.
|
||||
/// * Existing account: renews the session and unlocks.
|
||||
@@ -781,9 +800,28 @@ impl VaultCore {
|
||||
master_password: String,
|
||||
secret_key: String,
|
||||
totp: Option<String>,
|
||||
email: Option<String>,
|
||||
) -> Result<LoginResult> {
|
||||
let sk = SecretKey::parse(&secret_key)?;
|
||||
self.login_inner(master_password, sk, totp, None, None).await
|
||||
self.login_inner(master_password, sk, totp, None, None, email).await
|
||||
}
|
||||
|
||||
/// The account's email and whether it administers the server (`GET /v1/account`).
|
||||
pub async fn account_details(&self) -> Result<AccountDetails> {
|
||||
let info = self.revoked(self.api()?.account().await)?;
|
||||
Ok(AccountDetails { email: info.email, is_admin: info.is_admin })
|
||||
}
|
||||
|
||||
/// Sets the address this account signs in with (accounts from before multi-user have none).
|
||||
pub async fn set_account_email(&self, email: String) -> Result<()> {
|
||||
self.revoked(self.api()?.set_email(&email).await)
|
||||
}
|
||||
|
||||
/// Admins only: a one-time invite code for a new account (optionally only for `email`, valid `days`, default 7).
|
||||
/// The full admin page is in the web vault.
|
||||
pub async fn admin_create_invite(&self, email: Option<String>, days: Option<u32>) -> Result<InviteCode> {
|
||||
let c = self.revoked(self.api()?.admin_create_invite(&kv_proto::InviteCreate { email, days }).await)?;
|
||||
Ok(InviteCode { code: c.code, expires_at: c.expires_at })
|
||||
}
|
||||
|
||||
/// Pull, merge, push and verify every vault. Renews an expired session automatically when the
|
||||
@@ -802,6 +840,7 @@ impl VaultCore {
|
||||
totp: Option<String>,
|
||||
ticket: Option<String>,
|
||||
expect_account: Option<Uuid>,
|
||||
email: Option<String>,
|
||||
) -> Result<LoginResult> {
|
||||
let api = self.api()?;
|
||||
let local = self.lock_inner().account.clone();
|
||||
@@ -810,7 +849,12 @@ impl VaultCore {
|
||||
let (account_id, kdf_params) = match &local {
|
||||
Some(a) => (a.account_id, a.kdf.clone()),
|
||||
None => {
|
||||
let pre = api.prelogin().await?;
|
||||
let by = match (expect_account, email.as_deref().map(str::trim).filter(|e| !e.is_empty())) {
|
||||
(Some(id), _) => kv_sync::PreloginBy::Account(id),
|
||||
(None, Some(e)) => kv_sync::PreloginBy::Email(e),
|
||||
(None, None) => kv_sync::PreloginBy::OnlyAccount,
|
||||
};
|
||||
let pre = api.prelogin(by).await?;
|
||||
(pre.account_id, pre.kdf)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -155,7 +155,7 @@ impl VaultCore {
|
||||
}
|
||||
self.configure_server(p.server_url.clone(), p.spki_pins.clone())?;
|
||||
let sk = SecretKey::parse(&p.secret_key)?;
|
||||
self.login_inner(master_password, sk, None, p.ticket.clone(), Some(p.account_id)).await
|
||||
self.login_inner(master_password, sk, None, p.ticket.clone(), Some(p.account_id), None).await
|
||||
}
|
||||
|
||||
/// Joining device (no camera): opens a rendezvous on `server_url` and returns the QR to show.
|
||||
|
||||
@@ -194,14 +194,14 @@ async fn passkey_created_on_one_device_signs_on_another() {
|
||||
let a = VaultCore::new_in_memory();
|
||||
let sk = a.create_account(PW.into()).unwrap();
|
||||
connect(&a, &server);
|
||||
a.register_account(SETUP_TOKEN.into()).await.unwrap();
|
||||
a.register_account(SETUP_TOKEN.into(), None).await.unwrap();
|
||||
let reg = a.create_passkey(RP.into(), b"u".to_vec(), "me".into(), cdh(b"r"), None).unwrap();
|
||||
let vk = rp_verify_registration(®, RP);
|
||||
assert_eq!(a.sync_now().await.unwrap().pushed, 1);
|
||||
|
||||
let b = VaultCore::new_in_memory();
|
||||
connect(&b, &server);
|
||||
b.login(PW.into(), sk, None).await.unwrap();
|
||||
b.login(PW.into(), sk, None, None).await.unwrap();
|
||||
b.sync_now().await.unwrap();
|
||||
let on_b = b.passkeys_for_rp(RP.into(), vec![]).unwrap();
|
||||
assert_eq!(on_b.len(), 1);
|
||||
|
||||
+19
-19
@@ -172,7 +172,7 @@ async fn two_devices_sync_through_server() {
|
||||
let sk = a.create_account(PW.into()).unwrap();
|
||||
connect(&a, &server);
|
||||
assert!(a.server_info().await.unwrap().setup_open);
|
||||
a.register_account(SETUP_TOKEN.into()).await.unwrap();
|
||||
a.register_account(SETUP_TOKEN.into(), None).await.unwrap();
|
||||
assert!(a.is_logged_in());
|
||||
let id = a.save_item(login_item("Bank", "pw-a")).unwrap();
|
||||
let r = a.sync_now().await.unwrap();
|
||||
@@ -182,8 +182,8 @@ async fn two_devices_sync_through_server() {
|
||||
let b_path = dir.path().join("b.sqlite").to_string_lossy().into_owned();
|
||||
let b = VaultCore::open(b_path.clone()).unwrap();
|
||||
connect(&b, &server);
|
||||
assert!(matches!(b.login("wrong password!!".into(), sk.clone(), None).await, Err(CoreError::BadCredentials)));
|
||||
assert_eq!(b.login(PW.into(), sk.clone(), None).await.unwrap(), LoginResult::Ok);
|
||||
assert!(matches!(b.login("wrong password!!".into(), sk.clone(), None, None).await, Err(CoreError::BadCredentials)));
|
||||
assert_eq!(b.login(PW.into(), sk.clone(), None, None).await.unwrap(), LoginResult::Ok);
|
||||
assert!(b.has_account() && !b.is_locked());
|
||||
b.sync_now().await.unwrap();
|
||||
assert_eq!(b.get_item(id.clone()).unwrap().password.as_deref(), Some("pw-a"));
|
||||
@@ -233,7 +233,7 @@ async fn integrity_alarm_reaches_the_app() {
|
||||
let a = VaultCore::new_in_memory();
|
||||
a.create_account(PW.into()).unwrap();
|
||||
connect(&a, &server);
|
||||
a.register_account(SETUP_TOKEN.into()).await.unwrap();
|
||||
a.register_account(SETUP_TOKEN.into(), None).await.unwrap();
|
||||
for i in 0..2 {
|
||||
a.save_item(login_item(&format!("item {i}"), "p")).unwrap();
|
||||
a.sync_now().await.unwrap();
|
||||
@@ -258,13 +258,13 @@ async fn pending_approval_and_biometric_session_expiry() {
|
||||
let a = VaultCore::new_in_memory();
|
||||
let sk = a.create_account(PW.into()).unwrap();
|
||||
connect(&a, &server);
|
||||
a.register_account(SETUP_TOKEN.into()).await.unwrap();
|
||||
a.register_account(SETUP_TOKEN.into(), None).await.unwrap();
|
||||
a.sync_now().await.unwrap();
|
||||
|
||||
server.state().require_approval = true;
|
||||
let b = VaultCore::new_in_memory();
|
||||
connect(&b, &server);
|
||||
assert_eq!(b.login(PW.into(), sk.clone(), None).await.unwrap(), LoginResult::PendingApproval);
|
||||
assert_eq!(b.login(PW.into(), sk.clone(), None, None).await.unwrap(), LoginResult::PendingApproval);
|
||||
assert!(!b.has_account());
|
||||
// A approves B after comparing fingerprints, then B's next login succeeds.
|
||||
let devices = a.list_devices().await.unwrap();
|
||||
@@ -272,7 +272,7 @@ async fn pending_approval_and_biometric_session_expiry() {
|
||||
assert_eq!(pending.key_fingerprint, b.device_key_fingerprint().unwrap());
|
||||
assert!(devices.iter().any(|d| d.current && d.approved));
|
||||
a.approve_device(pending.device_id.clone()).await.unwrap();
|
||||
assert_eq!(b.login(PW.into(), sk, None).await.unwrap(), LoginResult::Ok);
|
||||
assert_eq!(b.login(PW.into(), sk, None, None).await.unwrap(), LoginResult::Ok);
|
||||
b.sync_now().await.unwrap();
|
||||
// Revoking B kills its session; B learns it was removed and resets its identity.
|
||||
a.revoke_device(pending.device_id.clone()).await.unwrap();
|
||||
@@ -294,7 +294,7 @@ async fn pending_approval_and_biometric_session_expiry() {
|
||||
c.lock();
|
||||
c.unlock_with_local_key(luk).unwrap();
|
||||
connect(&c, &server);
|
||||
assert!(matches!(c.register_account("x".into()).await, Err(CoreError::NotAuthenticated)));
|
||||
assert!(matches!(c.register_account("x".into(), None).await, Err(CoreError::NotAuthenticated)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -303,7 +303,7 @@ async fn save_item_bumps_seq_and_replays_are_refused() {
|
||||
let a = VaultCore::new_in_memory();
|
||||
let sk = a.create_account(PW.into()).unwrap();
|
||||
connect(&a, &server);
|
||||
a.register_account(SETUP_TOKEN.into()).await.unwrap();
|
||||
a.register_account(SETUP_TOKEN.into(), None).await.unwrap();
|
||||
let id = a.save_item(login_item("Bank", "pw1")).unwrap();
|
||||
a.sync_now().await.unwrap();
|
||||
let vid = *server.state().vaults.keys().next().unwrap();
|
||||
@@ -326,7 +326,7 @@ async fn save_item_bumps_seq_and_replays_are_refused() {
|
||||
|
||||
let b = VaultCore::new_in_memory();
|
||||
connect(&b, &server);
|
||||
b.login(PW.into(), sk, None).await.unwrap();
|
||||
b.login(PW.into(), sk, None, None).await.unwrap();
|
||||
b.sync_now().await.unwrap();
|
||||
assert_eq!(seq_of(&b), 3);
|
||||
|
||||
@@ -344,7 +344,7 @@ async fn delete_item_seals_the_tombstone_and_forged_deletes_are_refused() {
|
||||
let a = VaultCore::new_in_memory();
|
||||
let sk = a.create_account(PW.into()).unwrap();
|
||||
connect(&a, &server);
|
||||
a.register_account(SETUP_TOKEN.into()).await.unwrap();
|
||||
a.register_account(SETUP_TOKEN.into(), None).await.unwrap();
|
||||
let gone = a.save_item(login_item("Old", "pw0")).unwrap();
|
||||
let kept = a.save_item(login_item("Bank", "pw1")).unwrap();
|
||||
a.sync_now().await.unwrap();
|
||||
@@ -357,7 +357,7 @@ async fn delete_item_seals_the_tombstone_and_forged_deletes_are_refused() {
|
||||
|
||||
let b = VaultCore::new_in_memory();
|
||||
connect(&b, &server);
|
||||
b.login(PW.into(), sk, None).await.unwrap();
|
||||
b.login(PW.into(), sk, None, None).await.unwrap();
|
||||
b.sync_now().await.unwrap();
|
||||
assert_eq!(b.list_items().unwrap().len(), 1);
|
||||
|
||||
@@ -378,7 +378,7 @@ async fn phone_with_item(server: &TestServer) -> (Arc<VaultCore>, String, String
|
||||
let a = VaultCore::new_in_memory();
|
||||
let sk = a.create_account(PW.into()).unwrap();
|
||||
connect(&a, server);
|
||||
a.register_account(SETUP_TOKEN.into()).await.unwrap();
|
||||
a.register_account(SETUP_TOKEN.into(), None).await.unwrap();
|
||||
let id = a.save_item(login_item("Bank", "pw-a")).unwrap();
|
||||
a.sync_now().await.unwrap();
|
||||
server.state().require_approval = true;
|
||||
@@ -574,7 +574,7 @@ async fn revoked_device_resets_and_reenrols_with_the_same_ticket() {
|
||||
phone.configure_server(server.url(), vec![]).unwrap();
|
||||
phone.set_device_signer(signer.clone(), "iPhone".into(), PlatformDto::Ios).unwrap();
|
||||
let old_id = signer.device_id();
|
||||
assert_eq!(phone.login(PW.into(), sk.clone(), None).await.unwrap(), LoginResult::PendingApproval);
|
||||
assert_eq!(phone.login(PW.into(), sk.clone(), None, None).await.unwrap(), LoginResult::PendingApproval);
|
||||
a.revoke_device(old_id.clone()).await.unwrap();
|
||||
|
||||
// Reinstall: a new core, the same platform identity. A wrong password reveals nothing and resets nothing.
|
||||
@@ -615,7 +615,7 @@ async fn revoked_device_without_a_ticket_resets_and_asks_to_set_up_again() {
|
||||
let b = VaultCore::new_in_memory();
|
||||
b.configure_server(server.url(), vec![]).unwrap();
|
||||
b.set_device_signer(signer.clone(), "laptop".into(), PlatformDto::Linux).unwrap();
|
||||
assert_eq!(b.login(PW.into(), sk.clone(), None).await.unwrap(), LoginResult::Ok);
|
||||
assert_eq!(b.login(PW.into(), sk.clone(), None, None).await.unwrap(), LoginResult::Ok);
|
||||
b.sync_now().await.unwrap();
|
||||
let old_id = signer.device_id();
|
||||
|
||||
@@ -629,11 +629,11 @@ async fn revoked_device_without_a_ticket_resets_and_asks_to_set_up_again() {
|
||||
assert_eq!(signer.resets.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
|
||||
server.state().require_approval = true;
|
||||
assert_eq!(b.login(PW.into(), sk.clone(), None).await.unwrap(), LoginResult::PendingApproval);
|
||||
assert_eq!(b.login(PW.into(), sk.clone(), None, None).await.unwrap(), LoginResult::PendingApproval);
|
||||
let new_id = signer.device_id();
|
||||
assert_ne!(new_id, old_id);
|
||||
a.approve_device(new_id).await.unwrap();
|
||||
assert_eq!(b.login(PW.into(), sk, None).await.unwrap(), LoginResult::Ok);
|
||||
assert_eq!(b.login(PW.into(), sk, None, None).await.unwrap(), LoginResult::Ok);
|
||||
b.sync_now().await.unwrap();
|
||||
assert_eq!(b.get_item(id).unwrap().password.as_deref(), Some("pw-a"));
|
||||
// The old id can never come back.
|
||||
@@ -682,7 +682,7 @@ async fn sign_out_device_keeps_the_account_on_the_server() {
|
||||
assert_eq!(a.server_url(), None);
|
||||
connect(&a, &server);
|
||||
assert_eq!(a.server_url(), Some(server.url()));
|
||||
a.register_account(SETUP_TOKEN.into()).await.unwrap();
|
||||
a.register_account(SETUP_TOKEN.into(), None).await.unwrap();
|
||||
let id = a.save_item(login_item("Bank", "pw-a")).unwrap(); // not synced yet: sign-out syncs it first
|
||||
a.sign_out_device(false).await.unwrap();
|
||||
assert!(!a.has_account() && !a.is_logged_in());
|
||||
@@ -690,7 +690,7 @@ async fn sign_out_device_keeps_the_account_on_the_server() {
|
||||
|
||||
// Same device, signed in again: the item came back from the server.
|
||||
connect(&a, &server);
|
||||
assert_eq!(a.login(PW.into(), sk, None).await.unwrap(), LoginResult::Ok);
|
||||
assert_eq!(a.login(PW.into(), sk, None, None).await.unwrap(), LoginResult::Ok);
|
||||
a.sync_now().await.unwrap();
|
||||
assert_eq!(a.get_item(id).unwrap().password.as_deref(), Some("pw-a"));
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ async fn two_devices_against_local_server() {
|
||||
let sk = a.create_account(pw.into()).unwrap();
|
||||
connect(&a, &url, "device A");
|
||||
assert!(a.server_info().await.unwrap().setup_open, "server must be fresh (setup open)");
|
||||
a.register_account(token).await.unwrap();
|
||||
a.register_account(token, None).await.unwrap();
|
||||
let id = a.save_item(item("Bank", "pw-a")).unwrap();
|
||||
let r = a.sync_now().await.unwrap();
|
||||
assert_eq!(r.pushed, 1);
|
||||
@@ -79,13 +79,13 @@ async fn two_devices_against_local_server() {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(61)).await;
|
||||
let b = VaultCore::new_in_memory();
|
||||
connect(&b, &url, "device B");
|
||||
if b.login(pw.into(), sk.clone(), None).await.unwrap() == LoginResult::PendingApproval {
|
||||
if b.login(pw.into(), sk.clone(), None, None).await.unwrap() == LoginResult::PendingApproval {
|
||||
let devices = a.list_devices().await.unwrap();
|
||||
let pending = devices.iter().find(|d| !d.approved).expect("B listed as pending");
|
||||
assert_eq!(pending.key_fingerprint, b.device_key_fingerprint().unwrap());
|
||||
a.approve_device(pending.device_id.clone()).await.unwrap();
|
||||
tokio::time::sleep(std::time::Duration::from_secs(61)).await; // rate limit again
|
||||
assert_eq!(b.login(pw.into(), sk.clone(), None).await.unwrap(), LoginResult::Ok);
|
||||
assert_eq!(b.login(pw.into(), sk.clone(), None, None).await.unwrap(), LoginResult::Ok);
|
||||
}
|
||||
b.sync_now().await.unwrap();
|
||||
assert_eq!(b.get_item(id.clone()).unwrap().password.as_deref(), Some("pw-a"));
|
||||
@@ -167,7 +167,7 @@ async fn removed_device_reenrols_against_local_server() {
|
||||
let web = VaultCore::new_in_memory();
|
||||
web.create_account(pw.into()).unwrap();
|
||||
connect(&web, &url, "web vault");
|
||||
web.register_account(token).await.unwrap();
|
||||
web.register_account(token, None).await.unwrap();
|
||||
let id = web.save_item(item("Bank", "pw-a")).unwrap();
|
||||
web.sync_now().await.unwrap();
|
||||
|
||||
@@ -206,7 +206,7 @@ async fn delete_account_against_local_server() {
|
||||
let app = VaultCore::new_in_memory();
|
||||
app.create_account(pw.into()).unwrap();
|
||||
connect(&app, &url, "phone");
|
||||
app.register_account(token.clone()).await.unwrap();
|
||||
app.register_account(token.clone(), None).await.unwrap();
|
||||
app.save_item(item("Bank", "pw-a")).unwrap();
|
||||
app.sync_now().await.unwrap();
|
||||
|
||||
@@ -217,6 +217,55 @@ async fn delete_account_against_local_server() {
|
||||
let fresh = VaultCore::new_in_memory();
|
||||
fresh.create_account(pw.into()).unwrap();
|
||||
connect(&fresh, &url, "new phone");
|
||||
fresh.register_account(token).await.unwrap();
|
||||
fresh.register_account(token, None).await.unwrap();
|
||||
println!("account deleted and server reusable OK against {url}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "needs a fresh local kestrove-server; see the module docs"]
|
||||
async fn two_users_on_one_server() {
|
||||
let url = std::env::var("KESTROVE_SERVER_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".into());
|
||||
let token =
|
||||
std::env::var("KESTROVE_SETUP_TOKEN").expect("set KESTROVE_SETUP_TOKEN to the token the server printed");
|
||||
let (pw_a, pw_b) = ("the admin's long master password", "the second user's long master password");
|
||||
|
||||
// The admin: first account, with the setup code.
|
||||
let admin = VaultCore::new_in_memory();
|
||||
admin.create_account(pw_a.into()).unwrap();
|
||||
connect(&admin, &url, "admin laptop");
|
||||
admin.register_account(token, Some("[email protected]".into())).await.unwrap();
|
||||
let details = admin.account_details().await.unwrap();
|
||||
assert_eq!((details.email.as_deref(), details.is_admin), (Some("[email protected]"), true));
|
||||
admin.save_item(item("Admin bank", "admin-secret")).unwrap();
|
||||
admin.sync_now().await.unwrap();
|
||||
|
||||
// A second user, invited by the admin.
|
||||
let invite = admin.admin_create_invite(Some("[email protected]".into()), None).await.unwrap();
|
||||
let b = VaultCore::new_in_memory();
|
||||
let sk_b = b.create_account(pw_b.into()).unwrap();
|
||||
connect(&b, &url, "b phone");
|
||||
b.register_account(invite.code.clone(), Some("[email protected]".into())).await.unwrap();
|
||||
assert!(!b.account_details().await.unwrap().is_admin);
|
||||
let b_item = b.save_item(item("B mail", "b-secret")).unwrap();
|
||||
b.sync_now().await.unwrap();
|
||||
// The invite is used up.
|
||||
let c = VaultCore::new_in_memory();
|
||||
c.create_account("someone else's password!!".into()).unwrap();
|
||||
connect(&c, &url, "c");
|
||||
assert!(c.register_account(invite.code, Some("[email protected]".into())).await.is_err());
|
||||
|
||||
// Neither sees the other's items after syncing.
|
||||
admin.sync_now().await.unwrap();
|
||||
let titles = |core: &VaultCore| core.list_items().unwrap().into_iter().map(|i| i.title).collect::<Vec<_>>();
|
||||
assert_eq!(titles(&admin), vec!["Admin bank".to_string()]);
|
||||
assert_eq!(titles(&b), vec!["B mail".to_string()]);
|
||||
|
||||
// B's new device signs in with email + password + Secret Key and gets only B's vault (after approval).
|
||||
let laptop = VaultCore::new_in_memory();
|
||||
connect(&laptop, &url, "b laptop");
|
||||
let r = laptop.login(pw_b.into(), sk_b, None, Some("[email protected]".into())).await.unwrap();
|
||||
assert_eq!(r, LoginResult::PendingApproval);
|
||||
let pending = b.list_devices().await.unwrap().into_iter().find(|d| !d.approved).unwrap();
|
||||
b.approve_device(pending.device_id).await.unwrap();
|
||||
println!("two users on {url}: isolated OK ({b_item})");
|
||||
}
|
||||
|
||||
@@ -112,8 +112,22 @@ pub struct ServerInfo {
|
||||
pub version: String,
|
||||
pub api: u32,
|
||||
pub single_tenant: bool,
|
||||
/// No account exists yet: the first one is created with the server's setup code and becomes its admin.
|
||||
pub setup_open: bool,
|
||||
pub min_client: String,
|
||||
/// How new accounts join once the server has an admin (docs/03 "Accounts"). Absent on older servers.
|
||||
#[serde(default)]
|
||||
pub registration: Option<Registration>,
|
||||
}
|
||||
|
||||
/// Who may create an account on a server that already has its admin.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Registration {
|
||||
/// Only with an invite code from the admin.
|
||||
Invite,
|
||||
/// Anyone who can reach the server (optionally limited to some email domains), or with an invite.
|
||||
Open,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -129,7 +143,11 @@ pub struct DeviceEnroll {
|
||||
/// `POST /v1/setup/start`: OPAQUE registration, first message. Needs the setup token.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SetupStart {
|
||||
/// The server's setup code (first account), an invite code, or empty with open sign-up.
|
||||
#[serde(default)]
|
||||
pub setup_token: String,
|
||||
#[serde(default)]
|
||||
pub email: Option<String>,
|
||||
pub account_id: Uuid,
|
||||
/// Serialized opaque-ke `RegistrationRequest`.
|
||||
#[serde(with = "b64")]
|
||||
@@ -150,7 +168,12 @@ pub type SetupFinish = SetupRequest;
|
||||
/// Body of `POST /v1/setup/finish` (alias [`SetupFinish`]).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SetupRequest {
|
||||
/// The server's setup code (first account), an invite code, or empty with open sign-up.
|
||||
#[serde(default)]
|
||||
pub setup_token: String,
|
||||
/// The address this account signs in with. Required on a server that already has an account.
|
||||
#[serde(default)]
|
||||
pub email: Option<String>,
|
||||
pub account_id: Uuid,
|
||||
pub kdf: KdfParams,
|
||||
/// Serialized opaque-ke `RegistrationUpload`.
|
||||
@@ -235,6 +258,82 @@ pub struct AccountInfo {
|
||||
#[serde(with = "b64")]
|
||||
pub sig_pk: Vec<u8>,
|
||||
pub wrapped_keyset: Envelope,
|
||||
#[serde(default)]
|
||||
pub email: Option<String>,
|
||||
#[serde(default)]
|
||||
pub is_admin: bool,
|
||||
}
|
||||
|
||||
/// `PUT /v1/account/email`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AccountEmail {
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
/// `GET /v1/admin/users`: what an admin may know about an account. Never any ciphertext or key material.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AdminUser {
|
||||
pub account_id: Uuid,
|
||||
pub email: Option<String>,
|
||||
pub is_admin: bool,
|
||||
pub disabled: bool,
|
||||
/// RFC 3339.
|
||||
pub created_at: String,
|
||||
/// Unix seconds of the latest session activity, if any.
|
||||
pub last_seen: Option<i64>,
|
||||
pub devices: u32,
|
||||
pub items: u32,
|
||||
/// Bytes of encrypted vault data stored for the account.
|
||||
pub storage_bytes: u64,
|
||||
}
|
||||
|
||||
/// `PATCH /v1/admin/users/{id}`.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct AdminUserUpdate {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub disabled: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub is_admin: Option<bool>,
|
||||
}
|
||||
|
||||
/// `POST /v1/admin/invites`.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct InviteCreate {
|
||||
/// Only this address can use the invite.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub email: Option<String>,
|
||||
/// Days until it expires (1 to 30, default 7).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub days: Option<u32>,
|
||||
}
|
||||
|
||||
/// Reply to `POST /v1/admin/invites`. `code` is shown once; the server keeps only its hash.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InviteCreated {
|
||||
pub invite_id: Uuid,
|
||||
pub code: String,
|
||||
/// Unix seconds.
|
||||
pub expires_at: i64,
|
||||
}
|
||||
|
||||
/// `GET /v1/admin/invites`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct InviteInfo {
|
||||
pub invite_id: Uuid,
|
||||
pub email: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub expires_at: i64,
|
||||
pub used: bool,
|
||||
pub revoked: bool,
|
||||
}
|
||||
|
||||
/// `GET|PUT /v1/admin/settings`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AdminSettings {
|
||||
pub registration: Registration,
|
||||
/// With open sign-up, only these email domains may register (empty: any).
|
||||
#[serde(default)]
|
||||
pub allowed_domains: Vec<String>,
|
||||
}
|
||||
|
||||
/// `POST /v1/account/credentials/start`: OPAQUE re-registration for a password change.
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use kv_proto::{
|
||||
AccountInfo, ApiError, Changes, DeviceEnroll, DeviceInfo, Envelope, ItemPut, ItemRecord, JoinBundle, JoinCreate,
|
||||
JoinCreated, JoinHello, JoinHelloResponse, JoinPoll, KdfParams, LoginFinish, LoginFinishResponse, LoginStart,
|
||||
AccountEmail, AccountInfo, AdminSettings, AdminUser, AdminUserUpdate, ApiError, Changes, DeviceEnroll, DeviceInfo,
|
||||
Envelope, InviteCreate, InviteCreated, InviteInfo, ItemPut, ItemRecord, JoinBundle, JoinCreate, JoinCreated,
|
||||
JoinHello, JoinHelloResponse, JoinPoll, KdfParams, LoginFinish, LoginFinishResponse, LoginStart,
|
||||
LoginStartResponse, LoginStatus, Manifest, PreloginResponse, RegistrationStartResponse, ServerInfo, SetupFinish,
|
||||
SetupStart, TicketCreate, TicketCreated, TicketInfo, Vault, HEADER_JOIN_TOKEN,
|
||||
};
|
||||
@@ -32,6 +33,16 @@ pub enum LoginOutcome {
|
||||
PendingApproval,
|
||||
}
|
||||
|
||||
/// How a new device names its account at prelogin.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum PreloginBy<'a> {
|
||||
/// The server's only account (servers and clients from before multi-user).
|
||||
OnlyAccount,
|
||||
Email(&'a str),
|
||||
/// From a setup QR or Emergency Kit.
|
||||
Account(Uuid),
|
||||
}
|
||||
|
||||
pub struct ApiClient {
|
||||
base: String,
|
||||
http: reqwest::Client,
|
||||
@@ -166,17 +177,27 @@ impl ApiClient {
|
||||
self.json(Method::GET, PATH_SERVER_INFO, None::<&()>, None, false).await
|
||||
}
|
||||
|
||||
/// Account id and KDF parameters of the (single) account: what a new device needs to derive
|
||||
/// `AUTH_INPUT` before it can build KE1.
|
||||
pub async fn prelogin(&self) -> Result<PreloginResponse, SyncError> {
|
||||
self.json(Method::GET, PATH_PRELOGIN, None::<&()>, None, false).await
|
||||
/// Account id and KDF parameters: what a new device needs to derive `AUTH_INPUT` before it can build KE1.
|
||||
/// An unknown email or id gets a plausible stand-in, so this never tells whether an account exists.
|
||||
pub async fn prelogin(&self, by: PreloginBy<'_>) -> Result<PreloginResponse, SyncError> {
|
||||
let path = match by {
|
||||
PreloginBy::OnlyAccount => PATH_PRELOGIN.to_string(),
|
||||
PreloginBy::Email(email) => {
|
||||
let q: String = url::form_urlencoded::byte_serialize(email.trim().as_bytes()).collect();
|
||||
format!("{PATH_PRELOGIN}?email={q}")
|
||||
}
|
||||
PreloginBy::Account(id) => format!("{PATH_PRELOGIN}?account_id={id}"),
|
||||
};
|
||||
self.json(Method::GET, &path, None::<&()>, None, false).await
|
||||
}
|
||||
|
||||
/// OPAQUE registration plus account creation (first device only, needs the setup token).
|
||||
/// OPAQUE registration plus account creation. `setup_token` is the server's setup code (first account), an
|
||||
/// invite code, or empty with open sign-up; `email` is required once the server has an account.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn register(
|
||||
&self,
|
||||
setup_token: &str,
|
||||
email: Option<&str>,
|
||||
account_id: Uuid,
|
||||
kdf: &KdfParams,
|
||||
auth_input: &[u8],
|
||||
@@ -186,13 +207,19 @@ impl ApiClient {
|
||||
device: DeviceEnroll,
|
||||
) -> Result<(), SyncError> {
|
||||
let reg = opaque::registration_start(auth_input)?;
|
||||
let start =
|
||||
SetupStart { setup_token: setup_token.to_string(), account_id, registration_request: reg.request.clone() };
|
||||
let email = email.map(|e| e.trim().to_string()).filter(|e| !e.is_empty());
|
||||
let start = SetupStart {
|
||||
setup_token: setup_token.to_string(),
|
||||
email: email.clone(),
|
||||
account_id,
|
||||
registration_request: reg.request.clone(),
|
||||
};
|
||||
let resp: RegistrationStartResponse =
|
||||
self.json(Method::POST, PATH_SETUP_START, Some(&start), None, false).await?;
|
||||
let upload = reg.finish(auth_input, &resp.registration_response)?;
|
||||
let req = SetupFinish {
|
||||
setup_token: setup_token.to_string(),
|
||||
email,
|
||||
account_id,
|
||||
kdf: kdf.clone(),
|
||||
opaque_registration_upload: upload,
|
||||
@@ -299,6 +326,45 @@ impl ApiClient {
|
||||
r
|
||||
}
|
||||
|
||||
pub async fn set_email(&self, email: &str) -> Result<(), SyncError> {
|
||||
self.no_content(Method::PUT, PATH_ACCOUNT_EMAIL, Some(&AccountEmail { email: email.trim().to_string() }), true)
|
||||
.await
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- admin (docs/03 "Accounts")
|
||||
|
||||
pub async fn admin_users(&self) -> Result<Vec<AdminUser>, SyncError> {
|
||||
self.json(Method::GET, PATH_ADMIN_USERS, None::<&()>, None, true).await
|
||||
}
|
||||
|
||||
pub async fn admin_update_user(&self, account: Uuid, update: &AdminUserUpdate) -> Result<(), SyncError> {
|
||||
self.no_content(Method::PATCH, &format!("{PATH_ADMIN_USERS}/{account}"), Some(update), true).await
|
||||
}
|
||||
|
||||
pub async fn admin_delete_user(&self, account: Uuid) -> Result<(), SyncError> {
|
||||
self.no_content(Method::DELETE, &format!("{PATH_ADMIN_USERS}/{account}"), None::<&()>, true).await
|
||||
}
|
||||
|
||||
pub async fn admin_invites(&self) -> Result<Vec<InviteInfo>, SyncError> {
|
||||
self.json(Method::GET, PATH_ADMIN_INVITES, None::<&()>, None, true).await
|
||||
}
|
||||
|
||||
pub async fn admin_create_invite(&self, req: &InviteCreate) -> Result<InviteCreated, SyncError> {
|
||||
self.json(Method::POST, PATH_ADMIN_INVITES, Some(req), None, true).await
|
||||
}
|
||||
|
||||
pub async fn admin_revoke_invite(&self, invite: Uuid) -> Result<(), SyncError> {
|
||||
self.no_content(Method::DELETE, &format!("{PATH_ADMIN_INVITES}/{invite}"), None::<&()>, true).await
|
||||
}
|
||||
|
||||
pub async fn admin_settings(&self) -> Result<AdminSettings, SyncError> {
|
||||
self.json(Method::GET, PATH_ADMIN_SETTINGS, None::<&()>, None, true).await
|
||||
}
|
||||
|
||||
pub async fn admin_put_settings(&self, settings: &AdminSettings) -> Result<AdminSettings, SyncError> {
|
||||
self.json(Method::PUT, PATH_ADMIN_SETTINGS, Some(settings), None, true).await
|
||||
}
|
||||
|
||||
pub async fn account(&self) -> Result<AccountInfo, SyncError> {
|
||||
self.json(Method::GET, PATH_ACCOUNT, None::<&()>, None, true).await
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -229,6 +229,7 @@ fn handle(st: &mut State, req: &Request) -> Result<ResponseTemplate, ResponseTem
|
||||
single_tenant: true,
|
||||
setup_open: st.setup_token.is_some(),
|
||||
min_client: "0.1.0".into(),
|
||||
registration: None,
|
||||
},
|
||||
)),
|
||||
("POST", ["v1", "setup", "start"]) => {
|
||||
@@ -459,6 +460,8 @@ fn authed(
|
||||
enc_pk: a.enc_pk.clone(),
|
||||
sig_pk: a.sig_pk.clone(),
|
||||
wrapped_keyset: a.wrapped_keyset.clone(),
|
||||
email: None,
|
||||
is_admin: true,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -61,6 +61,7 @@ impl Device {
|
||||
d.api
|
||||
.register(
|
||||
SETUP_TOKEN,
|
||||
None,
|
||||
d.keyset.account_id,
|
||||
&test_kdf(),
|
||||
&AUTH,
|
||||
@@ -410,6 +411,7 @@ async fn auth_errors() {
|
||||
.api
|
||||
.register(
|
||||
SETUP_TOKEN,
|
||||
None,
|
||||
Uuid::new_v4(),
|
||||
&test_kdf(),
|
||||
&AUTH,
|
||||
|
||||
+120
-7
@@ -490,17 +490,110 @@ impl WebCore {
|
||||
api: info.api,
|
||||
setup_open: info.setup_open,
|
||||
min_client: info.min_client,
|
||||
registration: info.registration.map(registration_name),
|
||||
})
|
||||
}
|
||||
|
||||
/// First device only: registers the account with a fresh server using its one-time setup token.
|
||||
pub async fn register_account(&self, setup_token: &str) -> Result<()> {
|
||||
pub async fn account_details(&self) -> Result<AccountDetailsDto> {
|
||||
let info = self.api()?.account().await?;
|
||||
Ok(AccountDetailsDto { email: info.email, is_admin: info.is_admin })
|
||||
}
|
||||
|
||||
pub async fn set_account_email(&self, email: &str) -> Result<()> {
|
||||
Ok(self.api()?.set_email(email).await?)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ admin (docs/03 "Accounts")
|
||||
|
||||
pub async fn admin_users(&self) -> Result<Vec<AdminUserDto>> {
|
||||
Ok(self
|
||||
.api()?
|
||||
.admin_users()
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|u| AdminUserDto {
|
||||
account_id: u.account_id.to_string(),
|
||||
email: u.email,
|
||||
is_admin: u.is_admin,
|
||||
disabled: u.disabled,
|
||||
created_at: u.created_at,
|
||||
last_seen: u.last_seen,
|
||||
devices: u.devices,
|
||||
items: u.items,
|
||||
storage_bytes: u.storage_bytes,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn admin_update_user(
|
||||
&self,
|
||||
account_id: &str,
|
||||
disabled: Option<bool>,
|
||||
is_admin: Option<bool>,
|
||||
) -> Result<()> {
|
||||
let update = kv_proto::AdminUserUpdate { disabled, is_admin };
|
||||
Ok(self.api()?.admin_update_user(parse_id(account_id)?, &update).await?)
|
||||
}
|
||||
|
||||
pub async fn admin_delete_user(&self, account_id: &str) -> Result<()> {
|
||||
Ok(self.api()?.admin_delete_user(parse_id(account_id)?).await?)
|
||||
}
|
||||
|
||||
pub async fn admin_invites(&self) -> Result<Vec<InviteDto>> {
|
||||
Ok(self
|
||||
.api()?
|
||||
.admin_invites()
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|i| InviteDto {
|
||||
invite_id: i.invite_id.to_string(),
|
||||
email: i.email,
|
||||
created_at: i.created_at,
|
||||
expires_at: i.expires_at,
|
||||
used: i.used,
|
||||
revoked: i.revoked,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn admin_create_invite(&self, email: Option<String>, days: Option<u32>) -> Result<InviteCreatedDto> {
|
||||
let c = self.api()?.admin_create_invite(&kv_proto::InviteCreate { email, days }).await?;
|
||||
Ok(InviteCreatedDto { invite_id: c.invite_id.to_string(), code: c.code, expires_at: c.expires_at })
|
||||
}
|
||||
|
||||
pub async fn admin_revoke_invite(&self, invite_id: &str) -> Result<()> {
|
||||
Ok(self.api()?.admin_revoke_invite(parse_id(invite_id)?).await?)
|
||||
}
|
||||
|
||||
pub async fn admin_settings(&self) -> Result<AdminSettingsDto> {
|
||||
let s = self.api()?.admin_settings().await?;
|
||||
Ok(AdminSettingsDto { registration: registration_name(s.registration), allowed_domains: s.allowed_domains })
|
||||
}
|
||||
|
||||
pub async fn admin_put_settings(
|
||||
&self,
|
||||
registration: &str,
|
||||
allowed_domains: Vec<String>,
|
||||
) -> Result<AdminSettingsDto> {
|
||||
let registration = match registration {
|
||||
"open" => kv_proto::Registration::Open,
|
||||
"invite" => kv_proto::Registration::Invite,
|
||||
other => return Err(CoreError::Invalid { msg: format!("unknown sign-up mode {other}") }),
|
||||
};
|
||||
let s = self.api()?.admin_put_settings(&kv_proto::AdminSettings { registration, allowed_domains }).await?;
|
||||
Ok(AdminSettingsDto { registration: registration_name(s.registration), allowed_domains: s.allowed_domains })
|
||||
}
|
||||
|
||||
/// Registers the account: `setup_token` is the server's setup code (first account), an invite code, or empty
|
||||
/// with open sign-up; `email` is required once the server has an account.
|
||||
pub async fn register_account(&self, setup_token: &str, email: Option<String>) -> Result<()> {
|
||||
let api = self.api()?;
|
||||
let (acct, u) = self.unlocked_snapshot()?;
|
||||
let auth = u.auth_input.clone().ok_or(CoreError::NotAuthenticated)?;
|
||||
let device = self.enroll()?;
|
||||
api.register(
|
||||
setup_token,
|
||||
email.as_deref(),
|
||||
acct.account_id,
|
||||
&acct.kdf,
|
||||
auth.as_ref(),
|
||||
@@ -515,9 +608,15 @@ impl WebCore {
|
||||
|
||||
/// Logs in with the master password and Secret Key. A browser with no account fetches it from the
|
||||
/// server; call `sync_now` afterwards to download the vault.
|
||||
pub async fn login(&self, master_password: &str, secret_key: &str, totp: Option<String>) -> Result<LoginResult> {
|
||||
pub async fn login(
|
||||
&self,
|
||||
master_password: &str,
|
||||
secret_key: &str,
|
||||
totp: Option<String>,
|
||||
email: Option<String>,
|
||||
) -> Result<LoginResult> {
|
||||
let sk = SecretKey::parse(secret_key).inspect_err(|_| self.forget_secret_key())?;
|
||||
self.login_inner(master_password, sk, totp, None, None).await
|
||||
self.login_inner(master_password, sk, totp, None, None, email).await
|
||||
}
|
||||
|
||||
/// `login`, optionally presenting an enrollment ticket and checking the server holds `expect_account`.
|
||||
@@ -528,8 +627,9 @@ impl WebCore {
|
||||
totp: Option<String>,
|
||||
ticket: Option<String>,
|
||||
expect_account: Option<Uuid>,
|
||||
email: Option<String>,
|
||||
) -> Result<LoginResult> {
|
||||
let r = self.login_unchecked(master_password, sk, totp, ticket, expect_account).await;
|
||||
let r = self.login_unchecked(master_password, sk, totp, ticket, expect_account, email).await;
|
||||
r.inspect_err(|_| self.forget_secret_key())
|
||||
}
|
||||
|
||||
@@ -540,13 +640,19 @@ impl WebCore {
|
||||
totp: Option<String>,
|
||||
ticket: Option<String>,
|
||||
expect_account: Option<Uuid>,
|
||||
email: Option<String>,
|
||||
) -> Result<LoginResult> {
|
||||
let api = self.api()?;
|
||||
let local = self.lock_inner().account.clone();
|
||||
let (account_id, kdf_params) = match &local {
|
||||
Some(a) => (a.account_id, a.kdf.clone()),
|
||||
None => {
|
||||
let pre = api.prelogin().await?;
|
||||
let by = match (expect_account, email.as_deref().map(str::trim).filter(|e| !e.is_empty())) {
|
||||
(Some(id), _) => kv_sync::PreloginBy::Account(id),
|
||||
(None, Some(e)) => kv_sync::PreloginBy::Email(e),
|
||||
(None, None) => kv_sync::PreloginBy::OnlyAccount,
|
||||
};
|
||||
let pre = api.prelogin(by).await?;
|
||||
(pre.account_id, pre.kdf)
|
||||
}
|
||||
};
|
||||
@@ -720,7 +826,7 @@ impl WebCore {
|
||||
}
|
||||
self.configure_server(&p.server_url, p.spki_pins.clone())?;
|
||||
let sk = SecretKey::parse(&p.secret_key)?;
|
||||
self.login_inner(master_password, sk, None, p.ticket.clone(), Some(p.account_id)).await
|
||||
self.login_inner(master_password, sk, None, p.ticket.clone(), Some(p.account_id), None).await
|
||||
}
|
||||
|
||||
/// Joining browser: opens a rendezvous on `server_url` and returns the QR to show. Then call
|
||||
@@ -1169,3 +1275,10 @@ pub fn device_key_fingerprint(p256_pk: &[u8]) -> String {
|
||||
fn parse_id(s: &str) -> Result<Uuid> {
|
||||
Uuid::parse_str(s).map_err(|_| CoreError::Invalid { msg: "id must be a UUID".into() })
|
||||
}
|
||||
|
||||
fn registration_name(r: kv_proto::Registration) -> String {
|
||||
match r {
|
||||
kv_proto::Registration::Invite => "invite".into(),
|
||||
kv_proto::Registration::Open => "open".into(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,56 @@ pub struct ServerInfoDto {
|
||||
pub api: u32,
|
||||
pub setup_open: bool,
|
||||
pub min_client: String,
|
||||
/// "invite" or "open" (absent on servers from before multi-user).
|
||||
pub registration: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountDetailsDto {
|
||||
pub email: Option<String>,
|
||||
pub is_admin: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminUserDto {
|
||||
pub account_id: String,
|
||||
pub email: Option<String>,
|
||||
pub is_admin: bool,
|
||||
pub disabled: bool,
|
||||
pub created_at: String,
|
||||
pub last_seen: Option<i64>,
|
||||
pub devices: u32,
|
||||
pub items: u32,
|
||||
pub storage_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InviteDto {
|
||||
pub invite_id: String,
|
||||
pub email: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub expires_at: i64,
|
||||
pub used: bool,
|
||||
pub revoked: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InviteCreatedDto {
|
||||
pub invite_id: String,
|
||||
pub code: String,
|
||||
pub expires_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminSettingsDto {
|
||||
/// "invite" or "open".
|
||||
pub registration: String,
|
||||
pub allowed_domains: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
||||
@@ -62,7 +62,12 @@ export interface ItemDto {
|
||||
|
||||
export interface TotpCode { code: string; secondsRemaining: number; period: number; }
|
||||
|
||||
export interface ServerInfoDto { version: string; api: number; setupOpen: boolean; minClient: string; }
|
||||
export interface ServerInfoDto { version: string; api: number; setupOpen: boolean; minClient: string; registration: "invite" | "open" | null; }
|
||||
export interface AccountDetailsDto { email: string | null; isAdmin: boolean; }
|
||||
export interface AdminUserDto { accountId: string; email: string | null; isAdmin: boolean; disabled: boolean; createdAt: string; lastSeen: number | null; devices: number; items: number; storageBytes: number; }
|
||||
export interface InviteDto { inviteId: string; email: string | null; createdAt: number; expiresAt: number; used: boolean; revoked: boolean; }
|
||||
export interface InviteCreatedDto { inviteId: string; code: string; expiresAt: number; }
|
||||
export interface AdminSettingsDto { registration: "invite" | "open"; allowedDomains: string[]; }
|
||||
|
||||
export interface SyncReport {
|
||||
pulled: number;
|
||||
@@ -648,8 +653,12 @@ impl VaultCore {
|
||||
|
||||
/// First device only (the web vault is rarely that): registers the account with a fresh server.
|
||||
#[wasm_bindgen(js_name = registerAccount, unchecked_return_type = "Promise<void>")]
|
||||
pub fn register_account(&self, #[wasm_bindgen(js_name = "setupToken")] setup_token: String) -> Promise {
|
||||
self.write(move |s| async move { s.core.register_account(&setup_token).await })
|
||||
pub fn register_account(
|
||||
&self,
|
||||
#[wasm_bindgen(js_name = "setupToken")] setup_token: String,
|
||||
email: Option<String>,
|
||||
) -> Promise {
|
||||
self.write(move |s| async move { s.core.register_account(&setup_token, email).await })
|
||||
}
|
||||
|
||||
/// Logs in (and unlocks). Resolves `"pendingApproval"` until an enrolled device approves this
|
||||
@@ -660,9 +669,69 @@ impl VaultCore {
|
||||
#[wasm_bindgen(js_name = "masterPassword")] master_password: String,
|
||||
#[wasm_bindgen(js_name = "secretKey")] secret_key: String,
|
||||
totp: Option<String>,
|
||||
email: Option<String>,
|
||||
) -> Promise {
|
||||
let (pw, sk) = (zeroize::Zeroizing::new(master_password), zeroize::Zeroizing::new(secret_key));
|
||||
self.write(move |s| async move { s.core.login(&pw, &sk, totp).await })
|
||||
self.write(move |s| async move { s.core.login(&pw, &sk, totp, email).await })
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = accountDetails, unchecked_return_type = "Promise<AccountDetailsDto>")]
|
||||
pub fn account_details(&self) -> Promise {
|
||||
self.run(|s| async move { s.core.account_details().await })
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = setAccountEmail, unchecked_return_type = "Promise<void>")]
|
||||
pub fn set_account_email(&self, email: String) -> Promise {
|
||||
self.run(move |s| async move { s.core.set_account_email(&email).await })
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = adminUsers, unchecked_return_type = "Promise<AdminUserDto[]>")]
|
||||
pub fn admin_users(&self) -> Promise {
|
||||
self.run(|s| async move { s.core.admin_users().await })
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = adminUpdateUser, unchecked_return_type = "Promise<void>")]
|
||||
pub fn admin_update_user(
|
||||
&self,
|
||||
#[wasm_bindgen(js_name = "accountId")] account_id: String,
|
||||
disabled: Option<bool>,
|
||||
#[wasm_bindgen(js_name = "isAdmin")] is_admin: Option<bool>,
|
||||
) -> Promise {
|
||||
self.run(move |s| async move { s.core.admin_update_user(&account_id, disabled, is_admin).await })
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = adminDeleteUser, unchecked_return_type = "Promise<void>")]
|
||||
pub fn admin_delete_user(&self, #[wasm_bindgen(js_name = "accountId")] account_id: String) -> Promise {
|
||||
self.run(move |s| async move { s.core.admin_delete_user(&account_id).await })
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = adminInvites, unchecked_return_type = "Promise<InviteDto[]>")]
|
||||
pub fn admin_invites(&self) -> Promise {
|
||||
self.run(|s| async move { s.core.admin_invites().await })
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = adminCreateInvite, unchecked_return_type = "Promise<InviteCreatedDto>")]
|
||||
pub fn admin_create_invite(&self, email: Option<String>, days: Option<u32>) -> Promise {
|
||||
self.run(move |s| async move { s.core.admin_create_invite(email, days).await })
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = adminRevokeInvite, unchecked_return_type = "Promise<void>")]
|
||||
pub fn admin_revoke_invite(&self, #[wasm_bindgen(js_name = "inviteId")] invite_id: String) -> Promise {
|
||||
self.run(move |s| async move { s.core.admin_revoke_invite(&invite_id).await })
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = adminSettings, unchecked_return_type = "Promise<AdminSettingsDto>")]
|
||||
pub fn admin_settings(&self) -> Promise {
|
||||
self.run(|s| async move { s.core.admin_settings().await })
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = adminPutSettings, unchecked_return_type = "Promise<AdminSettingsDto>")]
|
||||
pub fn admin_put_settings(
|
||||
&self,
|
||||
registration: String,
|
||||
#[wasm_bindgen(js_name = "allowedDomains")] allowed_domains: Vec<String>,
|
||||
) -> Promise {
|
||||
self.run(move |s| async move { s.core.admin_put_settings(®istration, allowed_domains).await })
|
||||
}
|
||||
|
||||
#[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.set_device_signer(Arc::new(Signer(SoftwareSigner::generate(Uuid::new_v4()))), "phone".into(), PlatformDto::Ios)
|
||||
.unwrap();
|
||||
app.register_account(SETUP_TOKEN.into()).await.unwrap();
|
||||
app.register_account(SETUP_TOKEN.into(), None).await.unwrap();
|
||||
let mut on_app = kv_ffi::ItemDto {
|
||||
id: String::new(),
|
||||
kind: kv_ffi::ItemKindDto::Login,
|
||||
@@ -93,12 +93,12 @@ async fn web_vault_enrolls_and_syncs_with_an_app_device() {
|
||||
let signer = Arc::new(SoftwareSigner::generate(Uuid::new_v4()));
|
||||
let web = web_core(store.clone(), &server.url(), signer.clone());
|
||||
assert!(matches!(web.sync_now().await, Err(CoreError::NoAccount | CoreError::Locked)));
|
||||
assert_eq!(web.login(PW, &sk, None).await.unwrap(), LoginResult::PendingApproval);
|
||||
assert_eq!(web.login(PW, &sk, None, None).await.unwrap(), LoginResult::PendingApproval);
|
||||
assert!(!web.has_account());
|
||||
let pending = app.list_devices().await.unwrap().into_iter().find(|d| !d.approved).unwrap();
|
||||
assert_eq!(pending.key_fingerprint, web.device_key_fingerprint().unwrap());
|
||||
app.approve_device(pending.device_id).await.unwrap();
|
||||
assert_eq!(web.login(PW, &sk, None).await.unwrap(), LoginResult::Ok);
|
||||
assert_eq!(web.login(PW, &sk, None, None).await.unwrap(), LoginResult::Ok);
|
||||
let r = web.sync_now().await.unwrap();
|
||||
assert_eq!((r.pulled, r.vaults_synced), (1, 1));
|
||||
assert_eq!(web.get_item(&bank).unwrap().password.as_deref(), Some("from the app"));
|
||||
@@ -158,7 +158,7 @@ async fn concurrent_sync_is_refused_not_interleaved() {
|
||||
let server = TestServer::start().await;
|
||||
let web = web_core(Arc::new(WebStore::new()), &server.url(), Arc::new(SoftwareSigner::generate(Uuid::new_v4())));
|
||||
let sk = web.create_account(PW).unwrap();
|
||||
web.register_account(SETUP_TOKEN).await.unwrap();
|
||||
web.register_account(SETUP_TOKEN, None).await.unwrap();
|
||||
web.save_item(web_item("A", "1")).unwrap();
|
||||
let (a, b) = tokio::join!(web.sync_now(), web.sync_now());
|
||||
assert!(matches!((&a, &b), (Ok(_), Err(CoreError::Busy)) | (Err(CoreError::Busy), Ok(_))), "{a:?} {b:?}");
|
||||
@@ -176,7 +176,7 @@ async fn app_with_item(server: &TestServer) -> (Arc<kv_ffi::VaultCore>, String,
|
||||
app.configure_server(server.url(), vec![]).unwrap();
|
||||
app.set_device_signer(Arc::new(Signer(SoftwareSigner::generate(Uuid::new_v4()))), "phone".into(), PlatformDto::Ios)
|
||||
.unwrap();
|
||||
app.register_account(SETUP_TOKEN.into()).await.unwrap();
|
||||
app.register_account(SETUP_TOKEN.into(), None).await.unwrap();
|
||||
let id = app
|
||||
.save_item(kv_ffi::ItemDto {
|
||||
id: String::new(),
|
||||
@@ -254,7 +254,7 @@ async fn web_vault_enrolls_by_ticket_both_ways() {
|
||||
assert!(matches!(web.unlock("wrong password!!", &sk), Err(CoreError::BadCredentials)));
|
||||
assert!(matches!(web.setup_payload_for_kit(), Err(CoreError::Locked)));
|
||||
web.unlock(PW, &sk).unwrap();
|
||||
assert!(web.login("wrong password!!", &sk, None).await.is_err());
|
||||
assert!(web.login("wrong password!!", &sk, None, None).await.is_err());
|
||||
assert!(matches!(web.setup_payload_for_kit(), Err(CoreError::Locked)));
|
||||
web.unlock(PW, &sk).unwrap();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user