OXYGENHUB

Coverage / crates/hub/src/auth/phone.rs 98.7%

77 of 78 lines run, 15 of 19 functions · run #539 on main · run missed

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.
264×
pub fn public_point(hex_key: &str) -> Option<String> {
274×
    let bytes = hex::decode(hex_key.trim()).ok()?;
284×
    let point = match bytes.len() {
292×
        91 if bytes[..26] == P256_SPKI => &bytes[26..],
301×
        65 => &bytes[..],
311×
        _ => return None,
32
    };
333×
    if point[0] != 0x04 {
340×
        return None;
353×
    }
36
    // ring checks that the point is on the curve when it verifies, so a
37
    // bad key only ever fails to verify.
383×
    Some(hex::encode(point))
394×
}
40
41
/// What the phone signs to answer approval `id`.
4212×
pub fn message(id: &str, number: i64, approve: bool) -> Vec<u8> {
4312×
    format!("oxygenhub approval v1\n{id}\n{number}\n{}", if approve { "approve" } else { "deny" }).into_bytes()
4412×
}
45
46
/// Whether `signature` (DER, as ECDSA gives it) is the phone's over `msg`.
478×
pub fn verify(point_hex: &str, msg: &[u8], signature: &[u8]) -> bool {
488×
    let Ok(point) = hex::decode(point_hex) else { return false };
498×
    UnparsedPublicKey::new(&ECDSA_P256_SHA256_ASN1, point).verify(msg, signature).is_ok()
508×
}
51
52
/// Three different two-digit numbers in a random order, and the one the
53
/// browser shows.
54203×
pub fn numbers() -> (i64, [i64; 3]) {
55203×
    let mut rng = rand::thread_rng();
56203×
    let mut all: Vec<i64> = (10..100).collect();
57203×
    all.shuffle(&mut rng);
58203×
    let choices = [all[0], all[1], all[2]];
59203×
    (choices[rng.gen_range(0..3)], choices)
60203×
}
61
623×
fn new_id() -> String {
633×
    let mut b = [0u8; 16];
643×
    rand::rngs::OsRng.fill_bytes(&mut b);
653×
    hex::encode(b)
663×
}
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.
713×
pub async fn ask(app: &Shared, account: &Account, kind: &str, what: &str, session_id: Option<i64>) -> sqlx::Result<approvals::Approval> {
723×
    let id = new_id();
733×
    let (number, choices) = numbers();
749×
    let choices = choices.iter().map(|n| n.to_string()).collect::<Vec<_>>().join(",");
753×
    let what: String = what.chars().take(200).collect();
763×
    approvals::create(
773×
        &app.db,
783×
        approvals::NewApproval {
793×
            id: &id,
803×
            account_id: account.id,
813×
            kind,
823×
            what: &what,
833×
            number,
843×
            choices: &choices,
853×
            session_id,
863×
            expires_ms: db::now_ms() + APPROVAL_MS,
873×
        },
883×
    )
893×
    .await?;
903×
    crate::push::send(app, account.id, crate::push::Alert { kind: "approval".into(), title: what, url: format!("/approvals/{id}") });
913×
    Ok(approvals::get(&app.db, &id).await?.expect("just made"))
923×
}
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]
1011×
    fn a_phone_signature_verifies_and_nothing_else_does() {
1021×
        let rng = SystemRandom::new();
1031×
        let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_ASN1_SIGNING, &rng).unwrap();
1041×
        let pair = EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_ASN1_SIGNING, pkcs8.as_ref(), &rng).unwrap();
1051×
        let point = hex::encode(pair.public_key().as_ref());
1061×
        let spki = format!("{}{point}", hex::encode(P256_SPKI));
1071×
        assert_eq!(public_point(&spki).as_deref(), Some(point.as_str()), "the Keystore's encoding");
1081×
        assert_eq!(public_point(&point).as_deref(), Some(point.as_str()), "a bare point");
1091×
        assert_eq!(public_point("00ff"), None);
1101×
        let m = message("ab", 47, true);
1111×
        let sig = pair.sign(&rng, &m).unwrap();
1121×
        assert!(verify(&point, &m, sig.as_ref()));
1131×
        assert!(!verify(&point, &message("ab", 48, true), sig.as_ref()), "another number");
1141×
        assert!(!verify(&point, &message("ab", 47, false), sig.as_ref()), "a denial is not an approval");
1151×
        assert!(!verify(&point, &message("ac", 47, true), sig.as_ref()), "another approval");
1161×
    }
117
118
    #[test]
1191×
    fn three_different_numbers_one_shown() {
1201×
        for _ in 0..200 {
121200×
            let (n, c) = numbers();
122200×
            assert!(c.contains(&n));
123200×
            assert!(c[0] != c[1] && c[1] != c[2] && c[0] != c[2]);
124600×
            assert!(c.iter().all(|x| (10..100).contains(x)));
125
        }
1261×
    }
127
}