use affinidi_status_list::token::{StatusListReference, VerifiedStatusListToken, VerifyOptions};
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use serde_json::Value;
use crate::jws::{JwsKey, sd_jwt_issuer_method};
use crate::trust_task_proof::{ProofPurpose, PurposeVmResolver};
pub use affinidi_status_list::token::{STATUS_LIST_JWT_MEDIA_TYPE, TokenStatus};
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum TokenStatusListError {
#[error("status list token {uri} could not be fetched: {detail}")]
Fetch { uri: String, detail: String },
#[error("status list token is malformed: {0}")]
Malformed(String),
#[error("status list token is not the credential issuer's: {0}")]
Issuer(String),
#[error("status list token signing key: {0}")]
Key(String),
#[error("status list token refused: {0}")]
Rejected(#[from] affinidi_status_list::StatusListError),
}
pub async fn verify_status_list_token(
jwt: &str,
reference: &StatusListReference,
credential_issuer: &str,
resolver: &(dyn PurposeVmResolver + '_),
now_unix: i64,
) -> Result<VerifiedStatusListToken, TokenStatusListError> {
let header = jwt
.split('.')
.next()
.and_then(|segment| URL_SAFE_NO_PAD.decode(segment).ok())
.and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
.filter(Value::is_object)
.ok_or_else(|| TokenStatusListError::Malformed("no JSON header".into()))?;
let method = sd_jwt_issuer_method(&header, credential_issuer)
.map_err(|e| TokenStatusListError::Issuer(e.to_string()))?;
let resolved = resolver
.resolve_vm_for_purpose(&method, ProofPurpose::AssertionMethod)
.await
.map_err(|e| TokenStatusListError::Key(e.to_string()))?;
let key =
JwsKey::from_resolved(&resolved).map_err(|e| TokenStatusListError::Key(e.to_string()))?;
let token = VerifiedStatusListToken::verify(
jwt,
reference,
&VerifyOptions::at(now_unix),
|compact, _header| key.verify_compact(compact),
)?;
if let Some(iss) = token.issuer()
&& iss != credential_issuer
{
return Err(TokenStatusListError::Issuer(format!(
"the token's `iss` ({iss}) is not the credential's issuer ({credential_issuer})"
)));
}
Ok(token)
}
#[cfg(feature = "client")]
pub async fn resolve_token_status(
client: &reqwest::Client,
reference: &StatusListReference,
credential_issuer: &str,
resolver: &(dyn PurposeVmResolver + '_),
now_unix: i64,
) -> Result<TokenStatus, TokenStatusListError> {
let uri = reference.uri.as_str();
let fetch_failed = |detail: String| TokenStatusListError::Fetch {
uri: uri.to_string(),
detail,
};
crate::http::guard_public_url(uri).map_err(|e| fetch_failed(e.to_string()))?;
let response = client
.get(uri)
.header(reqwest::header::ACCEPT, STATUS_LIST_JWT_MEDIA_TYPE)
.send()
.await
.map_err(|e| fetch_failed(e.to_string()))?
.error_for_status()
.map_err(|e| fetch_failed(e.to_string()))?;
let body = crate::http::read_body_capped(response, crate::http::DEFAULT_MAX_FOREIGN_BODY)
.await
.map_err(|e| fetch_failed(e.to_string()))?;
let jwt = std::str::from_utf8(&body)
.map_err(|_| fetch_failed("the body is not UTF-8".into()))?
.trim();
let token =
verify_status_list_token(jwt, reference, credential_issuer, resolver, now_unix).await?;
Ok(token.status_of(reference)?)
}
#[cfg(test)]
mod tests {
use super::*;
use affinidi_data_integrity::{DataIntegrityError, ResolvedKey};
use affinidi_secrets_resolver::secrets::KeyType;
use affinidi_status_list::token::{TokenStatusList, status_list_token_payload};
use serde_json::json;
const ISSUER: &str = "did:web:issuer.example";
const URI: &str = "https://issuer.example/statuslists/1";
struct IssuerKeys(String, ResolvedKey);
#[async_trait::async_trait]
impl PurposeVmResolver for IssuerKeys {
async fn resolve_vm_for_purpose(
&self,
vm: &str,
purpose: ProofPurpose,
) -> Result<ResolvedKey, DataIntegrityError> {
if vm == self.0 && purpose == ProofPurpose::AssertionMethod {
Ok(self.1.clone())
} else {
Err(DataIntegrityError::Resolver(format!("{vm} not listed")))
}
}
}
struct Issuer {
private: Vec<u8>,
keys: IssuerKeys,
}
fn issuer(seed: u8) -> Issuer {
let kp = affinidi_crypto::p256::generate(Some(&[seed; 32])).unwrap();
Issuer {
private: kp.private_bytes,
keys: IssuerKeys(
format!("{ISSUER}#k1"),
ResolvedKey::new(KeyType::P256, kp.public_bytes),
),
}
}
fn reference(idx: u64) -> StatusListReference {
StatusListReference {
idx,
uri: URI.to_string(),
}
}
fn sign(private: &[u8], header: &Value, payload: &Value) -> String {
let encode = |v: &Value| URL_SAFE_NO_PAD.encode(serde_json::to_vec(v).unwrap());
let input = format!("{}.{}", encode(header), encode(payload));
let sig = affinidi_crypto::p256::sign(private, input.as_bytes()).unwrap();
format!("{input}.{}", URL_SAFE_NO_PAD.encode(sig))
}
fn list() -> TokenStatusList {
let mut list = TokenStatusList::new(2, 64).unwrap();
list.set(3, TokenStatus::Invalid).unwrap();
list.set(4, TokenStatus::Suspended).unwrap();
list
}
fn token(private: &[u8], kid: &str, iss: Option<&str>) -> String {
let mut payload =
status_list_token_payload(URI, 1_000, Some(9_000), None, &list()).unwrap();
if let Some(iss) = iss {
payload["iss"] = json!(iss);
}
sign(
private,
&json!({ "alg": "ES256", "typ": "statuslist+jwt", "kid": kid }),
&payload,
)
}
#[tokio::test]
async fn the_issuers_signed_list_is_read() {
let issuer = issuer(0x11);
let jwt = token(&issuer.private, &format!("{ISSUER}#k1"), Some(ISSUER));
let verified = verify_status_list_token(&jwt, &reference(3), ISSUER, &issuer.keys, 2_000)
.await
.expect("the issuer's list");
assert_eq!(
verified.status_of(&reference(3)).unwrap(),
TokenStatus::Invalid
);
assert_eq!(
verified.status_of(&reference(4)).unwrap(),
TokenStatus::Suspended
);
assert_eq!(
verified.status_of(&reference(5)).unwrap(),
TokenStatus::Valid
);
}
#[tokio::test]
async fn a_list_signed_under_another_did_is_refused() {
let other = issuer(0x12);
let jwt = token(&other.private, "did:web:attacker.example#k1", None);
let err = verify_status_list_token(&jwt, &reference(3), ISSUER, &other.keys, 2_000)
.await
.unwrap_err();
assert!(matches!(err, TokenStatusListError::Issuer(_)), "{err}");
}
#[tokio::test]
async fn a_forged_signature_is_refused() {
let real = issuer(0x13);
let forger = issuer(0x14);
let jwt = token(&forger.private, &format!("{ISSUER}#k1"), None);
let err = verify_status_list_token(&jwt, &reference(3), ISSUER, &real.keys, 2_000)
.await
.unwrap_err();
assert!(
matches!(
err,
TokenStatusListError::Rejected(affinidi_status_list::StatusListError::Signature(_))
),
"{err}"
);
}
#[tokio::test]
async fn a_token_naming_another_iss_is_refused() {
let issuer = issuer(0x15);
let jwt = token(
&issuer.private,
&format!("{ISSUER}#k1"),
Some("did:web:other.example"),
);
let err = verify_status_list_token(&jwt, &reference(3), ISSUER, &issuer.keys, 2_000)
.await
.unwrap_err();
assert!(err.to_string().contains("`iss`"), "{err}");
}
#[tokio::test]
async fn sub_must_be_the_referenced_uri() {
let issuer = issuer(0x16);
let jwt = token(&issuer.private, &format!("{ISSUER}#k1"), None);
let elsewhere = StatusListReference {
idx: 3,
uri: "https://issuer.example/statuslists/2".into(),
};
let err = verify_status_list_token(&jwt, &elsewhere, ISSUER, &issuer.keys, 2_000)
.await
.unwrap_err();
assert!(err.to_string().contains("sub"), "{err}");
}
#[tokio::test]
async fn an_expired_list_is_refused() {
let issuer = issuer(0x17);
let jwt = token(&issuer.private, &format!("{ISSUER}#k1"), None);
let err = verify_status_list_token(&jwt, &reference(3), ISSUER, &issuer.keys, 10_000)
.await
.unwrap_err();
assert!(err.to_string().contains("expired"), "{err}");
}
}