Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 28 additions & 8 deletions PRIVACY.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# OpenTubeX Sync Server Privacy Policy

Last updated: August 26, 2026
Last updated: September 1, 2026

This policy applies to the public OpenTubeX sync server at
[sync.d3sox.me](https://sync.d3sox.me). Other operators running this
Expand All @@ -18,9 +18,19 @@ does not control data processed by independently hosted instances.

**Account data.** The server stores a unique account ID, a deterministic
HMAC-derived value of your account name, and a salted Argon2 password hash. It
does not store your account name or password in plaintext. It issues a signed
authentication token after login but does not store that token in the
database.
does not store your account name or password in plaintext. Each current login
has an account session with a random session ID and device ID, an encrypted device
information record, creation and last-active times, an expiry time, and an
internal session-generation number. Pairing sessions additionally retain a
provisional-state flag until pairing succeeds. The
encrypted record contains the device name, operating system, system release,
and architecture. Your device encrypts it with your privacy key, so the server
cannot read those details. The server signs an authentication token that names
the stored session but does not store the full token in the database. Last-active
times are updated at most once every five minutes. For a still-valid token made
before account sessions were introduced, the server derives stable session and
device IDs from a SHA-256 digest of that token when it is first used. It does not
store the token itself.

**Encrypted sync.** The public server supports encrypted sync, and OpenTubeX
always uses it when the server supports it. Sync data is encrypted on your
Expand All @@ -31,11 +41,13 @@ 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
ID, SHA-256 recipient-token hash, recipient public key, 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
device claims the session. Claiming creates a provisional account session; it
becomes active only when the pairing payload is consumed and is removed if the
pairing expires or is cancelled. Pairing 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
Expand Down Expand Up @@ -100,6 +112,14 @@ Account and sync data remain in the active database until you delete individual
items or your account. Account deletion removes the account and its linked data
from the active database. Shared public YouTube metadata may remain.

Account sessions remain until their one-year authentication token expires.
Revocation prevents authentication immediately but retains the session as a
tombstone until expiry so a legacy token cannot recreate it. A background task
deletes expired records, normally within one hour. Changing an account password
rotates the requesting device's token, advances the account's session generation,
and revokes every previous session, including concurrent logins and legacy tokens
that the server has not seen yet.

- Request logs are retained for up to seven days.
- A database backup is created daily, with the seven most recent backups
retained. Temporary migration or restore safety copies are retained for up
Expand Down
54 changes: 49 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,48 @@ For example:
- Header: `Authorization: abcdefghijklmnopqrtuvwxyz`
- Cookie: `Authorization=abcdefghijklmnopqrtuvwxyz`

### Account sessions

Capability `account_sessions: 1` means authentication tokens are backed by
stored account sessions. Registration, password login, and OIDC login create an
active session. Secure pairing creates a provisional session that becomes active
only when the receiving device consumes the approved pairing payload. A token
contains that session's ID in its `jti`
claim, and authenticated requests fail after the session is revoked or expires.
Tokens issued before this capability was added do not contain `jti`. For
accounts present during the migration, the server accepts a still-valid legacy
token and creates its session on first use. New accounts require session-bound
tokens. The server
derives stable session and device IDs from a SHA-256 digest of the token and
does not store the token itself. Revoking that session leaves a tombstone until
the token expires, so the same token cannot recreate the session.

Current clients send a random 16-byte base64url device ID when they authenticate.
The field is optional so older clients can continue to register and sign in; the
server assigns their device ID. Clients
encrypt the user-visible device name, operating system, system release, and
architecture with the enhanced-privacy key, then update the session with the
ciphertext. Clients can replace that ciphertext to rename any active device.
The server retains creation, last-active, and expiry times. It writes
last-active changes at most once every five minutes.

The endpoints are:

- authenticated `GET /v1/account/sessions` to list active sessions and whether the account supports password login
- authenticated `PATCH /v1/account/sessions/{id}` to store encrypted device information; `current` may be used as the ID for the requesting session
- authenticated `DELETE /v1/account/sessions/{id}` to revoke one session
- authenticated `PUT /v1/account/password` to change a password, revoke every existing session, and return a replacement JWT for the requesting device

Expired and revoked sessions never authenticate. A background task removes them
after expiry, normally within one hour. Password changes verify the current
password and update its Argon2 hash in the same transaction that rotates the
requesting session, advances the account's session generation, and revokes the
rest. The generation check also rejects a concurrent login that verified the old
password but had not created its session yet. Password changes reject every legacy token
that has not yet created a session. This lets operators deploy the feature
without signing out old clients, while password changes still invalidate all
other access.

### Enhanced privacy sync

`GET /health` returns the server's capabilities alongside its health status.
Expand All @@ -203,13 +245,14 @@ collections for one account cannot exceed 128 MiB.
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 session for its account and approves it. During the claim, the server creates
a provisional account session and mints a 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
key, 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
Expand All @@ -223,13 +266,14 @@ 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.
ciphertext, activates the provisional account session, and cancellation or
expiry deletes it.

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 `POST /v1/pairing/{id}/claim` to bind it to an account, create its provisional session, and mint a 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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
DROP TABLE account_session;
ALTER TABLE account DROP COLUMN legacy_tokens_enabled;
ALTER TABLE account DROP COLUMN session_generation;
20 changes: 20 additions & 0 deletions migrations/postgres/2026-09-01-000000-0000_account_sessions/up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
ALTER TABLE account ADD COLUMN legacy_tokens_enabled BOOLEAN NOT NULL DEFAULT TRUE;
ALTER TABLE account ADD COLUMN session_generation BIGINT NOT NULL DEFAULT 0;

CREATE TABLE account_session(
id VARCHAR PRIMARY KEY NOT NULL,
account_id VARCHAR NOT NULL,
device_id VARCHAR NOT NULL,
encrypted_device_info TEXT,
created_at BIGINT NOT NULL,
last_active_at BIGINT NOT NULL,
expires_at BIGINT NOT NULL,
revoked_at BIGINT,
legacy BOOLEAN NOT NULL DEFAULT FALSE,
generation BIGINT NOT NULL,
pending_pairing BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT FK__account_session__account FOREIGN KEY(account_id) REFERENCES account(id) ON DELETE CASCADE
);

CREATE INDEX account_session_account_expires_idx
ON account_session(account_id, expires_at);
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
DROP TABLE account_session;
ALTER TABLE account DROP COLUMN legacy_tokens_enabled;
ALTER TABLE account DROP COLUMN session_generation;
20 changes: 20 additions & 0 deletions migrations/sqlite/2026-09-01-000000-0000_account_sessions/up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
ALTER TABLE account ADD COLUMN legacy_tokens_enabled BOOLEAN NOT NULL DEFAULT TRUE;
ALTER TABLE account ADD COLUMN session_generation BIGINT NOT NULL DEFAULT 0;

CREATE TABLE account_session(
id VARCHAR PRIMARY KEY NOT NULL,
account_id VARCHAR NOT NULL,
device_id VARCHAR NOT NULL,
encrypted_device_info TEXT,
created_at BIGINT NOT NULL,
last_active_at BIGINT NOT NULL,
expires_at BIGINT NOT NULL,
revoked_at BIGINT,
legacy BOOLEAN NOT NULL DEFAULT FALSE,
generation BIGINT NOT NULL,
pending_pairing BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT FK__account_session__account FOREIGN KEY(account_id) REFERENCES account(id) ON DELETE CASCADE
);

CREATE INDEX account_session_account_expires_idx
ON account_session(account_id, expires_at);
72 changes: 62 additions & 10 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,26 +20,30 @@ pub fn bytes_to_hex_string(bytes: &[u8]) -> String {
}

/// How long a freshly minted token stays valid. Should be enough in most cases.
const TOKEN_TTL: Duration = Duration::from_hours(365 * 24);
pub const TOKEN_TTL: Duration = Duration::from_hours(365 * 24);

/// Allowance for clock skew between minting and verifying.
const EXPIRY_LEEWAY: Duration = Duration::from_secs(5 * 60);

fn unix_now() -> u64 {
pub fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}

pub fn generate_jwt(account: &Account, secret_key: &[u8]) -> jsonwebtoken::errors::Result<String> {
pub fn generate_jwt(
account: &Account,
session_id: &str,
expires_at: u64,
secret_key: &[u8],
) -> jsonwebtoken::errors::Result<String> {
let key = EncodingKey::from_secret(secret_key);
// `exp` is defined in seconds since the epoch, not milliseconds.
let expiration_date = unix_now().saturating_add(TOKEN_TTL.as_secs());

let claims = JwtClaims {
sub: account.id.clone(),
exp: expiration_date as usize,
jti: Some(session_id.to_owned()),
exp: expires_at as usize,
};
encode(&Header::default(), &claims, &key)
}
Expand All @@ -56,16 +60,15 @@ fn expiry_is_plausible(exp: u64, now: u64) -> bool {
.saturating_add(EXPIRY_LEEWAY.as_secs())
}

/// Returns the User ID on success.
pub fn verify_jwt(encoded_jwt: &str, secret_key: &[u8]) -> jsonwebtoken::errors::Result<String> {
pub fn verify_jwt(encoded_jwt: &str, secret_key: &[u8]) -> jsonwebtoken::errors::Result<JwtClaims> {
let key = DecodingKey::from_secret(secret_key);
let claims: JwtClaims = decode(encoded_jwt.as_bytes(), &key, &Validation::default())?.claims;

if !expiry_is_plausible(claims.exp as u64, unix_now()) {
return Err(jsonwebtoken::errors::ErrorKind::InvalidToken.into());
}

Ok(claims.sub)
Ok(claims)
}

fn argon2_instance<'a>() -> Argon2<'a> {
Expand Down Expand Up @@ -101,10 +104,20 @@ pub fn hash_accountname(accountname: &str, secret_key: &[u8]) -> String {

#[cfg(test)]
mod tests {
use super::{EXPIRY_LEEWAY, TOKEN_TTL, expiry_is_plausible};
use jsonwebtoken::{EncodingKey, Header, encode};
use serde::Serialize;

use super::{EXPIRY_LEEWAY, TOKEN_TTL, expiry_is_plausible, generate_jwt, verify_jwt};
use crate::models::Account;

const NOW: u64 = 1_800_000_000;

#[derive(Serialize)]
struct LegacyClaims<'a> {
sub: &'a str,
exp: usize,
}

#[test]
fn normal_expiries_are_accepted() {
assert!(expiry_is_plausible(NOW + 60, NOW));
Expand Down Expand Up @@ -132,4 +145,43 @@ mod tests {
));
assert!(!expiry_is_plausible(u64::MAX, NOW));
}

#[test]
fn tokens_are_bound_to_their_account_session() {
let account = Account {
id: "account-id".to_owned(),
name_hash: "name-hash".to_owned(),
password_hash: Some("password-hash".to_owned()),
oidc_sub: None,
legacy_tokens_enabled: false,
session_generation: 0,
};
let secret = b"a secret key long enough for this unit test";
let expires_at = super::unix_now() + 60;
let jwt = generate_jwt(&account, "session-id", expires_at, secret).unwrap();
let claims = verify_jwt(&jwt, secret).unwrap();

assert_eq!(claims.sub, account.id);
assert_eq!(claims.jti.as_deref(), Some("session-id"));
assert_eq!(claims.exp, expires_at as usize);
}

#[test]
fn tokens_minted_before_session_management_still_verify() {
let secret = b"a secret key long enough for this unit test";
let expires_at = super::unix_now() + 60;
let jwt = encode(
&Header::default(),
&LegacyClaims {
sub: "account-id",
exp: expires_at as usize,
},
&EncodingKey::from_secret(secret),
)
.unwrap();
let claims = verify_jwt(&jwt, secret).unwrap();

assert_eq!(claims.sub, "account-id");
assert_eq!(claims.jti, None);
}
}
1 change: 1 addition & 0 deletions src/database.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod account;
pub mod account_session;
pub mod channel;
pub mod channel_playback_speed;
pub mod encrypted_sync;
Expand Down
Loading