#![cfg(feature = "method-webs")]
use affinidi_did_webs::{DidWebs, Kels, resolve_from_artifacts};
use super::{DidMethod, MethodError, ParsedDid};
pub const KERI_CESR: &str = affinidi_did_webs::KERI_CESR;
pub const DID_JSON: &str = affinidi_did_webs::DID_JSON;
pub const CESR_CONTENT_TYPE: &str = "application/cesr";
pub struct Webs;
impl DidMethod for Webs {
fn name(&self) -> &'static str {
"webs"
}
fn content_type(&self) -> &'static str {
"application/did+json"
}
fn data_ext(&self) -> &'static str {
"json"
}
fn parse_identifier(&self, did: &str) -> Result<ParsedDid, MethodError> {
let parsed = DidWebs::parse(did).map_err(|e| {
if did.starts_with("did:webs:") {
MethodError::Malformed(format!("{did}: {e}"))
} else {
MethodError::MethodMismatch {
expected: "webs",
found: super::parse_did_method(did)
.map(|s| s.to_string())
.unwrap_or_else(|_| "<malformed>".into()),
}
}
})?;
let rest = did
.strip_prefix("did:webs:")
.and_then(|r| r.split(['?', '#']).next())
.unwrap_or_default();
let domain = rest
.split(':')
.next()
.filter(|s| !s.is_empty())
.ok_or_else(|| MethodError::Malformed(did.to_string()))?
.to_string();
let mut path_labels: Vec<&str> = parsed.path().iter().map(String::as_str).collect();
path_labels.push(parsed.aid());
let path = path_labels.join(":");
Ok(ParsedDid {
method: "webs",
scid: Some(parsed.aid().to_string()),
domain,
path,
})
}
fn resolution_url(&self, domain: &str, mnemonic: &str) -> String {
Self::artifact_url(domain, mnemonic, DID_JSON)
}
fn validate(&self, data: &[u8]) -> Result<(), MethodError> {
if data.iter().all(u8::is_ascii_whitespace) {
return Err(MethodError::Validation(
"keri.cesr is empty / whitespace-only".into(),
));
}
let kels = Kels::parse(data)
.map_err(|e| MethodError::Validation(format!("keri.cesr could not be read: {e}")))?;
let has_inception = kels
.messages()
.iter()
.filter_map(|m| m.serder.ilk().ok())
.any(|ilk| ilk == "icp" || ilk == "dip");
if !has_inception {
return Err(MethodError::Validation(
"keri.cesr carries no inception event — not a key event log".into(),
));
}
Ok(())
}
fn apply_update(
&self,
_existing: Option<&[u8]>,
new_data: &[u8],
) -> Result<Vec<u8>, MethodError> {
self.validate(new_data)?;
Ok(new_data.to_vec())
}
}
impl Webs {
pub fn artifact_url(domain: &str, mnemonic: &str, artifact: &str) -> String {
let path = mnemonic.replace(':', "/");
if path.is_empty() {
return format!("https://{domain}/{artifact}");
}
format!("https://{domain}/{path}/{artifact}")
}
pub fn keri_cesr_url(domain: &str, mnemonic: &str) -> String {
Self::artifact_url(domain, mnemonic, KERI_CESR)
}
pub fn verify_artifacts(
did: &str,
keri_cesr: &[u8],
did_json: Option<&[u8]>,
) -> Result<Vec<u8>, MethodError> {
let parsed =
DidWebs::parse(did).map_err(|e| MethodError::Malformed(format!("{did}: {e}")))?;
let document = resolve_from_artifacts(&parsed, keri_cesr, did_json)
.map_err(|e| MethodError::Validation(format!("{did}: {e}")))?;
serde_json::to_vec(&document).map_err(|e| {
MethodError::Validation(format!("derived document could not be serialized: {e}"))
})
}
pub fn derive_document(did: &str, keri_cesr: &[u8]) -> Result<Vec<u8>, MethodError> {
Self::verify_artifacts(did, keri_cesr, None)
}
pub fn verify_continuation(
did: &str,
existing: &[u8],
new_data: &[u8],
) -> Result<(), MethodError> {
let parsed =
DidWebs::parse(did).map_err(|e| MethodError::Malformed(format!("{did}: {e}")))?;
let Ok(old_kels) = Kels::parse(existing) else {
return Ok(());
};
let Ok(old_state) = old_kels.key_state(parsed.aid()) else {
return Ok(());
};
let new_kels = Kels::parse(new_data)
.map_err(|e| MethodError::Validation(format!("keri.cesr could not be read: {e}")))?;
let new_state = new_kels
.key_state(parsed.aid())
.map_err(|e| MethodError::Validation(format!("key event log did not verify: {e}")))?;
if new_state.prefix != old_state.prefix {
return Err(MethodError::Validation(format!(
"update establishes {} but the hosted log is for {}",
new_state.prefix, old_state.prefix,
)));
}
if new_state.sn < old_state.sn {
return Err(MethodError::Validation(format!(
"update rewinds the key event log: hosted is at sequence {}, update is at {}",
old_state.sn, new_state.sn,
)));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
const KERI: &[u8] = include_bytes!("../../tests/fixtures/ENro7uf0.keri.cesr");
const PUBLISHED_DID_JSON: &[u8] = include_bytes!("../../tests/fixtures/ENro7uf0.did.json");
const AID: &str = "ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe";
const DID: &str =
"did:webs:did-webs-service%3a7676:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe";
#[test]
fn name_and_metadata() {
let m = Webs;
assert_eq!(m.name(), "webs");
assert_eq!(m.content_type(), "application/did+json");
assert_eq!(m.data_ext(), "json");
}
#[test]
fn parse_identifier_host_and_aid() {
let p = Webs
.parse_identifier(&format!("did:webs:example.com:{AID}"))
.unwrap();
assert_eq!(p.method, "webs");
assert_eq!(p.scid.as_deref(), Some(AID));
assert_eq!(p.domain, "example.com");
assert_eq!(p.path, AID);
}
#[test]
fn parse_identifier_with_path_segments() {
let p = Webs
.parse_identifier(&format!("did:webs:example.com:tenants:acme:{AID}"))
.unwrap();
assert_eq!(p.domain, "example.com");
assert_eq!(p.path, format!("tenants:acme:{AID}"));
}
#[test]
fn parse_identifier_keeps_port_percent_encoded() {
let p = Webs.parse_identifier(DID).unwrap();
assert_eq!(p.domain, "did-webs-service%3a7676");
assert_eq!(p.path, AID);
}
#[test]
fn parse_identifier_strips_query_and_fragment() {
let p = Webs
.parse_identifier(&format!("did:webs:example.com:{AID}#key-1"))
.unwrap();
assert_eq!(p.path, AID);
}
#[test]
fn parse_identifier_rejects_wrong_method() {
let err = Webs
.parse_identifier("did:web:example.com:user1")
.expect_err("did:web must reject");
assert!(matches!(
err,
MethodError::MethodMismatch {
expected: "webs",
..
}
));
}
#[test]
fn parse_identifier_rejects_host_only() {
assert!(Webs.parse_identifier("did:webs:example.com").is_err());
}
#[test]
fn parse_identifier_rejects_a_last_label_outside_the_cesr_alphabet() {
assert!(
Webs.parse_identifier("did:webs:example.com:not/an/aid")
.is_err()
);
assert!(Webs.parse_identifier("did:webs:example.com:ab").is_err());
}
#[test]
fn parse_identifier_accepts_a_plausible_but_bogus_aid() {
let p = Webs
.parse_identifier("did:webs:example.com:user1")
.expect("syntactically valid");
assert_eq!(p.scid.as_deref(), Some("user1"));
let err = Webs::verify_artifacts("did:webs:example.com:user1", KERI, None)
.expect_err("no key event log establishes `user1`");
assert!(matches!(err, MethodError::Validation(_)));
}
#[test]
fn parse_identifier_rejects_empty_label() {
assert!(
Webs.parse_identifier(&format!("did:webs:example.com::{AID}"))
.is_err()
);
}
#[test]
fn resolution_url_puts_aid_last() {
let url = Webs.resolution_url("example.com", AID);
assert_eq!(url, format!("https://example.com/{AID}/did.json"));
}
#[test]
fn resolution_url_converts_colons_to_slashes() {
let url = Webs.resolution_url("example.com", &format!("tenants:acme:{AID}"));
assert_eq!(
url,
format!("https://example.com/tenants/acme/{AID}/did.json")
);
}
#[test]
fn keri_cesr_url_sits_beside_the_document() {
assert_eq!(
Webs::keri_cesr_url("example.com", AID),
format!("https://example.com/{AID}/keri.cesr")
);
}
#[test]
fn validate_accepts_the_reference_stream() {
Webs.validate(KERI).expect("reference keri.cesr must parse");
}
#[test]
fn validate_rejects_empty() {
assert!(Webs.validate(b"").is_err());
assert!(Webs.validate(b" \n").is_err());
}
#[test]
fn validate_rejects_a_webvh_log_line() {
let err = Webs
.validate(br#"{"versionId":"1-abc","versionTime":"2025-01-01T00:00:00Z"}"#)
.expect_err("a webvh log line is not a key event log");
assert!(matches!(err, MethodError::Validation(_)));
assert!(err.to_string().contains("inception"));
}
#[test]
fn validate_rejects_a_stream_with_events_but_no_inception() {
let first_ixn = KERI
.windows(4)
.skip(1)
.position(|w| w == br#"{"v""#)
.map(|p| p + 1)
.expect("stream has more than one message");
let err = Webs
.validate(&KERI[first_ixn..])
.expect_err("a KEL without its inception is not a KEL");
assert!(err.to_string().contains("inception"));
}
#[test]
fn verify_artifacts_derives_the_document() {
let doc = Webs::verify_artifacts(DID, KERI, None).expect("reference artifacts verify");
let v: serde_json::Value = serde_json::from_slice(&doc).unwrap();
assert_eq!(v["id"].as_str(), Some(DID));
let vms = v["verificationMethod"].as_array().expect("vm array");
assert_eq!(vms.len(), 1);
assert_eq!(
vms[0]["publicKeyJwk"]["kid"].as_str(),
Some("DHr0-I-mMN7h6cLMOTRJkkfPuMd0vgQPrOk4Y3edaHjr"),
);
}
#[test]
fn verify_artifacts_derives_also_known_as_from_the_attestation() {
let doc = Webs::verify_artifacts(DID, KERI, None).expect("verify");
let v: serde_json::Value = serde_json::from_slice(&doc).unwrap();
let aka: Vec<&str> = v["alsoKnownAs"]
.as_array()
.expect("alsoKnownAs")
.iter()
.filter_map(|a| a.as_str())
.collect();
assert!(
aka.contains(
&"did:web:did-webs-service%3a7676:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe"
),
"twin missing from {aka:?}",
);
assert!(
aka.iter().any(|a| a.ends_with(&format!("foo.com:{AID}"))),
"attested alias missing from {aka:?}",
);
}
#[test]
fn verify_artifacts_accepts_the_published_document() {
Webs::verify_artifacts(DID, KERI, Some(PUBLISHED_DID_JSON))
.expect("published did.json agrees with the derivation");
}
#[test]
fn verify_artifacts_rejects_a_document_publishing_other_keys() {
let mut published: serde_json::Value = serde_json::from_slice(PUBLISHED_DID_JSON).unwrap();
published["verificationMethod"][0]["publicKeyJwk"]["kid"] =
serde_json::json!("DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
let bytes = serde_json::to_vec(&published).unwrap();
let err = Webs::verify_artifacts(DID, KERI, Some(&bytes))
.expect_err("a document publishing keys the KEL never authorised must be rejected");
assert!(matches!(err, MethodError::Validation(_)));
}
#[test]
fn verify_artifacts_rejects_a_stream_for_another_aid() {
let other = format!("did:webs:example.com:E{}", "A".repeat(43));
let err = Webs::verify_artifacts(&other, KERI, None)
.expect_err("the KEL establishes a different AID");
assert!(matches!(err, MethodError::Validation(_)));
}
#[test]
fn verify_artifacts_rejects_a_tampered_stream() {
let mut tampered = KERI.to_vec();
let pos = tampered
.windows(3)
.position(|w| w == b"\"s\"")
.expect("inception has an `s` field");
tampered[pos + 5] = b'9';
let err = Webs::verify_artifacts(DID, &tampered, None)
.expect_err("a tampered key event log must not resolve");
assert!(matches!(err, MethodError::Validation(_)));
}
#[test]
fn verify_artifacts_rejects_a_non_webs_did() {
let err = Webs::verify_artifacts("did:web:example.com:user1", KERI, None)
.expect_err("must reject");
assert!(matches!(err, MethodError::Malformed(_)));
}
#[test]
fn derive_document_matches_verify_artifacts() {
let a = Webs::derive_document(DID, KERI).unwrap();
let b = Webs::verify_artifacts(DID, KERI, None).unwrap();
assert_eq!(a, b, "derivation must be deterministic");
}
#[test]
fn apply_update_replaces_rather_than_appends() {
let out = Webs
.apply_update(Some(KERI), KERI)
.expect("republishing the same stream is valid");
assert_eq!(out, KERI, "the stream is replaced, not doubled");
}
#[test]
fn apply_update_rejects_an_unreadable_stream() {
assert!(Webs.apply_update(Some(KERI), b"not a cesr stream").is_err());
}
#[test]
fn verify_continuation_accepts_the_same_log() {
Webs::verify_continuation(DID, KERI, KERI).expect("republishing the same log is a no-op");
}
#[test]
fn verify_continuation_tolerates_an_unreadable_stored_log() {
Webs::verify_continuation(DID, b"corrupt", KERI)
.expect("an unreadable stored log must not block the update that repairs it");
}
#[test]
fn verify_continuation_rejects_an_unreadable_update() {
assert!(Webs::verify_continuation(DID, KERI, b"not cesr").is_err());
}
#[test]
fn verify_continuation_rejects_a_rewind() {
let text = KERI;
let second_event = text
.windows(4)
.skip(1)
.position(|w| w == br#"{"v""#)
.map(|p| p + 1)
.expect("stream has more than one message");
let truncated = &text[..second_event];
Webs.validate(truncated)
.expect("the inception event alone is a readable stream");
let err = Webs::verify_continuation(DID, KERI, truncated)
.expect_err("an update may not rewind the key event log");
assert!(matches!(err, MethodError::Validation(_)));
assert!(err.to_string().contains("rewinds"));
}
}