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
+75 -9
View File
@@ -3,8 +3,9 @@
use std::sync::{Arc, Mutex};
use kv_proto::{
AccountInfo, ApiError, Changes, DeviceEnroll, DeviceInfo, Envelope, ItemPut, ItemRecord, JoinBundle, JoinCreate,
JoinCreated, JoinHello, JoinHelloResponse, JoinPoll, KdfParams, LoginFinish, LoginFinishResponse, LoginStart,
AccountEmail, AccountInfo, AdminSettings, AdminUser, AdminUserUpdate, ApiError, Changes, DeviceEnroll, DeviceInfo,
Envelope, InviteCreate, InviteCreated, InviteInfo, ItemPut, ItemRecord, JoinBundle, JoinCreate, JoinCreated,
JoinHello, JoinHelloResponse, JoinPoll, KdfParams, LoginFinish, LoginFinishResponse, LoginStart,
LoginStartResponse, LoginStatus, Manifest, PreloginResponse, RegistrationStartResponse, ServerInfo, SetupFinish,
SetupStart, TicketCreate, TicketCreated, TicketInfo, Vault, HEADER_JOIN_TOKEN,
};
@@ -32,6 +33,16 @@ pub enum LoginOutcome {
PendingApproval,
}
/// How a new device names its account at prelogin.
#[derive(Debug, Clone, Copy)]
pub enum PreloginBy<'a> {
/// The server's only account (servers and clients from before multi-user).
OnlyAccount,
Email(&'a str),
/// From a setup QR or Emergency Kit.
Account(Uuid),
}
pub struct ApiClient {
base: String,
http: reqwest::Client,
@@ -166,17 +177,27 @@ impl ApiClient {
self.json(Method::GET, PATH_SERVER_INFO, None::<&()>, None, false).await
}
/// Account id and KDF parameters of the (single) account: what a new device needs to derive
/// `AUTH_INPUT` before it can build KE1.
pub async fn prelogin(&self) -> Result<PreloginResponse, SyncError> {
self.json(Method::GET, PATH_PRELOGIN, None::<&()>, None, false).await
/// Account id and KDF parameters: what a new device needs to derive `AUTH_INPUT` before it can build KE1.
/// An unknown email or id gets a plausible stand-in, so this never tells whether an account exists.
pub async fn prelogin(&self, by: PreloginBy<'_>) -> Result<PreloginResponse, SyncError> {
let path = match by {
PreloginBy::OnlyAccount => PATH_PRELOGIN.to_string(),
PreloginBy::Email(email) => {
let q: String = url::form_urlencoded::byte_serialize(email.trim().as_bytes()).collect();
format!("{PATH_PRELOGIN}?email={q}")
}
PreloginBy::Account(id) => format!("{PATH_PRELOGIN}?account_id={id}"),
};
self.json(Method::GET, &path, None::<&()>, None, false).await
}
/// OPAQUE registration plus account creation (first device only, needs the setup token).
/// OPAQUE registration plus account creation. `setup_token` is the server's setup code (first account), an
/// invite code, or empty with open sign-up; `email` is required once the server has an account.
#[allow(clippy::too_many_arguments)]
pub async fn register(
&self,
setup_token: &str,
email: Option<&str>,
account_id: Uuid,
kdf: &KdfParams,
auth_input: &[u8],
@@ -186,13 +207,19 @@ impl ApiClient {
device: DeviceEnroll,
) -> Result<(), SyncError> {
let reg = opaque::registration_start(auth_input)?;
let start =
SetupStart { setup_token: setup_token.to_string(), account_id, registration_request: reg.request.clone() };
let email = email.map(|e| e.trim().to_string()).filter(|e| !e.is_empty());
let start = SetupStart {
setup_token: setup_token.to_string(),
email: email.clone(),
account_id,
registration_request: reg.request.clone(),
};
let resp: RegistrationStartResponse =
self.json(Method::POST, PATH_SETUP_START, Some(&start), None, false).await?;
let upload = reg.finish(auth_input, &resp.registration_response)?;
let req = SetupFinish {
setup_token: setup_token.to_string(),
email,
account_id,
kdf: kdf.clone(),
opaque_registration_upload: upload,
@@ -299,6 +326,45 @@ impl ApiClient {
r
}
pub async fn set_email(&self, email: &str) -> Result<(), SyncError> {
self.no_content(Method::PUT, PATH_ACCOUNT_EMAIL, Some(&AccountEmail { email: email.trim().to_string() }), true)
.await
}
// ---------------------------------------------------------------- admin (docs/03 "Accounts")
pub async fn admin_users(&self) -> Result<Vec<AdminUser>, SyncError> {
self.json(Method::GET, PATH_ADMIN_USERS, None::<&()>, None, true).await
}
pub async fn admin_update_user(&self, account: Uuid, update: &AdminUserUpdate) -> Result<(), SyncError> {
self.no_content(Method::PATCH, &format!("{PATH_ADMIN_USERS}/{account}"), Some(update), true).await
}
pub async fn admin_delete_user(&self, account: Uuid) -> Result<(), SyncError> {
self.no_content(Method::DELETE, &format!("{PATH_ADMIN_USERS}/{account}"), None::<&()>, true).await
}
pub async fn admin_invites(&self) -> Result<Vec<InviteInfo>, SyncError> {
self.json(Method::GET, PATH_ADMIN_INVITES, None::<&()>, None, true).await
}
pub async fn admin_create_invite(&self, req: &InviteCreate) -> Result<InviteCreated, SyncError> {
self.json(Method::POST, PATH_ADMIN_INVITES, Some(req), None, true).await
}
pub async fn admin_revoke_invite(&self, invite: Uuid) -> Result<(), SyncError> {
self.no_content(Method::DELETE, &format!("{PATH_ADMIN_INVITES}/{invite}"), None::<&()>, true).await
}
pub async fn admin_settings(&self) -> Result<AdminSettings, SyncError> {
self.json(Method::GET, PATH_ADMIN_SETTINGS, None::<&()>, None, true).await
}
pub async fn admin_put_settings(&self, settings: &AdminSettings) -> Result<AdminSettings, SyncError> {
self.json(Method::PUT, PATH_ADMIN_SETTINGS, Some(settings), None, true).await
}
pub async fn account(&self) -> Result<AccountInfo, SyncError> {
self.json(Method::GET, PATH_ACCOUNT, None::<&()>, None, true).await
}
+1 -1
View File
@@ -25,7 +25,7 @@ pub mod test_server;
pub mod transport;
pub mod wire;
pub use client::{ApiClient, LoginOutcome};
pub use client::{ApiClient, LoginOutcome, PreloginBy};
pub use engine::{SyncContext, SyncReport};
use std::{future::Future, pin::Pin};
+3
View File
@@ -229,6 +229,7 @@ fn handle(st: &mut State, req: &Request) -> Result<ResponseTemplate, ResponseTem
single_tenant: true,
setup_open: st.setup_token.is_some(),
min_client: "0.1.0".into(),
registration: None,
},
)),
("POST", ["v1", "setup", "start"]) => {
@@ -459,6 +460,8 @@ fn authed(
enc_pk: a.enc_pk.clone(),
sig_pk: a.sig_pk.clone(),
wrapped_keyset: a.wrapped_keyset.clone(),
email: None,
is_admin: true,
},
))
}
+4
View File
@@ -8,6 +8,10 @@ pub const PATH_LOGIN_START: &str = "/v1/auth/login/start";
pub const PATH_LOGIN_FINISH: &str = "/v1/auth/login/finish";
pub const PATH_LOGOUT: &str = "/v1/auth/logout";
pub const PATH_ACCOUNT: &str = "/v1/account";
pub const PATH_ACCOUNT_EMAIL: &str = "/v1/account/email";
pub const PATH_ADMIN_USERS: &str = "/v1/admin/users";
pub const PATH_ADMIN_INVITES: &str = "/v1/admin/invites";
pub const PATH_ADMIN_SETTINGS: &str = "/v1/admin/settings";
pub const PATH_VAULTS: &str = "/v1/vaults";
pub const PATH_DEVICES: &str = "/v1/devices";
pub const PATH_TICKETS: &str = "/v1/devices/tickets";
+2
View File
@@ -61,6 +61,7 @@ impl Device {
d.api
.register(
SETUP_TOKEN,
None,
d.keyset.account_id,
&test_kdf(),
&AUTH,
@@ -410,6 +411,7 @@ async fn auth_errors() {
.api
.register(
SETUP_TOKEN,
None,
Uuid::new_v4(),
&test_kdf(),
&AUTH,