use didwebvh_rs::DIDWebVHState;
use didwebvh_rs::log_entry::{LogEntry, LogEntryMethods};
use didwebvh_rs::log_entry_state::{LogEntryState, LogEntryValidationStatus};
use didwebvh_rs::parameters::Parameters;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DidRecord {
pub owner: String,
pub mnemonic: String,
pub created_at: u64,
pub updated_at: u64,
pub version_count: u64,
#[serde(default)]
pub did_id: Option<String>,
#[serde(default)]
pub content_size: u64,
#[serde(default)]
pub disabled: bool,
#[serde(default)]
pub deleted_at: Option<u64>,
#[serde(default = "default_method")]
pub method: String,
#[serde(default)]
pub domain: String,
#[serde(default)]
pub services: Option<Vec<String>>,
#[serde(default)]
pub agent_names: Vec<AgentNameEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentNameEntry {
pub name: String,
pub enabled: bool,
pub created_at: u64,
}
fn default_method() -> String {
"webvh".to_string()
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LogEntryInfo {
pub version_id: Option<String>,
pub version_time: Option<String>,
pub state: Option<serde_json::Value>,
pub parameters: Option<serde_json::Value>,
}
#[derive(Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LogMetadata {
pub log_entry_count: u64,
pub latest_version_id: Option<String>,
pub latest_version_time: Option<String>,
pub method: Option<String>,
pub portable: bool,
pub pre_rotation: bool,
pub deactivated: bool,
pub ttl: Option<u32>,
pub witnesses: bool,
pub witness_count: u32,
pub witness_threshold: u32,
pub watchers: bool,
pub watcher_count: u32,
pub watcher_urls: Vec<String>,
}
pub fn did_key(mnemonic: &str) -> String {
format!("did:{mnemonic}")
}
pub fn content_log_key(mnemonic: &str) -> String {
format!("content:{mnemonic}:log")
}
pub fn content_witness_key(mnemonic: &str) -> String {
format!("content:{mnemonic}:witness")
}
pub fn owner_key(did: &str, mnemonic: &str) -> String {
format!("owner:{did}:{mnemonic}")
}
pub fn agent_name_key(domain: &str, name: &str) -> String {
format!("name:{domain}:{name}")
}
pub fn watcher_sync_key(mnemonic: &str) -> String {
format!("watcher_sync:{mnemonic}")
}
pub const MAX_LOG_ENTRIES: usize = 10_000;
pub fn validate_did_jsonl(content: &str) -> Result<(), String> {
if content.is_empty() {
return Err("did.jsonl content cannot be empty".into());
}
let entry_count = content.lines().filter(|l| !l.trim().is_empty()).count();
if entry_count == 0 {
return Err("did.jsonl content has no entries".into());
}
if entry_count > MAX_LOG_ENTRIES {
return Err(format!(
"did.jsonl has too many log entries (limit {MAX_LOG_ENTRIES})"
));
}
for (idx, line) in content.lines().enumerate() {
let line = line.trim();
if line.is_empty() {
continue;
}
LogEntry::deserialize_string(line, None)
.map_err(|e| format!("invalid log entry at line {}: {e}", idx + 1))?;
}
match extract_did_id(content) {
Some(id) if id.starts_with("did:webvh:") => Ok(()),
Some(other) => Err(format!(
"did.jsonl latest entry must encode a did:webvh identifier (got {other})",
)),
None => Err("did.jsonl latest entry has no resolvable state.id".into()),
}
}
pub fn verify_did_log_proofs(content: &str) -> Result<(), String> {
verify_did_log_chain(content, None).map(|_| ())
}
pub fn verify_did_log_and_witness_proofs(
content: &str,
witness_content: Option<&str>,
) -> Result<(), String> {
let witnessed = verify_did_log_chain(content, witness_content)?;
if witnessed && witness_content.is_none_or(|w| w.trim().is_empty()) {
return Err(
"the DID log configures witnesses but no witness proofs were supplied; refusing to \
serve it unwitnessed"
.into(),
);
}
Ok(())
}
fn parse_and_verify_chain(content: &str) -> Result<DIDWebVHState, String> {
validate_did_jsonl(content)?;
let mut state = DIDWebVHState::default();
let mut version = None;
for (idx, line) in content.lines().enumerate() {
let line = line.trim();
if line.is_empty() {
continue;
}
let entry = LogEntry::deserialize_string(line, version)
.map_err(|e| format!("invalid log entry at line {}: {e}", idx + 1))?;
version = Some(entry.get_webvh_version());
let version_number = entry
.get_version_id_fields()
.map_err(|e| format!("invalid versionId at line {}: {e}", idx + 1))?
.0;
state.log_entries_mut().push(LogEntryState {
log_entry: entry,
version_number,
validation_status: LogEntryValidationStatus::NotValidated,
validated_parameters: Parameters::default(),
});
}
{
let entries = state.log_entries_mut();
for idx in 0..entries.len() {
let (before, rest) = entries.split_at_mut(idx);
rest[0].verify_log_entry(before.last()).map_err(|e| {
format!(
"proof verification failed: entry {} does not verify: {e}",
rest[0].get_version_id()
)
})?;
}
}
Ok(state)
}
#[cfg(feature = "server-core")]
pub fn verify_witness_proof_for_version(
content: &str,
version_id: &str,
proof_value: &serde_json::Value,
) -> Result<(), String> {
use affinidi_data_integrity::DataIntegrityProof;
let proof: DataIntegrityProof = serde_json::from_value(proof_value.clone())
.map_err(|e| format!("invalid witness proof shape: {e}"))?;
let state = parse_and_verify_chain(content)?;
let entry = state
.log_entries()
.iter()
.find(|e| e.get_version_id() == version_id)
.ok_or_else(|| {
format!(
"witness proof names versionId {version_id:?}, which is not in this DID's \
published log"
)
})?;
let witnesses = entry
.validated_parameters
.active_witness
.as_ref()
.and_then(|w| w.witnesses())
.ok_or_else(|| format!("versionId {version_id:?} does not configure an active witness"))?;
if !witnesses
.iter()
.any(|w| w.as_did_key() == proof.verification_method)
{
return Err(format!(
"witness proof's verificationMethod ({}) does not name a witness configured for \
versionId {version_id:?}",
proof.verification_method
));
}
let options = didwebvh_rs::witness::WitnessVerifyOptions::new();
entry
.log_entry
.validate_witness_proof(&proof, &options)
.map_err(|e| format!("witness proof failed verification: {e}"))?;
Ok(())
}
pub fn verify_did_log_for_publish(
content: &str,
witness_content: Option<&str>,
previously_published: usize,
) -> Result<(), String> {
let mut state = parse_and_verify_chain(content)?;
let previously_published = previously_published.min(state.log_entries().len());
if previously_published == 0 {
return Ok(());
}
let needs_witness = state.log_entries()[..previously_published].iter().any(|e| {
e.validated_parameters
.active_witness
.as_ref()
.is_some_and(|w| w.witnesses().is_some())
});
if !needs_witness {
return Ok(());
}
let highest_already_published = state.log_entries()[previously_published - 1].version_number;
let mut proofs = match witness_content.filter(|w| !w.trim().is_empty()) {
Some(raw) => DIDWebVHState::parse_witness_proofs(raw)
.map_err(|e| format!("invalid witness proofs: {e}"))?,
None => didwebvh_rs::witness::proofs::WitnessProofCollection::default(),
};
proofs
.generate_proof_state(highest_already_published)
.map_err(|e| format!("invalid witness proofs: {e}"))?;
let options = didwebvh_rs::witness::WitnessVerifyOptions::new();
for entry in state
.log_entries_mut()
.iter_mut()
.take(previously_published)
{
proofs
.validate_log_entry(entry, highest_already_published, &options)
.map_err(|e| format!("proof verification failed: {e}"))?;
}
Ok(())
}
pub fn servable_did_log(content: &str, witness_content: Option<&str>) -> Option<String> {
if validate_did_jsonl(content).is_err() {
return Some(content.to_string());
}
let mut state = DIDWebVHState::default();
let mut version = None;
for line in content.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(entry) = LogEntry::deserialize_string(line, version) else {
return Some(content.to_string());
};
version = Some(entry.get_webvh_version());
let Ok((version_number, _)) = entry.get_version_id_fields() else {
return Some(content.to_string());
};
state.log_entries_mut().push(LogEntryState {
log_entry: entry,
version_number,
validation_status: LogEntryValidationStatus::NotValidated,
validated_parameters: Parameters::default(),
});
}
if state.log_entries().is_empty() {
return Some(content.to_string());
}
let highest_version_number = state
.log_entries()
.last()
.map(|e| e.version_number)
.unwrap_or(0);
let mut proofs = witness_content
.filter(|w| !w.trim().is_empty())
.and_then(|raw| DIDWebVHState::parse_witness_proofs(raw).ok())
.unwrap_or_default();
let _ = proofs.generate_proof_state(highest_version_number);
let options = didwebvh_rs::witness::WitnessVerifyOptions::new();
let total = state.log_entries().len();
let mut servable = 0usize;
{
let entries = state.log_entries_mut();
for idx in 0..entries.len() {
let (before, rest) = entries.split_at_mut(idx);
let entry = &mut rest[0];
if entry.verify_log_entry(before.last()).is_err() {
return Some(content.to_string());
}
let witnessed_here = entry
.validated_parameters
.active_witness
.as_ref()
.is_some_and(|w| w.witnesses().is_some());
if witnessed_here
&& proofs
.validate_log_entry(entry, highest_version_number, &options)
.is_err()
{
break;
}
servable = idx + 1;
}
}
if servable == total {
return Some(content.to_string());
}
if servable == 0 {
return None;
}
let lines: Vec<String> = state.log_entries()[..servable]
.iter()
.filter_map(|e| serde_json::to_string(&e.log_entry).ok())
.collect();
Some(lines.join("\n"))
}
fn verify_did_log_chain(content: &str, witness_content: Option<&str>) -> Result<bool, String> {
validate_did_jsonl(content)?;
let mut state = DIDWebVHState::default();
let mut version = None;
for (idx, line) in content.lines().enumerate() {
let line = line.trim();
if line.is_empty() {
continue;
}
let entry = LogEntry::deserialize_string(line, version)
.map_err(|e| format!("invalid log entry at line {}: {e}", idx + 1))?;
version = Some(entry.get_webvh_version());
let version_number = entry
.get_version_id_fields()
.map_err(|e| format!("invalid versionId at line {}: {e}", idx + 1))?
.0;
state.log_entries_mut().push(LogEntryState {
log_entry: entry,
version_number,
validation_status: LogEntryValidationStatus::NotValidated,
validated_parameters: Parameters::default(),
});
}
if let Some(raw) = witness_content.filter(|w| !w.trim().is_empty()) {
let proofs = DIDWebVHState::parse_witness_proofs(raw)
.map_err(|e| format!("invalid witness proofs: {e}"))?;
state.set_witness_proofs(proofs);
}
let report = state
.validate()
.map_err(|e| format!("proof verification failed: {e}"))?;
report
.assert_complete()
.map_err(|e| format!("proof verification failed (chain incomplete): {e}"))?;
let witnessed = state.log_entries().last().is_some_and(|e| {
e.validated_parameters
.witness
.as_ref()
.and_then(|w| serde_json::to_value(w).ok())
.and_then(|v| v.get("threshold").and_then(|t| t.as_u64()))
.is_some_and(|t| t > 0)
});
Ok(witnessed)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WitnessLogRefusal {
InvalidLog(String),
VersionNotLast { last: String },
Deactivated,
NotListed,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WitnessedLog {
pub scid: String,
pub version_ids: Vec<String>,
}
pub fn verify_log_for_witnessing(
content: &str,
version_id: &str,
witness_did: &str,
) -> Result<WitnessedLog, WitnessLogRefusal> {
validate_did_jsonl(content).map_err(WitnessLogRefusal::InvalidLog)?;
let mut entries: Vec<LogEntryState> = Vec::new();
let mut version = None;
for (idx, line) in content.lines().enumerate() {
let line = line.trim();
if line.is_empty() {
continue;
}
let entry = LogEntry::deserialize_string(line, version).map_err(|e| {
WitnessLogRefusal::InvalidLog(format!("invalid log entry at line {}: {e}", idx + 1))
})?;
version = Some(entry.get_webvh_version());
let version_number = entry
.get_version_id_fields()
.map_err(|e| {
WitnessLogRefusal::InvalidLog(format!("invalid versionId at line {}: {e}", idx + 1))
})?
.0;
entries.push(LogEntryState {
log_entry: entry,
version_number,
validation_status: LogEntryValidationStatus::NotValidated,
validated_parameters: Parameters::default(),
});
}
let deactivating_index = entries
.iter()
.position(|e| e.log_entry.get_parameters().deactivated == Some(true));
let last_index = entries.len().checked_sub(1);
let past_deactivation =
matches!((deactivating_index, last_index), (Some(d), Some(last)) if d < last);
let verify_through = deactivating_index.map_or(entries.len(), |d| d + 1);
for idx in 0..verify_through {
let (before, rest) = entries.split_at_mut(idx);
let entry = &mut rest[0];
entry.verify_log_entry(before.last()).map_err(|e| {
WitnessLogRefusal::InvalidLog(format!(
"entry {} does not verify: {e}",
entry.get_version_id()
))
})?;
}
let last = entries
.last()
.ok_or_else(|| WitnessLogRefusal::InvalidLog("the log has no entries".into()))?;
if last.get_version_id() != version_id {
return Err(WitnessLogRefusal::VersionNotLast {
last: last.get_version_id().to_string(),
});
}
if past_deactivation {
return Err(WitnessLogRefusal::Deactivated);
}
let listed = last
.validated_parameters
.active_witness
.as_deref()
.and_then(|w| w.witnesses())
.is_some_and(|ws| {
ws.iter().any(|w| {
let id = w.id.to_string();
id == witness_did || format!("did:key:{id}") == witness_did
})
});
if !listed {
return Err(WitnessLogRefusal::NotListed);
}
let scid = last
.validated_parameters
.scid
.as_deref()
.map(|s| s.to_string())
.ok_or_else(|| WitnessLogRefusal::InvalidLog("the log establishes no SCID".into()))?;
Ok(WitnessedLog {
scid,
version_ids: entries
.iter()
.map(|e| e.get_version_id().to_string())
.collect(),
})
}
pub fn verify_log_extends(previous: Option<&str>, next: &str) -> Result<(), String> {
let Some(previous) = previous else {
return Ok(());
};
let entries = |log: &str| -> Vec<String> {
log.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect()
};
let old = entries(previous);
let new = entries(next);
if new.len() < old.len() || new[..old.len()] != old[..] {
return Err(format!(
"did.jsonl is not an extension of the hosted log ({} entries held, {} received, \
or the shared history differs); refusing a rollback or fork",
old.len(),
new.len()
));
}
let deactivated = old.iter().any(|line| {
serde_json::from_str::<serde_json::Value>(line)
.ok()
.and_then(|v| v.get("parameters")?.get("deactivated")?.as_bool())
.unwrap_or(false)
});
if deactivated && new.len() != old.len() {
return Err(
"the hosted DID is deactivated; refusing entries appended after deactivation".into(),
);
}
Ok(())
}
pub fn validate_did_id_matches_request(
did_id: &str,
request_path: &str,
server_base_url: &str,
) -> Result<(), String> {
use didwebvh_rs::url::WebVHURL;
use url::Url;
let id_parsed = WebVHURL::parse_did_url(did_id)
.map_err(|e| format!("did_log's DID identifier {did_id} is not a valid did:webvh: {e}"))?;
let expected_str = format!(
"{}/{request_path}/did.jsonl",
server_base_url.trim_end_matches('/')
);
let expected_url = Url::parse(&expected_str).map_err(|e| {
format!(
"internal: server_base_url {server_base_url} cannot form a valid URL when combined \
with the requested path: {e}"
)
})?;
let expected_parsed = WebVHURL::parse_url(&expected_url).map_err(|e| {
format!(
"internal: the URL this server would publish at ({expected_str}) cannot be expressed \
as a webvh URL: {e}"
)
})?;
if id_parsed.domain != expected_parsed.domain || id_parsed.port != expected_parsed.port {
return Err(format!(
"did_log's DID host (domain={}, port={:?}) does not match this server's host \
(domain={}, port={:?})",
id_parsed.domain, id_parsed.port, expected_parsed.domain, expected_parsed.port,
));
}
if id_parsed.path != expected_parsed.path {
return Err(format!(
"did_log's DID path '{}' does not resolve at the requested path '{}' on this server \
(server would publish at '{}')",
id_parsed.path.trim_matches('/'),
request_path,
expected_parsed.path.trim_matches('/'),
));
}
Ok(())
}
pub fn extract_did_id(jsonl_content: &str) -> Option<String> {
let last_line = jsonl_content.lines().rfind(|l| !l.trim().is_empty())?;
let value: serde_json::Value = serde_json::from_str(last_line).ok()?;
value
.get("state")
.and_then(|state| state.get("id"))
.and_then(|id| id.as_str())
.filter(|s| s.starts_with("did:webvh:"))
.map(|s| s.to_string())
}
pub fn extract_service_types(jsonl_content: &str) -> Option<Vec<String>> {
let last_line = jsonl_content.lines().rfind(|l| !l.trim().is_empty())?;
let value: serde_json::Value = serde_json::from_str(last_line).ok()?;
let state = value.get("state")?;
Some(crate::did::service_types_from_doc(state))
}
pub fn extract_agent_names(jsonl_content: &str, domain: &str) -> Vec<String> {
let Some(last_line) = jsonl_content.lines().rfind(|l| !l.trim().is_empty()) else {
return Vec::new();
};
let Ok(value) = serde_json::from_str::<serde_json::Value>(last_line) else {
return Vec::new();
};
let Some(state) = value.get("state") else {
return Vec::new();
};
agent_names_from_document(state, domain)
}
pub fn agent_names_from_document(document: &serde_json::Value, domain: &str) -> Vec<String> {
let Some(entries) = document.get("alsoKnownAs").and_then(|a| a.as_array()) else {
return Vec::new();
};
let mut names = Vec::new();
for entry in entries.iter().filter_map(|e| e.as_str()) {
let Ok(parsed) = agent_names::AgentName::parse(entry) else {
continue; };
if parsed.authority() != domain {
continue; }
let local = parsed.local_name().to_string();
if !names.contains(&local) {
names.push(local);
}
}
names
}
pub fn extract_log_metadata(jsonl_content: &str) -> LogMetadata {
let lines: Vec<&str> = jsonl_content.lines().collect();
let mut meta = LogMetadata {
log_entry_count: lines.len() as u64,
..Default::default()
};
let Some(last_line) = lines.last() else {
return meta;
};
let Ok(entry) = serde_json::from_str::<serde_json::Value>(last_line) else {
return meta;
};
meta.latest_version_id = entry
.get("versionId")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
meta.latest_version_time = entry
.get("versionTime")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
if let Some(params) = entry.get("parameters") {
meta.method = params
.get("method")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
meta.portable = params
.get("portable")
.and_then(|v| v.as_bool())
.unwrap_or(false);
meta.pre_rotation = params
.get("nextKeyHashes")
.and_then(|v| v.as_array())
.is_some_and(|a| !a.is_empty());
meta.deactivated = params
.get("deactivated")
.and_then(|v| v.as_bool())
.unwrap_or(false);
meta.ttl = params.get("ttl").and_then(|v| v.as_u64()).map(|v| v as u32);
if let Some(witness) = params.get("witness") {
let threshold = witness
.get("threshold")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
let count = witness
.get("witnesses")
.and_then(|v| v.as_array())
.map(|a| a.len() as u32)
.unwrap_or(0);
if count > 0 {
meta.witnesses = true;
meta.witness_count = count;
meta.witness_threshold = threshold;
}
}
if let Some(watchers_val) = params.get("watchers")
&& let Some(arr) = watchers_val.as_array()
&& !arr.is_empty()
{
meta.watchers = true;
meta.watcher_count = arr.len() as u32;
meta.watcher_urls = arr
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
}
}
meta
}
pub fn extract_did_web_document(jsonl_content: &str, expected_did_web: &str) -> Option<Vec<u8>> {
let last_line = jsonl_content.lines().last()?;
let entry: serde_json::Value = serde_json::from_str(last_line).ok()?;
let state = entry.get("state")?;
let webvh_id = state.get("id")?.as_str()?;
if !webvh_id.starts_with("did:webvh:") {
return None;
}
let also_known_as = state.get("alsoKnownAs")?.as_array()?;
let found = also_known_as
.iter()
.any(|v| v.as_str() == Some(expected_did_web));
if !found {
return None;
}
let state_json = serde_json::to_string(state).ok()?;
let rewritten = state_json.replace(webvh_id, expected_did_web);
Some(rewritten.into_bytes())
}
pub fn parse_log_entries(jsonl_content: &str) -> Vec<LogEntryInfo> {
jsonl_content
.lines()
.filter(|line| !line.trim().is_empty())
.filter_map(|line| {
let value: serde_json::Value = serde_json::from_str(line).ok()?;
Some(LogEntryInfo {
version_id: value
.get("versionId")
.and_then(|v| v.as_str())
.map(String::from),
version_time: value
.get("versionTime")
.and_then(|v| v.as_str())
.map(String::from),
state: value.get("state").cloned(),
parameters: value.get("parameters").cloned(),
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn legacy_did_record_deserialises_with_webvh_default_method() {
let legacy = r#"{
"owner": "did:example:owner",
"mnemonic": "tenant/user1",
"created_at": 1700000000,
"updated_at": 1700000000,
"version_count": 1,
"did_id": null,
"content_size": 0,
"disabled": false,
"deleted_at": null
}"#;
let rec: DidRecord = serde_json::from_str(legacy).unwrap();
assert_eq!(rec.method, "webvh");
assert_eq!(rec.domain, "");
}
#[test]
fn new_did_record_round_trips_method_and_domain() {
let original = DidRecord {
owner: "did:example:owner".into(),
mnemonic: "user1".into(),
created_at: 0,
updated_at: 0,
version_count: 1,
did_id: None,
content_size: 0,
disabled: false,
deleted_at: None,
method: "web".into(),
domain: "tenant-a.example.com".into(),
services: None,
agent_names: Vec::new(),
};
let json = serde_json::to_string(&original).unwrap();
let back: DidRecord = serde_json::from_str(&json).unwrap();
assert_eq!(back.method, "web");
assert_eq!(back.domain, "tenant-a.example.com");
}
#[test]
fn explicit_empty_domain_round_trips() {
let legacy = r#"{
"owner": "did:example:owner",
"mnemonic": "u",
"created_at": 0,
"updated_at": 0,
"version_count": 0,
"method": "webvh"
}"#;
let rec: DidRecord = serde_json::from_str(legacy).unwrap();
assert_eq!(rec.method, "webvh");
assert_eq!(rec.domain, "");
}
#[test]
fn extract_did_id_from_state_id() {
let jsonl = r#"{"versionId":"1-abc","parameters":{"method":"did:webvh:1.0"},"state":{"id":"did:webvh:abc123:example.com:test"}}"#;
assert_eq!(
extract_did_id(jsonl),
Some("did:webvh:abc123:example.com:test".to_string())
);
}
#[test]
fn extract_did_id_returns_none_without_state() {
let jsonl = r#"{"parameters":{"method":"did:webvh:1.0"}}"#;
assert_eq!(extract_did_id(jsonl), None);
}
#[test]
fn extract_did_id_returns_none_for_non_webvh() {
let jsonl = r#"{"state":{"id":"did:key:z6Mk..."}}"#;
assert_eq!(extract_did_id(jsonl), None);
}
#[test]
fn extract_did_id_uses_last_line() {
let jsonl = r#"{"state":{"id":"did:webvh:first:host:path"}}
{"state":{"id":"did:webvh:second:host:path"}}"#;
assert_eq!(
extract_did_id(jsonl),
Some("did:webvh:second:host:path".to_string())
);
}
#[test]
fn log_metadata_empty_content() {
let meta = extract_log_metadata("");
assert_eq!(meta.log_entry_count, 0);
assert_eq!(meta.latest_version_id, None);
}
#[test]
fn log_metadata_basic_entry() {
let jsonl = r#"{"versionId":"1-QmHash","versionTime":"2025-01-23T04:12:36Z","parameters":{"method":"did:webvh:1.0","portable":true}}"#;
let meta = extract_log_metadata(jsonl);
assert_eq!(meta.log_entry_count, 1);
assert_eq!(meta.latest_version_id.as_deref(), Some("1-QmHash"));
assert!(meta.portable);
}
#[test]
fn validate_jsonl_empty_rejected() {
assert!(validate_did_jsonl("").is_err());
}
#[test]
fn validate_jsonl_invalid_json_rejected() {
assert!(validate_did_jsonl("not json").is_err());
}
#[test]
fn validate_jsonl_too_many_entries_rejected() {
let content = "x\n".repeat(MAX_LOG_ENTRIES + 1);
let err = validate_did_jsonl(&content).unwrap_err();
assert!(
err.contains("too many log entries"),
"expected entry-count rejection, got: {err}"
);
}
#[test]
fn did_record_deserialize_without_content_size() {
let json = r#"{"owner":"did:example:a","mnemonic":"test","created_at":100,"updated_at":100,"version_count":1}"#;
let record: DidRecord = serde_json::from_str(json).unwrap();
assert_eq!(record.content_size, 0);
assert!(record.did_id.is_none());
}
#[test]
fn parse_log_entries_works() {
let jsonl = r#"{"versionId":"1-abc","state":{"id":"test"},"parameters":{"method":"1.0"}}"#;
let entries = parse_log_entries(jsonl);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].version_id.as_deref(), Some("1-abc"));
}
}
#[cfg(test)]
mod agent_name_tests {
use super::*;
fn log_with_aka(aka: &[&str]) -> String {
let list = aka
.iter()
.map(|s| format!("\"{s}\""))
.collect::<Vec<_>>()
.join(",");
format!(
"{{\"versionId\":\"1\",\"state\":{{\"id\":\"did:webvh:x:host.example\",\"alsoKnownAs\":[{list}]}}}}"
)
}
#[test]
fn extracts_names_on_the_hosting_domain() {
let log = log_with_aka(&["https://host.example/@alice", "host.example/@bob"]);
let mut names = extract_agent_names(&log, "host.example");
names.sort();
assert_eq!(names, vec!["alice", "bob"]);
}
#[test]
fn ignores_names_on_other_domains() {
let log = log_with_aka(&["https://elsewhere.example/@alice"]);
assert!(extract_agent_names(&log, "host.example").is_empty());
}
#[test]
fn ignores_non_agent_name_entries() {
let log = log_with_aka(&[
"did:web:host.example:someone",
"https://host.example/@alice",
]);
assert_eq!(extract_agent_names(&log, "host.example"), vec!["alice"]);
}
#[test]
fn deduplicates() {
let log = log_with_aka(&["host.example/@alice", "https://host.example/@alice"]);
assert_eq!(extract_agent_names(&log, "host.example"), vec!["alice"]);
}
#[test]
fn no_also_known_as_is_empty() {
let log = "{\"versionId\":\"1\",\"state\":{\"id\":\"did:webvh:x:host.example\"}}";
assert!(extract_agent_names(log, "host.example").is_empty());
}
}
#[cfg(test)]
mod log_extension_tests {
use super::verify_log_extends;
const V1: &str = r#"{"versionId":"1-a","parameters":{}}"#;
const V2: &str = r#"{"versionId":"2-b","parameters":{}}"#;
const V2_FORK: &str = r#"{"versionId":"2-x","parameters":{}}"#;
const V2_DEACT: &str = r#"{"versionId":"2-b","parameters":{"deactivated":true}}"#;
const V3: &str = r#"{"versionId":"3-c","parameters":{}}"#;
fn log(lines: &[&str]) -> String {
lines.join("\n") + "\n"
}
#[test]
fn a_first_log_is_accepted() {
verify_log_extends(None, &log(&[V1])).unwrap();
}
#[test]
fn appending_and_resending_are_accepted() {
verify_log_extends(Some(&log(&[V1])), &log(&[V1, V2])).unwrap();
verify_log_extends(Some(&log(&[V1, V2])), &log(&[V1, V2])).unwrap();
}
#[test]
fn a_shorter_log_is_a_rollback() {
let err = verify_log_extends(Some(&log(&[V1, V2])), &log(&[V1])).unwrap_err();
assert!(err.contains("rollback"), "{err}");
}
#[test]
fn a_diverging_log_is_a_fork() {
let err = verify_log_extends(Some(&log(&[V1, V2])), &log(&[V1, V2_FORK, V3])).unwrap_err();
assert!(err.contains("fork"), "{err}");
}
#[test]
fn a_deactivated_log_cannot_grow_or_be_replaced() {
let held = log(&[V1, V2_DEACT]);
verify_log_extends(Some(&held), &held).unwrap();
let err = verify_log_extends(Some(&held), &log(&[V1, V2_DEACT, V3])).unwrap_err();
assert!(err.contains("deactivated"), "{err}");
assert!(verify_log_extends(Some(&held), &log(&[V1, V2, V3])).is_err());
}
}
#[cfg(test)]
mod verify_for_publish_tests {
use std::sync::Arc;
use affinidi_data_integrity::{DataIntegrityProof, SignOptions};
use affinidi_secrets_resolver::secrets::Secret;
use chrono::{DateTime, Duration, FixedOffset, Utc};
use didwebvh_rs::Multibase;
use didwebvh_rs::witness::{Witness, Witnesses};
use serde_json::json;
use super::verify_did_log_for_publish;
use didwebvh_rs::{DIDWebVHState, parameters::Parameters};
fn base_time() -> DateTime<FixedOffset> {
(Utc::now() - Duration::seconds(10)).fixed_offset()
}
fn signing_key() -> Secret {
let mut key = Secret::generate_ed25519(None, None);
let pk = key.get_public_keymultibase().unwrap();
key.id = format!("did:key:{pk}#{pk}");
key
}
fn witness_identity() -> (Secret, String) {
let key = signing_key();
let witness_did = key.id.split('#').next().unwrap().to_string();
(key, witness_did)
}
fn doc_with_key(did: &str, key: &Secret) -> serde_json::Value {
let pk = key.get_public_keymultibase().unwrap();
json!({
"id": did,
"@context": ["https://www.w3.org/ns/did/v1"],
"verificationMethod": [{
"id": format!("{did}#key-0"),
"type": "Multikey",
"publicKeyMultibase": pk,
"controller": did
}],
"authentication": [format!("{did}#key-0")],
"assertionMethod": [format!("{did}#key-0")],
})
}
fn to_jsonl(state: &DIDWebVHState) -> String {
state
.log_entries()
.iter()
.map(|e| serde_json::to_string(&e.log_entry).unwrap())
.collect::<Vec<_>>()
.join("\n")
}
async fn sign_witness_proof(witness_key: &Secret, version_id: &str) -> DataIntegrityProof {
DataIntegrityProof::sign(
&json!({ "versionId": version_id }),
witness_key,
SignOptions::new(),
)
.await
.expect("sign witness proof")
}
fn witness_file(version_id: &str, proof: &DataIntegrityProof) -> String {
json!([{ "versionId": version_id, "proof": [proof] }]).to_string()
}
fn witnessed_genesis_params(key: &Secret, witness_did: &str, threshold: u32) -> Parameters {
Parameters {
update_keys: Some(Arc::new(vec![Multibase::new(
key.get_public_keymultibase().unwrap(),
)])),
portable: Some(false),
witness: Some(Arc::new(Witnesses::Value {
threshold,
witnesses: vec![Witness {
id: Multibase::new(witness_did.to_string()),
}],
})),
..Default::default()
}
}
#[tokio::test]
async fn a_freshly_registered_witnessed_genesis_is_accepted_with_no_proof_yet() {
let key = signing_key();
let (_witness_key, witness_did) = witness_identity();
let params = witnessed_genesis_params(&key, &witness_did, 1);
let doc = doc_with_key("did:webvh:{SCID}:example.com", &key);
let mut state = DIDWebVHState::default();
state
.create_log_entry(None, &doc, ¶ms, &key)
.await
.expect("genesis entry");
let jsonl = to_jsonl(&state);
verify_did_log_for_publish(&jsonl, None, 0)
.expect("a witnessed DID's first version registers with no witness proof yet");
}
#[tokio::test]
async fn a_republish_still_requires_the_stored_proof_for_already_published_entries() {
let key = signing_key();
let (witness_key, witness_did) = witness_identity();
let params = witnessed_genesis_params(&key, &witness_did, 1);
let doc = doc_with_key("did:webvh:{SCID}:example.com", &key);
let mut state = DIDWebVHState::default();
state
.create_log_entry(Some(base_time()), &doc, ¶ms, &key)
.await
.expect("genesis entry");
let genesis_version_id = state
.log_entries()
.last()
.unwrap()
.get_version_id()
.to_string();
let genesis_doc = state.log_entries().last().unwrap().get_state().clone();
state
.create_log_entry(
Some(base_time() + Duration::seconds(1)),
&genesis_doc,
&Parameters::default(),
&key,
)
.await
.expect("second entry");
let jsonl = to_jsonl(&state);
let err = verify_did_log_for_publish(&jsonl, None, 1)
.expect_err("the stored genesis has no witness proof yet; threshold not met");
assert!(err.contains("threshold"), "{err}");
let proof = sign_witness_proof(&witness_key, &genesis_version_id).await;
let witness_content = witness_file(&genesis_version_id, &proof);
verify_did_log_for_publish(&jsonl, Some(&witness_content), 1).expect(
"the already-published entry's stored witness proof satisfies its threshold; \
the new tail needs none",
);
}
#[tokio::test]
async fn an_unwitnessed_log_registers_and_republishes_normally() {
let key = signing_key();
let params = Parameters {
update_keys: Some(Arc::new(vec![Multibase::new(
key.get_public_keymultibase().unwrap(),
)])),
portable: Some(false),
..Default::default()
};
let doc = doc_with_key("did:webvh:{SCID}:example.com", &key);
let mut state = DIDWebVHState::default();
state
.create_log_entry(Some(base_time()), &doc, ¶ms, &key)
.await
.expect("genesis entry");
verify_did_log_for_publish(&to_jsonl(&state), None, 0).expect("plain register");
let genesis_doc = state.log_entries().last().unwrap().get_state().clone();
state
.create_log_entry(
Some(base_time() + Duration::seconds(1)),
&genesis_doc,
&Parameters::default(),
&key,
)
.await
.expect("second entry");
verify_did_log_for_publish(&to_jsonl(&state), None, 1).expect("plain republish");
}
#[tokio::test]
async fn a_broken_chain_is_refused_even_as_new_tail() {
let key = signing_key();
let params = Parameters {
update_keys: Some(Arc::new(vec![Multibase::new(
key.get_public_keymultibase().unwrap(),
)])),
portable: Some(false),
..Default::default()
};
let doc = doc_with_key("did:webvh:{SCID}:example.com", &key);
let mut state = DIDWebVHState::default();
state
.create_log_entry(None, &doc, ¶ms, &key)
.await
.expect("genesis entry");
let mut jsonl = to_jsonl(&state);
jsonl.push_str("\nnot a log entry");
let err =
verify_did_log_for_publish(&jsonl, None, 0).expect_err("garbage tail must be refused");
assert!(!err.is_empty());
}
#[tokio::test]
async fn servable_serves_an_unwitnessed_log_unchanged() {
let key = signing_key();
let params = Parameters {
update_keys: Some(Arc::new(vec![Multibase::new(
key.get_public_keymultibase().unwrap(),
)])),
portable: Some(false),
..Default::default()
};
let doc = doc_with_key("did:webvh:{SCID}:example.com", &key);
let mut state = DIDWebVHState::default();
state
.create_log_entry(None, &doc, ¶ms, &key)
.await
.expect("genesis entry");
let jsonl = to_jsonl(&state);
assert_eq!(super::servable_did_log(&jsonl, None), Some(jsonl));
}
#[tokio::test]
async fn servable_refuses_a_witnessed_genesis_with_no_proof() {
let key = signing_key();
let (_witness_key, witness_did) = witness_identity();
let params = witnessed_genesis_params(&key, &witness_did, 1);
let doc = doc_with_key("did:webvh:{SCID}:example.com", &key);
let mut state = DIDWebVHState::default();
state
.create_log_entry(None, &doc, ¶ms, &key)
.await
.expect("genesis entry");
let jsonl = to_jsonl(&state);
assert_eq!(super::servable_did_log(&jsonl, None), None);
}
#[tokio::test]
async fn servable_truncates_an_unwitnessed_tail_but_keeps_the_witnessed_prefix() {
let key = signing_key();
let (witness_key, witness_did) = witness_identity();
let params = witnessed_genesis_params(&key, &witness_did, 1);
let doc = doc_with_key("did:webvh:{SCID}:example.com", &key);
let mut state = DIDWebVHState::default();
state
.create_log_entry(Some(base_time()), &doc, ¶ms, &key)
.await
.expect("genesis entry");
let genesis_jsonl = to_jsonl(&state);
let genesis_version_id = state
.log_entries()
.last()
.unwrap()
.get_version_id()
.to_string();
let genesis_doc = state.log_entries().last().unwrap().get_state().clone();
state
.create_log_entry(
Some(base_time() + Duration::seconds(1)),
&genesis_doc,
&Parameters::default(),
&key,
)
.await
.expect("second entry");
let full_jsonl = to_jsonl(&state);
let proof = sign_witness_proof(&witness_key, &genesis_version_id).await;
let witness_content = witness_file(&genesis_version_id, &proof);
assert_eq!(
super::servable_did_log(&full_jsonl, Some(&witness_content)),
Some(genesis_jsonl),
"only the witnessed genesis is servable; the unwitnessed republish is truncated"
);
}
#[test]
fn servable_fails_open_on_content_it_cannot_parse() {
let fixture =
"{\"versionId\":\"1-test\",\"state\":{\"id\":\"did:webvh:test:example.com:alice\"}}";
assert_eq!(
super::servable_did_log(fixture, None),
Some(fixture.to_string())
);
}
#[tokio::test]
async fn witness_proof_for_version_accepts_a_proof_from_the_named_witness() {
let key = signing_key();
let (witness_key, witness_did) = witness_identity();
let params = witnessed_genesis_params(&key, &witness_did, 1);
let doc = doc_with_key("did:webvh:{SCID}:example.com", &key);
let mut state = DIDWebVHState::default();
state
.create_log_entry(None, &doc, ¶ms, &key)
.await
.expect("genesis entry");
let jsonl = to_jsonl(&state);
let version_id = state
.log_entries()
.last()
.unwrap()
.get_version_id()
.to_string();
let proof = sign_witness_proof(&witness_key, &version_id).await;
let proof_value = serde_json::to_value(&proof).unwrap();
super::verify_witness_proof_for_version(&jsonl, &version_id, &proof_value)
.expect("a proof from the named witness, over the right versionId, must verify");
}
#[tokio::test]
async fn witness_proof_for_version_rejects_a_proof_from_an_unnamed_key() {
let key = signing_key();
let (_witness_key, witness_did) = witness_identity();
let params = witnessed_genesis_params(&key, &witness_did, 1);
let doc = doc_with_key("did:webvh:{SCID}:example.com", &key);
let mut state = DIDWebVHState::default();
state
.create_log_entry(None, &doc, ¶ms, &key)
.await
.expect("genesis entry");
let jsonl = to_jsonl(&state);
let version_id = state
.log_entries()
.last()
.unwrap()
.get_version_id()
.to_string();
let (forger_key, _forger_did) = witness_identity();
let proof = sign_witness_proof(&forger_key, &version_id).await;
let proof_value = serde_json::to_value(&proof).unwrap();
let err = super::verify_witness_proof_for_version(&jsonl, &version_id, &proof_value)
.expect_err("a proof from a non-witness key must not verify");
assert!(err.contains("does not name a witness"), "{err}");
}
#[tokio::test]
async fn witness_proof_for_version_rejects_an_unknown_version_id() {
let key = signing_key();
let (witness_key, witness_did) = witness_identity();
let params = witnessed_genesis_params(&key, &witness_did, 1);
let doc = doc_with_key("did:webvh:{SCID}:example.com", &key);
let mut state = DIDWebVHState::default();
state
.create_log_entry(None, &doc, ¶ms, &key)
.await
.expect("genesis entry");
let jsonl = to_jsonl(&state);
let proof = sign_witness_proof(&witness_key, "99-doesnotexist").await;
let proof_value = serde_json::to_value(&proof).unwrap();
let err = super::verify_witness_proof_for_version(&jsonl, "99-doesnotexist", &proof_value)
.expect_err("an unpublished versionId must be refused");
assert!(err.contains("not in this DID's"), "{err}");
}
#[tokio::test]
async fn witness_proof_for_version_rejects_a_malformed_proof_shape() {
let key = signing_key();
let (_witness_key, witness_did) = witness_identity();
let params = witnessed_genesis_params(&key, &witness_did, 1);
let doc = doc_with_key("did:webvh:{SCID}:example.com", &key);
let mut state = DIDWebVHState::default();
state
.create_log_entry(None, &doc, ¶ms, &key)
.await
.expect("genesis entry");
let jsonl = to_jsonl(&state);
let version_id = state
.log_entries()
.last()
.unwrap()
.get_version_id()
.to_string();
let bogus = json!({ "type": "DataIntegrityProof" });
let err = super::verify_witness_proof_for_version(&jsonl, &version_id, &bogus)
.expect_err("a malformed proof shape must be refused");
assert!(err.contains("invalid witness proof shape"), "{err}");
}
}