Multi-user: email sign-in, invite/open registration, admin calls

- kv-proto: email on setup, AccountInfo.email/is_admin, ServerInfo.registration, admin DTOs.
- kv-sync: prelogin by email or account id (setup QR / Emergency Kit), register with email,
  set_email, /v1/admin/* client calls.
- kv-ffi: register_account(code, email), login(.., email), account_details, set_account_email,
  admin_create_invite. kv-wasm: the same plus the full admin API for the web vault.
- Live test: two users on one server stay isolated; a new device signs in by email.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-24 07:35:23 +07:00
co-authored by Claude Opus 5.5
parent ead95bb605
commit c84015f67b
16 changed files with 563 additions and 64 deletions
+120 -7
View File
@@ -490,17 +490,110 @@ impl WebCore {
api: info.api,
setup_open: info.setup_open,
min_client: info.min_client,
registration: info.registration.map(registration_name),
})
}
/// First device only: registers the account with a fresh server using its one-time setup token.
pub async fn register_account(&self, setup_token: &str) -> Result<()> {
pub async fn account_details(&self) -> Result<AccountDetailsDto> {
let info = self.api()?.account().await?;
Ok(AccountDetailsDto { email: info.email, is_admin: info.is_admin })
}
pub async fn set_account_email(&self, email: &str) -> Result<()> {
Ok(self.api()?.set_email(email).await?)
}
// ------------------------------------------------------------ admin (docs/03 "Accounts")
pub async fn admin_users(&self) -> Result<Vec<AdminUserDto>> {
Ok(self
.api()?
.admin_users()
.await?
.into_iter()
.map(|u| AdminUserDto {
account_id: u.account_id.to_string(),
email: u.email,
is_admin: u.is_admin,
disabled: u.disabled,
created_at: u.created_at,
last_seen: u.last_seen,
devices: u.devices,
items: u.items,
storage_bytes: u.storage_bytes,
})
.collect())
}
pub async fn admin_update_user(
&self,
account_id: &str,
disabled: Option<bool>,
is_admin: Option<bool>,
) -> Result<()> {
let update = kv_proto::AdminUserUpdate { disabled, is_admin };
Ok(self.api()?.admin_update_user(parse_id(account_id)?, &update).await?)
}
pub async fn admin_delete_user(&self, account_id: &str) -> Result<()> {
Ok(self.api()?.admin_delete_user(parse_id(account_id)?).await?)
}
pub async fn admin_invites(&self) -> Result<Vec<InviteDto>> {
Ok(self
.api()?
.admin_invites()
.await?
.into_iter()
.map(|i| InviteDto {
invite_id: i.invite_id.to_string(),
email: i.email,
created_at: i.created_at,
expires_at: i.expires_at,
used: i.used,
revoked: i.revoked,
})
.collect())
}
pub async fn admin_create_invite(&self, email: Option<String>, days: Option<u32>) -> Result<InviteCreatedDto> {
let c = self.api()?.admin_create_invite(&kv_proto::InviteCreate { email, days }).await?;
Ok(InviteCreatedDto { invite_id: c.invite_id.to_string(), code: c.code, expires_at: c.expires_at })
}
pub async fn admin_revoke_invite(&self, invite_id: &str) -> Result<()> {
Ok(self.api()?.admin_revoke_invite(parse_id(invite_id)?).await?)
}
pub async fn admin_settings(&self) -> Result<AdminSettingsDto> {
let s = self.api()?.admin_settings().await?;
Ok(AdminSettingsDto { registration: registration_name(s.registration), allowed_domains: s.allowed_domains })
}
pub async fn admin_put_settings(
&self,
registration: &str,
allowed_domains: Vec<String>,
) -> Result<AdminSettingsDto> {
let registration = match registration {
"open" => kv_proto::Registration::Open,
"invite" => kv_proto::Registration::Invite,
other => return Err(CoreError::Invalid { msg: format!("unknown sign-up mode {other}") }),
};
let s = self.api()?.admin_put_settings(&kv_proto::AdminSettings { registration, allowed_domains }).await?;
Ok(AdminSettingsDto { registration: registration_name(s.registration), allowed_domains: s.allowed_domains })
}
/// Registers the account: `setup_token` is the server's setup code (first account), an invite code, or empty
/// with open sign-up; `email` is required once the server has an account.
pub async fn register_account(&self, setup_token: &str, email: Option<String>) -> Result<()> {
let api = self.api()?;
let (acct, u) = self.unlocked_snapshot()?;
let auth = u.auth_input.clone().ok_or(CoreError::NotAuthenticated)?;
let device = self.enroll()?;
api.register(
setup_token,
email.as_deref(),
acct.account_id,
&acct.kdf,
auth.as_ref(),
@@ -515,9 +608,15 @@ impl WebCore {
/// Logs in with the master password and Secret Key. A browser with no account fetches it from the
/// server; call `sync_now` afterwards to download the vault.
pub async fn login(&self, master_password: &str, secret_key: &str, totp: Option<String>) -> Result<LoginResult> {
pub async fn login(
&self,
master_password: &str,
secret_key: &str,
totp: Option<String>,
email: Option<String>,
) -> Result<LoginResult> {
let sk = SecretKey::parse(secret_key).inspect_err(|_| self.forget_secret_key())?;
self.login_inner(master_password, sk, totp, None, None).await
self.login_inner(master_password, sk, totp, None, None, email).await
}
/// `login`, optionally presenting an enrollment ticket and checking the server holds `expect_account`.
@@ -528,8 +627,9 @@ impl WebCore {
totp: Option<String>,
ticket: Option<String>,
expect_account: Option<Uuid>,
email: Option<String>,
) -> Result<LoginResult> {
let r = self.login_unchecked(master_password, sk, totp, ticket, expect_account).await;
let r = self.login_unchecked(master_password, sk, totp, ticket, expect_account, email).await;
r.inspect_err(|_| self.forget_secret_key())
}
@@ -540,13 +640,19 @@ impl WebCore {
totp: Option<String>,
ticket: Option<String>,
expect_account: Option<Uuid>,
email: Option<String>,
) -> Result<LoginResult> {
let api = self.api()?;
let local = self.lock_inner().account.clone();
let (account_id, kdf_params) = match &local {
Some(a) => (a.account_id, a.kdf.clone()),
None => {
let pre = api.prelogin().await?;
let by = match (expect_account, email.as_deref().map(str::trim).filter(|e| !e.is_empty())) {
(Some(id), _) => kv_sync::PreloginBy::Account(id),
(None, Some(e)) => kv_sync::PreloginBy::Email(e),
(None, None) => kv_sync::PreloginBy::OnlyAccount,
};
let pre = api.prelogin(by).await?;
(pre.account_id, pre.kdf)
}
};
@@ -720,7 +826,7 @@ impl WebCore {
}
self.configure_server(&p.server_url, p.spki_pins.clone())?;
let sk = SecretKey::parse(&p.secret_key)?;
self.login_inner(master_password, sk, None, p.ticket.clone(), Some(p.account_id)).await
self.login_inner(master_password, sk, None, p.ticket.clone(), Some(p.account_id), None).await
}
/// Joining browser: opens a rendezvous on `server_url` and returns the QR to show. Then call
@@ -1169,3 +1275,10 @@ pub fn device_key_fingerprint(p256_pk: &[u8]) -> String {
fn parse_id(s: &str) -> Result<Uuid> {
Uuid::parse_str(s).map_err(|_| CoreError::Invalid { msg: "id must be a UUID".into() })
}
fn registration_name(r: kv_proto::Registration) -> String {
match r {
kv_proto::Registration::Invite => "invite".into(),
kv_proto::Registration::Open => "open".into(),
}
}
+50
View File
@@ -88,6 +88,56 @@ pub struct ServerInfoDto {
pub api: u32,
pub setup_open: bool,
pub min_client: String,
/// "invite" or "open" (absent on servers from before multi-user).
pub registration: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountDetailsDto {
pub email: Option<String>,
pub is_admin: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminUserDto {
pub account_id: String,
pub email: Option<String>,
pub is_admin: bool,
pub disabled: bool,
pub created_at: String,
pub last_seen: Option<i64>,
pub devices: u32,
pub items: u32,
pub storage_bytes: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InviteDto {
pub invite_id: String,
pub email: Option<String>,
pub created_at: i64,
pub expires_at: i64,
pub used: bool,
pub revoked: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InviteCreatedDto {
pub invite_id: String,
pub code: String,
pub expires_at: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminSettingsDto {
/// "invite" or "open".
pub registration: String,
pub allowed_domains: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+73 -4
View File
@@ -62,7 +62,12 @@ export interface ItemDto {
export interface TotpCode { code: string; secondsRemaining: number; period: number; }
export interface ServerInfoDto { version: string; api: number; setupOpen: boolean; minClient: string; }
export interface ServerInfoDto { version: string; api: number; setupOpen: boolean; minClient: string; registration: "invite" | "open" | null; }
export interface AccountDetailsDto { email: string | null; isAdmin: boolean; }
export interface AdminUserDto { accountId: string; email: string | null; isAdmin: boolean; disabled: boolean; createdAt: string; lastSeen: number | null; devices: number; items: number; storageBytes: number; }
export interface InviteDto { inviteId: string; email: string | null; createdAt: number; expiresAt: number; used: boolean; revoked: boolean; }
export interface InviteCreatedDto { inviteId: string; code: string; expiresAt: number; }
export interface AdminSettingsDto { registration: "invite" | "open"; allowedDomains: string[]; }
export interface SyncReport {
pulled: number;
@@ -648,8 +653,12 @@ impl VaultCore {
/// First device only (the web vault is rarely that): registers the account with a fresh server.
#[wasm_bindgen(js_name = registerAccount, unchecked_return_type = "Promise<void>")]
pub fn register_account(&self, #[wasm_bindgen(js_name = "setupToken")] setup_token: String) -> Promise {
self.write(move |s| async move { s.core.register_account(&setup_token).await })
pub fn register_account(
&self,
#[wasm_bindgen(js_name = "setupToken")] setup_token: String,
email: Option<String>,
) -> Promise {
self.write(move |s| async move { s.core.register_account(&setup_token, email).await })
}
/// Logs in (and unlocks). Resolves `"pendingApproval"` until an enrolled device approves this
@@ -660,9 +669,69 @@ impl VaultCore {
#[wasm_bindgen(js_name = "masterPassword")] master_password: String,
#[wasm_bindgen(js_name = "secretKey")] secret_key: String,
totp: Option<String>,
email: Option<String>,
) -> Promise {
let (pw, sk) = (zeroize::Zeroizing::new(master_password), zeroize::Zeroizing::new(secret_key));
self.write(move |s| async move { s.core.login(&pw, &sk, totp).await })
self.write(move |s| async move { s.core.login(&pw, &sk, totp, email).await })
}
#[wasm_bindgen(js_name = accountDetails, unchecked_return_type = "Promise<AccountDetailsDto>")]
pub fn account_details(&self) -> Promise {
self.run(|s| async move { s.core.account_details().await })
}
#[wasm_bindgen(js_name = setAccountEmail, unchecked_return_type = "Promise<void>")]
pub fn set_account_email(&self, email: String) -> Promise {
self.run(move |s| async move { s.core.set_account_email(&email).await })
}
#[wasm_bindgen(js_name = adminUsers, unchecked_return_type = "Promise<AdminUserDto[]>")]
pub fn admin_users(&self) -> Promise {
self.run(|s| async move { s.core.admin_users().await })
}
#[wasm_bindgen(js_name = adminUpdateUser, unchecked_return_type = "Promise<void>")]
pub fn admin_update_user(
&self,
#[wasm_bindgen(js_name = "accountId")] account_id: String,
disabled: Option<bool>,
#[wasm_bindgen(js_name = "isAdmin")] is_admin: Option<bool>,
) -> Promise {
self.run(move |s| async move { s.core.admin_update_user(&account_id, disabled, is_admin).await })
}
#[wasm_bindgen(js_name = adminDeleteUser, unchecked_return_type = "Promise<void>")]
pub fn admin_delete_user(&self, #[wasm_bindgen(js_name = "accountId")] account_id: String) -> Promise {
self.run(move |s| async move { s.core.admin_delete_user(&account_id).await })
}
#[wasm_bindgen(js_name = adminInvites, unchecked_return_type = "Promise<InviteDto[]>")]
pub fn admin_invites(&self) -> Promise {
self.run(|s| async move { s.core.admin_invites().await })
}
#[wasm_bindgen(js_name = adminCreateInvite, unchecked_return_type = "Promise<InviteCreatedDto>")]
pub fn admin_create_invite(&self, email: Option<String>, days: Option<u32>) -> Promise {
self.run(move |s| async move { s.core.admin_create_invite(email, days).await })
}
#[wasm_bindgen(js_name = adminRevokeInvite, unchecked_return_type = "Promise<void>")]
pub fn admin_revoke_invite(&self, #[wasm_bindgen(js_name = "inviteId")] invite_id: String) -> Promise {
self.run(move |s| async move { s.core.admin_revoke_invite(&invite_id).await })
}
#[wasm_bindgen(js_name = adminSettings, unchecked_return_type = "Promise<AdminSettingsDto>")]
pub fn admin_settings(&self) -> Promise {
self.run(|s| async move { s.core.admin_settings().await })
}
#[wasm_bindgen(js_name = adminPutSettings, unchecked_return_type = "Promise<AdminSettingsDto>")]
pub fn admin_put_settings(
&self,
registration: String,
#[wasm_bindgen(js_name = "allowedDomains")] allowed_domains: Vec<String>,
) -> Promise {
self.run(move |s| async move { s.core.admin_put_settings(&registration, allowed_domains).await })
}
#[wasm_bindgen(js_name = syncNow, unchecked_return_type = "Promise<SyncReport>")]
+6 -6
View File
@@ -70,7 +70,7 @@ async fn web_vault_enrolls_and_syncs_with_an_app_device() {
app.configure_server(server.url(), vec![]).unwrap();
app.set_device_signer(Arc::new(Signer(SoftwareSigner::generate(Uuid::new_v4()))), "phone".into(), PlatformDto::Ios)
.unwrap();
app.register_account(SETUP_TOKEN.into()).await.unwrap();
app.register_account(SETUP_TOKEN.into(), None).await.unwrap();
let mut on_app = kv_ffi::ItemDto {
id: String::new(),
kind: kv_ffi::ItemKindDto::Login,
@@ -93,12 +93,12 @@ async fn web_vault_enrolls_and_syncs_with_an_app_device() {
let signer = Arc::new(SoftwareSigner::generate(Uuid::new_v4()));
let web = web_core(store.clone(), &server.url(), signer.clone());
assert!(matches!(web.sync_now().await, Err(CoreError::NoAccount | CoreError::Locked)));
assert_eq!(web.login(PW, &sk, None).await.unwrap(), LoginResult::PendingApproval);
assert_eq!(web.login(PW, &sk, None, None).await.unwrap(), LoginResult::PendingApproval);
assert!(!web.has_account());
let pending = app.list_devices().await.unwrap().into_iter().find(|d| !d.approved).unwrap();
assert_eq!(pending.key_fingerprint, web.device_key_fingerprint().unwrap());
app.approve_device(pending.device_id).await.unwrap();
assert_eq!(web.login(PW, &sk, None).await.unwrap(), LoginResult::Ok);
assert_eq!(web.login(PW, &sk, None, None).await.unwrap(), LoginResult::Ok);
let r = web.sync_now().await.unwrap();
assert_eq!((r.pulled, r.vaults_synced), (1, 1));
assert_eq!(web.get_item(&bank).unwrap().password.as_deref(), Some("from the app"));
@@ -158,7 +158,7 @@ async fn concurrent_sync_is_refused_not_interleaved() {
let server = TestServer::start().await;
let web = web_core(Arc::new(WebStore::new()), &server.url(), Arc::new(SoftwareSigner::generate(Uuid::new_v4())));
let sk = web.create_account(PW).unwrap();
web.register_account(SETUP_TOKEN).await.unwrap();
web.register_account(SETUP_TOKEN, None).await.unwrap();
web.save_item(web_item("A", "1")).unwrap();
let (a, b) = tokio::join!(web.sync_now(), web.sync_now());
assert!(matches!((&a, &b), (Ok(_), Err(CoreError::Busy)) | (Err(CoreError::Busy), Ok(_))), "{a:?} {b:?}");
@@ -176,7 +176,7 @@ async fn app_with_item(server: &TestServer) -> (Arc<kv_ffi::VaultCore>, String,
app.configure_server(server.url(), vec![]).unwrap();
app.set_device_signer(Arc::new(Signer(SoftwareSigner::generate(Uuid::new_v4()))), "phone".into(), PlatformDto::Ios)
.unwrap();
app.register_account(SETUP_TOKEN.into()).await.unwrap();
app.register_account(SETUP_TOKEN.into(), None).await.unwrap();
let id = app
.save_item(kv_ffi::ItemDto {
id: String::new(),
@@ -254,7 +254,7 @@ async fn web_vault_enrolls_by_ticket_both_ways() {
assert!(matches!(web.unlock("wrong password!!", &sk), Err(CoreError::BadCredentials)));
assert!(matches!(web.setup_payload_for_kit(), Err(CoreError::Locked)));
web.unlock(PW, &sk).unwrap();
assert!(web.login("wrong password!!", &sk, None).await.is_err());
assert!(web.login("wrong password!!", &sk, None, None).await.is_err());
assert!(matches!(web.setup_payload_for_kit(), Err(CoreError::Locked)));
web.unlock(PW, &sk).unwrap();