Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/nornir-identity-session/src/step_up.rs

//! **Step-up**: the window a user-verified touch stays fresh, who decides
//! whether a ceremony must verify the user, and the verification of a person's
//! OWN passkey over a challenge the server issued (A34 M-5).
//!
//! Gunnar had this twice (`Enterprise`'s approval and `Access`'s machine
//! approval, A34-21) and Holger once; the rule is here now. The user-verification
//! decision is the SERVER's (A34-12): a relay may raise it, never lower it.

use nornir_auth_webauthn::ceremony::{self, Challenges, RelyingParty};

/// How recently a session must have been minted from a USER-VERIFIED assertion
/// (a PIN or a biometric, not a touch) for a sensitive act to go ahead.
pub const STEP_UP_WINDOW_SECS: i64 = 10 * 60;

/// The machine-readable word for "a fresh touch is needed": a console answers it
/// in place with a step-up ceremony, then repeats the refused call.
pub const STEP_UP_CODE: &str = "stepup";

/// **Is a session minted at `auth_time` (user-verified or not) fresh enough for
/// a sensitive act at `now`?**
pub fn stepped_up(user_verified: bool, auth_time: i64, now: i64) -> bool {
    user_verified && now - auth_time <= STEP_UP_WINDOW_SECS && auth_time <= now
}

/// **Whether a ceremony must verify the user**, as the server's operator set it.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum UserVerification {
    /// A touch is enough unless the relay asks for more (the browsers' own
    /// `preferred`).
    #[default]
    Preferred,
    /// Every ceremony must verify the user, whatever the relay asks.
    Required,
}

/// **The user-verification demand of one ceremony** (A34-12): the server's
/// policy, raised (never lowered) by what the relay asks.
pub fn user_verification(policy: UserVerification, relay_asks: bool) -> bool {
    policy == UserVerification::Required || relay_asks
}

/// What a step-up presents: the browser's answer, relayed byte for byte.
#[derive(Debug, Clone, Copy)]
pub struct Presented<'a> {
    /// The credential the browser answered with (its rawId).
    pub credential_id: &'a [u8],
    /// `clientDataJSON`.
    pub client_data_json: &'a [u8],
    /// `authenticatorData`.
    pub authenticator_data: &'a [u8],
    /// ES256, ASN.1 DER.
    pub signature: &'a [u8],
    /// The relying party the relay states.
    pub rp_id: &'a str,
    /// The origin the relay states.
    pub origin: &'a str,
}

/// A stored passkey, as the product's store answers for a credential id.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Stored {
    /// Whose passkey it is.
    pub principal_id: String,
    /// P-256, SEC1 uncompressed.
    pub public_key: Vec<u8>,
    /// The counter as of its last use.
    pub sign_count: u32,
}

/// Why a step-up was refused.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Refused {
    /// The request itself is malformed (the product answers "invalid argument").
    Malformed(String),
    /// The ceremony is not a genuine, fresh, user-verified step-up of the
    /// caller's own passkey (the product answers "permission denied").
    Ceremony(String),
}

impl std::fmt::Display for Refused {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Refused::Malformed(why) | Refused::Ceremony(why) => f.write_str(why),
        }
    }
}

impl std::error::Error for Refused {}

/// A step-up that passed: the passkey, and its new counter to store back.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SteppedUp {
    /// The passkey that stepped up.
    pub credential_id: Vec<u8>,
    /// Its counter now.
    pub sign_count: u32,
}

/// **Verify a fresh, user-verified step-up of `caller`'s OWN passkey**, over a
/// challenge `challenges` issued: the challenge is spent FIRST, whatever the
/// rest says (single use; one that failed a check is not one to try again);
/// then the ceremony type and origin, the relying parties the server answers
/// for, the passkey (`lookup`, the product's store; its error passes through as
/// `Err(E)`), that it is the caller's, and the assertion with user verification
/// required.
pub fn verify_own_passkey<E>(
    up: &Presented<'_>,
    caller: &str,
    challenges: &Challenges,
    parties: &[RelyingParty],
    now_ms: i64,
    lookup: impl FnOnce(&[u8]) -> Result<Option<Stored>, E>,
) -> Result<Result<SteppedUp, Refused>, E> {
    if let Err(why) = ceremony::check_credential_id(up.credential_id) {
        return Ok(Err(Refused::Malformed(why)));
    }
    if up.rp_id.trim().is_empty() || up.origin.trim().is_empty() {
        return Ok(Err(Refused::Malformed("the step-up's `rp_id` and `origin` are both required".to_owned())));
    }
    let data = match ceremony::client_data(up.client_data_json) {
        Ok(data) => data,
        Err(why) => return Ok(Err(Refused::Ceremony(why))),
    };
    let fresh = challenges.spend(&data.challenge, now_ms);
    if let Err(why) = ceremony::check_ceremony(&data, "webauthn.get", up.origin, up.rp_id)
        .and_then(|()| ceremony::check_relying_party(parties, up.rp_id, up.origin))
    {
        return Ok(Err(Refused::Ceremony(why)));
    }
    if !fresh {
        return Ok(Err(Refused::Ceremony(
            "the step-up's challenge was not issued here, was already used, or expired".to_owned(),
        )));
    }
    let Some(stored) = lookup(up.credential_id)? else {
        return Ok(Err(Refused::Ceremony("the step-up's passkey is not registered here".to_owned())));
    };
    if stored.principal_id != caller {
        return Ok(Err(Refused::Ceremony(format!(
            "the step-up is a passkey of {}, and the approver is {caller}: a step-up is the approver's own",
            stored.principal_id
        ))));
    }
    Ok(ceremony::verify_assertion(
        &stored.public_key,
        stored.sign_count,
        up.client_data_json,
        up.authenticator_data,
        up.signature,
        up.rp_id.trim(),
        true,
    )
    .map(|v| SteppedUp { credential_id: up.credential_id.to_vec(), sign_count: v.sign_count })
    .map_err(Refused::Ceremony))
}

#[cfg(test)]
mod tests {
    use super::*;
    use p256::ecdsa::signature::Signer as _;
    use sha2::Digest as _;

    const RP: &str = "gunnar.rs";
    const ORIGIN: &str = "https://gunnar.rs";

    fn signer(seed: u8) -> p256::ecdsa::SigningKey {
        p256::ecdsa::SigningKey::from_slice(&[seed; 32]).expect("a scalar")
    }

    fn public(seed: u8) -> Vec<u8> {
        signer(seed).verifying_key().to_sec1_point(false).as_bytes().to_vec()
    }

    struct Answer {
        cdj: Vec<u8>,
        ad: Vec<u8>,
        sig: Vec<u8>,
    }

    fn answer(challenge: &[u8], seed: u8, flags: u8, count: u32) -> Answer {
        let cdj = format!(
            r#"{{"type":"webauthn.get","challenge":"{}","origin":"{ORIGIN}"}}"#,
            ceremony::b64url(challenge)
        )
        .into_bytes();
        let mut ad = sha2::Sha256::digest(RP.as_bytes()).to_vec();
        ad.push(flags);
        ad.extend_from_slice(&count.to_be_bytes());
        let mut message = ad.clone();
        message.extend_from_slice(&sha2::Sha256::digest(&cdj));
        let sig: p256::ecdsa::Signature = signer(seed).sign(&message);
        Answer { cdj, ad, sig: sig.to_der().as_bytes().to_vec() }
    }

    fn presented<'a>(a: &'a Answer, id: &'a [u8]) -> Presented<'a> {
        Presented {
            credential_id: id,
            client_data_json: &a.cdj,
            authenticator_data: &a.ad,
            signature: &a.sig,
            rp_id: RP,
            origin: ORIGIN,
        }
    }

    fn kims(_: &[u8]) -> Result<Option<Stored>, ()> {
        Ok(Some(Stored { principal_id: "kim".into(), public_key: public(1), sign_count: 3 }))
    }

    const UP: u8 = 0x01;
    const UV: u8 = 0x04;

    #[test]
    fn a_fresh_user_verified_touch_of_ones_own_passkey_steps_up_once() {
        let c = Challenges::default();
        let (challenge, _) = c.issue(None, 0).unwrap();
        let a = answer(&challenge, 1, UP | UV, 4);
        let ok = verify_own_passkey(&presented(&a, b"kim-key"), "kim", &c, &[], 1, kims).unwrap();
        assert_eq!(ok, Ok(SteppedUp { credential_id: b"kim-key".to_vec(), sign_count: 4 }));
        let again = verify_own_passkey(&presented(&a, b"kim-key"), "kim", &c, &[], 2, kims).unwrap();
        assert!(matches!(again, Err(Refused::Ceremony(ref why)) if why.contains("already used")), "{again:?}");
    }

    #[test]
    fn a_touch_without_verification_another_persons_key_or_an_unknown_one_is_refused() {
        let c = Challenges::default();
        let refused = |a: &Answer, caller: &str, lookup: fn(&[u8]) -> Result<Option<Stored>, ()>| {
            verify_own_passkey(&presented(a, b"kim-key"), caller, &c, &[], 1, lookup).unwrap()
        };
        let (ch, _) = c.issue(None, 0).unwrap();
        assert!(matches!(refused(&answer(&ch, 1, UP, 4), "kim", kims), Err(Refused::Ceremony(_))), "no UV");
        let (ch, _) = c.issue(None, 0).unwrap();
        let r = refused(&answer(&ch, 1, UP | UV, 4), "bo", kims);
        assert!(matches!(r, Err(Refused::Ceremony(ref why)) if why.contains("approver's own")), "{r:?}");
        let (ch, _) = c.issue(None, 0).unwrap();
        assert!(matches!(refused(&answer(&ch, 1, UP | UV, 4), "kim", |_| Ok(None)), Err(Refused::Ceremony(_))));
        let (ch, _) = c.issue(None, 0).unwrap();
        assert!(matches!(refused(&answer(&ch, 2, UP | UV, 4), "kim", kims), Err(Refused::Ceremony(_))), "another key signed");
        assert!(matches!(refused(&answer(&[7; 32], 1, UP | UV, 4), "kim", kims), Err(Refused::Ceremony(_))), "never issued");
        let a = answer(&[7; 32], 1, UP | UV, 4);
        let no_id = verify_own_passkey(&presented(&a, b""), "kim", &c, &[], 1, kims).unwrap();
        assert!(matches!(no_id, Err(Refused::Malformed(_))));
        let parties: Vec<RelyingParty> = vec!["other.example=https://other.example".parse().unwrap()];
        let (ch, _) = c.issue(None, 0).unwrap();
        let a = answer(&ch, 1, UP | UV, 4);
        assert!(matches!(verify_own_passkey(&presented(&a, b"kim-key"), "kim", &c, &parties, 1, kims).unwrap(), Err(Refused::Ceremony(_))));
        let (ch, _) = c.issue(None, 0).unwrap();
        let a = answer(&ch, 1, UP | UV, 4);
        let err = verify_own_passkey(&presented(&a, b"kim-key"), "kim", &c, &[], 1, |_| Err::<Option<Stored>, &str>("store down"));
        assert_eq!(err, Err("store down"), "the store's error is the product's");
    }

    #[test]
    fn the_server_decides_user_verification_and_a_relay_may_only_raise_it() {
        assert!(!user_verification(UserVerification::Preferred, false));
        assert!(user_verification(UserVerification::Preferred, true));
        assert!(user_verification(UserVerification::Required, false), "A34-12: the relay cannot lower it");
        assert_eq!(UserVerification::default(), UserVerification::Preferred);
    }

    #[test]
    fn a_step_up_is_fresh_for_ten_minutes_and_only_when_verified() {
        assert!(stepped_up(true, 1_000, 1_000 + STEP_UP_WINDOW_SECS));
        assert!(!stepped_up(true, 1_000, 1_001 + STEP_UP_WINDOW_SECS));
        assert!(!stepped_up(false, 1_000, 1_000));
        assert!(!stepped_up(true, 2_000, 1_000), "minted in the future");
    }
}