Skip to main content

sofie_identity/two_factor/
mod.rs

1mod challenge;
2mod code;
3mod qr;
4mod screen;
5
6use std::time::Duration;
7
8use sofie::core::Instant;
9use sofie::db::{Outcome, Plan, Reply, Stamp};
10use sofie::http::Sealing;
11use sofie::prelude::*;
12
13pub(crate) use challenge::{FactorCode, asking, routes as challenge_routes};
14pub use screen::routes;
15
16use crate::AccountId;
17
18const SECRET: &str = "sofie_identity.totp";
19const RECOVERY: &str = "sofie_identity.recovery";
20/// Wrong codes allowed per window before codes are refused until it ends.
21const MAXIMUM_FAILURES: i32 = 5;
22const FAILURE_WINDOW: Duration = Duration::from_mins(15);
23const NOT_RIGHT: &str =
24    "is not the current code of your authenticator app or an unused recovery code";
25const TOO_MANY: &str = "Too many wrong codes; try again in fifteen minutes.";
26
27/// Its key is sealed with the app's keys; a recovery code is kept only as its digest
28/// once the factor is confirmed.
29#[derive(Aggregate)]
30pub struct SecondFactor {
31    meta: Meta,
32    #[owner]
33    account: AccountId,
34    secret: String,
35    /// Until a code proved the app holds the key, signing in asks for nothing.
36    confirmed_at: Option<Instant>,
37    /// A code is accepted once: only for a later step than this one.
38    last_step: i64,
39    /// Digests of the unused recovery codes, separated by spaces.
40    recovery: String,
41    /// The recovery codes themselves, sealed, shown until the factor is confirmed.
42    pending_recovery: Option<String>,
43    failures: i32,
44    failing_since: Option<Instant>,
45}
46
47impl SecondFactor {
48    pub(crate) fn is_confirmed(&self) -> bool {
49        self.confirmed_at.is_some()
50    }
51
52    fn key(&self, sealing: &Sealing) -> Result<Vec<u8>> {
53        sealing.open(SECRET, &self.secret).ok_or_else(|| {
54            Error::internal_message("a second factor's key does not open with the app's keys")
55        })
56    }
57
58    /// Checks `code` and records what it used up or that it failed; the caller commits either.
59    pub(crate) fn prove(
60        &mut self,
61        code: &str,
62        sealing: &Sealing,
63        now: Instant,
64    ) -> Result<Result<()>> {
65        if self.failing(now)? && self.failures >= MAXIMUM_FAILURES {
66            return Ok(Err(Error::Rule(TOO_MANY.to_owned())));
67        }
68        if self.used_recovery(code) {
69            return Ok(Ok(()));
70        }
71        let key = self.key(sealing)?;
72        // Resealed with the current key, so retiring the one that sealed it locks nobody out.
73        self.secret = sealing.seal(SECRET, &key)?;
74        let now_seconds = now.timestamp().as_second();
75        if let Some(step) = code::matching_step(&key, code, now_seconds, self.last_step) {
76            self.last_step = step;
77            self.failures = 0;
78            self.failing_since = None;
79            return Ok(Ok(()));
80        }
81        self.failed(now)?;
82        Ok(Err(Error::invalid("code", NOT_RIGHT)))
83    }
84
85    fn used_recovery(&mut self, code: &str) -> bool {
86        let digest = code::recovery_digest(code);
87        let unused: Vec<&str> = self.recovery.split_whitespace().collect();
88        if !self.is_confirmed() || !unused.contains(&digest.as_str()) {
89            return false;
90        }
91        self.recovery = unused
92            .into_iter()
93            .filter(|kept| *kept != digest)
94            .collect::<Vec<_>>()
95            .join(" ");
96        true
97    }
98
99    fn failing(&self, now: Instant) -> Result<bool> {
100        match self.failing_since {
101            Some(since) => Ok(since.checked_add(FAILURE_WINDOW)? > now),
102            None => Ok(false),
103        }
104    }
105
106    fn failed(&mut self, now: Instant) -> Result<()> {
107        if !self.failing(now)? {
108            self.failing_since = Some(now);
109            self.failures = 0;
110        }
111        self.failures = self.failures.saturating_add(1);
112        Ok(())
113    }
114}
115
116/// The factor is committed even when its code is refused, so the limit on wrong codes holds
117/// across requests and pending sign-ins; `passed` only when the code was right.
118pub(crate) struct Proof<T> {
119    factor: SecondFactor,
120    passed: Result<T>,
121}
122
123impl<T: Outcome> Outcome for Proof<T> {
124    fn plan(&self, plan: &mut Plan<'_>) -> Result<()> {
125        self.factor.plan(plan)?;
126        match &self.passed {
127            Ok(passed) => passed.plan(plan),
128            Err(_) => Ok(()),
129        }
130    }
131
132    fn committed(&mut self, stamp: Stamp) {
133        self.factor.committed(stamp);
134        if let Ok(passed) = &mut self.passed {
135            passed.committed(stamp);
136        }
137    }
138
139    fn reply(self) -> Result<Reply> {
140        self.passed.map(|_| Reply::Empty)
141    }
142}
143
144/// Whether it is on and since when; never its key or recovery codes.
145#[cfg(feature = "account-data")]
146pub(crate) async fn exported(read: &Read, account: AccountId) -> Result<sofie::serde_json::Value> {
147    Ok(of(read, account)
148        .await?
149        .map_or(sofie::serde_json::Value::Null, |factor| {
150            sofie::serde_json::json!({
151                "on": factor.is_confirmed(),
152                "confirmed_at": factor.confirmed_at,
153            })
154        }))
155}
156
157pub(crate) async fn of(read: &Read, account: AccountId) -> Result<Option<SecondFactor>> {
158    SecondFactor::query()
159        .where_(SecondFactor::ACCOUNT.eq(account))
160        .first(read)
161        .await
162}
163
164pub(crate) async fn required(read: &Read, account: AccountId) -> Result<Option<SecondFactor>> {
165    Ok(of(read, account).await?.filter(SecondFactor::is_confirmed))
166}
167
168pub(crate) async fn removing(read: &Read, account: AccountId) -> Result<Vec<SecondFactor>> {
169    let mut factors: Vec<_> = of(read, account).await?.into_iter().collect();
170    for factor in &mut factors {
171        factor.secret = String::new();
172        factor.confirmed_at = None;
173        factor.recovery = String::new();
174        factor.pending_recovery = None;
175    }
176    Ok(factors)
177}