From 1b5cdeede51dec8aac09f9c48cffbad84c1334ed Mon Sep 17 00:00:00 2001 From: D3SOX Date: Wed, 26 Aug 2026 15:01:34 +0200 Subject: [PATCH 1/2] feat(sync): add secure device pairing Add short-lived anonymous pairing sessions that bind a new device to an account and transfer only an encrypted payload. Supports OpenTubeX/OpenTubeX#914 --- Cargo.lock | 1 + Cargo.toml | 1 + PRIVACY.md | 16 +- README.md | 38 ++ .../down.sql | 1 + .../2026-08-26-000000-0000_key_pairing/up.sql | 16 + .../down.sql | 1 + .../2026-08-26-000000-0000_key_pairing/up.sql | 16 + src/database.rs | 1 + src/database/pairing.rs | 397 ++++++++++++++ src/dto.rs | 53 ++ src/handlers.rs | 10 + src/handlers/encrypted_sync.rs | 19 +- src/handlers/pairing.rs | 513 ++++++++++++++++++ src/handlers/user.rs | 58 +- src/main.rs | 5 +- src/models.rs | 16 + src/schema.rs | 17 + 18 files changed, 1145 insertions(+), 34 deletions(-) create mode 100644 migrations/postgres/2026-08-26-000000-0000_key_pairing/down.sql create mode 100644 migrations/postgres/2026-08-26-000000-0000_key_pairing/up.sql create mode 100644 migrations/sqlite/2026-08-26-000000-0000_key_pairing/down.sql create mode 100644 migrations/sqlite/2026-08-26-000000-0000_key_pairing/up.sql create mode 100644 src/database/pairing.rs create mode 100644 src/handlers/pairing.rs diff --git a/Cargo.lock b/Cargo.lock index 2bd4801..ec33bdb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2236,6 +2236,7 @@ dependencies = [ "actix-rt", "actix-web", "argon2", + "base64 0.22.1", "config", "diesel", "diesel-async", diff --git a/Cargo.toml b/Cargo.toml index fa2eb2c..166e969 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ env_logger = "0.11.10" log = "0.4" # JSON +base64 = "0.22" serde = { version = "1", features = ["derive"] } serde_json = { version = "1" } diff --git a/PRIVACY.md b/PRIVACY.md index 85d47da..8e3b87f 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,6 +1,6 @@ # OpenTubeX Sync Server Privacy Policy -Last updated: July 23, 2026 +Last updated: August 26, 2026 This policy applies to the public OpenTubeX sync server at [sync.d3sox.me](https://sync.d3sox.me). Other operators running this @@ -30,6 +30,20 @@ never receives the passphrase or plaintext. The operator can still observe account activity, request timing, collection names, and approximate data size. The server cannot recover a lost privacy passphrase. +**Device pairing.** Secure device pairing temporarily stores a one-time session +ID, SHA-256 recipient-token hash, recipient public key, pairing-scoped device +identifiers, the receiving device's user-chosen display name, expiry time, and +an encrypted pairing payload. It adds the account ID when an authenticated +device claims the session. Sessions expire after two minutes and are deleted +when they are consumed or cancelled. Poll, consume, and cancel requests send +the raw recipient token in a request header; the server stores only its hash. +The server never receives the QR-only pairing secret, recipient private key, +privacy key, privacy passphrase, or login password. It creates a fresh +authentication token for the receiving device during the claim and therefore +knows that token. The approving device places the token inside the encrypted +relay payload together with the account name and privacy key. The server cannot +open or replace that payload without the QR-only secret. + **Legacy compatibility.** The server retains plaintext endpoints for older or non-OpenTubeX clients. Current OpenTubeX clients do not use them on this public server. A client using these endpoints may send readable subscriptions, groups, diff --git a/README.md b/README.md index 8566bad..9a66a3e 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,44 @@ for profiles, playback speeds, and sessions, 16 MiB for subscriptions and playli bookmarks, and 64 MiB for playlists and history. The combined active encrypted collections for one account cannot exceed 128 MiB. +### Secure device pairing + +Capability `key_pairing: 1` advertises passwordless device pairing for +enhanced-privacy sync. A receiving device anonymously creates a pending +session, then shows a QR or text code. An already authenticated device claims +the session for its account and approves it. During the claim, the server mints +a fresh JWT for the receiving device. The approving device encrypts that JWT, +the account name, privacy key, privacy salt, and a six-digit verification code +before uploading one opaque relay payload. + +The server stores the session ID, SHA-256 recipient-token hash, recipient public +key, pairing-scoped device IDs, receiving-device display name, expiry, account +ID after claim, and approved ciphertext. It never receives the QR-only secret, +recipient private key, privacy key, or privacy passphrase. Poll, consume, and +cancel requests send the raw recipient token in a request header; the server +stores only its hash. The server created the fresh JWT and therefore knows that +token, but it cannot open or replace the encrypted transfer without the QR-only +secret. + +Sessions expire after two minutes. The server permits at most 10,000 active +anonymous sessions globally and five claimed sessions per account. Authenticated +pairing requests are limited to 120 per account per minute, while anonymous +creation uses the server's address-based request limiter. Approval accepts an +identical retry after success. Consumption atomically returns and deletes the +ciphertext, and cancellation deletes the session. + +The endpoints are: + +- anonymous `POST /v1/pairing` to create a session with a recipient-token hash +- recipient-token `GET /v1/pairing/{id}` to inspect its metadata and state +- authenticated `POST /v1/pairing/{id}/claim` to bind it to an account and mint a fresh JWT +- authenticated `PUT /v1/pairing/{id}` to approve it with an opaque ciphertext +- recipient-token `POST /v1/pairing/{id}/consume` to atomically consume it +- recipient-token `DELETE /v1/pairing/{id}` to cancel it + +The protocol, threat model, fixed serialization, and interoperability vector +live in the OpenTubeX client repository at `docs/sync-key-pairing-v1.md`. + ## Development ### Running diff --git a/migrations/postgres/2026-08-26-000000-0000_key_pairing/down.sql b/migrations/postgres/2026-08-26-000000-0000_key_pairing/down.sql new file mode 100644 index 0000000..6dc7452 --- /dev/null +++ b/migrations/postgres/2026-08-26-000000-0000_key_pairing/down.sql @@ -0,0 +1 @@ +DROP TABLE pairing_session; diff --git a/migrations/postgres/2026-08-26-000000-0000_key_pairing/up.sql b/migrations/postgres/2026-08-26-000000-0000_key_pairing/up.sql new file mode 100644 index 0000000..051ed10 --- /dev/null +++ b/migrations/postgres/2026-08-26-000000-0000_key_pairing/up.sql @@ -0,0 +1,16 @@ +CREATE TABLE pairing_session( + id VARCHAR PRIMARY KEY NOT NULL, + version SMALLINT NOT NULL, + account_id VARCHAR, + recipient_public_key VARCHAR NOT NULL, + recipient_device_id VARCHAR NOT NULL, + recipient_device_name VARCHAR NOT NULL, + recipient_token_hash VARCHAR NOT NULL, + approving_device_id VARCHAR, + encrypted_payload TEXT, + expires_at BIGINT NOT NULL, + CONSTRAINT FK__pairing_session__account FOREIGN KEY(account_id) REFERENCES account(id) ON DELETE CASCADE +); + +CREATE INDEX pairing_session_account_expires_idx + ON pairing_session(account_id, expires_at); diff --git a/migrations/sqlite/2026-08-26-000000-0000_key_pairing/down.sql b/migrations/sqlite/2026-08-26-000000-0000_key_pairing/down.sql new file mode 100644 index 0000000..6dc7452 --- /dev/null +++ b/migrations/sqlite/2026-08-26-000000-0000_key_pairing/down.sql @@ -0,0 +1 @@ +DROP TABLE pairing_session; diff --git a/migrations/sqlite/2026-08-26-000000-0000_key_pairing/up.sql b/migrations/sqlite/2026-08-26-000000-0000_key_pairing/up.sql new file mode 100644 index 0000000..051ed10 --- /dev/null +++ b/migrations/sqlite/2026-08-26-000000-0000_key_pairing/up.sql @@ -0,0 +1,16 @@ +CREATE TABLE pairing_session( + id VARCHAR PRIMARY KEY NOT NULL, + version SMALLINT NOT NULL, + account_id VARCHAR, + recipient_public_key VARCHAR NOT NULL, + recipient_device_id VARCHAR NOT NULL, + recipient_device_name VARCHAR NOT NULL, + recipient_token_hash VARCHAR NOT NULL, + approving_device_id VARCHAR, + encrypted_payload TEXT, + expires_at BIGINT NOT NULL, + CONSTRAINT FK__pairing_session__account FOREIGN KEY(account_id) REFERENCES account(id) ON DELETE CASCADE +); + +CREATE INDEX pairing_session_account_expires_idx + ON pairing_session(account_id, expires_at); diff --git a/src/database.rs b/src/database.rs index 707aa13..8a35837 100644 --- a/src/database.rs +++ b/src/database.rs @@ -2,6 +2,7 @@ pub mod account; pub mod channel; pub mod channel_playback_speed; pub mod encrypted_sync; +pub mod pairing; pub mod playlist; pub mod playlist_bookmark; pub mod public_playlist; diff --git a/src/database/pairing.rs b/src/database/pairing.rs new file mode 100644 index 0000000..26a08dd --- /dev/null +++ b/src/database/pairing.rs @@ -0,0 +1,397 @@ +use diesel::prelude::*; +use diesel_async::{AsyncConnection, RunQueryDsl}; + +use crate::DbConnection; +use crate::database::DbError; +use crate::models::PairingSession; +use crate::schema::pairing_session::dsl::{ + account_id, approving_device_id, encrypted_payload, expires_at, id, pairing_session, + recipient_device_id, recipient_device_name, recipient_public_key, recipient_token_hash, + version, +}; + +const MAX_ACTIVE_SESSIONS: i64 = 10_000; +const MAX_ACTIVE_SESSIONS_PER_ACCOUNT: i64 = 5; + +#[derive(Debug, PartialEq, Eq)] +pub enum CreateResult { + Created, + Duplicate, + LimitExceeded, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum ClaimResult { + Claimed(Box), + Conflict, + LimitExceeded, +} + +pub async fn create( + conn: &mut DbConnection, + session: &PairingSession, + now: i64, +) -> Result { + conn.transaction(|conn| { + Box::pin(async move { + diesel::delete(pairing_session.filter(expires_at.le(now))) + .execute(conn) + .await?; + + let active = pairing_session.count().get_result::(conn).await?; + if active >= MAX_ACTIVE_SESSIONS { + return Ok(CreateResult::LimitExceeded); + } + + match diesel::insert_into(pairing_session) + .values(session) + .execute(conn) + .await + { + Ok(_) => Ok(CreateResult::Created), + Err(diesel::result::Error::DatabaseError( + diesel::result::DatabaseErrorKind::UniqueViolation, + _, + )) => Ok(CreateResult::Duplicate), + Err(error) => Err(error), + } + }) + }) + .await +} + +pub async fn claim( + conn: &mut DbConnection, + owner_id: &str, + request: &PairingSession, + now: i64, +) -> Result { + conn.transaction(|conn| { + Box::pin(async move { + use crate::schema::account; + + // Serialize the per-account limit across workers and replicas. + diesel::update(account::table.filter(account::id.eq(owner_id))) + .set(account::id.eq(account::id)) + .execute(conn) + .await?; + diesel::delete(pairing_session.filter(expires_at.le(now))) + .execute(conn) + .await?; + + let active = pairing_session + .filter(account_id.eq(owner_id)) + .count() + .get_result::(conn) + .await?; + if active >= MAX_ACTIVE_SESSIONS_PER_ACCOUNT { + return Ok(ClaimResult::LimitExceeded); + } + + let claimed = diesel::update( + pairing_session + .filter(id.eq(&request.id)) + .filter(version.eq(request.version)) + .filter(account_id.is_null()) + .filter(recipient_public_key.eq(&request.recipient_public_key)) + .filter(recipient_device_id.eq(&request.recipient_device_id)) + .filter(recipient_device_name.eq(&request.recipient_device_name)) + .filter(expires_at.gt(now)) + .filter(encrypted_payload.is_null()), + ) + .set(account_id.eq(owner_id)) + .returning(PairingSession::as_returning()) + .get_result(conn) + .await + .optional()?; + + Ok(match claimed { + Some(session) => ClaimResult::Claimed(Box::new(session)), + None => ClaimResult::Conflict, + }) + }) + }) + .await +} + +pub async fn get( + conn: &mut DbConnection, + session_id: &str, + token_hash: &str, + now: i64, +) -> Result, DbError> { + pairing_session + .filter(id.eq(session_id)) + .filter(version.eq(1)) + .filter(recipient_token_hash.eq(token_hash)) + .filter(expires_at.gt(now)) + .select(PairingSession::as_select()) + .first(conn) + .await + .optional() +} + +pub async fn approve( + conn: &mut DbConnection, + owner_id: &str, + session_id: &str, + device_id: &str, + payload: &str, + now: i64, +) -> Result { + let updated = diesel::update( + pairing_session + .filter(id.eq(session_id)) + .filter(version.eq(1)) + .filter(account_id.eq(owner_id)) + .filter(expires_at.gt(now)) + .filter(encrypted_payload.is_null()), + ) + .set(( + approving_device_id.eq(device_id), + encrypted_payload.eq(payload), + )) + .execute(conn) + .await?; + if updated == 1 { + return Ok(true); + } + + pairing_session + .filter(id.eq(session_id)) + .filter(version.eq(1)) + .filter(account_id.eq(owner_id)) + .filter(approving_device_id.eq(device_id)) + .filter(encrypted_payload.eq(payload)) + .filter(expires_at.gt(now)) + .select(id) + .first::(conn) + .await + .optional() + .map(|session| session.is_some()) +} + +pub async fn consume( + conn: &mut DbConnection, + session_id: &str, + token_hash: &str, + now: i64, +) -> Result, DbError> { + diesel::delete( + pairing_session + .filter(id.eq(session_id)) + .filter(version.eq(1)) + .filter(recipient_token_hash.eq(token_hash)) + .filter(expires_at.gt(now)) + .filter(encrypted_payload.is_not_null()), + ) + .returning(PairingSession::as_returning()) + .get_result(conn) + .await + .optional() +} + +pub async fn cancel( + conn: &mut DbConnection, + session_id: &str, + token_hash: &str, +) -> Result { + let deleted = diesel::delete( + pairing_session + .filter(id.eq(session_id)) + .filter(version.eq(1)) + .filter(recipient_token_hash.eq(token_hash)), + ) + .execute(conn) + .await?; + Ok(deleted == 1) +} + +#[cfg(all(test, feature = "sqlite"))] +mod tests { + use diesel::connection::SimpleConnection; + use diesel_async::AsyncConnection; + use diesel_migrations::MigrationHarness; + + use super::{ClaimResult, CreateResult, approve, cancel, claim, consume, create, get}; + use crate::models::PairingSession; + use crate::{DbConnection, MIGRATIONS}; + + async fn connection() -> DbConnection { + let mut conn = DbConnection::establish(":memory:").await.unwrap(); + conn.spawn_blocking(|conn| { + conn.run_pending_migrations(MIGRATIONS).unwrap(); + conn.batch_execute( + "PRAGMA foreign_keys = ON; \ + INSERT INTO account (id, name_hash, password_hash, oidc_sub) \ + VALUES ('account-a', 'hash-a', 'password', NULL); \ + INSERT INTO account (id, name_hash, password_hash, oidc_sub) \ + VALUES ('account-b', 'hash-b', 'password', NULL);", + )?; + Ok(()) + }) + .await + .unwrap(); + conn + } + + fn session(id: &str, expires_at: i64) -> PairingSession { + PairingSession { + id: id.to_owned(), + version: 1, + account_id: None, + recipient_public_key: "public-key".to_owned(), + recipient_device_id: "recipient-device".to_owned(), + recipient_device_name: "Laptop".to_owned(), + recipient_token_hash: format!("token-{id}"), + approving_device_id: None, + encrypted_payload: None, + expires_at, + } + } + + #[actix_rt::test] + async fn sessions_require_the_recipient_token_and_are_single_use() { + let mut conn = connection().await; + let request = session("session", 1_000); + assert_eq!( + create(&mut conn, &request, 100).await.unwrap(), + CreateResult::Created + ); + assert_eq!( + create(&mut conn, &request, 100).await.unwrap(), + CreateResult::Duplicate + ); + assert!( + get(&mut conn, "session", "wrong-token", 100) + .await + .unwrap() + .is_none() + ); + assert!( + get(&mut conn, "session", "token-session", 100) + .await + .unwrap() + .is_some() + ); + + assert!(matches!( + claim(&mut conn, "account-a", &request, 100).await.unwrap(), + ClaimResult::Claimed(_) + )); + assert_eq!( + claim(&mut conn, "account-b", &request, 100).await.unwrap(), + ClaimResult::Conflict + ); + assert!( + !approve(&mut conn, "account-b", "session", "device", "payload", 100) + .await + .unwrap() + ); + assert!( + approve(&mut conn, "account-a", "session", "device", "payload", 100) + .await + .unwrap() + ); + assert!( + approve(&mut conn, "account-a", "session", "device", "payload", 100) + .await + .unwrap() + ); + assert!( + !approve( + &mut conn, + "account-a", + "session", + "device", + "replacement", + 100 + ) + .await + .unwrap() + ); + + assert!( + consume(&mut conn, "session", "wrong-token", 100) + .await + .unwrap() + .is_none() + ); + let payload = consume(&mut conn, "session", "token-session", 100) + .await + .unwrap() + .unwrap(); + assert_eq!(payload.encrypted_payload.as_deref(), Some("payload")); + assert!( + consume(&mut conn, "session", "token-session", 100) + .await + .unwrap() + .is_none() + ); + } + + #[actix_rt::test] + async fn claims_match_the_request_and_expired_sessions_are_unusable() { + let mut conn = connection().await; + let request = session("session", 200); + assert_eq!( + create(&mut conn, &request, 100).await.unwrap(), + CreateResult::Created + ); + let mut changed = request.clone(); + changed.recipient_device_name = "Changed".to_owned(); + assert_eq!( + claim(&mut conn, "account-a", &changed, 100).await.unwrap(), + ClaimResult::Conflict + ); + assert!( + get(&mut conn, "session", "token-session", 200) + .await + .unwrap() + .is_none() + ); + assert_eq!( + claim(&mut conn, "account-a", &request, 200).await.unwrap(), + ClaimResult::Conflict + ); + } + + #[actix_rt::test] + async fn active_claims_are_bounded_per_account() { + let mut conn = connection().await; + for index in 0..5 { + let request = session(&format!("session-{index}"), 1_000); + assert_eq!( + create(&mut conn, &request, 100).await.unwrap(), + CreateResult::Created + ); + assert!(matches!( + claim(&mut conn, "account-a", &request, 100).await.unwrap(), + ClaimResult::Claimed(_) + )); + } + + let excess = session("session-5", 1_000); + assert_eq!( + create(&mut conn, &excess, 100).await.unwrap(), + CreateResult::Created + ); + assert_eq!( + claim(&mut conn, "account-a", &excess, 100).await.unwrap(), + ClaimResult::LimitExceeded + ); + assert!(matches!( + claim(&mut conn, "account-b", &excess, 100).await.unwrap(), + ClaimResult::Claimed(_) + )); + } + + #[actix_rt::test] + async fn recipient_can_cancel_a_pending_session() { + let mut conn = connection().await; + let request = session("session", 1_000); + create(&mut conn, &request, 100).await.unwrap(); + assert!(!cancel(&mut conn, "session", "wrong-token").await.unwrap()); + assert!(cancel(&mut conn, "session", "token-session").await.unwrap()); + } +} diff --git a/src/dto.rs b/src/dto.rs index 5ee358b..c0602d1 100644 --- a/src/dto.rs +++ b/src/dto.rs @@ -27,6 +27,7 @@ pub struct SyncCapabilities { pub encrypted_sync: u8, pub bulk_sync: u8, pub history_page_size: u32, + pub key_pairing: u8, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] @@ -61,6 +62,58 @@ pub struct PutEncryptedSync { pub payload: String, } +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct CreatePairingSession { + pub version: u8, + pub id: String, + pub recipient_public_key: String, + pub recipient_device_id: String, + pub recipient_device_name: String, + pub recipient_token_hash: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct ClaimPairingSession { + pub version: u8, + pub recipient_public_key: String, + pub recipient_device_id: String, + pub recipient_device_name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct ApprovePairingSession { + pub approving_device_id: String, + pub encrypted_payload: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct PairingSessionResponse { + pub version: u8, + pub id: String, + pub account_id: Option, + pub recipient_public_key: String, + pub recipient_device_id: String, + pub recipient_device_name: String, + pub approving_device_id: Option, + pub expires_at: i64, + pub approved: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct PairingClaimResponse { + pub session: PairingSessionResponse, + pub jwt: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct PairingPayloadResponse { + pub approving_device_id: String, + pub encrypted_payload: String, +} + #[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)] pub struct DeleteUser { pub password: String, diff --git a/src/handlers.rs b/src/handlers.rs index b33486e..cafa2a1 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -14,6 +14,7 @@ use crate::{models::Account, oidc::OidcError}; pub mod channel_playback_speeds; pub mod encrypted_sync; pub mod health; +pub mod pairing; pub mod playlist_bookmarks; pub mod playlists; pub mod subscriptions; @@ -72,6 +73,12 @@ pub enum HandlerError { EncryptedSyncQuotaExceeded, #[error("this account requires the encrypted sync endpoint")] EncryptedSyncRequired, + #[error("pairing session not found or expired")] + PairingNotFound, + #[error("pairing session has already changed state")] + PairingConflict, + #[error("too many active pairing sessions")] + PairingLimitExceeded, #[error("failed to load data from YouTube")] YouTubeConnectError, #[error("{0}")] @@ -108,6 +115,9 @@ impl ResponseError for HandlerError { Self::StorageQuotaExceeded => StatusCode::PAYLOAD_TOO_LARGE, Self::EncryptedSyncQuotaExceeded => StatusCode::PAYLOAD_TOO_LARGE, Self::EncryptedSyncRequired => StatusCode::CONFLICT, + Self::PairingNotFound => StatusCode::NOT_FOUND, + Self::PairingConflict => StatusCode::CONFLICT, + Self::PairingLimitExceeded => StatusCode::TOO_MANY_REQUESTS, Self::YouTubeConnectError => StatusCode::INTERNAL_SERVER_ERROR, Self::OidcError(_) => StatusCode::INTERNAL_SERVER_ERROR, Self::PasswordLoginDisabledForAccount => StatusCode::BAD_REQUEST, diff --git a/src/handlers/encrypted_sync.rs b/src/handlers/encrypted_sync.rs index cc38360..433e90e 100644 --- a/src/handlers/encrypted_sync.rs +++ b/src/handlers/encrypted_sync.rs @@ -40,16 +40,14 @@ impl ScopedHandler for EncryptedSyncHandler { Error = actix_web::Error, >, > { - scope::scope("").service( - scope::scope("/encrypted_sync") - .app_data(web::JsonConfig::default().limit(MAX_ENCRYPTED_SYNC_BYTES + 1024)) - .app_data(web::Data::new(PlaintextSyncExempt)) - .wrap(actix_web::middleware::from_fn(auth_middleware)) - .service(get_encrypted_sync_manifest) - .service(get_legacy_encrypted_sync) - .service(get_encrypted_sync_collection) - .service(put_encrypted_sync_collection), - ) + scope::scope("/encrypted_sync") + .app_data(web::JsonConfig::default().limit(MAX_ENCRYPTED_SYNC_BYTES + 1024)) + .app_data(web::Data::new(PlaintextSyncExempt)) + .wrap(actix_web::middleware::from_fn(auth_middleware)) + .service(get_encrypted_sync_manifest) + .service(get_legacy_encrypted_sync) + .service(get_encrypted_sync_collection) + .service(put_encrypted_sync_collection) } } @@ -58,6 +56,7 @@ pub(crate) fn sync_capabilities() -> SyncCapabilities { encrypted_sync: 1, bulk_sync: 1, history_page_size: MAX_PAGE_SIZE, + key_pairing: 1, } } diff --git a/src/handlers/pairing.rs b/src/handlers/pairing.rs new file mode 100644 index 0000000..f159898 --- /dev/null +++ b/src/handlers/pairing.rs @@ -0,0 +1,513 @@ +use std::collections::HashMap; +use std::sync::{LazyLock, Mutex}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use actix_web::body::MessageBody; +use actix_web::dev::{ServiceFactory, ServiceRequest, ServiceResponse}; +use actix_web::{HttpRequest, HttpResponse, Responder, delete, get, post, put, web}; +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use sha2::{Digest, Sha256}; +use utoipa_actix_web::scope; + +use crate::auth::generate_jwt; +use crate::database::pairing; +use crate::dto::{ + ApprovePairingSession, ClaimPairingSession, CreatePairingSession, PairingClaimResponse, + PairingPayloadResponse, PairingSessionResponse, +}; +use crate::handlers::user::{authenticate_account, request_within_rate_limit}; +use crate::handlers::{HandlerError, HandlerResult, ScopedHandler}; +use crate::models::{Account, PairingSession}; +use crate::rate_limit::RateLimiter; +use crate::{CONFIG, WebData, get_db_conn}; + +const PAIRING_TTL_MS: i64 = 2 * 60 * 1000; +const PAIRING_PROTOCOL_VERSION: u8 = 1; +const SESSION_ID_BYTES: usize = 32; +const PUBLIC_KEY_BYTES: usize = 32; +const DEVICE_ID_BYTES: usize = 16; +const RECIPIENT_TOKEN_BYTES: usize = 32; +const RECIPIENT_TOKEN_HEADER: &str = "X-Pairing-Token"; +const MAX_DEVICE_NAME_CHARS: usize = 80; +const MAX_DEVICE_NAME_BYTES: usize = 240; +const MIN_ENCRYPTED_PAYLOAD_BYTES: usize = 96; +const MAX_ENCRYPTED_PAYLOAD_BYTES: usize = 1536; +const MAX_ENCRYPTED_PAYLOAD_LENGTH: usize = 2048; +const MAX_PAIRING_REQUESTS_PER_MINUTE: u32 = 120; +const MAX_TRACKED_ACCOUNTS: usize = 100_000; + +struct PairingRateWindow { + started_at: Instant, + count: u32, +} + +struct PairingRateLimiter { + windows: Mutex>, +} + +impl PairingRateLimiter { + fn check(&self, owner_id: &str) -> bool { + let now = Instant::now(); + let mut windows = self.windows.lock().expect("pairing rate limiter poisoned"); + if windows.len() >= MAX_TRACKED_ACCOUNTS && !windows.contains_key(owner_id) { + windows.retain(|_, window| { + now.duration_since(window.started_at) < Duration::from_secs(60) + }); + if windows.len() >= MAX_TRACKED_ACCOUNTS { + return false; + } + } + let window = windows + .entry(owner_id.to_owned()) + .or_insert(PairingRateWindow { + started_at: now, + count: 0, + }); + if now.duration_since(window.started_at) >= Duration::from_secs(60) { + window.started_at = now; + window.count = 0; + } + window.count += 1; + window.count <= MAX_PAIRING_REQUESTS_PER_MINUTE + } +} + +static PAIRING_RATE_LIMITER: LazyLock = LazyLock::new(|| PairingRateLimiter { + windows: Mutex::new(HashMap::new()), +}); + +pub struct PairingHandler {} + +impl ScopedHandler for PairingHandler { + fn get_service() -> scope::Scope< + impl ServiceFactory< + ServiceRequest, + Response = ServiceResponse, + Config = (), + InitError = (), + Error = actix_web::Error, + >, + > { + scope::scope("/pairing") + .app_data(web::JsonConfig::default().limit(MAX_ENCRYPTED_PAYLOAD_LENGTH + 1024)) + .service(create_pairing_session) + .service(get_pairing_session) + .service(claim_pairing_session) + .service(approve_pairing_session) + .service(consume_pairing_session) + .service(cancel_pairing_session) + } +} + +fn now_ms() -> HandlerResult { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| HandlerError::InternalDatabaseError)? + .as_millis(); + i64::try_from(millis).map_err(|_| HandlerError::InternalDatabaseError) +} + +fn check_account_rate_limit(account: &Account) -> HandlerResult<()> { + if !PAIRING_RATE_LIMITER.check(&account.id) { + return Err(HandlerError::TooManyRequests); + } + Ok(()) +} + +fn is_base64url(value: &str, expected_length: usize) -> bool { + URL_SAFE_NO_PAD + .decode(value) + .is_ok_and(|bytes| bytes.len() == expected_length && URL_SAFE_NO_PAD.encode(bytes) == value) +} + +fn validate_session_id(value: &str) -> HandlerResult<()> { + if !is_base64url(value, SESSION_ID_BYTES) { + return Err(HandlerError::ValidationErrorWithContext( + "invalid pairing session id".to_owned(), + )); + } + Ok(()) +} + +fn validate_device_id(value: &str) -> HandlerResult<()> { + if !is_base64url(value, DEVICE_ID_BYTES) { + return Err(HandlerError::ValidationErrorWithContext( + "invalid pairing device id".to_owned(), + )); + } + Ok(()) +} + +fn validate_device_name(value: &str) -> HandlerResult<()> { + if value.is_empty() + || value.trim() != value + || value.chars().count() > MAX_DEVICE_NAME_CHARS + || value.len() > MAX_DEVICE_NAME_BYTES + || value.chars().any(char::is_control) + { + return Err(HandlerError::ValidationErrorWithContext( + "invalid pairing device name".to_owned(), + )); + } + Ok(()) +} + +fn validate_pairing_fields( + version: u8, + recipient_public_key: &str, + recipient_device_id: &str, + recipient_device_name: &str, +) -> HandlerResult<()> { + if version != PAIRING_PROTOCOL_VERSION { + return Err(HandlerError::ValidationErrorWithContext( + "unsupported pairing protocol version".to_owned(), + )); + } + if !is_base64url(recipient_public_key, PUBLIC_KEY_BYTES) { + return Err(HandlerError::ValidationErrorWithContext( + "invalid pairing recipient public key".to_owned(), + )); + } + validate_device_id(recipient_device_id)?; + validate_device_name(recipient_device_name) +} + +fn validate_create(form: &CreatePairingSession) -> HandlerResult<()> { + validate_session_id(&form.id)?; + validate_pairing_fields( + form.version, + &form.recipient_public_key, + &form.recipient_device_id, + &form.recipient_device_name, + )?; + if !is_base64url(&form.recipient_token_hash, RECIPIENT_TOKEN_BYTES) { + return Err(HandlerError::ValidationErrorWithContext( + "invalid pairing recipient token hash".to_owned(), + )); + } + Ok(()) +} + +fn validate_claim(form: &ClaimPairingSession) -> HandlerResult<()> { + validate_pairing_fields( + form.version, + &form.recipient_public_key, + &form.recipient_device_id, + &form.recipient_device_name, + ) +} + +fn validate_approval(form: &ApprovePairingSession) -> HandlerResult<()> { + validate_device_id(&form.approving_device_id)?; + let valid_payload = URL_SAFE_NO_PAD + .decode(&form.encrypted_payload) + .is_ok_and(|bytes| { + (MIN_ENCRYPTED_PAYLOAD_BYTES..=MAX_ENCRYPTED_PAYLOAD_BYTES).contains(&bytes.len()) + && URL_SAFE_NO_PAD.encode(bytes) == form.encrypted_payload + }); + if !valid_payload { + return Err(HandlerError::ValidationErrorWithContext( + "invalid encrypted pairing payload".to_owned(), + )); + } + Ok(()) +} + +fn recipient_token_hash(request: &HttpRequest) -> HandlerResult { + let token = request + .headers() + .get(RECIPIENT_TOKEN_HEADER) + .and_then(|value| value.to_str().ok()) + .ok_or(HandlerError::InvalidToken)?; + let token = URL_SAFE_NO_PAD + .decode(token) + .ok() + .filter(|bytes| { + bytes.len() == RECIPIENT_TOKEN_BYTES && URL_SAFE_NO_PAD.encode(bytes) == token + }) + .ok_or(HandlerError::InvalidToken)?; + Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(token))) +} + +fn response(session: PairingSession) -> PairingSessionResponse { + PairingSessionResponse { + version: PAIRING_PROTOCOL_VERSION, + id: session.id, + account_id: session.account_id, + recipient_public_key: session.recipient_public_key, + recipient_device_id: session.recipient_device_id, + recipient_device_name: session.recipient_device_name, + approving_device_id: session.approving_device_id, + expires_at: session.expires_at, + approved: session.encrypted_payload.is_some(), + } +} + +#[utoipa::path(request_body = CreatePairingSession, responses((status = CREATED, body = PairingSessionResponse)))] +#[post("")] +async fn create_pairing_session( + request: HttpRequest, + pool: WebData, + limiter: web::Data, + form: web::Json, +) -> HandlerResult { + if !request_within_rate_limit(&request, &limiter) { + return Err(HandlerError::TooManyRequests); + } + validate_create(&form)?; + let now = now_ms()?; + let session = PairingSession { + id: form.id.clone(), + version: i16::from(form.version), + account_id: None, + recipient_public_key: form.recipient_public_key.clone(), + recipient_device_id: form.recipient_device_id.clone(), + recipient_device_name: form.recipient_device_name.clone(), + recipient_token_hash: form.recipient_token_hash.clone(), + approving_device_id: None, + encrypted_payload: None, + expires_at: now + PAIRING_TTL_MS, + }; + let mut conn = get_db_conn!(pool); + match pairing::create(&mut conn, &session, now) + .await + .map_err(|_| HandlerError::InternalDatabaseError)? + { + pairing::CreateResult::Created => Ok(HttpResponse::Created().json(response(session))), + pairing::CreateResult::Duplicate => Err(HandlerError::PairingConflict), + pairing::CreateResult::LimitExceeded => Err(HandlerError::PairingLimitExceeded), + } +} + +#[utoipa::path(responses((status = OK, body = PairingSessionResponse)))] +#[get("/{id}")] +async fn get_pairing_session( + request: HttpRequest, + pool: WebData, + id: web::Path, +) -> HandlerResult { + validate_session_id(&id)?; + let token_hash = recipient_token_hash(&request)?; + let mut conn = get_db_conn!(pool); + let session = pairing::get(&mut conn, &id, &token_hash, now_ms()?) + .await + .map_err(|_| HandlerError::InternalDatabaseError)? + .ok_or(HandlerError::PairingNotFound)?; + Ok(web::Json(response(session))) +} + +#[utoipa::path(request_body = ClaimPairingSession, responses((status = OK, body = PairingClaimResponse)), security(("api_jwt_token" = [])))] +#[post("/{id}/claim")] +async fn claim_pairing_session( + request: HttpRequest, + pool: WebData, + id: web::Path, + form: web::Json, +) -> HandlerResult { + let account = authenticate_account(&request, &pool).await?; + check_account_rate_limit(&account)?; + validate_session_id(&id)?; + validate_claim(&form)?; + let jwt = generate_jwt(&account, CONFIG.secret.as_bytes()) + .map_err(|_| HandlerError::InternalDatabaseError)?; + let candidate = PairingSession { + id: id.into_inner(), + version: i16::from(form.version), + account_id: None, + recipient_public_key: form.recipient_public_key.clone(), + recipient_device_id: form.recipient_device_id.clone(), + recipient_device_name: form.recipient_device_name.clone(), + recipient_token_hash: String::new(), + approving_device_id: None, + encrypted_payload: None, + expires_at: 0, + }; + let mut conn = get_db_conn!(pool); + let session = match pairing::claim(&mut conn, &account.id, &candidate, now_ms()?) + .await + .map_err(|_| HandlerError::InternalDatabaseError)? + { + pairing::ClaimResult::Claimed(session) => session, + pairing::ClaimResult::Conflict => return Err(HandlerError::PairingConflict), + pairing::ClaimResult::LimitExceeded => return Err(HandlerError::PairingLimitExceeded), + }; + Ok(web::Json(PairingClaimResponse { + session: response(*session), + jwt, + })) +} + +#[utoipa::path(request_body = ApprovePairingSession, responses((status = NO_CONTENT)), security(("api_jwt_token" = [])))] +#[put("/{id}")] +async fn approve_pairing_session( + request: HttpRequest, + pool: WebData, + id: web::Path, + form: web::Json, +) -> HandlerResult { + let account = authenticate_account(&request, &pool).await?; + check_account_rate_limit(&account)?; + validate_session_id(&id)?; + validate_approval(&form)?; + let mut conn = get_db_conn!(pool); + let approved = pairing::approve( + &mut conn, + &account.id, + &id, + &form.approving_device_id, + &form.encrypted_payload, + now_ms()?, + ) + .await + .map_err(|_| HandlerError::InternalDatabaseError)?; + if !approved { + return Err(HandlerError::PairingConflict); + } + Ok(HttpResponse::NoContent()) +} + +#[utoipa::path(responses((status = OK, body = PairingPayloadResponse)))] +#[post("/{id}/consume")] +async fn consume_pairing_session( + request: HttpRequest, + pool: WebData, + id: web::Path, +) -> HandlerResult { + validate_session_id(&id)?; + let token_hash = recipient_token_hash(&request)?; + let mut conn = get_db_conn!(pool); + let session = pairing::consume(&mut conn, &id, &token_hash, now_ms()?) + .await + .map_err(|_| HandlerError::InternalDatabaseError)? + .ok_or(HandlerError::PairingNotFound)?; + Ok(web::Json(PairingPayloadResponse { + approving_device_id: session + .approving_device_id + .ok_or(HandlerError::InternalDatabaseError)?, + encrypted_payload: session + .encrypted_payload + .ok_or(HandlerError::InternalDatabaseError)?, + })) +} + +#[utoipa::path(responses((status = NO_CONTENT)))] +#[delete("/{id}")] +async fn cancel_pairing_session( + request: HttpRequest, + pool: WebData, + id: web::Path, +) -> HandlerResult { + validate_session_id(&id)?; + let token_hash = recipient_token_hash(&request)?; + let mut conn = get_db_conn!(pool); + if !pairing::cancel(&mut conn, &id, &token_hash) + .await + .map_err(|_| HandlerError::InternalDatabaseError)? + { + return Err(HandlerError::PairingNotFound); + } + Ok(HttpResponse::NoContent()) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::Mutex; + + use actix_web::{App, http::StatusCode, test as actix_test}; + use base64::Engine as _; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use utoipa_actix_web::{AppExt, scope}; + + use super::{ + ApprovePairingSession, ClaimPairingSession, CreatePairingSession, PairingHandler, + validate_approval, validate_claim, validate_create, + }; + use super::{MAX_PAIRING_REQUESTS_PER_MINUTE, PairingRateLimiter}; + use crate::handlers::{ScopedHandler, encrypted_sync::EncryptedSyncHandler}; + + fn encoded(byte: u8, length: usize) -> String { + URL_SAFE_NO_PAD.encode(vec![byte; length]) + } + + #[test] + fn accepts_canonical_pairing_fields() { + let create = CreatePairingSession { + version: 1, + id: encoded(0, 32), + recipient_public_key: encoded(1, 32), + recipient_device_id: encoded(2, 16), + recipient_device_name: "Living room laptop".to_owned(), + recipient_token_hash: encoded(3, 32), + }; + assert!(validate_create(&create).is_ok()); + let claim = ClaimPairingSession { + version: create.version, + recipient_public_key: create.recipient_public_key.clone(), + recipient_device_id: create.recipient_device_id.clone(), + recipient_device_name: create.recipient_device_name.clone(), + }; + assert!(validate_claim(&claim).is_ok()); + + let approval = ApprovePairingSession { + approving_device_id: encoded(4, 16), + encrypted_payload: encoded(5, 256), + }; + assert!(validate_approval(&approval).is_ok()); + } + + #[test] + fn rejects_noncanonical_or_oversized_pairing_fields() { + let create = CreatePairingSession { + version: 1, + id: "A".repeat(42), + recipient_public_key: encoded(1, 32), + recipient_device_id: encoded(2, 16), + recipient_device_name: " device ".to_owned(), + recipient_token_hash: encoded(3, 32), + }; + assert!(validate_create(&create).is_err()); + + let approval = ApprovePairingSession { + approving_device_id: encoded(4, 16), + encrypted_payload: "=".repeat(256), + }; + assert!(validate_approval(&approval).is_err()); + } + + #[test] + fn pairing_requests_are_rate_limited_per_account() { + let limiter = PairingRateLimiter { + windows: Mutex::new(HashMap::new()), + }; + for _ in 0..MAX_PAIRING_REQUESTS_PER_MINUTE { + assert!(limiter.check("account-a")); + } + assert!(!limiter.check("account-a")); + assert!(limiter.check("account-b")); + } + + #[actix_web::test] + async fn pairing_route_is_reachable_after_encrypted_sync_routes() { + let (app, _) = App::new() + .into_utoipa_app() + .service( + scope::scope("/v1") + .service(EncryptedSyncHandler::get_service()) + .service(PairingHandler::get_service()), + ) + .split_for_parts(); + let app = actix_test::init_service(app).await; + + let request = actix_test::TestRequest::get() + .uri("/v1/pairing/not-a-session") + .to_request(); + let status = match actix_test::try_call_service(&app, request).await { + Ok(response) => response.status(), + Err(error) => error.as_response_error().status_code(), + }; + + assert_ne!(status, StatusCode::NOT_FOUND); + } +} diff --git a/src/handlers/user.rs b/src/handlers/user.rs index 47eb37f..645bc74 100644 --- a/src/handlers/user.rs +++ b/src/handlers/user.rs @@ -254,38 +254,54 @@ fn forwarded_client(header: &str, trusted_proxy_hops: usize) -> Option { }) } -/// Middleware that ensures that the account is authenticated. -pub async fn auth_middleware( - req: ServiceRequest, - next: Next, -) -> Result, actix_web::Error> { +pub(crate) fn request_within_rate_limit(req: &HttpRequest, limiter: &RateLimiter) -> bool { + let peer = req.peer_addr().map(|address| address.ip()); + let client = if limiter.trusts_forwarded_for() { + req.headers() + .get("X-Forwarded-For") + .and_then(|value| value.to_str().ok()) + .and_then(|header| forwarded_client(header, limiter.trusted_proxy_hops())) + .or(peer) + } else { + peer + }; + client.is_none_or(|address| limiter.check(address)) +} + +pub(crate) async fn authenticate_account( + req: &HttpRequest, + pool: &WebData, +) -> HandlerResult { let auth_header = req .headers() .get(AUTH_HEADER_KEY) .and_then(|header| header.to_str().ok()) - .map(|value| value.to_string()); + .map(str::to_owned); let auth_cookie = req .cookie(AUTH_HEADER_KEY) - .map(|cookie| cookie.value().to_string()); + .map(|cookie| cookie.value().to_owned()); - let Some(jwt) = auth_cookie.or(auth_header) else { - return Err(HandlerError::InvalidToken.into()); - }; - let Ok(account_id) = verify_jwt(&jwt, CONFIG.secret.as_bytes()) else { - return Err(HandlerError::InvalidToken.into()); - }; + let jwt = auth_cookie + .or(auth_header) + .ok_or(HandlerError::InvalidToken)?; + let account_id = + verify_jwt(&jwt, CONFIG.secret.as_bytes()).map_err(|_| HandlerError::InvalidToken)?; + let mut conn = get_db_conn!(pool); + find_account_by_id(&mut conn, &account_id) + .await + .map_err(|_| HandlerError::InternalDatabaseError)? + .ok_or(HandlerError::AccountNotExists) +} +/// Middleware that ensures that the account is authenticated. +pub async fn auth_middleware( + req: ServiceRequest, + next: Next, +) -> Result, actix_web::Error> { let pool: WebData = req.app_data().cloned().unwrap(); + let account = authenticate_account(req.request(), &pool).await?; let mut conn = get_db_conn!(pool); - let Some(account) = find_account_by_id(&mut conn, &account_id) - .await - .ok() - .flatten() - else { - return Err(HandlerError::AccountNotExists.into()); - }; - // Scopes opt out explicitly. Matching on the request path instead would let // a route parameter such as `/playlists/encrypted_sync/videos` slip past. let exempt = req.app_data::>().is_some(); diff --git a/src/main.rs b/src/main.rs index c7f7ab1..41e099d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,7 +24,7 @@ use utoipa_scalar::{Scalar, Servable}; use crate::{ handlers::{ ScopedHandler, channel_playback_speeds::ChannelPlaybackSpeedsHandler, - encrypted_sync::EncryptedSyncHandler, health::HealthHandler, + encrypted_sync::EncryptedSyncHandler, health::HealthHandler, pairing::PairingHandler, playlist_bookmarks::PlaylistBookmarksHandler, playlists::PlaylistsHandler, subscriptions::SubscriptionsHandler, user::UserHandler, watch_history::WatchHistoryHandler, }, @@ -116,7 +116,8 @@ async fn main() -> io::Result<()> { .service(PlaylistsHandler::get_service()) .service(PlaylistBookmarksHandler::get_service()) .service(WatchHistoryHandler::get_service()) - .service(EncryptedSyncHandler::get_service()), + .service(EncryptedSyncHandler::get_service()) + .service(PairingHandler::get_service()), ) .split_for_parts(); diff --git a/src/models.rs b/src/models.rs index 372150d..0b1a340 100644 --- a/src/models.rs +++ b/src/models.rs @@ -97,6 +97,22 @@ pub struct EncryptedSync { pub payload: String, } +#[derive(Debug, Clone, Queryable, Selectable, Insertable, Eq, PartialEq)] +#[diesel(belongs_to(Account))] +#[diesel(table_name = pairing_session)] +pub struct PairingSession { + pub id: String, + pub version: i16, + pub account_id: Option, + pub recipient_public_key: String, + pub recipient_device_id: String, + pub recipient_device_name: String, + pub recipient_token_hash: String, + pub approving_device_id: Option, + pub encrypted_payload: Option, + pub expires_at: i64, +} + #[derive( Debug, Clone, diff --git a/src/schema.rs b/src/schema.rs index 9686e07..fc40b7f 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -9,6 +9,21 @@ diesel::table! { } } +diesel::table! { + pairing_session (id) { + id -> Text, + version -> SmallInt, + account_id -> Nullable, + recipient_public_key -> Text, + recipient_device_id -> Text, + recipient_device_name -> Text, + recipient_token_hash -> Text, + approving_device_id -> Nullable, + encrypted_payload -> Nullable, + expires_at -> BigInt, + } +} + diesel::table! { account (id) { id -> Text, @@ -117,6 +132,7 @@ diesel::table! { diesel::joinable!(playlist -> account (account_id)); diesel::joinable!(channel_playback_speed -> account (account_id)); diesel::joinable!(encrypted_sync -> account (account_id)); +diesel::joinable!(pairing_session -> account (account_id)); diesel::joinable!(playlist_bookmark -> account (account_id)); diesel::joinable!(playlist_bookmark -> public_playlist (public_playlist_id)); diesel::joinable!(playlist_video_member -> account (account_id)); @@ -136,6 +152,7 @@ diesel::allow_tables_to_appear_in_same_query!( channel, channel_playback_speed, encrypted_sync, + pairing_session, playlist, playlist_bookmark, playlist_video_member, From d56d81a75d133498e24429f0036fb92426ee2d34 Mon Sep 17 00:00:00 2001 From: D3SOX Date: Wed, 26 Aug 2026 15:17:34 +0200 Subject: [PATCH 2/2] fix(sync): enforce the pairing session lifecycle --- PRIVACY.md | 7 +++++-- README.md | 15 +++++++------- src/database/pairing.rs | 43 ++++++++++++++++++++++++++++++++++------- src/handlers/pairing.rs | 21 ++++++++++++++++++++ src/main.rs | 1 + 5 files changed, 71 insertions(+), 16 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index 8e3b87f..95fd307 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -41,8 +41,11 @@ The server never receives the QR-only pairing secret, recipient private key, privacy key, privacy passphrase, or login password. It creates a fresh authentication token for the receiving device during the claim and therefore knows that token. The approving device places the token inside the encrypted -relay payload together with the account name and privacy key. The server cannot -open or replace that payload without the QR-only secret. +relay payload together with the account name, privacy key, privacy salt, and +six-digit verification code. The server can drop or overwrite that ciphertext, +but it cannot decrypt it or forge a valid replacement without the QR-only +secret. A background task deletes expired sessions, normally within 30 seconds +after their two-minute expiry. **Legacy compatibility.** The server retains plaintext endpoints for older or non-OpenTubeX clients. Current OpenTubeX clients do not use them on this public diff --git a/README.md b/README.md index 9a66a3e..1a615c1 100644 --- a/README.md +++ b/README.md @@ -214,13 +214,14 @@ ID after claim, and approved ciphertext. It never receives the QR-only secret, recipient private key, privacy key, or privacy passphrase. Poll, consume, and cancel requests send the raw recipient token in a request header; the server stores only its hash. The server created the fresh JWT and therefore knows that -token, but it cannot open or replace the encrypted transfer without the QR-only -secret. - -Sessions expire after two minutes. The server permits at most 10,000 active -anonymous sessions globally and five claimed sessions per account. Authenticated -pairing requests are limited to 120 per account per minute, while anonymous -creation uses the server's address-based request limiter. Approval accepts an +token. It can drop or overwrite the encrypted transfer, but it cannot decrypt +it or forge a valid replacement without the QR-only secret. + +Sessions expire after two minutes, and a background task normally deletes them +within another 30 seconds. The server permits at most 10,000 active pairing +sessions globally and five claimed sessions per account. Authenticated pairing +requests are limited to 120 per account per minute, while anonymous creation +uses the server's address-based request limiter. Claim and approval accept an identical retry after success. Consumption atomically returns and deletes the ciphertext, and cancellation deletes the session. diff --git a/src/database/pairing.rs b/src/database/pairing.rs index 26a08dd..ca5990a 100644 --- a/src/database/pairing.rs +++ b/src/database/pairing.rs @@ -27,6 +27,12 @@ pub enum ClaimResult { LimitExceeded, } +pub async fn delete_expired(conn: &mut DbConnection, now: i64) -> Result { + diesel::delete(pairing_session.filter(expires_at.le(now))) + .execute(conn) + .await +} + pub async fn create( conn: &mut DbConnection, session: &PairingSession, @@ -34,9 +40,7 @@ pub async fn create( ) -> Result { conn.transaction(|conn| { Box::pin(async move { - diesel::delete(pairing_session.filter(expires_at.le(now))) - .execute(conn) - .await?; + delete_expired(conn, now).await?; let active = pairing_session.count().get_result::(conn).await?; if active >= MAX_ACTIVE_SESSIONS { @@ -75,9 +79,24 @@ pub async fn claim( .set(account::id.eq(account::id)) .execute(conn) .await?; - diesel::delete(pairing_session.filter(expires_at.le(now))) - .execute(conn) - .await?; + delete_expired(conn, now).await?; + + let existing = pairing_session + .filter(id.eq(&request.id)) + .filter(version.eq(request.version)) + .filter(account_id.eq(owner_id)) + .filter(recipient_public_key.eq(&request.recipient_public_key)) + .filter(recipient_device_id.eq(&request.recipient_device_id)) + .filter(recipient_device_name.eq(&request.recipient_device_name)) + .filter(expires_at.gt(now)) + .filter(encrypted_payload.is_null()) + .select(PairingSession::as_select()) + .first(conn) + .await + .optional()?; + if let Some(session) = existing { + return Ok(ClaimResult::Claimed(Box::new(session))); + } let active = pairing_session .filter(account_id.eq(owner_id)) @@ -213,7 +232,9 @@ mod tests { use diesel_async::AsyncConnection; use diesel_migrations::MigrationHarness; - use super::{ClaimResult, CreateResult, approve, cancel, claim, consume, create, get}; + use super::{ + ClaimResult, CreateResult, approve, cancel, claim, consume, create, delete_expired, get, + }; use crate::models::PairingSession; use crate::{DbConnection, MIGRATIONS}; @@ -350,10 +371,12 @@ mod tests { .unwrap() .is_none() ); + assert_eq!(delete_expired(&mut conn, 200).await.unwrap(), 1); assert_eq!( claim(&mut conn, "account-a", &request, 200).await.unwrap(), ClaimResult::Conflict ); + assert!(!cancel(&mut conn, "session", "token-session").await.unwrap()); } #[actix_rt::test] @@ -371,6 +394,12 @@ mod tests { )); } + let retry = session("session-0", 1_000); + assert!(matches!( + claim(&mut conn, "account-a", &retry, 100).await.unwrap(), + ClaimResult::Claimed(_) + )); + let excess = session("session-5", 1_000); assert_eq!( create(&mut conn, &excess, 100).await.unwrap(), diff --git a/src/handlers/pairing.rs b/src/handlers/pairing.rs index f159898..352c0e9 100644 --- a/src/handlers/pairing.rs +++ b/src/handlers/pairing.rs @@ -36,6 +36,7 @@ const MAX_ENCRYPTED_PAYLOAD_BYTES: usize = 1536; const MAX_ENCRYPTED_PAYLOAD_LENGTH: usize = 2048; const MAX_PAIRING_REQUESTS_PER_MINUTE: u32 = 120; const MAX_TRACKED_ACCOUNTS: usize = 100_000; +const PAIRING_CLEANUP_INTERVAL: Duration = Duration::from_secs(30); struct PairingRateWindow { started_at: Instant, @@ -77,6 +78,26 @@ static PAIRING_RATE_LIMITER: LazyLock = LazyLock::new(|| Pai windows: Mutex::new(HashMap::new()), }); +pub fn start_expired_session_cleanup(pool: crate::DbPool) { + actix_web::rt::spawn(async move { + let mut interval = actix_web::rt::time::interval(PAIRING_CLEANUP_INTERVAL); + loop { + interval.tick().await; + let Ok(now) = now_ms() else { + log::error!("could not determine the time for pairing-session cleanup"); + continue; + }; + let Ok(mut conn) = pool.get().await else { + log::error!("could not get a database connection for pairing-session cleanup"); + continue; + }; + if let Err(error) = pairing::delete_expired(&mut conn, now).await { + log::error!("could not delete expired pairing sessions: {error}"); + } + } + }); +} + pub struct PairingHandler {} impl ScopedHandler for PairingHandler { diff --git a/src/main.rs b/src/main.rs index 41e099d..f6b230b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -86,6 +86,7 @@ async fn main() -> io::Result<()> { CONFIG.migration_approval.as_deref(), ) .await; + handlers::pairing::start_expired_session_cleanup(pool.clone()); if let Some(oidc) = &CONFIG.oidc { init_oidc(oidc).await;