Files
jellytau/src-tauri/src/profiles/pin.rs
T
dtourolle da762da55d 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).
2026-08-30 19:03:59 +02:00

291 lines
9.7 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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");
}
}