OXYGENHUB
◆ main oxygenhub / crates / hub / src / auth / phone.rs
5.1 KiB · Rust · last changed A phone as the second factor: Keystore keys approve sign-ins by the number the browser shows, and the sudo step asks a stale session again before sensitive changes (closes #356) 3 days ago source blame history raw
1
//! The phone as a second factor (oxygenhub#356): a P-256 key made in the
2
//! phone's Keystore, usable only after a fingerprint, face or the device
3
//! PIN, signs each approval; the browser shows one number and the phone
4
//! offers three, so a tap without reading approves nothing.
5
6
use crate::db::{self, accounts::Account, approvals};
7
use crate::Shared;
8
use rand::{seq::SliceRandom, Rng, RngCore};
9
use ring::signature::{UnparsedPublicKey, ECDSA_P256_SHA256_ASN1};
10
11
/// How long a phone has to answer.
12
pub const APPROVAL_MS: i64 = 2 * 60_000;
13
/// How long a proved second factor lets a session past the sudo step.
14
pub const FRESH_MS: i64 = 15 * 60_000;
15
16
/// The DER header of a P-256 SubjectPublicKeyInfo (what Android's
17
/// `PublicKey.getEncoded()` gives), before the 65-byte point.
18
const P256_SPKI: [u8; 26] = [
19
    0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01,
20
    0x07, 0x03, 0x42, 0x00,
21
];
22
23
/// A phone's public key, given as hex of its SubjectPublicKeyInfo or of
24
/// the bare uncompressed point: the point, as hex. None when it is
25
/// neither, or not on the curve.
26
pub fn public_point(hex_key: &str) -> Option<String> {
27
    let bytes = hex::decode(hex_key.trim()).ok()?;
28
    let point = match bytes.len() {
29
        91 if bytes[..26] == P256_SPKI => &bytes[26..],
30
        65 => &bytes[..],
31
        _ => return None,
32
    };
33
    if point[0] != 0x04 {
34
        return None;
35
    }
36
    // ring checks that the point is on the curve when it verifies, so a
37
    // bad key only ever fails to verify.
38
    Some(hex::encode(point))
39
}
40
41
/// What the phone signs to answer approval `id`.
42
pub fn message(id: &str, number: i64, approve: bool) -> Vec<u8> {
43
    format!("oxygenhub approval v1\n{id}\n{number}\n{}", if approve { "approve" } else { "deny" }).into_bytes()
44
}
45
46
/// Whether `signature` (DER, as ECDSA gives it) is the phone's over `msg`.
47
pub fn verify(point_hex: &str, msg: &[u8], signature: &[u8]) -> bool {
48
    let Ok(point) = hex::decode(point_hex) else { return false };
49
    UnparsedPublicKey::new(&ECDSA_P256_SHA256_ASN1, point).verify(msg, signature).is_ok()
50
}
51
52
/// Three different two-digit numbers in a random order, and the one the
53
/// browser shows.
54
pub fn numbers() -> (i64, [i64; 3]) {
55
    let mut rng = rand::thread_rng();
56
    let mut all: Vec<i64> = (10..100).collect();
57
    all.shuffle(&mut rng);
58
    let choices = [all[0], all[1], all[2]];
59
    (choices[rng.gen_range(0..3)], choices)
60
}
61
62
fn new_id() -> String {
63
    let mut b = [0u8; 16];
64
    rand::rngs::OsRng.fill_bytes(&mut b);
65
    hex::encode(b)
66
}
67
68
/// Ask the account's phones to approve `what`: recorded, and sent as an
69
/// alert where alerts are on (the app also looks when it opens). For a
70
/// step-up, `session_id` is the browser session it makes fresh.
71
pub async fn ask(app: &Shared, account: &Account, kind: &str, what: &str, session_id: Option<i64>) -> sqlx::Result<approvals::Approval> {
72
    let id = new_id();
73
    let (number, choices) = numbers();
74
    let choices = choices.iter().map(|n| n.to_string()).collect::<Vec<_>>().join(",");
75
    let what: String = what.chars().take(200).collect();
76
    approvals::create(
77
        &app.db,
78
        approvals::NewApproval {
79
            id: &id,
80
            account_id: account.id,
81
            kind,
82
            what: &what,
83
            number,
84
            choices: &choices,
85
            session_id,
86
            expires_ms: db::now_ms() + APPROVAL_MS,
87
        },
88
    )
89
    .await?;
90
    crate::push::send(app, account.id, crate::push::Alert { kind: "approval".into(), title: what, url: format!("/approvals/{id}") });
91
    Ok(approvals::get(&app.db, &id).await?.expect("just made"))
92
}
93
94
#[cfg(test)]
95
mod tests {
96
    use super::*;
97
    use ring::rand::SystemRandom;
98
    use ring::signature::{EcdsaKeyPair, KeyPair, ECDSA_P256_SHA256_ASN1_SIGNING};
99
100
    #[test]
101
    fn a_phone_signature_verifies_and_nothing_else_does() {
102
        let rng = SystemRandom::new();
103
        let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_ASN1_SIGNING, &rng).unwrap();
104
        let pair = EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_ASN1_SIGNING, pkcs8.as_ref(), &rng).unwrap();
105
        let point = hex::encode(pair.public_key().as_ref());
106
        let spki = format!("{}{point}", hex::encode(P256_SPKI));
107
        assert_eq!(public_point(&spki).as_deref(), Some(point.as_str()), "the Keystore's encoding");
108
        assert_eq!(public_point(&point).as_deref(), Some(point.as_str()), "a bare point");
109
        assert_eq!(public_point("00ff"), None);
110
        let m = message("ab", 47, true);
111
        let sig = pair.sign(&rng, &m).unwrap();
112
        assert!(verify(&point, &m, sig.as_ref()));
113
        assert!(!verify(&point, &message("ab", 48, true), sig.as_ref()), "another number");
114
        assert!(!verify(&point, &message("ab", 47, false), sig.as_ref()), "a denial is not an approval");
115
        assert!(!verify(&point, &message("ac", 47, true), sig.as_ref()), "another approval");
116
    }
117
118
    #[test]
119
    fn three_different_numbers_one_shown() {
120
        for _ in 0..200 {
121
            let (n, c) = numbers();
122
            assert!(c.contains(&n));
123
            assert!(c[0] != c[1] && c[1] != c[2] && c[0] != c[2]);
124
            assert!(c.iter().all(|x| (10..100).contains(x)));
125
        }
126
    }
127
}