diff --git a/Cargo.lock b/Cargo.lock index 62411aa..cb85da9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1875,6 +1875,24 @@ dependencies = [ "zeroize", ] +[[package]] +name = "orbyn-device-client" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "getrandom 0.4.3", + "orbyn-auth-client", + "orbyn-contracts", + "orbyn-crypto", + "reqwest", + "serde", + "serde_json", + "thiserror", + "tokio", + "uuid", + "zeroize", +] + [[package]] name = "orbyn-engineering" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 7583b1e..7f3f7e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ orbyn-sync = { path = "crates/orbyn-sync" } orbyn-core = { path = "crates/orbyn-core" } orbyn-planner = { path = "crates/orbyn-planner" } orbyn-engineering = { path = "crates/orbyn-engineering" } +orbyn-device-client = { path = "crates/orbyn-device-client" } orbyn-server = { path = "crates/orbyn-server" } orbyn-keyservice = { path = "crates/orbyn-keyservice" } orbyn-translog = { path = "crates/orbyn-translog" } diff --git a/crates/README.md b/crates/README.md index d47e9bc..ffd631a 100644 --- a/crates/README.md +++ b/crates/README.md @@ -13,6 +13,7 @@ wire boundary. A device crate never depends on a server crate. | [orbyn-translog](orbyn-translog/README.md) | Primitive | The transparency log's Merkle tree, sparse map, proofs and cosignature rules | | [orbyn-planner](orbyn-planner/README.md) | Domain | Planner records: tasks, events, lists and repeat rules | | [orbyn-engineering](orbyn-engineering/README.md) | Domain | Orbyn Engineering records: its zone names and the settings that follow a person | +| [orbyn-device-client](orbyn-device-client/README.md) | Device transport | The native, async client for an Orbyn account: sign-up, sign-in, re-login and sign-out over rustls | | [orbyn-client](orbyn-client/README.md) | Device provider | The device sync engine: record encryption, uploads, merges and zone keys; no IO | | [orbyn-wasm](orbyn-wasm/README.md) | Device consumer | JavaScript bindings for sign-in, device keys and the sync engine | | [orbyn-core](orbyn-core/README.md) | Server provider | Configuration, Postgres, migrations, jobs, mail, accounts, feeds and push | diff --git a/crates/orbyn-device-client/Cargo.toml b/crates/orbyn-device-client/Cargo.toml new file mode 100644 index 0000000..0f08f74 --- /dev/null +++ b/crates/orbyn-device-client/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "orbyn-device-client" +license = "MIT OR Apache-2.0" +description = "The native device client for an Orbyn account: sign-up, sign-in and the calls a desktop app makes, over rustls." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true + +[dependencies] +base64.workspace = true +getrandom.workspace = true +orbyn-auth-client.workspace = true +orbyn-contracts.workspace = true +orbyn-crypto.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tokio.workspace = true +uuid.workspace = true +zeroize.workspace = true + +[dev-dependencies] +serde_json.workspace = true +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "io-util"] } + +[lints] +workspace = true diff --git a/crates/orbyn-device-client/README.md b/crates/orbyn-device-client/README.md new file mode 100644 index 0000000..15fafed --- /dev/null +++ b/crates/orbyn-device-client/README.md @@ -0,0 +1,3 @@ +# orbyn-device-client + +The native, async client a desktop app uses to be a device of an Orbyn account: sign-up with an invite, the emailed code and proof of work, sign-in (with a trusted start and the re-login that answers `reauthentication_required`), and sign-out. Built on the IO-free primitives (`orbyn-auth-client`, `orbyn-crypto`, `orbyn-contracts`); it adds the call sequence, the transport (reqwest with rustls and ring only, redirects off) and typed errors. The password never leaves the device. The design is in [docs/device-client.md](../../docs/device-client.md). diff --git a/crates/orbyn-device-client/src/api.rs b/crates/orbyn-device-client/src/api.rs new file mode 100644 index 0000000..f7821ef --- /dev/null +++ b/crates/orbyn-device-client/src/api.rs @@ -0,0 +1,217 @@ +//! The transport. [`DeviceApi`] is one call; [`HttpApi`] makes it over HTTPS with rustls (ring +//! only, no OpenSSL), redirects off and a timeout. A host or a test can supply its own. + +use crate::error::DeviceError; +use std::future::Future; +use std::net::IpAddr; +use std::time::Duration; +use zeroize::Zeroizing; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Method { + Get, + Post, + Put, + Delete, +} + +/// One request: a path under the engine's address, an optional bearer token and a JSON body. The +/// body can hold a code or a key exchange message, so it is wiped on drop and **never printed**: +/// `Debug` shows the method, the path and the body's length only, so a logging wrapper or a test +/// double around [`DeviceApi`] cannot leak it. +pub struct Request { + pub method: Method, + pub path: String, + pub bearer: Option>, + pub body: Option>>, +} + +impl std::fmt::Debug for Request { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Request") + .field("method", &self.method) + .field("path", &self.path) + .field("body_len", &self.body.as_ref().map(|b| b.len())) + .finish_non_exhaustive() + } +} + +/// One answer: the status, the `Retry-After` seconds when there is one, and the body (wiped on +/// drop; it can hold a session token). `Debug` shows the status and the body's length only. +pub struct Response { + pub status: u16, + pub retry_after: Option, + pub body: Zeroizing>, +} + +impl std::fmt::Debug for Response { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Response") + .field("status", &self.status) + .field("body_len", &self.body.len()) + .finish_non_exhaustive() + } +} + +/// One call to the engine. A failure to get any answer is [`DeviceError::Transport`]. +pub trait DeviceApi: Send + Sync { + fn send(&self, request: Request) -> impl Future> + Send; +} + +/// Whether `address` is a URL whose host is a loopback **IP literal** (`127.0.0.1`, `[::1]`). +/// A name such as `localhost` is not: a name can be made to point anywhere. The app's "local only" +/// gate uses this, and [`HttpApi`] allows plain `http` only for such a host. +pub fn is_loopback_literal(address: &str) -> bool { + let Ok(url) = reqwest::Url::parse(address) else { + return false; + }; + let Some(host) = url.host_str() else { + return false; + }; + let host = host.trim_start_matches('[').trim_end_matches(']'); + host.parse::().is_ok_and(|ip| ip.is_loopback()) +} + +/// The real transport. `https` only, except `http` to a loopback IP literal (a local stack). +/// Redirects are off: a redirect could carry the bearer token somewhere else. +pub struct HttpApi { + client: reqwest::Client, + base: String, + max_body: u64, +} + +const TIMEOUT: Duration = Duration::from_secs(30); +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +/// The most a response body may be. +const MAX_BODY: u64 = 32 * 1024 * 1024; + +impl HttpApi { + /// `base` is the engine's address, such as `https://api.orbyn.dev`: no user info, query or + /// path beyond `/`. + pub fn new(base: &str) -> Result { + let url = reqwest::Url::parse(base) + .map_err(|_| DeviceError::InvalidInput("the engine address is not a valid URL"))?; + let loopback = is_loopback_literal(base); + let scheme_ok = url.scheme() == "https" || (url.scheme() == "http" && loopback); + let plain = url.username().is_empty() + && url.password().is_none() + && url.query().is_none() + && url.fragment().is_none() + && url.path() == "/"; + if !scheme_ok || !plain || url.host_str().is_none() { + return Err(DeviceError::InvalidInput( + "the engine address must be https (or http to 127.0.0.1 or [::1]) with no path", + )); + } + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(TIMEOUT) + .connect_timeout(CONNECT_TIMEOUT) + .https_only(!loopback) + .user_agent(concat!("orbyn-device-client/", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(|_| DeviceError::Transport)?; + Ok(Self { + client, + base: url.as_str().trim_end_matches('/').to_owned(), + max_body: MAX_BODY, + }) + } + + /// A smaller cap on a response body, in bytes (the default is 32 MiB). It is enforced while + /// reading, whether or not the server sent a `Content-Length`. + pub fn with_body_limit(mut self, bytes: u64) -> Self { + self.max_body = bytes; + self + } +} + +impl DeviceApi for HttpApi { + async fn send(&self, request: Request) -> Result { + let url = format!("{}{}", self.base, request.path); + let mut builder = match request.method { + Method::Get => self.client.get(url), + Method::Post => self.client.post(url), + Method::Put => self.client.put(url), + Method::Delete => self.client.delete(url), + }; + if let Some(token) = &request.bearer { + builder = builder.bearer_auth(token.as_str()); + } + if let Some(body) = request.body { + builder = builder + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(body.to_vec()); + } + let mut response = builder.send().await.map_err(|_| DeviceError::Transport)?; + let status = response.status().as_u16(); + let retry_after = response + .headers() + .get(reqwest::header::RETRY_AFTER) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.trim().parse::().ok()); + if response.content_length().is_some_and(|n| n > self.max_body) { + return Err(DeviceError::Transport); + } + // Read with a running limit: a server that sends no Content-Length, or lies, is stopped too. + let mut body = Zeroizing::new(Vec::new()); + while let Some(chunk) = response.chunk().await.map_err(|_| DeviceError::Transport)? { + if body.len() as u64 + chunk.len() as u64 > self.max_body { + return Err(DeviceError::Transport); + } + body.extend_from_slice(&chunk); + } + Ok(Response { + status, + retry_after, + body, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_loopback_ip_literals_count_and_names_never_do() { + for yes in [ + "http://127.0.0.1:18080", + "https://127.0.0.1", + "http://[::1]:8080/", + "http://127.1:9/", + ] { + assert!(is_loopback_literal(yes), "{yes}"); + } + for no in [ + "http://localhost:18080", + "http://localhost.evil.example", + "http://127.0.0.1.evil.example", + "http://0.0.0.0:8080", + "http://192.168.1.2", + "https://api.orbyn.dev", + "not a url", + "http://[::ffff:8.8.8.8]", + "", + ] { + assert!(!is_loopback_literal(no), "{no}"); + } + } + + #[test] + fn the_http_transport_refuses_plain_http_off_loopback_and_odd_addresses() { + assert!(HttpApi::new("https://api.orbyn.dev").is_ok()); + assert!(HttpApi::new("http://127.0.0.1:18080").is_ok()); + for bad in [ + "http://api.orbyn.dev", + "http://localhost:18080", + "https://user:pw@api.orbyn.dev", + "https://api.orbyn.dev/v1", + "https://api.orbyn.dev/?x=1", + "ftp://api.orbyn.dev", + "nonsense", + ] { + assert!(HttpApi::new(bad).is_err(), "{bad}"); + } + } +} diff --git a/crates/orbyn-device-client/src/client.rs b/crates/orbyn-device-client/src/client.rs new file mode 100644 index 0000000..f03943c --- /dev/null +++ b/crates/orbyn-device-client/src/client.rs @@ -0,0 +1,508 @@ +//! The call sequences: sign-up, sign-in, re-login and sign-out. Passwords are only ever held in a +//! [`Zeroizing`] string, are turned into OPAQUE messages on this device and are never sent. + +use crate::api::{DeviceApi, Method, Request, Response}; +use crate::clock::Clock; +use crate::error::DeviceError; +use crate::store::{KeyStore, StoredIdentity, StoredSession}; +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64; +use orbyn_auth_client as auth; +use orbyn_contracts as wire; +use orbyn_crypto::{DeviceKeys, DevicePublic}; +use serde::Serialize; +use serde::de::DeserializeOwned; +use std::time::Duration; +use zeroize::Zeroizing; + +/// A session as the server answers it. The token is wiped when this is dropped. +#[derive(serde::Deserialize, zeroize::ZeroizeOnDrop)] +pub(crate) struct Created { + token: String, + #[zeroize(skip)] + pub(crate) account_id: String, + #[zeroize(skip)] + pub(crate) device_id: String, + #[zeroize(skip)] + pub(crate) device_state: wire::DeviceState, + #[zeroize(skip)] + pub(crate) expires_at: String, +} + +/// The body of `login/second-factor`, written straight into a wiped buffer so the code is never +/// copied into a `String` that is dropped without being wiped. +#[derive(Serialize)] +struct SecondFactorBody<'a> { + login_id: &'a str, + code: Option<&'a str>, + recovery_code: Option<&'a str>, +} + +/// A password. Wiped when dropped. +pub type Password = Zeroizing; + +/// What the host says about this device. +#[derive(Clone, Debug)] +pub struct ClientConfig { + /// A name the person chose for this device, 1 to 100 characters. + pub device_name: String, + pub platform: wire::DevicePlatform, +} + +/// What a sign-up needs. +pub struct SignUpRequest { + pub email: String, + pub password: Password, + /// The invite code, when the server asks for one. + pub invite: Option, +} + +/// Who this device is signed in as. Never holds a token. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SessionInfo { + pub account_id: String, + pub device_id: String, + pub email: String, + pub device_state: wire::DeviceState, +} + +/// A sign-in that waits for the second factor. +pub struct SecondFactorChallenge { + login_id: String, + email: String, + keys: DeviceKeys, +} + +impl std::fmt::Debug for SecondFactorChallenge { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("SecondFactorChallenge(..)") + } +} + +/// A code from the authenticator app, or one of the one-time recovery codes. +pub enum SecondFactor { + Code(Zeroizing), + Recovery(Zeroizing), +} + +/// How a sign-in ended. +#[derive(Debug)] +pub enum SignInOutcome { + /// Signed in on a trusted device. + SignedIn(SessionInfo), + /// Signed in, but this is a new device: a trusted device must approve it first. + NeedsApproval(SessionInfo), + /// The account has a second factor: pass this to [`DeviceClient::complete_second_factor`]. + SecondFactor(Box), +} + +/// The device client: a transport, a key store and a clock. +pub struct DeviceClient { + pub(crate) api: A, + pub(crate) store: S, + pub(crate) clock: C, + pub(crate) config: ClientConfig, +} + +pub(crate) fn encode(bytes: &[u8]) -> String { + B64.encode(bytes) +} + +pub(crate) fn decode(text: &str) -> Result, DeviceError> { + B64.decode(text).map_err(|_| protocol()) +} + +pub(crate) fn protocol() -> DeviceError { + DeviceError::Server { + status: 0, + code: "bad_response".to_owned(), + } +} + +pub(crate) fn json(value: &T) -> Result>, DeviceError> { + serde_json::to_vec(value) + .map(Zeroizing::new) + .map_err(|_| DeviceError::Crypto) +} + +pub(crate) fn parse(body: &[u8]) -> Result { + serde_json::from_slice(body).map_err(|_| protocol()) +} + +/// An emailed code as people type it: spaces and hyphens ignored, any case. +pub(crate) fn normalise_code(code: &str) -> String { + code.chars() + .filter(|c| !c.is_whitespace() && *c != '-') + .map(|c| c.to_ascii_uppercase()) + .collect() +} + +/// Turns a refusal into a typed error. +pub(crate) fn failure(response: &Response) -> DeviceError { + let code = serde_json::from_slice::(&response.body) + .map(|e| e.code) + .unwrap_or_default(); + match (response.status, code.as_str()) { + (401, "reauthentication_required") => DeviceError::ReauthenticationRequired, + (400, "invalid_email_code") => DeviceError::InvalidCode, + (429, _) => DeviceError::RateLimited { + retry_after: response.retry_after.map(Duration::from_secs), + }, + (409, "deletion_scheduled") => DeviceError::DeletionScheduled, + (413, _) => DeviceError::TooLarge, + (401, "login_failed") => DeviceError::LoginFailed, + (400, "invite_invalid") => DeviceError::InviteInvalid, + (401, "second_factor_failed") => DeviceError::SecondFactorFailed, + (403, "device_not_trusted") => DeviceError::DeviceNotTrusted, + (401, "unauthorized") => DeviceError::SessionExpired, + (status, _) => DeviceError::Server { status, code }, + } +} + +pub(crate) fn is_ok(response: &Response) -> bool { + (200..300).contains(&response.status) +} + +impl DeviceClient { + pub fn new(api: A, store: S, clock: C, config: ClientConfig) -> Self { + Self { + api, + store, + clock, + config, + } + } + + /// The key store, for the host. + pub fn store(&self) -> &S { + &self.store + } + + /// Who this device is signed in as, from the key store. + pub fn identity(&self) -> Result, DeviceError> { + Ok(self + .store + .load()? + .map(|i| (i.account_id, i.device_id, i.email))) + } + + pub(crate) fn new_device(&self, keys: &DevicePublic) -> wire::NewDevice { + wire::NewDevice { + name: self.config.device_name.clone(), + platform: self.config.platform, + signing_key: encode(&keys.signing), + agreement_key: encode(&keys.agreement), + } + } + + pub(crate) async fn call( + &self, + method: Method, + path: &str, + bearer: Option>, + body: Option>>, + ) -> Result { + self.api + .send(Request { + method, + path: path.to_owned(), + bearer, + body, + }) + .await + } + + /// A call that must answer 2xx with a JSON body of type `R`. + pub(crate) async fn post( + &self, + path: &str, + body: &T, + ) -> Result { + let response = self + .call(Method::Post, path, None, Some(json(body)?)) + .await?; + if !is_ok(&response) { + return Err(failure(&response)); + } + parse(&response.body) + } + + /// An authenticated call with this device's session. A 401 asks the server whether the + /// account still exists: [`DeviceError::AccountGone`] if it was deleted, else + /// [`DeviceError::SessionExpired`]. + pub(crate) async fn authed( + &self, + method: Method, + path: &str, + body: Option>>, + ) -> Result { + let identity = self.store.load()?.ok_or(DeviceError::NotSignedIn)?; + let session = identity.session.as_ref().ok_or(DeviceError::NotSignedIn)?; + let response = self + .call(method, path, Some(session.token.clone()), body) + .await?; + if is_ok(&response) { + return Ok(response); + } + let error = failure(&response); + if error == DeviceError::SessionExpired { + let status: Result = self + .post( + "/v1/accounts/status", + &wire::AccountStatusRequest { + account_id: identity.account_id.clone(), + }, + ) + .await; + if status.is_ok_and(|s| !s.deleted_at.is_empty()) { + return Err(DeviceError::AccountGone); + } + } + Err(error) + } + + // ---- sign-in ---- + + /// Signs in with the email and password. On a device this store already holds for the same + /// account the start is signed with the device key (a trusted start), which skips the + /// per-email limit and the second-factor code; any other device is new and is pending until a + /// trusted device approves it. A store that holds another account's device is + /// [`DeviceError::StoreNotEmpty`]. + pub async fn sign_in( + &self, + email: &str, + password: &Password, + ) -> Result { + let email = auth::normalize_email(email) + .ok_or(DeviceError::InvalidInput("the email is not valid"))?; + let existing = self.store.load()?; + if existing.as_ref().is_some_and(|i| i.email != email) { + return Err(DeviceError::StoreNotEmpty); + } + self.sign_in_with(&email, password, existing).await + } + + /// The sign-in that answers [`DeviceError::ReauthenticationRequired`]: the same email, this + /// device's key and the password again. It returns a new, recent session for the same device, + /// with no approval; then send the refused request again. + pub async fn reauthenticate(&self, password: &Password) -> Result { + let identity = self.store.load()?.ok_or(DeviceError::NotSignedIn)?; + let email = identity.email.clone(); + match self.sign_in_with(&email, password, Some(identity)).await? { + SignInOutcome::SignedIn(info) | SignInOutcome::NeedsApproval(info) => Ok(info), + // A trusted start skips the factor, so this is a device that is not trusted. + SignInOutcome::SecondFactor(_) => Err(DeviceError::DeviceNotTrusted), + } + } + + async fn sign_in_with( + &self, + email: &str, + password: &Password, + existing: Option, + ) -> Result { + let (login, request) = + auth::start_login(password.as_bytes()).map_err(|_| DeviceError::Crypto)?; + let keys = match &existing { + Some(identity) => identity.keys()?, + None => DeviceKeys::generate().map_err(|_| DeviceError::Crypto)?, + }; + let public = keys.public(); + let trusted_device = existing.as_ref().map(|_| { + let signed_at = self.clock.now_secs(); + wire::TrustedDeviceProof { + signing_key: encode(&public.signing), + signed_at, + signature: encode(&keys.sign( + auth::TRUSTED_START_CONTEXT, + &auth::trusted_start_message(email, &request, signed_at), + )), + } + }); + let started: wire::LoginStarted = self + .post( + "/v1/sessions/login/start", + &wire::LoginStart { + email: email.to_owned(), + credential_request: encode(&request), + trusted_device, + }, + ) + .await?; + // A wrong password fails here, on the device, and nothing more is sent. + let finish = login + .finish(password.as_bytes(), &decode(&started.credential_response)?) + .map_err(|_| DeviceError::LoginFailed)?; + let login_id: [u8; 16] = *uuid::Uuid::parse_str(&started.login_id) + .map_err(|_| protocol())? + .as_bytes(); + let signature = keys.sign( + auth::DEVICE_LOGIN_CONTEXT, + &auth::login_message(&login_id, &public), + ); + let response = self + .call( + Method::Post, + "/v1/sessions/login/finish", + None, + Some(json(&wire::LoginFinish { + login_id: started.login_id.clone(), + credential_finalization: encode(&finish.finalization), + device: self.new_device(&public), + device_signature: encode(&signature), + })?), + ) + .await?; + if is_ok(&response) { + let created: Created = parse(&response.body)?; + return self.finish_sign_in(&created, &keys, email, existing.as_ref()); + } + if response.status == 401 + && let Ok(factor) = serde_json::from_slice::(&response.body) + && factor.code == "second_factor_required" + { + return Ok(SignInOutcome::SecondFactor(Box::new( + SecondFactorChallenge { + login_id: factor.login_id, + email: email.to_owned(), + keys, + }, + ))); + } + Err(failure(&response)) + } + + /// Finishes a sign-in that asked for the second factor. A wrong code is + /// [`DeviceError::SecondFactorFailed`] and leaves the challenge usable (the server closes it + /// after five). + pub async fn complete_second_factor( + &self, + challenge: &SecondFactorChallenge, + factor: SecondFactor, + ) -> Result { + let (code, recovery_code) = match &factor { + SecondFactor::Code(code) => (Some(code.as_str()), None), + SecondFactor::Recovery(code) => (None, Some(code.as_str())), + }; + let body = json(&SecondFactorBody { + login_id: &challenge.login_id, + code, + recovery_code, + })?; + let response = self + .call( + Method::Post, + "/v1/sessions/login/second-factor", + None, + Some(body), + ) + .await?; + if !is_ok(&response) { + return Err(failure(&response)); + } + let created: Created = parse(&response.body)?; + let existing = self.store.load()?; + self.finish_sign_in( + &created, + &challenge.keys, + &challenge.email, + existing.as_ref(), + ) + } + + fn finish_sign_in( + &self, + created: &Created, + keys: &DeviceKeys, + email: &str, + existing: Option<&StoredIdentity>, + ) -> Result { + let info = self.save_session(created, keys, email, existing)?; + Ok(match info.device_state { + wire::DeviceState::Trusted => SignInOutcome::SignedIn(info), + wire::DeviceState::Pending => SignInOutcome::NeedsApproval(info), + }) + } + + /// Saves the device and its new session. The vault key is made once, on this device, and kept + /// across sign-ins. + pub(crate) fn save_session( + &self, + created: &Created, + keys: &DeviceKeys, + email: &str, + existing: Option<&StoredIdentity>, + ) -> Result { + // The vault key is kept across sign-ins of the same account. A different account (the old + // one was deleted and the address signed up again) gets a new one, so it can never open + // the old account's snapshot. + let vault_key = match existing { + Some(identity) if identity.account_id == created.account_id => { + identity.vault_key.clone() + } + _ => { + let mut key = Zeroizing::new([0u8; 32]); + getrandom::fill(key.as_mut_slice()).map_err(|_| DeviceError::Crypto)?; + key + } + }; + self.store.save(&StoredIdentity { + device_keys: keys.to_bytes(), + account_id: created.account_id.clone(), + device_id: created.device_id.clone(), + email: email.to_owned(), + session: Some(StoredSession { + token: Zeroizing::new(created.token.clone()), + expires_at: created.expires_at.clone(), + }), + vault_key, + })?; + Ok(SessionInfo { + account_id: created.account_id.clone(), + device_id: created.device_id.clone(), + email: email.to_owned(), + device_state: created.device_state, + }) + } + + // ---- sign-out ---- + + /// Signs this device out. The server is told when it can be (a failure to reach it does not + /// stop the local sign-out). With `forget` the device keys and everything else are removed + /// too, and the next sign-in is a new device that needs approval; without it the keys stay, + /// so signing in again is the same device. + pub async fn sign_out(&self, forget: bool) -> Result<(), DeviceError> { + let Some(mut identity) = self.store.load()? else { + return Ok(()); + }; + if let Some(session) = &identity.session { + let _ = self + .call( + Method::Delete, + "/v1/sessions/current", + Some(session.token.clone()), + None, + ) + .await; + } + if forget { + self.store.clear()?; + } else { + identity.session = None; + self.store.save(&identity)?; + } + Ok(()) + } + + /// Signs every session of every device out, this one included. Needs a recent sign-in + /// ([`DeviceError::ReauthenticationRequired`]; call [`DeviceClient::reauthenticate`] and try + /// again). The device keys stay. + pub async fn sign_out_all(&self) -> Result<(), DeviceError> { + self.authed(Method::Delete, "/v1/sessions", None).await?; + if let Some(mut identity) = self.store.load()? { + identity.session = None; + self.store.save(&identity)?; + } + Ok(()) + } +} diff --git a/crates/orbyn-device-client/src/clock.rs b/crates/orbyn-device-client/src/clock.rs new file mode 100644 index 0000000..cbec693 --- /dev/null +++ b/crates/orbyn-device-client/src/clock.rs @@ -0,0 +1,19 @@ +//! The clock, so a test can fix the time a trusted start is signed at. + +/// Wall-clock time. +pub trait Clock: Send + Sync { + /// Unix seconds. + fn now_secs(&self) -> i64; +} + +/// The system clock. +#[derive(Clone, Copy, Debug, Default)] +pub struct SystemClock; + +impl Clock for SystemClock { + fn now_secs(&self) -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX)) + } +} diff --git a/crates/orbyn-device-client/src/error.rs b/crates/orbyn-device-client/src/error.rs new file mode 100644 index 0000000..13403d4 --- /dev/null +++ b/crates/orbyn-device-client/src/error.rs @@ -0,0 +1,67 @@ +//! Typed errors. None carries a password, a token, a code, a key or a URL. + +use std::time::Duration; + +/// The key store failed. +#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] +#[error("the key store failed")] +pub struct StoreError; + +/// Why a call failed. +#[derive(Debug, PartialEq, Eq, thiserror::Error)] +pub enum DeviceError { + /// The network failed or timed out. Nothing is known about whether the server acted. + #[error("the network failed")] + Transport, + /// The session's password sign-in is older than ten minutes: call + /// [`crate::DeviceClient::reauthenticate`] and send the same request again. + #[error("sign in again to do this")] + ReauthenticationRequired, + /// The emailed code, or a second-factor code, is wrong or has expired. + #[error("the code is wrong or has expired")] + InvalidCode, + /// Too many attempts; try again after `retry_after` when the server said when. + #[error("too many attempts")] + RateLimited { retry_after: Option }, + /// The account's deletion is scheduled; changes that could lock the owner out wait. + #[error("the account is scheduled for deletion")] + DeletionScheduled, + /// The account was deleted. This device's data on the server is gone. + #[error("this account was deleted")] + AccountGone, + /// A value, a record or an upload is over a limit. + #[error("too large")] + TooLarge, + /// The email or the password is wrong (the server cannot tell which). + #[error("the email or password is wrong")] + LoginFailed, + /// The invite is unknown, used, expired or missing. + #[error("the invite is not valid")] + InviteInvalid, + /// The second-factor code or recovery code was refused. + #[error("the second-factor code was refused")] + SecondFactorFailed, + /// There is no signed-in identity in the key store. + #[error("not signed in")] + NotSignedIn, + /// The key store already holds a device. Sign out, forgetting it, before signing in as another. + #[error("this device already belongs to an account")] + StoreNotEmpty, + /// The session is expired or was signed out elsewhere, and the account still exists. + #[error("the session ended; sign in again")] + SessionExpired, + /// Only a trusted device can do this. + #[error("this device is not trusted yet")] + DeviceNotTrusted, + /// A value the caller gave is not valid (an email, an address, a name). + #[error("{0}")] + InvalidInput(&'static str), + /// The server answered something this client does not handle. + #[error("the server answered {status} {code}")] + Server { status: u16, code: String }, + #[error(transparent)] + Store(#[from] StoreError), + /// A cryptographic step failed (random source, key material). + #[error("a cryptographic step failed")] + Crypto, +} diff --git a/crates/orbyn-device-client/src/lib.rs b/crates/orbyn-device-client/src/lib.rs new file mode 100644 index 0000000..4f39318 --- /dev/null +++ b/crates/orbyn-device-client/src/lib.rs @@ -0,0 +1,21 @@ +//! The native device client. A host (the Engineering app) supplies three things: a [`DeviceApi`] +//! (the transport; [`HttpApi`] is the real one), a [`KeyStore`] (where the device's secrets live, +//! the OS keychain in the app) and a [`Clock`]. [`DeviceClient`] does the rest. The design is +//! `docs/device-client.md`. + +mod api; +mod client; +mod clock; +mod error; +mod signup; +mod store; + +pub use api::{DeviceApi, HttpApi, Method, Request, Response, is_loopback_literal}; +pub use client::{ + ClientConfig, DeviceClient, Password, SecondFactor, SecondFactorChallenge, SessionInfo, + SignInOutcome, SignUpRequest, +}; +pub use clock::{Clock, SystemClock}; +pub use error::{DeviceError, StoreError}; +pub use signup::PendingSignUp; +pub use store::{KeyStore, MemoryStore, StoredIdentity, StoredSession}; diff --git a/crates/orbyn-device-client/src/signup.rs b/crates/orbyn-device-client/src/signup.rs new file mode 100644 index 0000000..3578735 --- /dev/null +++ b/crates/orbyn-device-client/src/signup.rs @@ -0,0 +1,196 @@ +//! Sign-up: the OPAQUE steps, the proof of work and the emailed code. + +use crate::api::Method; +use crate::client::{ + Created, DeviceClient, SessionInfo, SignUpRequest, decode, encode, failure, is_ok, json, + normalise_code, parse, protocol, +}; +use crate::error::DeviceError; +use crate::{Clock, DeviceApi, KeyStore}; +use orbyn_auth_client as auth; +use orbyn_contracts as wire; +use orbyn_crypto::{DeviceKeys, pow}; + +const MAX_PROOF_ROUNDS: usize = 2; + +/// A sign-up that waits for the emailed code. It holds the new device's keys in memory only: an +/// abandoned sign-up leaves nothing behind. Drop it to cancel. +pub struct PendingSignUp { + signup_id: String, + email: String, + keys: Option, +} + +impl PendingSignUp { + pub fn email(&self) -> &str { + &self.email + } +} + +impl std::fmt::Debug for PendingSignUp { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("PendingSignUp(..)") + } +} + +impl DeviceClient { + // ---- sign-up ---- + + /// Starts a sign-up: the OPAQUE steps, this device's first signature, and the proof of work if + /// the server asks for one (solved here, off the async threads). The server then emails a code + /// to the address. Nothing is stored until [`DeviceClient::verify_sign_up`] succeeds. + pub async fn begin_sign_up( + &self, + request: SignUpRequest, + ) -> Result { + if self.store.load()?.is_some() { + return Err(DeviceError::StoreNotEmpty); + } + let email = auth::normalize_email(&request.email) + .ok_or(DeviceError::InvalidInput("the email is not valid"))?; + if request.password.is_empty() { + return Err(DeviceError::InvalidInput("the password is empty")); + } + let (registration, message) = auth::start_registration(request.password.as_bytes()) + .map_err(|_| DeviceError::Crypto)?; + let started: wire::RegisterStarted = self + .post( + "/v1/accounts/register/start", + &wire::RegisterStart { + email: email.clone(), + registration_request: encode(&message), + }, + ) + .await?; + let finish = registration + .finish( + request.password.as_bytes(), + &decode(&started.registration_response)?, + ) + .map_err(|_| DeviceError::Crypto)?; + // The password is not needed again; wipe it now. + drop(request.password); + let keys = DeviceKeys::generate().map_err(|_| DeviceError::Crypto)?; + let public = keys.public(); + let signature = keys.sign( + auth::DEVICE_REGISTER_CONTEXT, + &auth::register_message(&email, &finish.upload, &public), + ); + let mut body = wire::RegisterFinish { + email: email.clone(), + registration_upload: encode(&finish.upload), + device: self.new_device(&public), + device_signature: encode(&signature), + invite: request.invite, + proof: None, + }; + for round in 0..=MAX_PROOF_ROUNDS { + let response = self + .call( + Method::Post, + "/v1/accounts/register/finish", + None, + Some(json(&body)?), + ) + .await?; + if is_ok(&response) { + let pending: wire::SignUpPending = parse(&response.body)?; + return Ok(PendingSignUp { + signup_id: pending.signup_id, + email, + keys: Some(keys), + }); + } + if response.status != 428 || round == MAX_PROOF_ROUNDS { + return Err(failure(&response)); + } + let required: wire::ProofRequired = parse(&response.body)?; + body.proof = Some(solve(required).await?); + } + Err(protocol()) + } + + /// Finishes a sign-up with the emailed code (any case, hyphen or not). A wrong code is + /// [`DeviceError::InvalidCode`] and leaves the sign-up open: try again or ask for a new code. + /// On success the keys, ids and session are saved and the account exists. + pub async fn verify_sign_up( + &self, + pending: &mut PendingSignUp, + code: &str, + ) -> Result { + let keys = pending + .keys + .as_ref() + .ok_or(DeviceError::InvalidInput("this sign-up is finished"))?; + // A sign-in finished in between would be overwritten: refuse before the code is spent. + if self.store.load()?.is_some() { + return Err(DeviceError::StoreNotEmpty); + } + let created: Created = self + .post( + "/v1/accounts/email/verify", + &wire::EmailVerify { + signup_id: pending.signup_id.clone(), + code: normalise_code(code), + }, + ) + .await?; + let info = self.save_session(&created, keys, &pending.email, None)?; + pending.keys = None; + Ok(info) + } + + /// Asks for the code again (the server allows three an hour and answers the same whatever + /// happens). + pub async fn resend_sign_up(&self, pending: &PendingSignUp) -> Result<(), DeviceError> { + let response = self + .call( + Method::Post, + "/v1/accounts/email/resend", + None, + Some(json(&wire::EmailResend { + signup_id: pending.signup_id.clone(), + })?), + ) + .await?; + if is_ok(&response) { + Ok(()) + } else { + Err(failure(&response)) + } + } +} + +/// Solves a proof-of-work challenge off the async threads, from a random start (the server takes +/// a solution once, so two starts must not find the same nonce). +async fn solve(required: wire::ProofRequired) -> Result { + if required.bits > pow::MAX_BITS { + return Err(DeviceError::Server { + status: 428, + code: required.code, + }); + } + let challenge = unhex(&required.challenge).ok_or_else(protocol)?; + let mut start = [0u8; 4]; + getrandom::fill(&mut start).map_err(|_| DeviceError::Crypto)?; + let bits = required.bits; + let nonce = tokio::task::spawn_blocking(move || { + pow::solve_from(&challenge, bits, u32::from_be_bytes(start)) + }) + .await + .map_err(|_| DeviceError::Crypto)? + .ok_or(DeviceError::Crypto)?; + Ok(wire::SignupProof { + challenge: required.challenge, + nonce, + }) +} + +fn unhex(text: &str) -> Option> { + if !text.len().is_multiple_of(2) || !text.is_ascii() { + return None; + } + (0..text.len() / 2) + .map(|i| u8::from_str_radix(&text[i * 2..i * 2 + 2], 16).ok()) + .collect() +} diff --git a/crates/orbyn-device-client/src/store.rs b/crates/orbyn-device-client/src/store.rs new file mode 100644 index 0000000..b1c837a --- /dev/null +++ b/crates/orbyn-device-client/src/store.rs @@ -0,0 +1,90 @@ +//! Where a device keeps its secrets. Only what must outlive a run: the device keys, the account +//! and device ids with the email, the session token and the vault key. The zone-key cache is not +//! here: the host keeps it in its own snapshot, sealed under the vault key. + +use crate::error::StoreError; +use orbyn_crypto::DeviceKeys; +use std::sync::Mutex; +use zeroize::Zeroizing; + +/// A session as the server gave it. +#[derive(Clone)] +pub struct StoredSession { + /// The bearer token. Never logged. + pub token: Zeroizing, + /// RFC 3339, as the server sent it. + pub expires_at: String, +} + +impl std::fmt::Debug for StoredSession { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("StoredSession(..)") + } +} + +/// Everything a device persists. +#[derive(Clone)] +pub struct StoredIdentity { + /// The device keys: Ed25519 then X25519 secrets ([`DeviceKeys::to_bytes`]). + pub device_keys: Zeroizing<[u8; 64]>, + pub account_id: String, + pub device_id: String, + /// The normalised email: an identifier, kept so a re-login needs only the password. + pub email: String, + /// `None` after a sign-out that kept the device. + pub session: Option, + /// 32 random bytes made on this device. The host seals its snapshot under it. It never leaves + /// the device and is not derived from the password. + pub vault_key: Zeroizing<[u8; 32]>, +} + +impl std::fmt::Debug for StoredIdentity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("StoredIdentity(..)") + } +} + +impl StoredIdentity { + /// The device keys as a key object. + pub fn keys(&self) -> Result { + DeviceKeys::from_bytes(self.device_keys.as_slice()).map_err(|_| crate::DeviceError::Crypto) + } +} + +/// The device's secret storage: the OS keychain in the app. Calls are synchronous and short. +pub trait KeyStore: Send + Sync { + /// The saved identity, if any. + fn load(&self) -> Result, StoreError>; + /// Replaces the saved identity, all or nothing. + fn save(&self, identity: &StoredIdentity) -> Result<(), StoreError>; + /// Forgets everything. + fn clear(&self) -> Result<(), StoreError>; +} + +/// A store in memory, for tests and for a run that must keep nothing. +#[derive(Default)] +pub struct MemoryStore { + slot: Mutex>, +} + +impl MemoryStore { + pub fn new() -> Self { + Self::default() + } +} + +impl KeyStore for MemoryStore { + fn load(&self) -> Result, StoreError> { + Ok(self.slot.lock().map_err(|_| StoreError)?.clone()) + } + + fn save(&self, identity: &StoredIdentity) -> Result<(), StoreError> { + *self.slot.lock().map_err(|_| StoreError)? = Some(identity.clone()); + Ok(()) + } + + fn clear(&self) -> Result<(), StoreError> { + *self.slot.lock().map_err(|_| StoreError)? = None; + Ok(()) + } +} diff --git a/crates/orbyn-device-client/tests/client.rs b/crates/orbyn-device-client/tests/client.rs new file mode 100644 index 0000000..75921d5 --- /dev/null +++ b/crates/orbyn-device-client/tests/client.rs @@ -0,0 +1,543 @@ +//! The device client against an in-memory engine that speaks the real OPAQUE and checks the real +//! signatures (`tests/support`). + +mod support; + +use orbyn_contracts::{DevicePlatform, DeviceState}; +use orbyn_device_client::{ + ClientConfig, Clock, DeviceApi, DeviceClient, DeviceError, HttpApi, KeyStore, MemoryStore, + Method, Request, Response, SecondFactor, SignInOutcome, SignUpRequest, StoredIdentity, +}; +use std::sync::Arc; +use std::sync::atomic::{AtomicI64, Ordering}; +use std::time::Duration; +use support::{FakeEngine, StatusMode}; +use zeroize::Zeroizing; + +const PASSWORD: &str = "correct horse battery staple"; +const EMAIL: &str = "Walker@Example.com "; +const NORMAL: &str = "walker@example.com"; + +struct SharedClock(Arc); + +impl Clock for SharedClock { + fn now_secs(&self) -> i64 { + self.0.load(Ordering::SeqCst) + } +} + +type Client = DeviceClient; + +fn client(engine: &FakeEngine, name: &str) -> Client { + DeviceClient::new( + engine.clone(), + MemoryStore::new(), + SharedClock(engine.now.clone()), + ClientConfig { + device_name: name.to_owned(), + platform: DevicePlatform::Linux, + }, + ) +} + +fn password(text: &str) -> Zeroizing { + Zeroizing::new(text.to_owned()) +} + +fn request(invite: Option<&str>) -> SignUpRequest { + SignUpRequest { + email: EMAIL.to_owned(), + password: password(PASSWORD), + invite: invite.map(str::to_owned), + } +} + +/// Signs the first device up and returns its client. +async fn first_device(engine: &FakeEngine) -> Client { + let a = client(engine, "Laptop"); + let mut pending = a.begin_sign_up(request(None)).await.expect("begin"); + let code = engine.code_for(NORMAL); + a.verify_sign_up(&mut pending, &code).await.expect("verify"); + a +} + +#[tokio::test(flavor = "multi_thread")] +async fn sign_up_runs_the_invite_the_proof_of_work_and_the_emailed_code() { + let engine = FakeEngine::new(); + engine.add_invite("ABCD1234"); + engine.require_proof(10); + let a = client(&engine, "Laptop"); + let mut pending = a + .begin_sign_up(request(Some("abcd-1234"))) + .await + .expect("a sign-up with a proof of work solved inside"); + assert_eq!(pending.email(), NORMAL, "the email is normalised"); + assert_eq!(a.identity().expect("store"), None, "nothing is kept yet"); + // Register/finish was sent twice: refused with a challenge, then with the solution. + let finishes = engine + .log() + .iter() + .filter(|(path, _)| path.ends_with("register/finish")) + .count(); + assert_eq!(finishes, 2); + + // A wrong code leaves the sign-up open; the right one, typed with a hyphen, finishes it. + assert_eq!( + a.verify_sign_up(&mut pending, "00000000").await.err(), + Some(DeviceError::InvalidCode) + ); + let code = engine.code_for(NORMAL); + let typed = format!("{}-{}", &code[..4], &code[4..]); + let session = a + .verify_sign_up(&mut pending, &typed) + .await + .expect("verify"); + assert_eq!(session.device_state, DeviceState::Trusted); + let (account, device, email) = a.identity().expect("store").expect("saved"); + assert_eq!( + (account, device, email), + (session.account_id, session.device_id, NORMAL.to_owned()) + ); + assert_eq!(engine.device_count(NORMAL), 1); + + // The password never went anywhere: not in any body, not as base64. + use base64::Engine as _; + let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(PASSWORD); + for (path, body) in engine.log() { + assert!( + !body.contains(PASSWORD) && !body.contains(&encoded), + "{path}" + ); + } + // A second finish of the same sign-up is refused. + assert!(a.verify_sign_up(&mut pending, &code).await.is_err()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn an_invite_that_is_missing_or_wrong_is_refused() { + let engine = FakeEngine::new(); + engine.add_invite("ABCD1234"); + for invite in [None, Some("WRONG-CODE")] { + let a = client(&engine, "Laptop"); + assert_eq!( + a.begin_sign_up(request(invite)).await.err(), + Some(DeviceError::InviteInvalid) + ); + assert_eq!(a.identity().expect("store"), None); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_new_device_signs_in_pending_and_a_trusted_one_signs_the_start() { + let engine = FakeEngine::new(); + let a = first_device(&engine).await; + let b = client(&engine, "Phone"); + engine.clear_log(); + let outcome = b + .sign_in(NORMAL, &password(PASSWORD)) + .await + .expect("sign in"); + let SignInOutcome::NeedsApproval(info) = outcome else { + panic!("a new device waits for approval: {outcome:?}"); + }; + assert_eq!(info.device_state, DeviceState::Pending); + assert_eq!(engine.device_count(NORMAL), 2); + let start = engine + .log() + .into_iter() + .find(|(p, _)| p.ends_with("login/start")) + .expect("start") + .1; + assert!( + start.contains("\"trusted_device\":null"), + "a new device sends no trusted start: {start}" + ); + + // The first device signs in again as itself: the start is signed, no new device appears. + engine.clear_log(); + let outcome = a + .sign_in(NORMAL, &password(PASSWORD)) + .await + .expect("sign in"); + assert!(matches!(outcome, SignInOutcome::SignedIn(_)), "{outcome:?}"); + assert_eq!(engine.device_count(NORMAL), 2); + let start = engine + .log() + .into_iter() + .find(|(p, _)| p.ends_with("login/start")) + .expect("start") + .1; + assert!( + start.contains("\"signing_key\""), + "a trusted start is signed: {start}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_wrong_password_fails_on_the_device_and_sends_nothing_more() { + let engine = FakeEngine::new(); + let _a = first_device(&engine).await; + let b = client(&engine, "Phone"); + engine.clear_log(); + assert_eq!( + b.sign_in(NORMAL, &password("wrong horse")).await.err(), + Some(DeviceError::LoginFailed) + ); + assert!( + engine + .log() + .iter() + .all(|(path, _)| !path.ends_with("login/finish")) + ); + assert_eq!(b.identity().expect("store"), None); + assert_eq!(engine.device_count(NORMAL), 1, "no device was added"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_second_factor_is_asked_of_a_new_device_and_skipped_by_a_trusted_start() { + let engine = FakeEngine::new(); + let a = first_device(&engine).await; + engine.set_totp(NORMAL, "123456"); + let b = client(&engine, "Phone"); + let outcome = b + .sign_in(NORMAL, &password(PASSWORD)) + .await + .expect("sign in"); + let SignInOutcome::SecondFactor(challenge) = outcome else { + panic!("the factor is asked: {outcome:?}"); + }; + assert_eq!( + b.identity().expect("store"), + None, + "nothing is kept before the factor" + ); + assert_eq!( + b.complete_second_factor(&challenge, SecondFactor::Code(password("000000"))) + .await + .err(), + Some(DeviceError::SecondFactorFailed) + ); + let outcome = b + .complete_second_factor(&challenge, SecondFactor::Code(password("123456"))) + .await + .expect("the right code"); + assert!( + matches!(outcome, SignInOutcome::NeedsApproval(_)), + "{outcome:?}" + ); + assert!(b.identity().expect("store").is_some()); + // The trusted first device needs no code. + let outcome = a + .sign_in(NORMAL, &password(PASSWORD)) + .await + .expect("sign in"); + assert!(matches!(outcome, SignInOutcome::SignedIn(_)), "{outcome:?}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn signing_out_everywhere_needs_a_recent_sign_in_and_reauthenticate_gives_one() { + let engine = FakeEngine::new(); + let a = first_device(&engine).await; + let (_, device_before, _) = a.identity().expect("store").expect("saved"); + engine.advance(11 * 60); + assert_eq!( + a.sign_out_all().await.err(), + Some(DeviceError::ReauthenticationRequired) + ); + // A wrong password refreshes nothing. + assert_eq!( + a.reauthenticate(&password("wrong horse")).await.err(), + Some(DeviceError::LoginFailed) + ); + assert_eq!( + a.sign_out_all().await.err(), + Some(DeviceError::ReauthenticationRequired) + ); + // The right one gives a recent session for the same device, with no approval. + let info = a + .reauthenticate(&password(PASSWORD)) + .await + .expect("re-login"); + assert_eq!(info.device_id, device_before); + assert_eq!(info.device_state, DeviceState::Trusted); + assert_eq!(engine.device_count(NORMAL), 1); + a.sign_out_all().await.expect("signs everything out"); + assert_eq!(a.sign_out_all().await.err(), Some(DeviceError::NotSignedIn)); + // The keys stayed, so the next sign-in is the same device. + let info = match a + .sign_in(NORMAL, &password(PASSWORD)) + .await + .expect("sign in") + { + SignInOutcome::SignedIn(info) => info, + other => panic!("{other:?}"), + }; + assert_eq!(info.device_id, device_before); +} + +#[tokio::test(flavor = "multi_thread")] +async fn sign_out_keeps_the_device_unless_asked_to_forget_it() { + let engine = FakeEngine::new(); + let a = first_device(&engine).await; + let (_, device, _) = a.identity().expect("store").expect("saved"); + a.sign_out(false).await.expect("sign out"); + assert!(a.identity().expect("store").is_some(), "the device stays"); + let SignInOutcome::SignedIn(info) = a.sign_in(NORMAL, &password(PASSWORD)).await.expect("in") + else { + panic!("same device"); + }; + assert_eq!(info.device_id, device); + a.sign_out(true).await.expect("forget"); + assert_eq!(a.identity().expect("store"), None); + let outcome = a.sign_in(NORMAL, &password(PASSWORD)).await.expect("in"); + assert!( + matches!(outcome, SignInOutcome::NeedsApproval(_)), + "{outcome:?}" + ); + assert_eq!(engine.device_count(NORMAL), 2); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_deleted_account_a_limit_and_a_busy_store_have_their_own_errors() { + let engine = FakeEngine::new(); + let a = first_device(&engine).await; + // Another sign-up or sign-in while the store holds a device is refused. + assert_eq!( + a.begin_sign_up(request(None)).await.err(), + Some(DeviceError::StoreNotEmpty) + ); + assert_eq!( + a.sign_in("someone@else.example", &password(PASSWORD)) + .await + .err(), + Some(DeviceError::StoreNotEmpty) + ); + engine.delete_account(NORMAL); + assert_eq!(a.sign_out_all().await.err(), Some(DeviceError::AccountGone)); + + let limited = FakeEngine::new(); + limited.limit_register(30); + let c = client(&limited, "Laptop"); + assert_eq!( + c.begin_sign_up(request(None)).await.err(), + Some(DeviceError::RateLimited { + retry_after: Some(Duration::from_secs(30)) + }) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_session_ended_elsewhere_is_not_an_account_that_is_gone() { + // The app destroys key material on `AccountGone`, so only a deleted account may give it. + for mode in [ + StatusMode::Normal, + StatusMode::ServerError, + StatusMode::Unreachable, + ] { + let engine = FakeEngine::new(); + let a = first_device(&engine).await; + engine.revoke_sessions(NORMAL); + engine.status_mode(mode); + assert_eq!( + a.sign_out_all().await.err(), + Some(DeviceError::SessionExpired), + "an alive account, status {}", + match mode { + StatusMode::Normal => "answers with no deletion", + StatusMode::ServerError => "answers 500", + StatusMode::Unreachable => "is unreachable", + } + ); + // Nothing was wiped: the keys and ids are still there. + assert!(a.identity().expect("store").is_some()); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn debug_output_never_shows_a_body() { + let secret = "SESSION-TOKEN-AND-CODE-1234"; + let response = Response { + status: 200, + retry_after: None, + body: zeroize::Zeroizing::new(format!("{{\"token\":\"{secret}\"}}").into_bytes()), + }; + let request = Request { + method: Method::Post, + path: "/v1/sessions/login/second-factor".to_owned(), + bearer: Some(Zeroizing::new("BEARER-SECRET".to_owned())), + body: Some(zeroize::Zeroizing::new( + format!("{{\"code\":\"{secret}\"}}").into_bytes(), + )), + }; + for text in [ + format!("{response:?}"), + format!("{response:#?}"), + format!("{request:?}"), + format!("{request:#?}"), + ] { + assert!( + !text.contains(secret) && !text.contains("BEARER-SECRET"), + "{text}" + ); + // Nor as the bytes of the body: `83, 69, 83` is "SES". + assert!(!text.contains("83, 69, 83"), "{text}"); + } + assert!(format!("{request:?}").contains("/v1/sessions/login/second-factor")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_re_created_account_never_inherits_the_old_vault_key() { + let engine = FakeEngine::new(); + let a = first_device(&engine).await; + let before = a.store().load().expect("store").expect("saved"); + // The account is deleted and the address signs up again, from another machine. + engine.delete_account(NORMAL); + let _b = first_device(&engine).await; + let outcome = a + .sign_in(NORMAL, &password(PASSWORD)) + .await + .expect("sign in"); + let SignInOutcome::NeedsApproval(info) = outcome else { + panic!("this device is new to the new account: {outcome:?}"); + }; + let after = a.store().load().expect("store").expect("saved"); + assert_ne!(info.account_id, before.account_id); + assert_ne!( + *after.vault_key, *before.vault_key, + "a new account gets a new vault key" + ); + // The same account keeps its key across sign-ins. + let c = first_device(&FakeEngine::new()).await; + let first = c.store().load().expect("store").expect("saved"); + c.sign_in(NORMAL, &password(PASSWORD)).await.expect("again"); + assert_eq!( + *first.vault_key, + *c.store().load().expect("store").expect("saved").vault_key + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_sign_up_finishing_does_not_overwrite_a_device_signed_in_meanwhile() { + let engine = FakeEngine::new(); + let a = client(&engine, "Laptop"); + let mut pending = a.begin_sign_up(request(None)).await.expect("begin"); + let code = engine.code_for(NORMAL); + // Something else filled the store while the code was on its way. + let other = StoredIdentity { + device_keys: zeroize::Zeroizing::new([7u8; 64]), + account_id: "other-account".to_owned(), + device_id: "other-device".to_owned(), + email: "other@example.com".to_owned(), + session: None, + vault_key: zeroize::Zeroizing::new([9u8; 32]), + }; + a.store().save(&other).expect("save"); + assert_eq!( + a.verify_sign_up(&mut pending, &code).await.err(), + Some(DeviceError::StoreNotEmpty) + ); + assert_eq!( + a.store().load().expect("store").expect("kept").account_id, + "other-account" + ); + // The code was not spent: with the store empty again, the same sign-up finishes. + a.store().clear().expect("clear"); + a.verify_sign_up(&mut pending, &code).await.expect("verify"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_body_limit_holds_with_and_without_a_content_length() { + let (address, _task) = serve_once( + "HTTP/1.1 200 OK\r\nConnection: close\r\n\r\nxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + ) + .await; + // No Content-Length: the body ends when the server closes, and it is longer than the limit. + let api = HttpApi::new(&address).expect("api").with_body_limit(100); + let get = || Request { + method: Method::Get, + path: "/x".to_owned(), + bearer: None, + body: None, + }; + assert_eq!(api.send(get()).await.err(), Some(DeviceError::Transport)); + let (address, _task) = + serve_once("HTTP/1.1 200 OK\r\nContent-Length: 5\r\nConnection: close\r\n\r\nhello").await; + let response = HttpApi::new(&address) + .expect("api") + .with_body_limit(100) + .send(get()) + .await + .expect("within the limit"); + assert_eq!(&response.body[..], b"hello"); + let (address, _task) = + serve_once("HTTP/1.1 200 OK\r\nContent-Length: 500\r\nConnection: close\r\n\r\nhello") + .await; + assert_eq!( + HttpApi::new(&address) + .expect("api") + .with_body_limit(100) + .send(get()) + .await + .err(), + Some(DeviceError::Transport) + ); +} + +/// One connection served by hand: the raw request is returned, `reply` is sent. +async fn serve_once(reply: &'static str) -> (String, tokio::task::JoinHandle) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let address = format!("http://{}", listener.local_addr().expect("addr")); + let task = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.expect("accept"); + let mut buffer = vec![0u8; 8192]; + let n = socket.read(&mut buffer).await.expect("read"); + socket.write_all(reply.as_bytes()).await.expect("write"); + socket.shutdown().await.ok(); + String::from_utf8_lossy(&buffer[..n]).to_string() + }); + (address, task) +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_http_transport_does_not_follow_redirects_and_reads_retry_after() { + use orbyn_device_client::DeviceApi; + let (address, task) = serve_once( + "HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:1/elsewhere\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .await; + let api = HttpApi::new(&address).expect("loopback http is allowed"); + let response = api + .send(Request { + method: Method::Get, + path: "/v1/devices".to_owned(), + bearer: Some(Zeroizing::new("secret-token".to_owned())), + body: None, + }) + .await + .expect("the redirect is an answer, not followed"); + assert_eq!(response.status, 302); + let seen = task.await.expect("served").to_lowercase(); + assert!( + seen.contains("authorization: bearer secret-token"), + "{seen}" + ); + + let (address, _task) = serve_once( + "HTTP/1.1 429 Too Many Requests\r\nRetry-After: 7\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .await; + let response = HttpApi::new(&address) + .expect("api") + .send(Request { + method: Method::Post, + path: "/x".to_owned(), + bearer: None, + body: Some(Zeroizing::new(b"{}".to_vec())), + }) + .await + .expect("answer"); + assert_eq!((response.status, response.retry_after), (429, Some(7))); +} diff --git a/crates/orbyn-device-client/tests/local_stack.rs b/crates/orbyn-device-client/tests/local_stack.rs new file mode 100644 index 0000000..ddc6de8 --- /dev/null +++ b/crates/orbyn-device-client/tests/local_stack.rs @@ -0,0 +1,112 @@ +//! The client against the real engine on the local stack (`scripts/local.sh up`): an invite, a +//! sign-up with the emailed code, a second device that waits for approval, the re-login and +//! signing everyone out. Test data only. +//! +//! ```sh +//! scripts/local.sh up +//! ORBYN_LOCAL_STACK=1 cargo test -p orbyn-device-client --test local_stack -- --ignored +//! ``` +//! The test is ignored **and** needs `ORBYN_LOCAL_STACK=1`: CI runs `--include-ignored` without a +//! local stack, and there the test prints a note and passes without doing anything. +//! `ORBYN_LOCAL_URL` changes the address (default `http://127.0.0.1:8080`). + +use orbyn_contracts::{DevicePlatform, DeviceState}; +use orbyn_device_client::{ + ClientConfig, DeviceClient, DeviceError, HttpApi, MemoryStore, SignInOutcome, SignUpRequest, + SystemClock, +}; +use std::process::Command; +use std::time::Duration; +use zeroize::Zeroizing; + +fn script(args: &[&str]) -> String { + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../scripts/local.sh"); + let out = Command::new("bash") + .arg(path) + .args(args) + .output() + .expect("scripts/local.sh runs"); + String::from_utf8_lossy(&out.stdout).trim().to_owned() +} + +fn client(name: &str) -> DeviceClient { + let address = + std::env::var("ORBYN_LOCAL_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".to_owned()); + DeviceClient::new( + HttpApi::new(&address).expect("a loopback address"), + MemoryStore::new(), + SystemClock, + ClientConfig { + device_name: name.to_owned(), + platform: DevicePlatform::Linux, + }, + ) +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "needs the local stack: scripts/local.sh up"] +async fn sign_up_second_device_re_login_and_sign_out_everywhere() { + if std::env::var("ORBYN_LOCAL_STACK").as_deref() != Ok("1") { + eprintln!("skipped: set ORBYN_LOCAL_STACK=1 with the local stack up (scripts/local.sh up)"); + return; + } + let password = Zeroizing::new("correct horse battery staple".to_owned()); + let email = format!("e2e-{}@example.test", uuid::Uuid::new_v4().simple()); + let invite = script(&["invite-code", "device-client"]); + assert_eq!(invite.len(), 19, "an invite code: {invite:?}"); + + // The first device signs up with the invite and the code from the worker's log. + let first = client("First"); + let mut pending = first + .begin_sign_up(SignUpRequest { + email: email.clone(), + password: password.clone(), + invite: Some(invite), + }) + .await + .expect("begin"); + let mut code = String::new(); + for _ in 0..30 { + code = script(&["latest-code", &email]); + if !code.is_empty() { + break; + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + assert!(!code.is_empty(), "the worker logged no code for {email}"); + let session = first + .verify_sign_up(&mut pending, &code) + .await + .expect("verify"); + assert_eq!(session.device_state, DeviceState::Trusted); + + // A second device signs in with the password and waits for approval. + let second = client("Second"); + let outcome = second.sign_in(&email, &password).await.expect("sign in"); + let SignInOutcome::NeedsApproval(info) = outcome else { + panic!("a new device is pending: {outcome:?}"); + }; + assert_eq!(info.device_state, DeviceState::Pending); + assert_ne!(info.device_id, session.device_id); + + // A wrong password is refused on the device. + let third = client("Third"); + assert_eq!( + third + .sign_in(&email, &Zeroizing::new("wrong horse".to_owned())) + .await + .err(), + Some(DeviceError::LoginFailed) + ); + + // The first device signs in again as itself (a trusted start), and signs everyone out. The + // sign-in just finished, so it is recent. + let outcome = first.sign_in(&email, &password).await.expect("again"); + assert!(matches!(outcome, SignInOutcome::SignedIn(_)), "{outcome:?}"); + first.reauthenticate(&password).await.expect("re-login"); + first.sign_out_all().await.expect("sign out everywhere"); + assert_eq!( + first.sign_out_all().await.err(), + Some(DeviceError::NotSignedIn) + ); +} diff --git a/crates/orbyn-device-client/tests/support/mod.rs b/crates/orbyn-device-client/tests/support/mod.rs new file mode 100644 index 0000000..520e56f --- /dev/null +++ b/crates/orbyn-device-client/tests/support/mod.rs @@ -0,0 +1,601 @@ +//! A fake engine in memory: the same OPAQUE (real `opaque-ke` server side), the same signatures +//! and the same answers as the real server for sign-up, sign-in, the second factor and sign-out, +//! so the client is tested against the protocol and not against a stub of it. + +use base64::Engine as _; +use orbyn_auth_client::opaque_ke::{ + CredentialFinalization, CredentialRequest, RegistrationRequest, RegistrationUpload, + ServerLogin, ServerLoginParameters, ServerRegistration, ServerSetup, +}; +use orbyn_auth_client::{ + DEVICE_LOGIN_CONTEXT, DEVICE_REGISTER_CONTEXT, LOGIN_CONTEXT, OsRng, Suite, + TRUSTED_START_CONTEXT, login_message, normalize_email, register_message, trusted_start_message, +}; +use orbyn_contracts as wire; +use orbyn_crypto::{DevicePublic, pow}; +use orbyn_device_client::{DeviceApi, DeviceError, Method, Request, Response}; +use serde_json::{Value, json}; +use std::collections::{HashMap, HashSet}; +use std::sync::atomic::{AtomicI64, Ordering}; +use std::sync::{Arc, Mutex}; +use zeroize::Zeroizing; + +const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::URL_SAFE_NO_PAD; + +fn b64(bytes: &[u8]) -> String { + B64.encode(bytes) +} + +fn unb64(text: &str) -> Vec { + B64.decode(text).expect("base64url") +} + +fn key32(text: &str) -> [u8; 32] { + unb64(text).try_into().expect("32 bytes") +} + +pub struct Device { + pub id: String, + pub keys: DevicePublic, + pub trusted: bool, +} + +pub struct Account { + pub id: String, + pub file: Vec, + pub devices: Vec, + /// The code the second factor accepts, when it is on. + pub totp: Option, +} + +struct Pending { + email: String, + file: Vec, + keys: DevicePublic, + code: String, + invite: Option, +} + +struct Login { + state: ServerLogin, + email: String, + trusted_signer: Option<[u8; 32]>, +} + +struct SecondPending { + account: String, + device: String, + attempts: u32, +} + +pub struct Session { + pub account: String, + pub authenticated_at: i64, +} + +struct State { + setup: ServerSetup, + invites: HashSet, + accounts: HashMap, + pending: HashMap, + logins: HashMap, + second: HashMap, + sessions: HashMap, + mail: Vec<(String, String)>, + proof: Option<([u8; 32], u32)>, + proofs_spent: HashSet, + register_limit: Option, + deleted: HashSet, + status_mode: StatusMode, + counter: u64, + log: Vec<(String, String)>, +} + +/// How `POST /v1/accounts/status` behaves. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum StatusMode { + Normal, + /// Answers 500. + ServerError, + /// Answers nothing: a transport failure. + Unreachable, +} + +/// The fake. Cloning shares the one server. +#[derive(Clone)] +pub struct FakeEngine { + state: Arc>, + pub now: Arc, +} + +fn answer(status: u16, body: Value) -> Result { + Ok(Response { + status, + retry_after: None, + body: Zeroizing::new(serde_json::to_vec(&body).expect("json")), + }) +} + +fn error(status: u16, code: &str) -> Result { + answer(status, json!({ "code": code, "message": code })) +} + +impl FakeEngine { + pub fn new() -> Self { + Self { + state: Arc::new(Mutex::new(State { + setup: ServerSetup::::new(&mut OsRng), + invites: HashSet::new(), + accounts: HashMap::new(), + pending: HashMap::new(), + logins: HashMap::new(), + second: HashMap::new(), + sessions: HashMap::new(), + mail: Vec::new(), + proof: None, + proofs_spent: HashSet::new(), + register_limit: None, + deleted: HashSet::new(), + status_mode: StatusMode::Normal, + counter: 0, + log: Vec::new(), + })), + now: Arc::new(AtomicI64::new(1_800_000_000)), + } + } + + pub fn add_invite(&self, code: &str) { + self.state + .lock() + .expect("lock") + .invites + .insert(code.to_owned()); + } + + /// Asks for a proof of work of `bits` bits on `register/finish`. + pub fn require_proof(&self, bits: u32) { + self.state.lock().expect("lock").proof = Some(([7u8; 32], bits)); + } + + /// Answers 429 with this `Retry-After` to every `register/start`. + pub fn limit_register(&self, retry_after: u64) { + self.state.lock().expect("lock").register_limit = Some(retry_after); + } + + /// The newest code mailed to `email`. + pub fn code_for(&self, email: &str) -> String { + let s = self.state.lock().expect("lock"); + s.mail + .iter() + .rev() + .find(|(to, _)| to == email) + .map(|(_, code)| code.clone()) + .expect("a mail") + } + + pub fn set_totp(&self, email: &str, code: &str) { + let mut s = self.state.lock().expect("lock"); + s.accounts.get_mut(email).expect("account").totp = Some(code.to_owned()); + } + + pub fn delete_account(&self, email: &str) { + let mut s = self.state.lock().expect("lock"); + let id = s.accounts.remove(email).expect("account").id; + s.sessions.retain(|_, v| v.account != id); + s.deleted.insert(id); + } + + /// Ends every session of the account elsewhere; the account stays. + pub fn revoke_sessions(&self, email: &str) { + let mut s = self.state.lock().expect("lock"); + let id = s.accounts[email].id.clone(); + s.sessions.retain(|_, v| v.account != id); + } + + pub fn status_mode(&self, mode: StatusMode) { + self.state.lock().expect("lock").status_mode = mode; + } + + pub fn advance(&self, secs: i64) { + self.now.fetch_add(secs, Ordering::SeqCst); + } + + pub fn device_count(&self, email: &str) -> usize { + self.state.lock().expect("lock").accounts[email] + .devices + .len() + } + + /// Every request body the fake has seen, as `(path, body text)`. + pub fn log(&self) -> Vec<(String, String)> { + self.state.lock().expect("lock").log.clone() + } + + pub fn clear_log(&self) { + self.state.lock().expect("lock").log.clear(); + } + + fn handle(&self, request: Request) -> Result { + let mut s = self.state.lock().expect("lock"); + let body: Value = request + .body + .as_deref() + .map(|b| serde_json::from_slice(b).expect("json body")) + .unwrap_or(Value::Null); + s.log.push(( + format!("{:?} {}", request.method, request.path), + body.to_string(), + )); + let now = self.now.load(Ordering::SeqCst); + let token = request.bearer.as_ref().map(|t| t.to_string()); + match (request.method, request.path.as_str()) { + (Method::Post, "/v1/accounts/register/start") => register_start(&mut s, &body), + (Method::Post, "/v1/accounts/register/finish") => register_finish(&mut s, body), + (Method::Post, "/v1/accounts/email/verify") => email_verify(&mut s, &body, now), + (Method::Post, "/v1/accounts/email/resend") => answer(204, Value::Null), + (Method::Post, "/v1/sessions/login/start") => login_start(&mut s, &body, now), + (Method::Post, "/v1/sessions/login/finish") => login_finish(&mut s, &body, now), + (Method::Post, "/v1/sessions/login/second-factor") => second_factor(&mut s, &body, now), + (Method::Post, "/v1/accounts/status") => { + match s.status_mode { + StatusMode::ServerError => return error(500, "internal"), + StatusMode::Unreachable => return Err(DeviceError::Transport), + StatusMode::Normal => {} + } + let id = body["account_id"].as_str().unwrap_or_default(); + let deleted = if s.deleted.contains(id) { + "2026-11-12T00:00:00Z" + } else { + "" + }; + answer(200, json!({ "deleted_at": deleted })) + } + (Method::Delete, "/v1/sessions/current") => { + let Some(session) = token.and_then(|t| s.sessions.remove(&t)) else { + return error(401, "unauthorized"); + }; + let _ = session; + answer(204, Value::Null) + } + (Method::Delete, "/v1/sessions") => { + let Some(session) = token.as_ref().and_then(|t| s.sessions.get(t)) else { + return error(401, "unauthorized"); + }; + if now - session.authenticated_at > 600 { + return error(401, "reauthentication_required"); + } + let account = session.account.clone(); + s.sessions.retain(|_, v| v.account != account); + answer(204, Value::Null) + } + _ => error(404, "not_found"), + } + } +} + +impl DeviceApi for FakeEngine { + async fn send(&self, request: Request) -> Result { + self.handle(request) + } +} + +fn register_start(s: &mut State, body: &Value) -> Result { + if let Some(after) = s.register_limit { + return Ok(Response { + status: 429, + retry_after: Some(after), + body: Zeroizing::new( + serde_json::to_vec(&json!({"code": "too_many_attempts", "message": "x"})) + .expect("json"), + ), + }); + } + let email = normalize_email(body["email"].as_str().unwrap_or_default()).expect("email"); + let request = RegistrationRequest::::deserialize(&unb64( + body["registration_request"].as_str().expect("request"), + )) + .expect("registration request"); + let start = + ServerRegistration::::start(&s.setup, request, email.as_bytes()).expect("start"); + answer( + 200, + json!({ "registration_response": b64(&start.message.serialize()) }), + ) +} + +fn register_finish(s: &mut State, body: Value) -> Result { + let finish: wire::RegisterFinish = serde_json::from_value(body).expect("register finish"); + let email = normalize_email(&finish.email).expect("email"); + // The invite is judged first, as the server does. + let invite = finish + .invite + .as_ref() + .map(|i| i.replace(['-', ' '], "").to_uppercase()); + if !s.invites.is_empty() && !invite.as_ref().is_some_and(|i| s.invites.contains(i)) { + return error(400, "invite_invalid"); + } + let upload = unb64(&finish.registration_upload); + let keys = DevicePublic { + signing: key32(&finish.device.signing_key), + agreement: key32(&finish.device.agreement_key), + }; + let signature: [u8; 64] = unb64(&finish.device_signature) + .try_into() + .expect("signature"); + orbyn_crypto::verify( + &keys, + DEVICE_REGISTER_CONTEXT, + ®ister_message(&email, &upload, &keys), + &signature, + ) + .expect("the device signed the registration"); + if let Some((challenge, bits)) = s.proof { + let solved = finish.proof.as_ref().is_some_and(|p| { + p.challenge == hex(&challenge) + && pow::verify(&challenge, p.nonce, bits) + && !s.proofs_spent.contains(&p.nonce) + }); + if !solved { + return answer( + 428, + json!({"code": "proof_required", "message": "x", "challenge": hex(&challenge), "bits": bits}), + ); + } + s.proofs_spent.insert(finish.proof.expect("proof").nonce); + } + let file = ServerRegistration::::finish( + RegistrationUpload::::deserialize(&upload).expect("upload"), + ) + .serialize() + .to_vec(); + s.counter += 1; + let code = format!("{:08}", 23_456_789 + s.counter) + .replace('0', "2") + .replace('1', "3"); + let signup_id = format!("00000000-0000-4000-8000-{:012}", s.counter); + s.mail.push((email.clone(), code.clone())); + s.pending.insert( + signup_id.clone(), + Pending { + email, + file, + keys, + code, + invite, + }, + ); + answer(201, json!({ "signup_id": signup_id })) +} + +fn email_verify(s: &mut State, body: &Value, now: i64) -> Result { + let id = body["signup_id"].as_str().unwrap_or_default(); + let code = body["code"].as_str().unwrap_or_default(); + let Some(pending) = s.pending.get(id) else { + return error(400, "invalid_email_code"); + }; + if pending.code != code { + return error(400, "invalid_email_code"); + } + let pending = s.pending.remove(id).expect("pending"); + if let Some(invite) = &pending.invite { + s.invites.remove(invite); + } + s.counter += 1; + let account_id = format!("00000000-0000-4000-9000-{:012}", s.counter); + let device_id = format!("00000000-0000-4000-a000-{:012}", s.counter); + s.accounts.insert( + pending.email.clone(), + Account { + id: account_id.clone(), + file: pending.file, + devices: vec![Device { + id: device_id.clone(), + keys: pending.keys, + trusted: true, + }], + totp: None, + }, + ); + session(s, &account_id, &device_id, true, now, 201) +} + +fn session( + s: &mut State, + account: &str, + device: &str, + trusted: bool, + now: i64, + status: u16, +) -> Result { + s.counter += 1; + let token = format!("token-{}", s.counter); + s.sessions.insert( + token.clone(), + Session { + account: account.to_owned(), + authenticated_at: now, + }, + ); + answer( + status, + json!({ + "token": token, + "account_id": account, + "device_id": device, + "device_state": if trusted { "trusted" } else { "pending" }, + "expires_at": "2027-11-12T00:00:00Z", + }), + ) +} + +fn login_start(s: &mut State, body: &Value, now: i64) -> Result { + let email = normalize_email(body["email"].as_str().unwrap_or_default()).expect("email"); + let request_bytes = unb64(body["credential_request"].as_str().expect("request")); + let request = CredentialRequest::::deserialize(&request_bytes).expect("request"); + let file = s + .accounts + .get(&email) + .map(|a| ServerRegistration::::deserialize(&a.file).expect("file")); + // A trusted start: a fresh signature, by a device the account trusts, over this very request. + let mut trusted_signer = None; + if let Some(proof) = body.get("trusted_device").filter(|p| !p.is_null()) { + let signing = key32(proof["signing_key"].as_str().expect("key")); + let signed_at = proof["signed_at"].as_i64().expect("time"); + let signature: [u8; 64] = unb64(proof["signature"].as_str().expect("sig")) + .try_into() + .expect("sig"); + let known = s.accounts.get(&email).is_some_and(|a| { + a.devices + .iter() + .any(|d| d.trusted && d.keys.signing == signing) + }); + let signer = DevicePublic { + signing, + agreement: [0; 32], + }; + if known + && (now - signed_at).abs() <= 300 + && orbyn_crypto::verify( + &signer, + TRUSTED_START_CONTEXT, + &trusted_start_message(&email, &request_bytes, signed_at), + &signature, + ) + .is_ok() + { + trusted_signer = Some(signing); + } + } + let start = ServerLogin::start( + &mut OsRng, + &s.setup, + file, + request, + email.as_bytes(), + ServerLoginParameters { + context: Some(LOGIN_CONTEXT), + ..ServerLoginParameters::default() + }, + ) + .expect("login start"); + s.counter += 1; + let login_id = format!("00000000-0000-4000-b000-{:012}", s.counter); + s.logins.insert( + login_id.clone(), + Login { + state: start.state, + email, + trusted_signer, + }, + ); + answer( + 200, + json!({ "login_id": login_id, "credential_response": b64(&start.message.serialize()) }), + ) +} + +fn login_finish(s: &mut State, body: &Value, now: i64) -> Result { + let finish: wire::LoginFinish = serde_json::from_value(body.clone()).expect("login finish"); + let Some(login) = s.logins.remove(&finish.login_id) else { + return error(401, "login_failed"); + }; + let verified = login + .state + .finish( + CredentialFinalization::::deserialize(&unb64(&finish.credential_finalization)) + .expect("finalization"), + ServerLoginParameters { + context: Some(LOGIN_CONTEXT), + ..ServerLoginParameters::default() + }, + ) + .is_ok(); + if !verified || !s.accounts.contains_key(&login.email) { + return error(401, "login_failed"); + } + let keys = DevicePublic { + signing: key32(&finish.device.signing_key), + agreement: key32(&finish.device.agreement_key), + }; + let login_id: [u8; 16] = *uuid::Uuid::parse_str(&finish.login_id) + .expect("uuid") + .as_bytes(); + let signature: [u8; 64] = unb64(&finish.device_signature) + .try_into() + .expect("signature"); + orbyn_crypto::verify( + &keys, + DEVICE_LOGIN_CONTEXT, + &login_message(&login_id, &keys), + &signature, + ) + .expect("the device signed the login"); + let account = s.accounts.get_mut(&login.email).expect("account"); + let existing = account.devices.iter().position(|d| d.keys == keys); + let (device_id, trusted) = match existing { + Some(i) => (account.devices[i].id.clone(), account.devices[i].trusted), + None => { + let id = format!( + "00000000-0000-4000-c000-{:012}", + account.devices.len() + 1 + s.counter as usize + ); + account.devices.push(Device { + id: id.clone(), + keys, + trusted: false, + }); + (id, false) + } + }; + let skips = trusted && login.trusted_signer == Some(keys.signing); + let account_id = account.id.clone(); + if account.totp.is_some() && !skips { + s.counter += 1; + let second_id = format!("00000000-0000-4000-d000-{:012}", s.counter); + s.second.insert( + second_id.clone(), + SecondPending { + account: login.email, + device: device_id, + attempts: 0, + }, + ); + return answer( + 401, + json!({"code": "second_factor_required", "message": "x", "login_id": second_id}), + ); + } + session(s, &account_id, &device_id, trusted, now, 200) +} + +fn second_factor(s: &mut State, body: &Value, now: i64) -> Result { + let id = body["login_id"].as_str().unwrap_or_default().to_owned(); + let Some(pending) = s.second.get_mut(&id) else { + return error(401, "login_failed"); + }; + let email = pending.account.clone(); + let device = pending.device.clone(); + let right = s.accounts[&email].totp.as_deref() == body["code"].as_str(); + if !right { + let pending = s.second.get_mut(&id).expect("pending"); + pending.attempts += 1; + if pending.attempts >= 5 { + s.second.remove(&id); + return error(401, "login_failed"); + } + return error(401, "second_factor_failed"); + } + s.second.remove(&id); + let account = &s.accounts[&email]; + let trusted = account + .devices + .iter() + .find(|d| d.id == device) + .is_some_and(|d| d.trusted); + let account_id = account.id.clone(); + session(s, &account_id, &device, trusted, now, 200) +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} diff --git a/docs/device-client.md b/docs/device-client.md new file mode 100644 index 0000000..65d1b86 --- /dev/null +++ b/docs/device-client.md @@ -0,0 +1,78 @@ +# The device client + +`orbyn-device-client` is the native, async client a desktop app uses to be a device of an Orbyn account. It is built on the IO-free primitives (`orbyn-auth-client`, `orbyn-crypto`, `orbyn-contracts`; later `orbyn-client` and `orbyn-sync`) and adds what they leave out: the call sequence, the transport and typed errors. MIT OR Apache-2.0, and it depends on no app: the Engineering app links it, never the other way. The Planner's web app does the same sequence in TypeScript on `orbyn-wasm`. + +It ships in three pull requests: + +1. **This one.** The design, the crate skeleton and sign-up, sign-in, the re-login and sign-out. +2. **Sync.** Push and pull per zone with plain records in and out (sealing, opening and zone keys inside), the notices stream and the status watch. +3. **Approval and the label.** Approval digits, approve and deny, `devices()`, `remove_device`, and the device `app` label bound into the signed register message (engine#73). + +## What the host supplies + +| Piece | Trait | What it is | +| --- | --- | --- | +| Transport | `DeviceApi` | One call: a `Request` (method, path, optional bearer, JSON body) gives a `Response` (status, `Retry-After`, body) or `Transport`. `HttpApi` is the real one; a test supplies its own. | +| Secrets | `KeyStore` | `load`, `save`, `clear` of one `StoredIdentity`. The OS keychain in the app; `MemoryStore` in tests. | +| Time | `Clock` | Unix seconds, so a test fixes the time a trusted start is signed at. `SystemClock` is the real one. | + +`DeviceClient` holds the three and a `ClientConfig` (the device's name and platform). + +### What the key store holds, and what it does not + +Only what has to outlive a run: the **device keys** (Ed25519 and X25519 secrets), the **account id, device id and email** (identifiers: the email is kept so a re-login needs only the password), the **session token** with its expiry, and the **vault key**. The vault key is 32 random bytes made once on this device; the host seals its own snapshot under it. It is not derived from the password and never leaves the device. + +The vault key is kept across sign-ins of the **same account**. If the account id changes (the old account was deleted and the address signed up again), the device gets a **new** vault key, so the new account can never open the old account's snapshot. + +The **zone-key cache is not here.** The host keeps zone keys in its own sealed snapshot, so the OS keychain holds one small entry, not a growing store. The OPAQUE export key is never kept. Nothing secret appears in a `Debug` print or an error. + +## Sign-up + +`begin_sign_up(SignUpRequest { email, password, invite })` runs OPAQUE registration on the device (`register/start`, then the upload), generates the device keys, signs the `orbyn/device-register/v1` message and sends `register/finish`. If the server answers 428 `proof_required`, the client **solves the proof of work itself** (`orbyn_crypto::pow::solve_from`, from a random start, on a blocking thread, at most two rounds) and sends the same request again with the proof. The password is wiped once OPAQUE has used it, and no request body contains it or any encoding of it. + +The result is a `PendingSignUp`. It holds the new device's keys **in memory only**: nothing is stored, and dropping it cancels, so an abandoned sign-up leaves nothing behind. `verify_sign_up(&mut pending, code)` sends the emailed code (any case; spaces and hyphens ignored). A wrong code is `InvalidCode` and leaves the sign-up open; `resend_sign_up` asks for a new one. Only on success are the keys, ids, session and a fresh vault key saved. A store that already holds a device is refused up front (`StoreNotEmpty`), and again before the code is sent, so a sign-in that finished in between is never overwritten and the code is not spent. + +## Sign-in + +`sign_in(email, password)` runs OPAQUE login. A wrong password fails **on the device** (`LoginFailed`) and nothing more is sent. + +- **A device this store already holds** (same email) signs the start with its key, a *trusted start*: it skips the per-email limit that slows password guessing, and the same trusted device skips the second-factor code (its key is the factor). It is the same device, trusted, and needs no approval. +- **Any other device** generates new keys and is **pending** until a trusted device approves it (`NeedsApproval`). A store that holds a different account's device is `StoreNotEmpty`: sign out, forgetting it, first. +- **A second factor** gives `SecondFactor(challenge)`; `complete_second_factor` takes a code or a recovery code. A wrong code is `SecondFactorFailed` and leaves the challenge usable (the server closes it after five). + +## The re-login for `reauthentication_required` + +Removing a device, turning the second factor off or starting it, changing protection (except the first Standard setting) and signing every session out need a sign-in finished in the last ten minutes (engine#120). The server answers 401 `reauthentication_required` otherwise, which is `DeviceError::ReauthenticationRequired`. + +`reauthenticate(password)` is the answer, and **no new route**: the stored email, the same device key and the password, through the ordinary `login/start` (as a trusted start) and `login/finish`. It returns a new, recent session for the same device and replaces the stored token. Then the caller sends the refused request again; the refusal did not use up its signature. A wrong password changes nothing. + +## Sign-out + +`sign_out(forget)` tells the server when it can (a failure to reach it does not stop the local sign-out) and drops the session. With `forget` the keys and everything else go too, and the next sign-in is a new device that needs approval; without it the keys stay and signing in again is the same device. `sign_out_all()` signs every session of every device out and needs the recent sign-in. + +## Errors + +| `DeviceError` | When | +| --- | --- | +| `Transport` | No answer: the network failed or timed out | +| `ReauthenticationRequired` | 401 `reauthentication_required`: call `reauthenticate` and retry | +| `InvalidCode` | 400 `invalid_email_code` | +| `RateLimited { retry_after }` | 429, with the `Retry-After` seconds when sent | +| `DeletionScheduled` | 409 `deletion_scheduled` | +| `AccountGone` | A session call got 401 **and** `POST /v1/accounts/status` answered 200 with a deletion time. Nothing else gives it: the app destroys key material on this error | +| `SessionExpired` | 401 `unauthorized` and the account is alive, or the status call failed or answered anything but 200 | +| `TooLarge` | 413 | +| `LoginFailed`, `InviteInvalid`, `SecondFactorFailed`, `DeviceNotTrusted` | the matching server code | +| `NotSignedIn`, `StoreNotEmpty`, `InvalidInput`, `Store`, `Crypto` | local conditions | +| `Server { status, code }` | anything else, carrying only the status and the machine code | + +An error never carries a password, a token, a code, a key or a URL. `Request` and `Response` print their method, path or status and **the body's length only**, never the body, and their bodies are wiped on drop, so a logging wrapper or a test double around `DeviceApi` cannot leak a session token or a code. The second-factor code is written straight into a wiped buffer, and the session token is wiped when the answer that carried it is dropped. + +## The transport + +`HttpApi` is reqwest with **rustls and ring only** (no OpenSSL, no aws-lc). **Redirects are off**: a redirect could carry the bearer token elsewhere, and a redirect is returned as an answer. The address must be `https`, or `http` to a **loopback IP literal** (`127.0.0.1`, `[::1]`) for a local stack, with no user info, query or path. A name such as `localhost` is not a loopback literal, because a name can be made to point anywhere. `is_loopback_literal(address)` is public so the app's "local only" gate can use the same test. Calls time out at 30 seconds, connects at 10, and a response body is capped at 32 MiB (`with_body_limit` lowers it), counted **while reading**, so a server that sends no `Content-Length`, or lies about it, is stopped too. + +## Tests + +- `tests/client.rs` runs the client against an in-memory engine (`tests/support`) that speaks the **real OPAQUE server side** and checks the real signatures: proof of work, invite, code, trusted start, second factor, re-login after eleven minutes, account gone, rate limit, and that no request body holds the password. The same file serves a raw HTTP reply to check that redirects are not followed and `Retry-After` is read. +- `tests/local_stack.rs` (ignored by default, and skipped with a printed note unless `ORBYN_LOCAL_STACK=1`, so `cargo test -- --include-ignored` in CI passes without a stack) runs sign-up, a second pending device, a wrong password, the re-login and sign-out against the real engine on `scripts/local.sh up`, using `scripts/local.sh invite-code` and `scripts/local.sh latest-code `. diff --git a/docs/local.md b/docs/local.md index e744a67..9f8f392 100644 --- a/docs/local.md +++ b/docs/local.md @@ -39,6 +39,9 @@ scripts/local.sh invite create --count 3 --label local scripts/local.sh invite list scripts/local.sh codes # the codes the worker "sent" in the last 10 minutes, with their addresses scripts/local.sh codes 1h # a longer window +scripts/local.sh invite-code # makes one invite and prints only its code (for scripts and tests) +scripts/local.sh latest-code a@b.example # prints only the newest email code for that address +ORBYN_LOCAL_STACK=1 cargo test -p orbyn-device-client --test local_stack -- --ignored # the device client end to end against this stack scripts/local.sh logs worker # follow a service's log (default: server) ``` diff --git a/scripts/check_structure.py b/scripts/check_structure.py index ae184d9..3b8b24e 100644 --- a/scripts/check_structure.py +++ b/scripts/check_structure.py @@ -50,6 +50,7 @@ "orbyn-contracts": set(), "orbyn-core": {"orbyn-auth-client", "orbyn-crypto"}, "orbyn-crypto": set(), + "orbyn-device-client": {"orbyn-auth-client", "orbyn-contracts", "orbyn-crypto"}, "orbyn-engineering": {"orbyn-sync"}, "orbyn-keyservice": {"orbyn-crypto", "orbyn-translog"}, "orbyn-keyservice-client": set(), diff --git a/scripts/local.sh b/scripts/local.sh index 278cc8c..9b5b666 100755 --- a/scripts/local.sh +++ b/scripts/local.sh @@ -33,7 +33,11 @@ case "${1:-}" in status) dc ps; curl -fsS http://127.0.0.1:8080/health && echo;; invite) need_env; ensure_db; shift; dc run --rm --no-deps server invite "${@:-create}";; codes) dc logs --no-log-prefix --since "${2:-10m}" worker | grep -i "code" || echo "no codes yet";; + # For scripts and tests: only the value, nothing else on stdout. + invite-code) need_env; ensure_db; dc run --rm --no-deps server invite create --count 1 --label "${2:-script}" 2>/dev/null | grep -Eo '[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{4}' | head -n 1;; + latest-code) [ -n "${2:-}" ] || { echo "usage: scripts/local.sh latest-code " >&2; exit 2; } + dc logs --no-log-prefix --since "${3:-10m}" worker 2>/dev/null | grep -F " for $(printf '%s' "$2" | tr 'A-Z' 'a-z')\"" | grep -Eo 'code [2-9A-Z]{4}-[2-9A-Z]{4}' | tail -n 1 | cut -d' ' -f2;; logs) dc logs -f "${2:-server}";; reset) dc stop server worker >/dev/null 2>&1 || true; ensure_db; dc exec -T postgres psql -U orbyn -d postgres -c "DROP DATABASE IF EXISTS orbyn_local WITH (FORCE)" >/dev/null; echo "orbyn_local dropped; scripts/local.sh up makes it again";; # test data only - *) echo "usage: scripts/local.sh setup|up|down|status|invite [create --count N --label L|list]|codes [since]|logs [service]|reset" >&2; exit 2;; + *) echo "usage: scripts/local.sh setup|up|down|status|invite [create --count N --label L|list]|codes [since]|invite-code [label]|latest-code [since]|logs [service]|reset" >&2; exit 2;; esac