pub const STATUS_ENTRY_TYPE: &str = "BitstringStatusListEntry";
pub const STATUS_PURPOSE_REVOCATION: &str = "revocation";
pub const STATUS_LIST_CREDENTIAL_TYPE: &str = "BitstringStatusListCredential";
pub const STATUS_LIST_SUBJECT_TYPE: &str = "BitstringStatusList";
pub const STATUS_ENDPOINT_LEAF: &str = "aph-status.json";
pub const STATUS_MAX_AGE_SECONDS: i64 = 300;
pub const STATUS_CLOCK_SKEW_SECONDS: i64 = 60;
pub const STATUS_REPUBLISH_INTERVAL_SECONDS: i64 = 120;
pub const MULTIBASE_BASE64URL_PREFIX: char = 'u';
pub const MAX_EXPANDED_LIST_BYTES: usize = 1024 * 1024;
#[derive(
std::fmt::Debug,
std::clone::Clone,
std::marker::Copy,
std::cmp::PartialEq,
std::cmp::Eq,
serde::Serialize,
serde::Deserialize,
)]
pub enum StatusEntryType {
#[serde(rename = "BitstringStatusListEntry")]
BitstringStatusListEntry,
}
#[derive(
std::fmt::Debug,
std::clone::Clone,
std::marker::Copy,
std::cmp::PartialEq,
std::cmp::Eq,
serde::Serialize,
serde::Deserialize,
)]
pub enum StatusPurpose {
#[serde(rename = "revocation")]
Revocation,
}
#[derive(
std::fmt::Debug,
std::clone::Clone,
std::cmp::PartialEq,
std::cmp::Eq,
serde::Serialize,
serde::Deserialize,
)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CredentialStatusEntry {
#[serde(default, skip_serializing_if = "std::option::Option::is_none")]
pub id: std::option::Option<String>,
#[serde(rename = "type")]
pub r#type: StatusEntryType,
pub status_purpose: StatusPurpose,
pub status_list_index: String,
pub status_list_credential: String,
}
impl CredentialStatusEntry {
pub fn index(&self) -> std::result::Result<u64, crate::errors::AphError> {
if self.status_list_index.is_empty()
|| !self.status_list_index.bytes().all(|b| b.is_ascii_digit())
{
return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
}
match self.status_list_index.parse::<u64>() {
std::result::Result::Ok(index) => std::result::Result::Ok(index),
std::result::Result::Err(_) => {
std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable)
}
}
}
}
#[derive(std::fmt::Debug, std::clone::Clone, serde::Serialize, serde::Deserialize)]
pub struct StatusListCredential {
#[serde(default, rename = "@context")]
pub context: std::vec::Vec<serde_json::Value>,
#[serde(default)]
pub id: std::option::Option<String>,
#[serde(default, rename = "type")]
pub r#type: std::vec::Vec<String>,
#[serde(default)]
pub issuer: serde_json::Value,
#[serde(default, rename = "validFrom")]
pub valid_from: std::option::Option<String>,
#[serde(rename = "credentialSubject")]
pub credential_subject: StatusListSubject,
#[serde(default)]
pub proof: serde_json::Value,
}
#[derive(std::fmt::Debug, std::clone::Clone, serde::Serialize, serde::Deserialize)]
pub struct StatusListSubject {
#[serde(default)]
pub id: std::option::Option<String>,
#[serde(default, rename = "type")]
pub r#type: std::option::Option<String>,
#[serde(rename = "statusPurpose")]
pub status_purpose: String,
#[serde(rename = "encodedList")]
pub encoded_list: String,
}
impl StatusListCredential {
pub fn issuer_did(&self) -> std::option::Option<&str> {
match &self.issuer {
serde_json::Value::String(did) => std::option::Option::Some(did.as_str()),
serde_json::Value::Object(object) => object.get("id").and_then(|v| v.as_str()),
_ => std::option::Option::None,
}
}
pub fn proof_verification_method(&self) -> std::option::Option<&str> {
match &self.proof {
serde_json::Value::Object(object) => {
object.get("verificationMethod").and_then(|v| v.as_str())
}
_ => std::option::Option::None,
}
}
pub fn validate(
&self,
notary_did: &str,
now_rfc3339: &str,
) -> std::result::Result<(), crate::errors::AphError> {
if !self.r#type.iter().any(|t| t == STATUS_LIST_CREDENTIAL_TYPE) {
return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
}
if self
.credential_subject
.r#type
.as_deref()
.is_some_and(|subject_type| subject_type != STATUS_LIST_SUBJECT_TYPE)
{
return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
}
if self.credential_subject.status_purpose != STATUS_PURPOSE_REVOCATION {
return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
}
if self.issuer_did() != std::option::Option::Some(notary_did) {
return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
}
self.check_freshness(now_rfc3339)
}
pub fn check_freshness(
&self,
now_rfc3339: &str,
) -> std::result::Result<(), crate::errors::AphError> {
let age_seconds = self.age_seconds(now_rfc3339)?;
if age_seconds > STATUS_MAX_AGE_SECONDS + STATUS_CLOCK_SKEW_SECONDS {
return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
}
if age_seconds < -STATUS_CLOCK_SKEW_SECONDS {
return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
}
std::result::Result::Ok(())
}
pub fn seconds_until_republish_due(
&self,
now_rfc3339: &str,
) -> std::result::Result<i64, crate::errors::AphError> {
std::result::Result::Ok(STATUS_REPUBLISH_INTERVAL_SECONDS - self.age_seconds(now_rfc3339)?)
}
pub fn seconds_until_stale(
&self,
now_rfc3339: &str,
) -> std::result::Result<i64, crate::errors::AphError> {
std::result::Result::Ok(
STATUS_MAX_AGE_SECONDS + STATUS_CLOCK_SKEW_SECONDS - self.age_seconds(now_rfc3339)?,
)
}
fn age_seconds(
&self,
now_rfc3339: &str,
) -> std::result::Result<i64, crate::errors::AphError> {
let issued = match self.valid_from.as_deref() {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
}
};
let issued = match chrono::DateTime::parse_from_rfc3339(issued) {
std::result::Result::Ok(t) => t,
std::result::Result::Err(_) => {
return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
}
};
let now = match chrono::DateTime::parse_from_rfc3339(now_rfc3339) {
std::result::Result::Ok(t) => t,
std::result::Result::Err(_) => {
return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
}
};
std::result::Result::Ok((now - issued).num_seconds())
}
}
pub fn parse_status_list_credential(
json: &str,
) -> std::result::Result<StatusListCredential, crate::errors::AphError> {
match serde_json::from_str::<StatusListCredential>(json) {
std::result::Result::Ok(credential) => std::result::Result::Ok(credential),
std::result::Result::Err(_) => {
std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable)
}
}
}
pub fn status_list_signing_base(
raw_json: &str,
) -> std::result::Result<String, crate::errors::AphError> {
let mut value = match serde_json::from_str::<serde_json::Value>(raw_json) {
std::result::Result::Ok(v) => v,
std::result::Result::Err(_) => {
return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
}
};
match value.as_object_mut() {
std::option::Option::Some(object) => {
object.remove("proof");
}
std::option::Option::None => {
return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
}
}
std::result::Result::Ok(crate::crypto::jcs::canonicalize_rfc8785(&value))
}
pub fn verify_status_list_proof(
raw_json: &str,
credential: &StatusListCredential,
key: &ed25519_dalek::VerifyingKey,
) -> std::result::Result<(), crate::errors::AphError> {
let proof_value = match &credential.proof {
serde_json::Value::Object(object) => match object.get("proofValue").and_then(|v| v.as_str()) {
std::option::Option::Some(value) if !value.is_empty() => value,
_ => return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable),
},
_ => return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable),
};
let raw = match crate::crypto::multibase::base58btc_decode(proof_value) {
std::result::Result::Ok(bytes) => bytes,
std::result::Result::Err(_) => {
return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
}
};
let bytes: [u8; 64] = match raw.as_slice().try_into() {
std::result::Result::Ok(b) => b,
std::result::Result::Err(_) => {
return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
}
};
let signature = ed25519_dalek::Signature::from_bytes(&bytes);
let canonical = status_list_signing_base(raw_json)?;
match ed25519_dalek::Verifier::verify(key, canonical.as_bytes(), &signature) {
std::result::Result::Ok(()) => std::result::Result::Ok(()),
std::result::Result::Err(_) => {
std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable)
}
}
}
pub fn decode_encoded_list(
encoded: &str,
) -> std::result::Result<std::vec::Vec<u8>, crate::errors::AphError> {
let body = match encoded.strip_prefix(MULTIBASE_BASE64URL_PREFIX) {
std::option::Option::Some(body) => body,
std::option::Option::None => {
return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
}
};
let bytes = match crate::crypto::base64url::decode(body) {
std::result::Result::Ok(bytes) => bytes,
std::result::Result::Err(_) => {
return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
}
};
if bytes.len() < 2 || bytes[0] != 0x1f || bytes[1] != 0x8b {
return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
}
std::result::Result::Ok(bytes)
}
pub fn revocation_bit(
expanded_list: &[u8],
index: u64,
) -> std::result::Result<bool, crate::errors::AphError> {
if expanded_list.len() > MAX_EXPANDED_LIST_BYTES {
return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
}
let byte_offset = index / 8;
let byte_offset = match usize::try_from(byte_offset) {
std::result::Result::Ok(offset) => offset,
std::result::Result::Err(_) => {
return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
}
};
let byte = match expanded_list.get(byte_offset) {
std::option::Option::Some(byte) => *byte,
std::option::Option::None => {
return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
}
};
let bit_in_byte = (index % 8) as u32;
let mask = 0x80u8 >> bit_in_byte;
std::result::Result::Ok(byte & mask != 0)
}
#[derive(std::fmt::Debug, std::clone::Clone, std::marker::Copy, std::cmp::PartialEq, std::cmp::Eq)]
pub enum StatusCheck {
Skipped,
NotRevoked,
}
pub type ExpandEncodedList<'a> =
dyn std::ops::Fn(&[u8]) -> std::result::Result<std::vec::Vec<u8>, crate::errors::AphError>
+ std::marker::Send
+ std::marker::Sync
+ 'a;
pub async fn check_envelope_status(
envelope: &crate::envelope::NotarizationEnvelope,
fetch: &dyn crate::discovery::ports::StatusCredentialFetch,
expand_encoded_list: &ExpandEncodedList<'_>,
issuer_key: &ed25519_dalek::VerifyingKey,
now_rfc3339: &str,
) -> std::result::Result<StatusCheck, crate::errors::AphError> {
let entry = match envelope.credential_status.as_ref() {
std::option::Option::Some(entry) => entry,
std::option::Option::None => return std::result::Result::Ok(StatusCheck::Skipped),
};
let mandate_id = match envelope
.credential_subject
.policy
.delegation_mandate_id
.as_deref()
{
std::option::Option::Some(id) => id,
std::option::Option::None => {
return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
}
};
check_credential_status(
entry,
mandate_id,
envelope
.credential_subject
.notarization
.notary_service
.id
.as_str(),
fetch,
expand_encoded_list,
issuer_key,
now_rfc3339,
)
.await
}
pub async fn check_credential_status(
entry: &CredentialStatusEntry,
delegation_mandate_id: &str,
notary_service_id: &str,
fetch: &dyn crate::discovery::ports::StatusCredentialFetch,
expand_encoded_list: &ExpandEncodedList<'_>,
issuer_key: &ed25519_dalek::VerifyingKey,
now_rfc3339: &str,
) -> std::result::Result<StatusCheck, crate::errors::AphError> {
let index = entry.index()?;
let derived = match crate::discovery::DidUrl::parse(notary_service_id).web_status_url() {
std::option::Option::Some(url) => url,
std::option::Option::None => {
return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
}
};
if !crate::discovery::same_origin(&derived, &entry.status_list_credential) {
return std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
}
let body = fetch
.fetch_status_credential(&entry.status_list_credential)
.await?;
let credential = parse_status_list_credential(&body)?;
credential.validate(notary_service_id, now_rfc3339)?;
verify_status_list_proof(&body, &credential, issuer_key)?;
let compressed = decode_encoded_list(&credential.credential_subject.encoded_list)?;
let expanded = expand_encoded_list(&compressed)?;
if revocation_bit(&expanded, index)? {
return std::result::Result::Err(crate::errors::AphError::mandate_revoked(
delegation_mandate_id,
));
}
std::result::Result::Ok(StatusCheck::NotRevoked)
}
#[cfg(test)]
mod tests {
const NOTARY_DID: &str = "did:web:aph-notary.squillo.com";
const DERIVED_ENDPOINT: &str =
"https://aph-notary.squillo.com/.well-known/aph-status.json";
const ISSUED_AT: &str = "2026-05-28T00:00:00Z";
const NOW: &str = "2026-05-28T00:00:30Z";
struct RecordingFetch {
body: String,
urls: std::sync::Mutex<std::vec::Vec<String>>,
}
impl RecordingFetch {
fn new(body: &str) -> Self {
Self {
body: String::from(body),
urls: std::sync::Mutex::new(std::vec::Vec::new()),
}
}
fn calls(&self) -> usize {
self.urls.lock().expect("test mutex is uncontended").len()
}
}
impl crate::discovery::ports::StatusCredentialFetch for RecordingFetch {
fn fetch_status_credential<'a>(
&'a self,
url: &'a str,
) -> crate::discovery::ports::DiscoveryFuture<'a, String> {
std::boxed::Box::pin(async move {
self
.urls
.lock()
.expect("test mutex is uncontended")
.push(String::from(url));
let out: std::result::Result<String, crate::errors::AphError> =
std::result::Result::Ok(self.body.clone());
out
})
}
}
struct UnreachableFetch {
calls: std::sync::atomic::AtomicUsize,
}
impl crate::discovery::ports::StatusCredentialFetch for UnreachableFetch {
fn fetch_status_credential<'a>(
&'a self,
_url: &'a str,
) -> crate::discovery::ports::DiscoveryFuture<'a, String> {
std::boxed::Box::pin(async move {
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let out: std::result::Result<String, crate::errors::AphError> =
std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable);
out
})
}
}
fn test_expander(
compressed: &[u8],
) -> std::result::Result<std::vec::Vec<u8>, crate::errors::AphError> {
match compressed.get(GZIP_HEADER.len()..) {
std::option::Option::Some(rest) => std::result::Result::Ok(std::vec::Vec::from(rest)),
std::option::Option::None => {
std::result::Result::Err(crate::errors::AphError::NotaryServiceUnreachable)
}
}
}
const GZIP_HEADER: [u8; 10] = [0x1f, 0x8b, 0x08, 0x00, 0, 0, 0, 0, 0x00, 0xff];
fn encoded_list_for(bits: &[u8]) -> String {
let mut bytes = std::vec::Vec::from(GZIP_HEADER);
bytes.extend_from_slice(bits);
std::format!(
"{}{}",
super::MULTIBASE_BASE64URL_PREFIX,
crate::crypto::base64url::encode(&bytes)
)
}
const FIXTURE_SEED: [u8; 32] = [9u8; 32];
fn fixture_key() -> ed25519_dalek::VerifyingKey {
ed25519_dalek::SigningKey::from_bytes(&FIXTURE_SEED).verifying_key()
}
fn sign_document(unsigned_json: &str) -> String {
let base = super::status_list_signing_base(unsigned_json).expect("fixture is a JSON object");
let sk = ed25519_dalek::SigningKey::from_bytes(&FIXTURE_SEED);
let signature = ed25519_dalek::Signer::sign(&sk, base.as_bytes());
let proof_value = crate::crypto::multibase::base58btc_encode(&signature.to_bytes());
let mut value: serde_json::Value =
serde_json::from_str(unsigned_json).expect("fixture is valid JSON");
let object = value.as_object_mut().expect("fixture is a JSON object");
object.insert(
String::from("proof"),
serde_json::json!({
"type": "DataIntegrityProof",
"cryptosuite": "eddsa-jcs-2022",
"proofPurpose": "assertionMethod",
"verificationMethod": std::format!("{NOTARY_DID}#k1"),
"proofValue": proof_value,
}),
);
serde_json::to_string(&value).expect("re-serialising a JSON object cannot fail")
}
fn status_document(bits: &[u8]) -> String {
sign_document(&status_document_unsigned(bits))
}
fn status_document_unsigned(bits: &[u8]) -> String {
std::format!(
r#"{{
"@context": ["https://www.w3.org/ns/credentials/v2"],
"id": "{endpoint}",
"type": ["VerifiableCredential", "BitstringStatusListCredential"],
"issuer": "{issuer}",
"validFrom": "{issued}",
"credentialSubject": {{
"id": "{endpoint}#list",
"type": "BitstringStatusList",
"statusPurpose": "revocation",
"encodedList": "{encoded}"
}}
}}"#,
endpoint = DERIVED_ENDPOINT,
issuer = NOTARY_DID,
issued = ISSUED_AT,
encoded = encoded_list_for(bits)
)
}
fn entry_at(url: &str, index: &str) -> super::CredentialStatusEntry {
super::CredentialStatusEntry {
id: std::option::Option::None,
r#type: super::StatusEntryType::BitstringStatusListEntry,
status_purpose: super::StatusPurpose::Revocation,
status_list_index: String::from(index),
status_list_credential: String::from(url),
}
}
#[test]
fn entry_round_trips_and_omits_an_absent_id() {
let json = r#"{"type":"BitstringStatusListEntry","statusPurpose":"revocation","statusListIndex":"94567","statusListCredential":"https://aph-notary.squillo.com/.well-known/aph-status.json"}"#;
let entry: super::CredentialStatusEntry =
serde_json::from_str(json).expect("the §6.3.3.1 shape parses");
std::assert_eq!(entry.index().unwrap(), 94567u64);
let back = serde_json::to_string(&entry).expect("entry serializes");
std::assert_eq!(back, json);
}
#[test]
fn a_numeric_index_is_a_parse_failure() {
let json = r#"{"type":"BitstringStatusListEntry","statusPurpose":"revocation","statusListIndex":94567,"statusListCredential":"https://aph-notary.squillo.com/.well-known/aph-status.json"}"#;
let parsed = serde_json::from_str::<super::CredentialStatusEntry>(json);
std::assert!(parsed.is_err(), "a JSON number at statusListIndex must not parse");
}
#[test]
fn an_unrecognized_purpose_is_a_parse_failure_not_a_skip() {
let json = r#"{"type":"BitstringStatusListEntry","statusPurpose":"suspension","statusListIndex":"1","statusListCredential":"https://aph-notary.squillo.com/.well-known/aph-status.json"}"#;
std::assert!(serde_json::from_str::<super::CredentialStatusEntry>(json).is_err());
let wrong_type = r#"{"type":"StatusList2021Entry","statusPurpose":"revocation","statusListIndex":"1","statusListCredential":"https://aph-notary.squillo.com/.well-known/aph-status.json"}"#;
std::assert!(serde_json::from_str::<super::CredentialStatusEntry>(wrong_type).is_err());
}
#[test]
fn entry_rejects_an_unknown_member() {
let json = r#"{"type":"BitstringStatusListEntry","statusPurpose":"revocation","statusListIndex":"1","statusListCredential":"https://aph-notary.squillo.com/.well-known/aph-status.json","statusSize":2}"#;
std::assert!(serde_json::from_str::<super::CredentialStatusEntry>(json).is_err());
}
#[test]
fn index_refuses_signs_and_whitespace() {
let mut entry = entry_at(DERIVED_ENDPOINT, "+1");
std::assert!(entry.index().is_err());
entry.status_list_index = String::from(" 1");
std::assert!(entry.index().is_err());
entry.status_list_index = String::from("");
std::assert!(entry.index().is_err());
entry.status_list_index = String::from("1_000");
std::assert!(entry.index().is_err());
}
#[test]
fn revocation_bit_reads_the_most_significant_bit_first() {
let list: [u8; 2] = [0b1000_0001, 0b0000_0010];
std::assert!(super::revocation_bit(&list, 0).unwrap());
std::assert!(!super::revocation_bit(&list, 1).unwrap());
std::assert!(super::revocation_bit(&list, 7).unwrap());
std::assert!(super::revocation_bit(&list, 14).unwrap());
std::assert!(!super::revocation_bit(&list, 15).unwrap());
}
#[test]
fn an_index_past_the_list_is_a_refusal_not_a_zero() {
let list: [u8; 1] = [0x00];
std::assert_eq!(super::revocation_bit(&list, 8).unwrap_err().code(), "APH_E008");
}
#[test]
fn encoded_list_requires_the_multibase_prefix_and_gzip_magic() {
let bare = crate::crypto::base64url::encode(&[0x1f, 0x8b, 0x08]);
std::assert_eq!(
super::decode_encoded_list(&bare).unwrap_err().code(),
"APH_E008"
);
let not_gzip = std::format!(
"{}{}",
super::MULTIBASE_BASE64URL_PREFIX,
crate::crypto::base64url::encode(b"plain")
);
std::assert_eq!(
super::decode_encoded_list(¬_gzip).unwrap_err().code(),
"APH_E008"
);
let good = encoded_list_for(&[0x00]);
std::assert_eq!(super::decode_encoded_list(&good).unwrap()[0], 0x1f);
}
#[test]
fn freshness_bound_is_five_minutes_plus_one_minute_of_skew() {
let document = status_document(&[0x00]);
let credential = super::parse_status_list_credential(&document).unwrap();
std::assert!(credential.check_freshness("2026-05-28T00:05:59Z").is_ok());
std::assert_eq!(
credential
.check_freshness("2026-05-28T00:06:01Z")
.unwrap_err()
.code(),
"APH_E008"
);
std::assert_eq!(
credential
.check_freshness("2026-05-27T23:58:00Z")
.unwrap_err()
.code(),
"APH_E008"
);
}
#[test]
fn the_republish_deadline_falls_due_long_before_the_refusal_cliff() {
let document = status_document(&[0x00]);
let credential = super::parse_status_list_credential(&document).unwrap();
std::assert_eq!(credential.seconds_until_republish_due(NOW).unwrap(), 90);
std::assert_eq!(credential.seconds_until_stale(NOW).unwrap(), 330);
std::assert_eq!(
credential
.seconds_until_republish_due("2026-05-28T00:03:00Z")
.unwrap(),
-60
);
std::assert!(credential.check_freshness("2026-05-28T00:03:00Z").is_ok());
std::assert_eq!(
credential.seconds_until_stale("2026-05-28T00:05:59Z").unwrap(),
1
);
std::assert!(credential.check_freshness("2026-05-28T00:05:59Z").is_ok());
std::assert_eq!(
credential.seconds_until_stale("2026-05-28T00:06:01Z").unwrap(),
-1
);
std::assert!(credential.check_freshness("2026-05-28T00:06:01Z").is_err());
std::assert_eq!(
credential
.seconds_until_stale("not-a-timestamp")
.unwrap_err()
.code(),
"APH_E008"
);
std::assert_eq!(
credential
.seconds_until_republish_due("not-a-timestamp")
.unwrap_err()
.code(),
"APH_E008"
);
}
#[test]
fn validate_binds_the_issuer_to_the_derived_notary() {
let document = status_document(&[0x00]).replace(NOTARY_DID, "did:web:evil.example");
let credential = super::parse_status_list_credential(&document).unwrap();
std::assert_eq!(
credential.validate(NOTARY_DID, NOW).unwrap_err().code(),
"APH_E008"
);
}
#[test]
fn validate_refuses_a_document_whose_purpose_is_not_revocation() {
let document = status_document_unsigned(&[0x00]).replace(
"\"statusPurpose\": \"revocation\"",
"\"statusPurpose\": \"suspension\"",
);
let credential = super::parse_status_list_credential(&document).unwrap();
std::assert_eq!(
credential.validate(NOTARY_DID, NOW).unwrap_err().code(),
"APH_E008"
);
}
#[test]
fn a_status_document_may_carry_members_aph_does_not_read() {
let document = status_document(&[0x00]).replace(
"\"validFrom\":",
"\"name\": \"Squillo revocation list\", \"ttl\": 300, \"validFrom\":",
);
let credential = super::parse_status_list_credential(&document)
.expect("extra W3C members must not refuse the document");
std::assert!(credential.validate(NOTARY_DID, NOW).is_ok());
}
#[test]
fn issuer_is_read_from_a_string_or_an_object() {
let document = status_document(&[0x00]).replace(
&std::format!("\"issuer\": \"{}\"", NOTARY_DID),
&std::format!("\"issuer\": {{\"id\": \"{}\"}}", NOTARY_DID),
);
let credential = super::parse_status_list_credential(&document).unwrap();
std::assert_eq!(credential.issuer_did(), std::option::Option::Some(NOTARY_DID));
std::assert!(credential.validate(NOTARY_DID, NOW).is_ok());
}
#[test]
fn signing_base_drops_the_proof_and_is_stable() {
let signed = status_document(&[0x00]).replace(
"\n}",
",\n \"proof\": {\"type\": \"DataIntegrityProof\", \"proofValue\": \"zNotChecked\"}\n}",
);
let base = super::status_list_signing_base(&signed).unwrap();
std::assert!(!base.contains("proofValue"));
let unsigned = super::status_list_signing_base(&status_document(&[0x00])).unwrap();
std::assert_eq!(base, unsigned);
}
fn envelope_with(
status: std::option::Option<super::CredentialStatusEntry>,
mandate: std::option::Option<&str>,
) -> crate::envelope::NotarizationEnvelope {
let raw = std::include_str!("../tests/golden/slack_reply_envelope.json");
let mut envelope: crate::envelope::NotarizationEnvelope =
serde_json::from_str(raw).expect("golden fixture parses");
envelope.credential_subject.notarization.notary_service.id = String::from(NOTARY_DID);
envelope.credential_subject.policy.delegation_mandate_id =
mandate.map(String::from);
envelope.credential_status = status;
envelope
}
const MANDATE_ID: &str = "urn:uuid:00000000-0000-4000-8000-0000000000d1";
#[test]
fn absent_status_skips_without_error_and_without_touching_the_port() {
let fetch = RecordingFetch::new("");
let envelope = envelope_with(std::option::Option::None, std::option::Option::Some(MANDATE_ID));
let outcome = crate::discovery::test_support::block_on(super::check_envelope_status(
&envelope,
&fetch,
&test_expander,
&fixture_key(),
NOW,
))
.expect("an absent status reference is not an error");
std::assert_eq!(outcome, super::StatusCheck::Skipped);
std::assert_eq!(fetch.calls(), 0, "case 1 must perform no fetch");
}
#[test]
fn a_clear_bit_admits_the_envelope() {
let fetch = RecordingFetch::new(&status_document(&[0b0100_0000]));
let entry = entry_at(DERIVED_ENDPOINT, "0");
let envelope = envelope_with(
std::option::Option::Some(entry),
std::option::Option::Some(MANDATE_ID),
);
let outcome = crate::discovery::test_support::block_on(super::check_envelope_status(
&envelope,
&fetch,
&test_expander,
&fixture_key(),
NOW,
))
.expect("a clear bit continues verification");
std::assert_eq!(outcome, super::StatusCheck::NotRevoked);
std::assert_eq!(fetch.calls(), 1);
}
#[test]
fn an_unsigned_status_list_is_refused_even_when_the_bit_is_clear() {
let fetch = RecordingFetch::new(&status_document_unsigned(&[0b0100_0000]));
let entry = entry_at(DERIVED_ENDPOINT, "0");
let envelope = envelope_with(
std::option::Option::Some(entry),
std::option::Option::Some(MANDATE_ID),
);
let error = crate::discovery::test_support::block_on(super::check_envelope_status(
&envelope,
&fetch,
&test_expander,
&fixture_key(),
NOW,
))
.expect_err("an unsigned status list must never be admitted");
std::assert!(std::matches!(
error,
crate::errors::AphError::NotaryServiceUnreachable
));
}
#[test]
fn a_status_list_signed_by_the_wrong_key_is_refused() {
let fetch = RecordingFetch::new(&status_document(&[0b0100_0000]));
let entry = entry_at(DERIVED_ENDPOINT, "0");
let envelope = envelope_with(
std::option::Option::Some(entry),
std::option::Option::Some(MANDATE_ID),
);
let impostor = ed25519_dalek::SigningKey::from_bytes(&[13u8; 32]).verifying_key();
let error = crate::discovery::test_support::block_on(super::check_envelope_status(
&envelope,
&fetch,
&test_expander,
&impostor,
NOW,
))
.expect_err("a status list signed by a foreign key must be refused");
std::assert!(std::matches!(
error,
crate::errors::AphError::NotaryServiceUnreachable
));
}
#[test]
fn a_set_bit_rejects_with_aph_e015_naming_the_mandate() {
let fetch = RecordingFetch::new(&status_document(&[0b0010_0000]));
let entry = entry_at(DERIVED_ENDPOINT, "2");
let envelope = envelope_with(
std::option::Option::Some(entry),
std::option::Option::Some(MANDATE_ID),
);
let error = crate::discovery::test_support::block_on(super::check_envelope_status(
&envelope,
&fetch,
&test_expander,
&fixture_key(),
NOW,
))
.expect_err("a set bit must refuse the envelope");
std::assert_eq!(error.code(), "APH_E015");
std::assert!(
std::format!("{}", error).contains(MANDATE_ID),
"APH_E015 must name the revoked mandate"
);
}
#[test]
fn an_unreachable_status_surface_rejects_rather_than_skipping() {
let fetch = UnreachableFetch {
calls: std::sync::atomic::AtomicUsize::new(0),
};
let entry = entry_at(DERIVED_ENDPOINT, "0");
let envelope = envelope_with(
std::option::Option::Some(entry),
std::option::Option::Some(MANDATE_ID),
);
let error = crate::discovery::test_support::block_on(super::check_envelope_status(
&envelope,
&fetch,
&test_expander,
&fixture_key(),
NOW,
))
.expect_err("an offered-but-unreachable reference must refuse");
std::assert_eq!(error.code(), "APH_E008");
std::assert_eq!(
fetch.calls.load(std::sync::atomic::Ordering::SeqCst),
1,
"the derived origin was reached for, and it is the reach that failed"
);
}
#[test]
fn a_cross_origin_status_url_is_refused_without_being_fetched() {
let fetch = RecordingFetch::new(&status_document(&[0x00]));
let entry = entry_at("https://evil.example/.well-known/aph-status.json", "0");
let envelope = envelope_with(
std::option::Option::Some(entry),
std::option::Option::Some(MANDATE_ID),
);
let error = crate::discovery::test_support::block_on(super::check_envelope_status(
&envelope,
&fetch,
&test_expander,
&fixture_key(),
NOW,
))
.expect_err("a cross-origin status URL must refuse");
std::assert_eq!(error.code(), "APH_E008");
std::assert_eq!(fetch.calls(), 0, "the named URL must never be fetched");
}
#[test]
fn a_same_host_different_port_or_scheme_is_also_cross_origin() {
for url in [
"http://aph-notary.squillo.com/.well-known/aph-status.json",
"https://aph-notary.squillo.com:8443/.well-known/aph-status.json",
"https://aph-notary.squillo.com@evil.example/.well-known/aph-status.json",
] {
let fetch = RecordingFetch::new(&status_document(&[0x00]));
let envelope = envelope_with(
std::option::Option::Some(entry_at(url, "0")),
std::option::Option::Some(MANDATE_ID),
);
let error = crate::discovery::test_support::block_on(super::check_envelope_status(
&envelope,
&fetch,
&test_expander,
&fixture_key(),
NOW,
))
.expect_err(url);
std::assert_eq!(error.code(), "APH_E008", "{}", url);
std::assert_eq!(fetch.calls(), 0, "{}", url);
}
}
#[test]
fn a_different_path_on_the_derived_origin_is_admitted_and_is_what_gets_fetched() {
let fetch = RecordingFetch::new(&status_document(&[0x00]));
let second_list = "https://aph-notary.squillo.com/status/list-2.json";
let envelope = envelope_with(
std::option::Option::Some(entry_at(second_list, "0")),
std::option::Option::Some(MANDATE_ID),
);
let outcome = crate::discovery::test_support::block_on(super::check_envelope_status(
&envelope,
&fetch,
&test_expander,
&fixture_key(),
NOW,
))
.expect("a same-origin second list is legitimate");
std::assert_eq!(outcome, super::StatusCheck::NotRevoked);
let asked = fetch.urls.lock().expect("test mutex is uncontended").clone();
std::assert_eq!(
asked,
std::vec![String::from(second_list)],
"the BOUND url is fetched, not the derived one"
);
}
#[test]
fn a_notary_that_is_not_did_web_cannot_be_status_checked() {
let fetch = RecordingFetch::new(&status_document(&[0x00]));
let mut envelope = envelope_with(
std::option::Option::Some(entry_at(DERIVED_ENDPOINT, "0")),
std::option::Option::Some(MANDATE_ID),
);
envelope.credential_subject.notarization.notary_service.id =
String::from("did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV");
let error = crate::discovery::test_support::block_on(super::check_envelope_status(
&envelope,
&fetch,
&test_expander,
&fixture_key(),
NOW,
))
.expect_err("no derivable origin must refuse");
std::assert_eq!(error.code(), "APH_E008");
std::assert_eq!(fetch.calls(), 0);
}
#[test]
fn a_status_reference_with_no_parent_mandate_is_refused() {
let fetch = RecordingFetch::new(&status_document(&[0x00]));
let envelope = envelope_with(
std::option::Option::Some(entry_at(DERIVED_ENDPOINT, "0")),
std::option::Option::None,
);
let error = crate::discovery::test_support::block_on(super::check_envelope_status(
&envelope,
&fetch,
&test_expander,
&fixture_key(),
NOW,
))
.expect_err("a subjectless status reference must refuse");
std::assert_eq!(error.code(), "APH_E008");
std::assert_eq!(fetch.calls(), 0);
}
#[test]
fn a_stale_document_rejects_even_though_it_fetched_cleanly() {
let fetch = RecordingFetch::new(&status_document(&[0x00]));
let envelope = envelope_with(
std::option::Option::Some(entry_at(DERIVED_ENDPOINT, "0")),
std::option::Option::Some(MANDATE_ID),
);
let error = crate::discovery::test_support::block_on(super::check_envelope_status(
&envelope,
&fetch,
&test_expander,
&fixture_key(),
"2026-05-28T00:06:01Z",
))
.expect_err("a stale status list must refuse");
std::assert_eq!(error.code(), "APH_E008");
std::assert_eq!(fetch.calls(), 1);
}
#[test]
fn the_expander_never_runs_on_a_document_that_failed_an_earlier_check() {
let expanded = std::sync::atomic::AtomicUsize::new(0);
let counting = |compressed: &[u8]| -> std::result::Result<
std::vec::Vec<u8>,
crate::errors::AphError,
> {
expanded.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
test_expander(compressed)
};
let fetch = RecordingFetch::new(
&status_document(&[0x00]).replace(NOTARY_DID, "did:web:evil.example"),
);
let envelope = envelope_with(
std::option::Option::Some(entry_at(DERIVED_ENDPOINT, "0")),
std::option::Option::Some(MANDATE_ID),
);
let error = crate::discovery::test_support::block_on(super::check_envelope_status(
&envelope,
&fetch,
&counting,
&fixture_key(),
NOW,
))
.expect_err("a foreign issuer must refuse");
std::assert_eq!(error.code(), "APH_E008");
std::assert_eq!(
expanded.load(std::sync::atomic::Ordering::SeqCst),
0,
"the caller's decompressor must not see bytes from a rejected document"
);
}
#[test]
fn golden_envelope_bytes_are_unchanged_by_the_new_field() {
let raw = std::include_str!("../tests/golden/slack_reply_envelope.json");
let envelope: crate::envelope::NotarizationEnvelope =
serde_json::from_str(raw).expect("golden fixture parses");
std::assert!(
envelope.credential_status.is_none(),
"the golden predates the field and must parse with it absent"
);
let reserialized = serde_json::to_string(&envelope).expect("envelope serializes");
std::assert!(
!reserialized.contains("credentialStatus"),
"an absent status reference must emit no key at all"
);
let from_type: serde_json::Value =
serde_json::from_str(&reserialized).expect("round-trip parses");
let from_disk: serde_json::Value = serde_json::from_str(raw).expect("golden parses as JSON");
std::assert_eq!(
crate::crypto::jcs::canonicalize_rfc8785(&from_type),
crate::crypto::jcs::canonicalize_rfc8785(&from_disk),
"the canonical bytes a signature covers must be unchanged"
);
}
#[test]
fn proof_verification_method_is_surfaced_for_the_caller() {
let credential = super::parse_status_list_credential(&status_document(&[0x00])).unwrap();
std::assert_eq!(
credential.proof_verification_method(),
std::option::Option::Some("did:web:aph-notary.squillo.com#k1")
);
let unsigned =
super::parse_status_list_credential(&status_document_unsigned(&[0x00])).unwrap();
std::assert_eq!(
unsigned.proof_verification_method(),
std::option::Option::None
);
}
#[test]
fn both_entry_points_return_futures_that_can_cross_a_thread_boundary() {
let fetch = RecordingFetch::new(&status_document(&[0b0100_0000]));
let entry = entry_at(DERIVED_ENDPOINT, "0");
let envelope = envelope_with(
std::option::Option::Some(entry.clone()),
std::option::Option::Some(MANDATE_ID),
);
let expander: &super::ExpandEncodedList<'_> = &test_expander;
let key = fixture_key();
let envelope_future = super::check_envelope_status(&envelope, &fetch, expander, &key, NOW);
let direct_future =
super::check_credential_status(&entry, MANDATE_ID, NOTARY_DID, &fetch, expander, &key, NOW);
let outcomes = std::thread::scope(|scope| {
scope
.spawn(move || {
(
crate::discovery::test_support::block_on(envelope_future),
crate::discovery::test_support::block_on(direct_future),
)
})
.join()
})
.expect("the moved futures must complete on the other thread");
std::assert_eq!(
outcomes.0.expect("a clear bit admits the envelope"),
super::StatusCheck::NotRevoked
);
std::assert_eq!(
outcomes.1.expect("a clear bit admits the entry"),
super::StatusCheck::NotRevoked
);
}
}