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).
291 lines
9.7 KiB
Rust
291 lines
9.7 KiB
Rust
//! 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 4–8 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");
|
||
}
|
||
}
|