use serde::Serialize;
use crate::signed::{Envelope, canonical_bytes};
use crate::signing_role::ApprovalSigner;
pub const STEP_UP_DOMAIN: &str = "polychrome.wallet-session.step-up.v1";
pub const STEP_UP_MAX_AGE_MS: u64 = 5 * 60 * 1_000;
pub const STEP_UP_CLOCK_SKEW_MS: u64 = 30 * 1_000;
#[derive(Debug, Clone, Copy)]
pub struct StepUpClaim<'a> {
pub account_id: &'a str,
pub sign_in_id: &'a str,
pub action: &'a str,
pub issued_at_ms: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum StepUpRefusal {
#[error("the step-up signature does not verify")]
BadSignature,
#[error("the step-up assertion is too old")]
Expired,
#[error("the step-up assertion is dated in the future")]
FromTheFuture,
}
#[derive(Serialize)]
struct StepUpCanonical<'a> {
domain: &'a str,
account_id: &'a str,
sign_in_id: &'a str,
action: &'a str,
issued_at_ms: u64,
}
#[must_use]
pub fn step_up_message(claim: &StepUpClaim<'_>) -> Vec<u8> {
canonical_bytes(&StepUpCanonical {
domain: STEP_UP_DOMAIN,
account_id: claim.account_id,
sign_in_id: claim.sign_in_id,
action: claim.action,
issued_at_ms: claim.issued_at_ms,
})
}
#[must_use]
pub fn sign_step_up(signer: &crate::Signer, claim: &StepUpClaim<'_>) -> Vec<u8> {
signer.sign(&step_up_message(claim))
}
pub fn verify_step_up(
public_key: &[u8],
claim: &StepUpClaim<'_>,
signature: &[u8],
now_ms: u64,
) -> Result<(), StepUpRefusal> {
if !crate::verify(public_key, &step_up_message(claim), signature) {
return Err(StepUpRefusal::BadSignature);
}
if claim.issued_at_ms > now_ms.saturating_add(STEP_UP_CLOCK_SKEW_MS) {
return Err(StepUpRefusal::FromTheFuture);
}
if now_ms.saturating_sub(claim.issued_at_ms) > STEP_UP_MAX_AGE_MS {
return Err(StepUpRefusal::Expired);
}
Ok(())
}
#[derive(Debug, Clone, Copy)]
pub struct WalletSessionEventPayload<'a> {
pub kind: &'a str,
pub action: &'a str,
pub outcome: &'a str,
pub subject: &'a str,
pub caller: &'a str,
pub account_id: &'a str,
pub sign_in_id: &'a str,
pub timestamp: &'a str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedWalletSessionEvent {
pub kind: String,
pub action: String,
pub outcome: String,
pub subject: String,
pub caller: String,
pub account_id: String,
pub sign_in_id: String,
pub timestamp: String,
pub signer_public_key: Vec<u8>,
}
#[derive(Serialize)]
struct EventCanonical<'a> {
kind: &'a str,
action: &'a str,
outcome: &'a str,
subject: &'a str,
caller: &'a str,
account_id: &'a str,
sign_in_id: &'a str,
timestamp: &'a str,
}
impl<'a> From<&WalletSessionEventPayload<'a>> for EventCanonical<'a> {
fn from(fields: &WalletSessionEventPayload<'a>) -> Self {
Self {
kind: fields.kind,
action: fields.action,
outcome: fields.outcome,
subject: fields.subject,
caller: fields.caller,
account_id: fields.account_id,
sign_in_id: fields.sign_in_id,
timestamp: fields.timestamp,
}
}
}
#[must_use]
pub fn wallet_session_event_payload(
fields: &WalletSessionEventPayload<'_>,
signer: &ApprovalSigner,
) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
Envelope::seal(EventCanonical::from(fields), signer.as_signer())
}
#[must_use]
pub fn verify_signed_wallet_session_event(
payload: &[u8],
trusted_signers: &[Vec<u8>],
) -> Option<VerifiedWalletSessionEvent> {
let value: serde_json::Value = serde_json::from_slice(payload).ok()?;
let text = |key: &str| value.get(key)?.as_str().map(str::to_owned);
let kind = text("kind")?;
let action = text("action")?;
let outcome = text("outcome")?;
let subject = text("subject")?;
let caller = text("caller")?;
let account_id = text("account_id")?;
let sign_in_id = text("sign_in_id")?;
let timestamp = text("timestamp")?;
let public_key = crate::hex::decode(value.get("signed_by")?.as_str()?)?;
let signature = crate::hex::decode(value.get("signature_hex")?.as_str()?)?;
if !trusted_signers
.iter()
.any(|key| key.as_slice() == public_key.as_slice())
{
return None;
}
let canonical = canonical_bytes(&EventCanonical {
kind: &kind,
action: &action,
outcome: &outcome,
subject: &subject,
caller: &caller,
account_id: &account_id,
sign_in_id: &sign_in_id,
timestamp: ×tamp,
});
crate::verify(&public_key, &canonical, &signature).then_some(VerifiedWalletSessionEvent {
kind,
action,
outcome,
subject,
caller,
account_id,
sign_in_id,
timestamp,
signer_public_key: public_key,
})
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
use crate::Signer;
const NOW: u64 = 1_800_000_000_000;
fn claim(issued_at_ms: u64) -> StepUpClaim<'static> {
StepUpClaim {
account_id: "acct-1",
sign_in_id: "sign-in-1",
action: "retire",
issued_at_ms,
}
}
#[test]
fn step_up_message_is_the_frozen_canonical() {
assert_eq!(
String::from_utf8(step_up_message(&claim(NOW))).unwrap(),
r#"{"domain":"polychrome.wallet-session.step-up.v1","account_id":"acct-1","sign_in_id":"sign-in-1","action":"retire","issued_at_ms":1800000000000}"#
);
}
#[test]
fn a_fresh_assertion_verifies() {
let signer = Signer::from_seed(7);
let signature = sign_step_up(&signer, &claim(NOW));
assert_eq!(
verify_step_up(&signer.public_key_bytes(), &claim(NOW), &signature, NOW),
Ok(())
);
}
#[test]
fn an_assertion_exactly_five_minutes_old_verifies() {
let signer = Signer::from_seed(7);
let issued = NOW - STEP_UP_MAX_AGE_MS;
let signature = sign_step_up(&signer, &claim(issued));
assert_eq!(
verify_step_up(&signer.public_key_bytes(), &claim(issued), &signature, NOW),
Ok(())
);
}
#[test]
fn an_assertion_older_than_five_minutes_is_expired() {
let signer = Signer::from_seed(7);
let issued = NOW - STEP_UP_MAX_AGE_MS - 1;
let signature = sign_step_up(&signer, &claim(issued));
assert_eq!(
verify_step_up(&signer.public_key_bytes(), &claim(issued), &signature, NOW),
Err(StepUpRefusal::Expired)
);
}
#[test]
fn an_assertion_dated_beyond_the_skew_is_refused() {
let signer = Signer::from_seed(7);
let issued = NOW + STEP_UP_CLOCK_SKEW_MS + 1;
let signature = sign_step_up(&signer, &claim(issued));
assert_eq!(
verify_step_up(&signer.public_key_bytes(), &claim(issued), &signature, NOW),
Err(StepUpRefusal::FromTheFuture)
);
}
#[test]
fn an_assertion_dated_inside_the_skew_verifies() {
let signer = Signer::from_seed(7);
let issued = NOW + STEP_UP_CLOCK_SKEW_MS;
let signature = sign_step_up(&signer, &claim(issued));
assert_eq!(
verify_step_up(&signer.public_key_bytes(), &claim(issued), &signature, NOW),
Ok(())
);
}
#[test]
fn each_bound_field_changes_the_verdict() {
let signer = Signer::from_seed(7);
let signature = sign_step_up(&signer, &claim(NOW));
let pk = signer.public_key_bytes();
for tampered in [
StepUpClaim {
account_id: "acct-2",
..claim(NOW)
},
StepUpClaim {
sign_in_id: "sign-in-2",
..claim(NOW)
},
StepUpClaim {
action: "revoke",
..claim(NOW)
},
claim(NOW - 1),
] {
assert_eq!(
verify_step_up(&pk, &tampered, &signature, NOW),
Err(StepUpRefusal::BadSignature),
"{tampered:?}"
);
}
}
#[test]
fn another_key_and_a_garbage_signature_are_refused() {
let signer = Signer::from_seed(7);
let other = Signer::from_seed(8);
let signature = sign_step_up(&signer, &claim(NOW));
assert_eq!(
verify_step_up(&other.public_key_bytes(), &claim(NOW), &signature, NOW),
Err(StepUpRefusal::BadSignature)
);
assert_eq!(
verify_step_up(&signer.public_key_bytes(), &claim(NOW), b"garbage", NOW),
Err(StepUpRefusal::BadSignature)
);
assert_eq!(
verify_step_up(b"not a key", &claim(NOW), &signature, NOW),
Err(StepUpRefusal::BadSignature)
);
}
fn event<'a>() -> WalletSessionEventPayload<'a> {
WalletSessionEventPayload {
kind: "wallet_session_event",
action: "link",
outcome: "minted",
subject: "persona-1",
caller: "uno-api",
account_id: "acct-1",
sign_in_id: "sign-in-1",
timestamp: "1800000000",
}
}
#[test]
fn a_signed_event_round_trips_and_carries_every_field() {
let signer = ApprovalSigner::from_seed(9);
let (payload, _signature, public_key) = wallet_session_event_payload(&event(), &signer);
let verified =
verify_signed_wallet_session_event(&payload, std::slice::from_ref(&public_key))
.expect("a payload this signer made verifies");
assert_eq!(
verified,
VerifiedWalletSessionEvent {
kind: "wallet_session_event".to_owned(),
action: "link".to_owned(),
outcome: "minted".to_owned(),
subject: "persona-1".to_owned(),
caller: "uno-api".to_owned(),
account_id: "acct-1".to_owned(),
sign_in_id: "sign-in-1".to_owned(),
timestamp: "1800000000".to_owned(),
signer_public_key: public_key,
}
);
}
#[test]
fn a_tampered_or_untrusted_event_does_not_verify() {
let signer = ApprovalSigner::from_seed(9);
let (payload, _signature, public_key) = wallet_session_event_payload(&event(), &signer);
let tampered = String::from_utf8(payload.clone())
.unwrap()
.replace("minted", "retired")
.into_bytes();
assert!(
verify_signed_wallet_session_event(&tampered, std::slice::from_ref(&public_key))
.is_none()
);
assert!(verify_signed_wallet_session_event(&payload, &[vec![0; 32]]).is_none());
assert!(verify_signed_wallet_session_event(b"{}", &[public_key]).is_none());
}
#[test]
fn the_event_canonical_is_frozen() {
let canonical = canonical_bytes(&EventCanonical::from(&event()));
assert_eq!(
String::from_utf8(canonical).unwrap(),
r#"{"kind":"wallet_session_event","action":"link","outcome":"minted","subject":"persona-1","caller":"uno-api","account_id":"acct-1","sign_in_id":"sign-in-1","timestamp":"1800000000"}"#
);
}
}