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");
}
}