use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use crate::signing_role::{
RoleTrustSet, SigningRole as _, WebSessionGrantRole, WebSessionGrantSigner,
};
const GRANT_DOMAIN_PREFIX: &[u8] = b"polychrome.web-session-grant.v1\0";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GrantClaims {
pub issuer: String,
pub key_id: String,
pub family_id: String,
pub generation: u64,
}
#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct WirePayload {
issuer: String,
key_id: String,
family_id: String,
generation: u64,
}
fn grant_parts(payload: &WirePayload) -> (String, Vec<u8>) {
let body = serde_json::to_string(payload).expect("WirePayload serializes (a String and a u64)");
let mut signed = Vec::with_capacity(GRANT_DOMAIN_PREFIX.len() + body.len());
signed.extend_from_slice(GRANT_DOMAIN_PREFIX);
signed.extend_from_slice(body.as_bytes());
(body, signed)
}
#[must_use]
pub fn mint_grant(signer: &WebSessionGrantSigner, family_id: &str, generation: u64) -> String {
let payload = WirePayload {
issuer: WebSessionGrantRole::ISSUER.to_owned(),
key_id: signer.identity().key_id().to_owned(),
family_id: family_id.to_owned(),
generation,
};
let (payload_json, signed_bytes) = grant_parts(&payload);
let signature = signer.sign(&signed_bytes);
format!(
"{}.{}",
URL_SAFE_NO_PAD.encode(payload_json.as_bytes()),
URL_SAFE_NO_PAD.encode(signature)
)
}
#[must_use]
pub fn verify_grant(signer_public_key: &[u8], grant: &str) -> Option<GrantClaims> {
let trusted_signers =
RoleTrustSet::<WebSessionGrantRole>::from_public_keys(vec![signer_public_key.to_vec()])
.ok()?;
verify_grant_with_trust(&trusted_signers, grant)
}
#[must_use]
pub fn verify_grant_with_trust(
trusted_signers: &RoleTrustSet<WebSessionGrantRole>,
grant: &str,
) -> Option<GrantClaims> {
let (payload_b64, signature_b64) = grant.split_once('.')?;
if payload_b64.is_empty() || signature_b64.is_empty() || signature_b64.contains('.') {
return None;
}
let payload_bytes = URL_SAFE_NO_PAD.decode(payload_b64).ok()?;
let signature = URL_SAFE_NO_PAD.decode(signature_b64).ok()?;
let payload: WirePayload = serde_json::from_slice(&payload_bytes).ok()?;
let (_, signed_bytes) = grant_parts(&payload);
if payload.issuer != WebSessionGrantRole::ISSUER
|| !trusted_signers.verify(&payload.key_id, &signed_bytes, &signature)
{
return None;
}
Some(GrantClaims {
issuer: payload.issuer,
key_id: payload.key_id,
family_id: payload.family_id,
generation: payload.generation,
})
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
fn test_signer() -> WebSessionGrantSigner {
WebSessionGrantSigner::from_key_bytes(&[7u8; 32])
.expect("valid 32-byte ed25519 key material")
}
#[test]
fn round_trips() {
let signer = test_signer();
let grant = mint_grant(&signer, "family-1", 3);
let claims = verify_grant(&signer.public_key_bytes(), &grant)
.expect("a freshly minted grant verifies");
assert_eq!(claims.family_id, "family-1");
assert_eq!(claims.generation, 3);
}
#[test]
fn minting_the_same_pair_twice_is_byte_identical() {
let signer = test_signer();
let first = mint_grant(&signer, "family-1", 3);
let second = mint_grant(&signer, "family-1", 3);
assert_eq!(first, second);
}
#[test]
fn tampered_generation_is_rejected() {
let signer = test_signer();
let grant = mint_grant(&signer, "family-1", 3);
let (payload_b64, sig_b64) = grant.split_once('.').expect("shape");
let payload_bytes = URL_SAFE_NO_PAD.decode(payload_b64).expect("decodes");
let mut payload: WirePayload =
serde_json::from_slice(&payload_bytes).expect("valid payload");
payload.generation = 4;
let tampered_bytes = serde_json::to_string(&payload).expect("serializes");
let tampered = format!(
"{}.{sig_b64}",
URL_SAFE_NO_PAD.encode(tampered_bytes.as_bytes())
);
assert!(
verify_grant(&signer.public_key_bytes(), &tampered).is_none(),
"a grant whose generation was changed after signing must not verify"
);
}
#[test]
fn tampered_family_id_is_rejected() {
let signer = test_signer();
let grant = mint_grant(&signer, "family-1", 3);
let (payload_b64, sig_b64) = grant.split_once('.').expect("shape");
let payload_bytes = URL_SAFE_NO_PAD.decode(payload_b64).expect("decodes");
let mut payload: WirePayload =
serde_json::from_slice(&payload_bytes).expect("valid payload");
payload.family_id = "family-2".to_owned();
let tampered_bytes = serde_json::to_string(&payload).expect("serializes");
let tampered = format!(
"{}.{sig_b64}",
URL_SAFE_NO_PAD.encode(tampered_bytes.as_bytes())
);
assert!(
verify_grant(&signer.public_key_bytes(), &tampered).is_none(),
"a grant whose family_id was changed after signing must not verify"
);
}
#[test]
fn valid_signatures_with_unknown_role_identity_fail_closed() {
let signer = test_signer();
let original = WirePayload {
issuer: WebSessionGrantRole::ISSUER.to_owned(),
key_id: signer.identity().key_id().to_owned(),
family_id: "family-1".to_owned(),
generation: 3,
};
for payload in [
WirePayload {
issuer: "polychrome.control.session".to_owned(),
..original.clone()
},
WirePayload {
key_id: "unknown-key-id".to_owned(),
..original.clone()
},
] {
let (body, canonical) = grant_parts(&payload);
let forged = format!(
"{}.{}",
URL_SAFE_NO_PAD.encode(body),
URL_SAFE_NO_PAD.encode(signer.sign(&canonical))
);
assert!(verify_grant(&signer.public_key_bytes(), &forged).is_none());
}
}
#[test]
fn a_grant_minted_before_rotation_verifies_only_with_historical_trust() {
let retired = WebSessionGrantSigner::from_seed(91);
let current = WebSessionGrantSigner::from_seed(92);
let grant = mint_grant(&retired, "family-before-rotation", 4);
let current_only = RoleTrustSet::<WebSessionGrantRole>::current(¤t);
let historical = RoleTrustSet::<WebSessionGrantRole>::checked(vec![
current.identity(),
retired.identity(),
])
.expect("valid rotation history");
assert!(verify_grant_with_trust(¤t_only, &grant).is_none());
assert!(verify_grant_with_trust(&historical, &grant).is_some());
}
#[test]
fn garbage_input_returns_none() {
let signer = test_signer();
assert!(verify_grant(&signer.public_key_bytes(), "").is_none());
assert!(verify_grant(&signer.public_key_bytes(), "not-a-grant-at-all").is_none());
assert!(verify_grant(&signer.public_key_bytes(), "a.b.c").is_none());
assert!(verify_grant(&signer.public_key_bytes(), ".").is_none());
}
#[test]
fn domain_confusion_is_rejected() {
let signer = test_signer();
let session_token = crate::session::mint_session(
&signer.relabel_for_test(),
&crate::session::SessionSubject::Persona {
persona_id: "persona-1".to_owned(),
},
&[crate::session::SessionScope::ExplorerRead],
1_700_000_000_000,
8 * 60 * 60 * 1000,
);
assert!(
verify_grant(&signer.public_key_bytes(), &session_token).is_none(),
"a session token must not verify as a grant"
);
}
}