feat(profiles): multi-user profiles with PIN switching

A shared device can hold several accounts from the same server and switch
between them in a couple of taps. A profile can be locked behind a 4-8
digit PIN; one without a PIN is one tap away. Forgetting a PIN falls
through to the account's own Jellyfin password, so there is no reset flow
and no recovery secret to store.

Opt-in by construction: a single account with no PIN starts, plays and
downloads exactly as before, and never sees a picker.

Two decisions worth keeping:

- Switching is not logging out. auth_logout invalidates the token
  server-side, which is precisely what a switch must not do, or every
  switch back would cost a password. The switch runs as a plan
  (profiles/switch.rs) so the teardown *ordering* is unit-testable with
  no player and no server -- a straggler reporting after the active user
  flips would attribute one account's viewing to another, silently.

- The PIN gates switching, not the token at rest. Wrapping each token
  with its PIN would leave a locked profile unable to resume its own
  downloads or drain its own sync queue until somebody typed the code,
  which on a device that reboots nightly costs more than it defends
  against a four-digit secret. auth_initialize does refuse to restore a
  PIN-protected session, so the gate is on the session rather than on
  which screen is shown.

"Child account" is not modelled anywhere -- a child's profile is simply
one with no PIN. The frontend renders an opaque unlockMethod and never
compares a PIN, counts an attempt or infers a role.

Migration 024 adds user_pins, user_item_visibility, user_libraries and
download_grants, and backfills the existing user so an upgrade does not
blank its library. The visibility and grant tables are the schema half of
the cache-scoping and shared-download work; the read-path enforcement is
still to come (see docs/specs/multi-user-profiles.md).
This commit is contained in:
2026-08-30 19:03:59 +02:00
parent 8a04a6fad0
commit da762da55d
22 changed files with 3392 additions and 5 deletions
+290
View File
@@ -0,0 +1,290 @@
//! Profile PIN: hashing, and the lockout policy that decides what a guess costs.
//!
//! The policy half is deliberately pure — it takes the stored counter state and
//! the current time, and returns the decision plus the next state. That is what
//! makes "five wrong guesses then a lockout that survives a restart" testable
//! without a database, a clock, or a running app.
//!
//! What this is *not*: at-rest protection. The PIN gates switching to a profile;
//! it does not encrypt that profile's access token, so anyone holding the
//! database and the keyring has every token regardless. That trade is deliberate
//! and its reasoning lives in DR-268 — a wrapped token would leave a locked
//! profile unable to resume its own downloads or drain its own sync queue until
//! somebody walked past and typed the code.
//!
//! TRACES: UR-083 | DR-268
use argon2::Argon2;
use chrono::{DateTime, Duration, Utc};
use password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
/// Wrong guesses allowed before the first lockout.
pub const MAX_ATTEMPTS: u32 = 5;
/// How long the first lockout lasts. Each subsequent failure doubles it.
const BASE_LOCKOUT_SECS: i64 = 60;
/// Ceiling on the doubling, so a forgotten PIN never bricks the tile — the
/// password route is always there, and a lockout measured in hours would push
/// people towards not setting a PIN at all.
const MAX_LOCKOUT_SECS: i64 = 15 * 60;
/// Persisted counter state for one profile's PIN.
///
/// TRACES: UR-083 | DR-268
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PinState {
pub failed_count: u32,
pub locked_until: Option<DateTime<Utc>>,
}
impl PinState {
pub fn fresh() -> Self {
Self {
failed_count: 0,
locked_until: None,
}
}
}
/// What the caller should do with an attempt.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PinDecision {
Accept,
Reject { attempts_remaining: u32 },
Locked { until: DateTime<Utc> },
}
/// Decide an attempt and produce the state to persist.
///
/// `pin_matches` is the result of the hash comparison; passing it in rather than
/// doing the comparison here is what keeps this function pure and cheap to test
/// across the whole attempt/lockout space.
///
/// A locked profile is refused *without consulting the hash*, so a caller cannot
/// burn through a lockout by guessing quickly.
///
/// TRACES: UR-083 | DR-268
pub fn evaluate(
state: &PinState,
now: DateTime<Utc>,
pin_matches: bool,
) -> (PinDecision, PinState) {
if let Some(until) = state.locked_until {
if now < until {
return (PinDecision::Locked { until }, state.clone());
}
}
if pin_matches {
return (PinDecision::Accept, PinState::fresh());
}
let failed_count = state.failed_count.saturating_add(1);
if failed_count >= MAX_ATTEMPTS {
let over = i64::from(failed_count - MAX_ATTEMPTS);
let secs = BASE_LOCKOUT_SECS
.saturating_mul(1i64.checked_shl(over.min(16) as u32).unwrap_or(i64::MAX))
.min(MAX_LOCKOUT_SECS);
let until = now + Duration::seconds(secs);
(
PinDecision::Locked { until },
PinState {
failed_count,
locked_until: Some(until),
},
)
} else {
(
PinDecision::Reject {
attempts_remaining: MAX_ATTEMPTS - failed_count,
},
PinState {
failed_count,
locked_until: None,
},
)
}
}
/// A PIN must be 48 digits. Rejecting non-digits here rather than in the pad
/// keeps the rule where the rule is enforced.
///
/// TRACES: UR-083 | DR-268
pub fn validate_pin(pin: &str) -> Result<(), String> {
if pin.len() < 4 || pin.len() > 8 {
return Err("PIN must be between 4 and 8 digits".to_string());
}
if !pin.chars().all(|c| c.is_ascii_digit()) {
return Err("PIN must contain only digits".to_string());
}
Ok(())
}
/// Hash a PIN for storage. Returns a PHC string with the salt embedded.
///
/// TRACES: UR-083 | DR-268
pub fn hash_pin(pin: &str) -> Result<String, String> {
let salt = SaltString::generate(&mut OsRng);
Argon2::default()
.hash_password(pin.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| format!("Failed to hash PIN: {}", e))
}
/// Compare a candidate PIN against a stored PHC string.
///
/// A malformed stored hash verifies as `false` rather than erroring: a corrupt
/// row should send the user down the password route, not wedge the picker.
///
/// TRACES: UR-083 | DR-268
pub fn verify_pin(pin: &str, stored: &str) -> bool {
match PasswordHash::new(stored) {
Ok(parsed) => Argon2::default()
.verify_password(pin.as_bytes(), &parsed)
.is_ok(),
Err(e) => {
log::warn!("[Profiles] Stored PIN hash is unreadable: {}", e);
false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn t0() -> DateTime<Utc> {
DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc)
}
/// UT: a correct PIN is accepted and clears any accumulated failures.
#[test]
fn correct_pin_accepts_and_resets() {
let state = PinState {
failed_count: 3,
locked_until: None,
};
let (decision, next) = evaluate(&state, t0(), true);
assert_eq!(decision, PinDecision::Accept);
assert_eq!(next, PinState::fresh());
}
/// UT: wrong guesses count down, and the count is what gets persisted —
/// this is the half that must survive an app restart.
#[test]
fn wrong_pin_counts_down() {
let mut state = PinState::fresh();
for expected in (1..MAX_ATTEMPTS).rev() {
let (decision, next) = evaluate(&state, t0(), false);
assert_eq!(
decision,
PinDecision::Reject {
attempts_remaining: expected
}
);
state = next;
}
assert_eq!(state.failed_count, MAX_ATTEMPTS - 1);
}
/// UT: the attempt that exhausts the allowance locks out rather than
/// reporting zero attempts remaining.
#[test]
fn exhausting_attempts_locks_out() {
let state = PinState {
failed_count: MAX_ATTEMPTS - 1,
locked_until: None,
};
let (decision, next) = evaluate(&state, t0(), false);
match decision {
PinDecision::Locked { until } => {
assert_eq!(until, t0() + Duration::seconds(BASE_LOCKOUT_SECS));
}
other => panic!("expected lockout, got {:?}", other),
}
assert_eq!(next.locked_until, Some(t0() + Duration::seconds(60)));
}
/// UT: a locked profile is refused without the hash being consulted — the
/// correct PIN does not shortcut an active lockout.
#[test]
fn lockout_refuses_even_a_correct_pin() {
let until = t0() + Duration::seconds(60);
let state = PinState {
failed_count: MAX_ATTEMPTS,
locked_until: Some(until),
};
let (decision, next) = evaluate(&state, t0(), true);
assert_eq!(decision, PinDecision::Locked { until });
assert_eq!(next, state, "a refused attempt must not extend the lockout");
}
/// UT: once the window passes the profile accepts again.
#[test]
fn lockout_expires() {
let until = t0() + Duration::seconds(60);
let state = PinState {
failed_count: MAX_ATTEMPTS,
locked_until: Some(until),
};
let (decision, next) = evaluate(&state, until + Duration::seconds(1), true);
assert_eq!(decision, PinDecision::Accept);
assert_eq!(next, PinState::fresh());
}
/// UT: repeated lockouts escalate, but stop at the ceiling so a forgotten
/// PIN never becomes an hours-long wait.
#[test]
fn lockout_escalates_to_a_ceiling() {
let mut seen = Vec::new();
for failed in MAX_ATTEMPTS - 1..MAX_ATTEMPTS + 12 {
let state = PinState {
failed_count: failed,
locked_until: None,
};
if let (PinDecision::Locked { until }, _) = evaluate(&state, t0(), false) {
seen.push((until - t0()).num_seconds());
}
}
assert_eq!(seen[0], BASE_LOCKOUT_SECS);
assert!(seen[1] > seen[0], "second lockout should be longer");
assert_eq!(*seen.last().unwrap(), MAX_LOCKOUT_SECS);
assert!(seen.windows(2).all(|w| w[1] >= w[0]), "must not shrink");
}
/// UT: hashing round-trips, and a wrong PIN does not verify.
#[test]
fn hash_round_trips() {
let hash = hash_pin("1234").unwrap();
assert!(verify_pin("1234", &hash));
assert!(!verify_pin("4321", &hash));
}
/// UT: the stored hash never contains the PIN itself.
#[test]
fn hash_does_not_leak_the_pin() {
let hash = hash_pin("246813").unwrap();
assert!(!hash.contains("246813"));
}
/// UT: an unreadable stored hash fails closed instead of erroring, so a
/// corrupt row sends the user to the password route.
#[test]
fn corrupt_hash_fails_closed() {
assert!(!verify_pin("1234", "not-a-phc-string"));
}
/// UT: PIN shape is enforced in Rust, not in the pad.
#[test]
fn pin_shape_is_validated() {
assert!(validate_pin("1234").is_ok());
assert!(validate_pin("12345678").is_ok());
assert!(validate_pin("123").is_err(), "too short");
assert!(validate_pin("123456789").is_err(), "too long");
assert!(validate_pin("12a4").is_err(), "non-digit");
}
}