//! Invites: E-3 accept (all three kinds), E-12 create, E-13 list, E-14 cancel (SPEC §8.3, FR-2), plus the code and //! token generation shared with the `bootstrap` and `pair-device` subcommands. //! //! Codes are 12 Crockford base32 characters from the OS CSPRNG, stored only as the SHA-256 hex of the normalised //! code, single-use, valid for 72 hours, cancellable. Every failure of a code is `invite_invalid` without saying why //! (AC-2.3). Tokens are `kvt_` + base64url(32 random bytes), stored only as their SHA-256 hex (SPEC §8.2). use axum::extract::State; use axum::http::{HeaderMap, StatusCode}; use base64::Engine as _; use chrono::{DateTime, Duration, Utc}; use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; use sqlx::SqliteConnection; use uuid::Uuid; use crate::AppState; use crate::api::{ApiJson, IdPath, JsonBody, rows, validate}; use crate::auth::{APP_VERSION_HEADER, AdminDevice, TOKEN_PREFIX, app_version_name, sha256_hex}; use crate::db; use crate::error::{AppError, ErrorCode}; use crate::openapi::responses::{BadRequest, Conflict, Forbidden, NotFound, Unprocessable}; use crate::time::{format_ts, parse_ts}; use crate::wire::{ AcceptInviteRequest, AcceptInviteResponse, CreateInviteKind, CreateInviteRequest, Invite, InviteKind, InviteList, Role, UuidSchema, }; /// The Crockford base32 alphabet of invite codes (SPEC §8.3). pub const CODE_ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ"; /// Characters in an invite code. pub const CODE_LEN: usize = 12; /// How long an invite is valid (AC-2.3). pub const INVITE_LIFETIME: Duration = Duration::hours(72); /// Most users a household has (SPEC §14.1). pub const MAX_USERS: usize = 2; /// Fills `buf` from the OS CSPRNG. fn random_bytes(buf: &mut [u8]) -> Result<(), AppError> { getrandom::fill(buf).map_err(|e| AppError::internal(format!("OS random source failed: {e}"))) } /// A new invite code: 12 characters of the alphabet (60 random bits), without hyphens. pub fn generate_code() -> Result { let mut bytes = [0u8; 8]; random_bytes(&mut bytes)?; let bits = u64::from_le_bytes(bytes); Ok((0..CODE_LEN) .map(|i| char::from(CODE_ALPHABET[usize::try_from((bits >> (5 * i)) & 31).unwrap_or(0)])) .collect()) } /// Normalises a typed or linked code (SPEC §8.3): uppercase, `-` and spaces removed, `O` → `0`, `I`/`L` → `1`. /// `None` unless the result is exactly 12 alphabet characters. pub fn normalise_code(input: &str) -> Option { let code: String = input .chars() .filter(|c| *c != '-' && *c != ' ') .flat_map(char::to_uppercase) .map(|c| match c { 'O' => '0', 'I' | 'L' => '1', other => other, }) .collect(); (code.len() == CODE_LEN && code.bytes().all(|b| CODE_ALPHABET.contains(&b))).then_some(code) } /// `XXXX-XXXX-XXXX` (SPEC §8.3 display form). pub fn display_code(code: &str) -> String { let chars: Vec = code.chars().collect(); chars .chunks(4) .map(|c| c.iter().collect::()) .collect::>() .join("-") } /// A new device token: `kvt_` + base64url without padding of 32 CSPRNG bytes (47 characters, SPEC §8.2). pub fn generate_token() -> Result { let mut bytes = [0u8; 32]; random_bytes(&mut bytes)?; Ok(format!( "{TOKEN_PREFIX}{}", base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) )) } /// Query parameter of the landing link and of `kvitt://invite` that names the inviting admin (AC-2.6, S-1 help line). pub const FROM_PARAM: &str = "from"; /// `/i/`, with `?from=` when `from` is given (E-12). pub fn invite_link(public_url: &str, code: &str, from: Option<&str>) -> String { match from { Some(name) => format!( "{public_url}/i/{code}?{FROM_PARAM}={}", utf8_percent_encode(name, NON_ALPHANUMERIC) ), None => format!("{public_url}/i/{code}"), } } /// A freshly created invite with its plain code (shown once). #[derive(Debug, Clone, PartialEq, Eq)] pub struct NewInvite { pub id: Uuid, pub kind: InviteKind, pub target_user_id: Option, pub code: String, pub created_at: String, pub expires_at: String, } impl NewInvite { /// The wire form with `code` and `link` (E-12 response); `from` is the inviting admin's display name. pub fn wire(&self, public_url: &str, from: Option<&str>) -> Invite { Invite { id: self.id, kind: self.kind, target_user_id: self.target_user_id, code: Some(self.code.clone()), link: Some(invite_link(public_url, &self.code, from)), created_at: self.created_at.clone(), expires_at: self.expires_at.clone(), } } } /// Cancels every pending invite of `kind` (not accepted, not cancelled, not expired). pub async fn cancel_pending( conn: &mut SqliteConnection, kind: InviteKind, now: DateTime, ) -> Result<(), AppError> { sqlx::query( "UPDATE invites SET cancelled_at = ?1 WHERE kind = ?2 AND accepted_at IS NULL AND cancelled_at IS NULL \ AND expires_at > ?1", ) .bind(format_ts(now)) .bind(rows::token_of(&kind)?) .execute(&mut *conn) .await?; Ok(()) } /// Inserts a new invite inside the caller's write transaction. pub async fn insert_invite( conn: &mut SqliteConnection, kind: InviteKind, target_user_id: Option, created_by_user_id: Option, now: DateTime, ) -> Result { let code = generate_code()?; let invite = NewInvite { id: Uuid::now_v7(), kind, target_user_id, created_at: format_ts(now), expires_at: format_ts(now + INVITE_LIFETIME), code, }; sqlx::query( "INSERT INTO invites (id, kind, code_hash, target_user_id, created_by_user_id, created_at, expires_at) \ VALUES (?, ?, ?, ?, ?, ?, ?)", ) .bind(invite.id.to_string()) .bind(rows::token_of(&kind)?) .bind(sha256_hex(&invite.code)) .bind(target_user_id.map(|id| id.to_string())) .bind(created_by_user_id.map(|id| id.to_string())) .bind(&invite.created_at) .bind(&invite.expires_at) .execute(&mut *conn) .await?; Ok(invite) } fn invite_invalid() -> AppError { AppError::new( ErrorCode::InviteInvalid, "the invite code is unknown, expired, used or cancelled", ) } fn household_full() -> AppError { AppError::new( ErrorCode::HouseholdFull, "the household already has two users", ) } async fn user_count(conn: &mut SqliteConnection) -> Result { let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users") .fetch_one(&mut *conn) .await?; usize::try_from(n).map_err(AppError::internal) } fn app_version(headers: &HeaderMap) -> Option { headers .get(APP_VERSION_HEADER)? .to_str() .ok()? .trim() .parse::() .ok() .filter(|v| *v >= 0) } #[derive(sqlx::FromRow)] struct InviteRow { id: String, kind: String, target_user_id: Option, expires_at: String, accepted_at: Option, cancelled_at: Option, } #[utoipa::path( post, path = "/invites/accept", operation_id = "acceptInvite", summary = "E-3 Pair a device with an invite code (all three invite kinds)", security(()), request_body(content = AcceptInviteRequest, content_type = "application/json; charset=utf-8"), responses( (status = 200, description = "Paired. The token is shown only here.", body = AcceptInviteResponse, content_type = "application/json; charset=utf-8"), (status = 400, response = BadRequest), (status = 409, response = Conflict), (status = 422, response = Unprocessable), ) )] pub async fn accept_invite( State(state): State, headers: HeaderMap, ApiJson(body): ApiJson, ) -> Result, AppError> { let code = normalise_code(&body.code).ok_or_else(invite_invalid)?; let now = state.clock.now(); let mut tx = db::begin_immediate(&state.db).await?; let row = sqlx::query_as::<_, InviteRow>( "SELECT id, kind, target_user_id, expires_at, accepted_at, cancelled_at FROM invites WHERE code_hash = ?", ) .bind(sha256_hex(&code)) .fetch_optional(&mut *tx) .await? .ok_or_else(invite_invalid)?; let expired = parse_ts(&row.expires_at).is_none_or(|t| t <= now); if row.accepted_at.is_some() || row.cancelled_at.is_some() || expired { return Err(invite_invalid()); } let kind = match row.kind.as_str() { "admin_setup" => InviteKind::AdminSetup, "partner" => InviteKind::Partner, "device" => InviteKind::Device, other => return Err(AppError::internal(format!("unknown invite kind {other:?}"))), }; let device_name = validate::text( "device_name", &body.device_name, 1, validate::DEVICE_NAME_MAX, )?; let now_ts = format_ts(now); let user_id = match kind { InviteKind::Device => { let target = row .target_user_id .as_deref() .ok_or_else(|| AppError::internal("device invite without target user"))?; rows::uuid(target)? } InviteKind::AdminSetup | InviteKind::Partner => { let display_name = body.display_name.as_deref().ok_or_else(|| { AppError::invalid_request("display_name is required for this invite") .with_field("display_name") })?; let display_name = validate::text("display_name", display_name, 1, validate::DISPLAY_NAME_MAX)?; let users = user_count(&mut tx).await?; let role = if kind == InviteKind::AdminSetup { if users > 0 { // A household exists already: this setup code can no longer be used. return Err(invite_invalid()); } Role::Admin } else { if users >= MAX_USERS { return Err(household_full()); } Role::Member }; let user_id = Uuid::now_v7(); let version = db::next_server_version(&mut tx).await?; sqlx::query( "INSERT INTO users (id, role, display_name, picture_version, created_at, updated_at, server_version) \ VALUES (?, ?, ?, NULL, ?, ?, ?)", ) .bind(user_id.to_string()) .bind(role.as_str()) .bind(&display_name) .bind(&now_ts) .bind(&now_ts) .bind(version) .execute(&mut *tx) .await?; user_id } }; let token = generate_token()?; let device_id = Uuid::now_v7(); sqlx::query( "INSERT INTO devices (id, user_id, name, token_hash, fcm_token, app_version_code, app_version_name, \ created_at, last_seen_at) VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?)", ) .bind(device_id.to_string()) .bind(user_id.to_string()) .bind(&device_name) .bind(sha256_hex(&token)) .bind(app_version(&headers)) .bind(app_version_name(&headers)) .bind(&now_ts) .bind(&now_ts) .execute(&mut *tx) .await?; sqlx::query("UPDATE invites SET accepted_at = ?, accepted_device_id = ? WHERE id = ?") .bind(&now_ts) .bind(device_id.to_string()) .bind(&row.id) .execute(&mut *tx) .await?; let user = rows::user(&mut tx, user_id) .await? .ok_or_else(|| AppError::internal("invite target user is missing"))?; let device = rows::device(&mut tx, device_id, device_id) .await? .ok_or_else(|| AppError::internal("new device vanished"))?; let household = rows::household(&mut tx).await?; tx.commit().await?; tracing::info!(device_id = %device_id, invite_kind = row.kind, "device paired"); Ok(JsonBody(AcceptInviteResponse { token, invite_kind: kind, user, device, household, })) } #[utoipa::path( post, path = "/invites", operation_id = "createInvite", summary = "E-12 Create a partner or device invite (admin)", description = "Creating a `partner` invite cancels any pending one (AC-2.1).", request_body(content = CreateInviteRequest, content_type = "application/json; charset=utf-8"), responses( (status = 201, description = "The invite with `code` (12 characters, no hyphens) and `link` (`/i/?from=`).", body = Invite, content_type = "application/json; charset=utf-8"), (status = 400, response = BadRequest), (status = 403, response = Forbidden), (status = 404, response = NotFound), (status = 409, response = Conflict), ) )] pub async fn create_invite( AdminDevice(admin): AdminDevice, State(state): State, ApiJson(body): ApiJson, ) -> Result<(StatusCode, JsonBody), AppError> { let now = state.clock.now(); let mut tx = db::begin_immediate(&state.db).await?; let invite = match body.kind { CreateInviteKind::Partner => { if body.target_user_id.is_some() { return Err(AppError::invalid_request( "target_user_id must be null for a partner invite", ) .with_field("target_user_id")); } if user_count(&mut tx).await? >= MAX_USERS { return Err(household_full()); } cancel_pending(&mut tx, InviteKind::Partner, now).await?; insert_invite(&mut tx, InviteKind::Partner, None, Some(admin.user_id), now).await? } CreateInviteKind::Device => { let target = body.target_user_id.ok_or_else(|| { AppError::invalid_request("target_user_id is required for a device invite") .with_field("target_user_id") })?; rows::user(&mut tx, target) .await? .ok_or_else(AppError::not_found)?; insert_invite( &mut tx, InviteKind::Device, Some(target), Some(admin.user_id), now, ) .await? } }; // The link names the admin so S-1 can say whom the code came from (AC-2.6). let from = rows::user(&mut tx, admin.user_id) .await? .map(|user| user.display_name); tx.commit().await?; Ok(( StatusCode::CREATED, JsonBody(invite.wire(&state.config.public_url, from.as_deref())), )) } #[utoipa::path( get, path = "/invites", operation_id = "listInvites", summary = "E-13 List pending invites (admin)", responses( (status = 200, description = "Pending invites only (not accepted, not cancelled, not expired), `code` and `link` null.", body = InviteList, content_type = "application/json; charset=utf-8"), (status = 403, response = Forbidden), ) )] pub async fn list_invites( _admin: AdminDevice, State(state): State, ) -> Result, AppError> { let rows: Vec<(String, String, Option, String, String)> = sqlx::query_as( "SELECT id, kind, target_user_id, created_at, expires_at FROM invites \ WHERE accepted_at IS NULL AND cancelled_at IS NULL AND expires_at > ? ORDER BY created_at, id", ) .bind(state.clock.now_ts()) .fetch_all(&state.db) .await?; let invites = rows .into_iter() .map(|(id, kind, target, created_at, expires_at)| { Ok(Invite { id: rows::uuid(&id)?, kind: serde_json::from_value(serde_json::Value::String(kind)) .map_err(AppError::internal)?, target_user_id: target.as_deref().map(rows::uuid).transpose()?, code: None, link: None, created_at, expires_at, }) }) .collect::>()?; Ok(JsonBody(InviteList { invites })) } #[utoipa::path( delete, path = "/invites/{invite_id}", operation_id = "cancelInvite", summary = "E-14 Cancel an invite (admin)", description = "Idempotent for an already cancelled invite.", params( ("invite_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."), ), responses( (status = 204, description = "Cancelled."), (status = 403, response = Forbidden), (status = 404, response = NotFound), ) )] pub async fn cancel_invite( _admin: AdminDevice, State(state): State, IdPath(invite_id): IdPath, ) -> Result { let mut tx = db::begin_immediate(&state.db).await?; let exists: bool = sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM invites WHERE id = ?)") .bind(invite_id.to_string()) .fetch_one(&mut *tx) .await?; if !exists { return Err(AppError::not_found()); } sqlx::query( "UPDATE invites SET cancelled_at = ? WHERE id = ? AND accepted_at IS NULL AND cancelled_at IS NULL", ) .bind(state.clock.now_ts()) .bind(invite_id.to_string()) .execute(&mut *tx) .await?; tx.commit().await?; Ok(StatusCode::NO_CONTENT) } #[cfg(test)] mod tests { use super::*; #[test] fn codes_have_the_alphabet_and_length() { for _ in 0..100 { let code = generate_code().unwrap(); assert_eq!(normalise_code(&code).as_deref(), Some(code.as_str())); } } #[test] fn normalisation() { assert_eq!( normalise_code("7k3m-qx9d 2hpa").as_deref(), Some("7K3MQX9D2HPA") ); assert_eq!( normalise_code("OOOO-IIII-LLLL").as_deref(), Some("000011111111") ); assert_eq!(normalise_code("7K3MQX9D2HP"), None); assert_eq!(normalise_code("7K3MQX9D2HPU"), None); assert_eq!(display_code("7K3MQX9D2HPA"), "7K3M-QX9D-2HPA"); } #[test] fn tokens_have_the_shape_of_section_8_2() { let token = generate_token().unwrap(); assert!(crate::auth::is_token_shaped(&token), "{}", token.len()); assert_ne!(token, generate_token().unwrap()); } }