use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use vti_common::error::AppError;
use crate::config_store::ConfigStore;
pub const WITNESSES_TRUSTED: &str = "witnesses.trusted";
pub const MAX_TRUSTED_WITNESSES: usize = 64;
pub const MAX_LABEL_CHARS: usize = 128;
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub struct WitnessesConfig {
#[serde(default)]
pub trusted: Vec<TrustedWitness>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct TrustedWitness {
pub did: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}
impl WitnessesConfig {
pub fn is_empty(&self) -> bool {
self.trusted.is_empty()
}
pub fn validate(&self, own_did: Option<&str>) -> Result<(), AppError> {
validate_entries(&self.trusted, own_did)
.map_err(|e| AppError::Config(format!("[witnesses] trusted: {e}")))
}
}
pub fn parse_list(value: &Value, own_did: Option<&str>) -> Result<Vec<TrustedWitness>, AppError> {
let list: Vec<TrustedWitness> = serde_json::from_value(value.clone()).map_err(|e| {
AppError::Validation(format!(
"expected an array of {{ \"did\": \"did:…\", \"label\": \"…\" }} objects: {e}"
))
})?;
validate_entries(&list, own_did).map_err(AppError::Validation)?;
Ok(list)
}
fn validate_entries(list: &[TrustedWitness], own_did: Option<&str>) -> Result<(), String> {
if list.len() > MAX_TRUSTED_WITNESSES {
return Err(format!(
"{} witnesses listed; at most {MAX_TRUSTED_WITNESSES} may be",
list.len()
));
}
let mut seen = BTreeSet::new();
for w in list {
vti_common::identifier::validate_did("did", &w.did).map_err(|e| e.to_string())?;
if w.did.contains(['#', '?', '/']) {
return Err(format!(
"{} is not a bare DID: name the witness, not one of its keys or resources",
w.did
));
}
if own_did == Some(w.did.as_str()) {
return Err(format!(
"{} is this community's own DID. A witness has value only as a party \
independent of what it attests, and a community never witnesses \
(VTI-KEY-007)",
w.did
));
}
if !seen.insert(w.did.as_str()) {
return Err(format!("{} is listed twice", w.did));
}
if let Some(label) = &w.label {
if label.trim().is_empty() {
return Err(format!("the label of {} is blank; omit it instead", w.did));
}
if label.chars().count() > MAX_LABEL_CHARS {
return Err(format!(
"the label of {} is longer than {MAX_LABEL_CHARS} characters",
w.did
));
}
if label.chars().any(char::is_control) {
return Err(format!(
"the label of {} contains a control character",
w.did
));
}
}
}
Ok(())
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TrustedWitnesses {
dids: BTreeSet<String>,
}
impl TrustedWitnesses {
pub fn none() -> Self {
Self::default()
}
pub fn from_list(list: &[TrustedWitness]) -> Self {
Self {
dids: list.iter().map(|w| w.did.clone()).collect(),
}
}
pub fn contains(&self, issuer: &str) -> bool {
self.dids.contains(issuer)
}
pub fn is_empty(&self) -> bool {
self.dids.is_empty()
}
}
pub async fn live(cfg: &crate::config::AppConfig, db: &ConfigStore) -> TrustedWitnesses {
match db.get(WITNESSES_TRUSTED).await {
Ok(Some(value)) => match parse_list(&value, cfg.vtc_did.as_deref()) {
Ok(list) => TrustedWitnesses::from_list(&list),
Err(e) => {
tracing::warn!(
error = %e,
"stored `{WITNESSES_TRUSTED}` does not parse; trusting no witness until it is \
rewritten"
);
TrustedWitnesses::none()
}
},
Ok(None) => TrustedWitnesses::from_list(&cfg.witnesses.trusted),
Err(e) => {
tracing::warn!(
error = %e,
"could not read `{WITNESSES_TRUSTED}`; trusting no witness for this decision"
);
TrustedWitnesses::none()
}
}
}
pub async fn live_for(state: &crate::server::AppState) -> TrustedWitnesses {
let cfg = state.config.read().await.clone();
live(&cfg, &ConfigStore::new(state.config_ks.clone())).await
}
pub async fn current_value(state: &crate::server::AppState) -> Result<Value, AppError> {
if let Some(value) = ConfigStore::new(state.config_ks.clone())
.get(WITNESSES_TRUSTED)
.await?
{
return Ok(value);
}
let cfg = state.config.read().await;
serde_json::to_value(&cfg.witnesses.trusted)
.map_err(|e| AppError::Internal(format!("serialising the witness list: {e}")))
}
pub async fn change_in<'a>(
state: &crate::server::AppState,
overrides: impl IntoIterator<Item = (&'a String, &'a Value)>,
) -> Result<Option<(Vec<TrustedWitness>, Vec<TrustedWitness>)>, AppError> {
let Some((_, value)) = overrides
.into_iter()
.find(|(k, _)| k.as_str() == WITNESSES_TRUSTED)
else {
return Ok(None);
};
let own = state.config.read().await.vtc_did.clone();
let Ok(next) = parse_list(value, own.as_deref()) else {
return Ok(None);
};
let current = current_value(state).await?;
let prev = parse_list(¤t, own.as_deref()).unwrap_or_default();
let sorted = |l: &[TrustedWitness]| {
let mut l = l.to_vec();
l.sort_by(|a, b| a.did.cmp(&b.did));
l
};
Ok((sorted(&prev) != sorted(&next)).then_some((prev, next)))
}
pub fn describe(list: &[TrustedWitness]) -> String {
if list.is_empty() {
return "none".into();
}
list.iter()
.map(|w| match &w.label {
Some(label) => format!("{label} ({})", w.did),
None => w.did.clone(),
})
.collect::<Vec<_>>()
.join(", ")
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
const W: &str = "did:webvh:QmScid:witness.example";
#[test]
fn the_default_trusts_no_witness() {
let cfg = WitnessesConfig::default();
assert!(cfg.is_empty());
assert!(TrustedWitnesses::from_list(&cfg.trusted).is_empty());
assert!(!TrustedWitnesses::none().contains(W));
}
#[test]
fn a_listed_witness_is_trusted_verbatim() {
let t = TrustedWitnesses::from_list(&[TrustedWitness {
did: W.into(),
label: Some("Independent witness".into()),
}]);
assert!(t.contains(W));
assert!(!t.contains(&W.to_uppercase()), "no case folding");
assert!(
!t.contains(&format!("{W}#key-0")),
"a key is not the witness"
);
}
#[test]
fn a_patch_value_parses_and_round_trips() {
let v = json!([{ "did": W, "label": "A" }, { "did": "did:key:z6MkWitness" }]);
let list = parse_list(&v, None).unwrap();
assert_eq!(list.len(), 2);
assert_eq!(serde_json::to_value(&list).unwrap(), v);
}
#[test]
fn malformed_lists_are_refused() {
let own = "did:webvh:QmScid:vtc.example";
let refused = [
json!("did:key:z6Mk"), json!([W]), json!([{ "did": "not-a-did" }]), json!([{ "did": format!("{W}#key-0") }]), json!([{ "did": W }, { "did": W }]), json!([{ "did": W, "label": " " }]), json!([{ "did": W, "label": "a\nb" }]), json!([{ "did": W, "label": "x".repeat(MAX_LABEL_CHARS + 1) }]),
json!([{ "did": W, "weight": 2 }]), json!([{ "did": own }]), ];
for v in refused {
assert!(parse_list(&v, Some(own)).is_err(), "{v} must be refused");
}
let too_many: Vec<_> = (0..=MAX_TRUSTED_WITNESSES)
.map(|n| json!({ "did": format!("did:key:z6MkWitness{n}") }))
.collect();
assert!(parse_list(&json!(too_many), None).is_err());
}
#[test]
fn the_toml_section_reads_and_validates() {
let cfg: WitnessesConfig = toml::from_str(&format!(
"trusted = [{{ did = \"{W}\", label = \"Independent\" }}]"
))
.unwrap();
assert!(cfg.validate(None).is_ok());
assert!(cfg.validate(Some(W)).is_err(), "never the community itself");
assert!(toml::from_str::<WitnessesConfig>("trusted = [{ did = \"x\", y = 1 }]").is_err());
}
}