use std::ops::ControlFlow;
use std::path::{Path, PathBuf};
use auths_id::ports::registry::{RegistryBackend, RegistryError};
use auths_keri::witness::{encode_nontrans_receipt_couples, parse_nontrans_receipt_couples};
use auths_keri::{Event, Prefix, Said};
pub const OOBI_KEL_FILE: &str = "keri.cesr";
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum OobiExportError {
#[error("KEL not found for {0}")]
NotFound(String),
#[error("registry read failed: {0}")]
Backend(#[source] RegistryError),
#[error("serializing KEL failed: {0}")]
Serialize(#[source] serde_json::Error),
#[error("writing OOBI files failed: {0}")]
Io(#[source] std::io::Error),
#[error("CESR receipt couplet failed: {0}")]
Cesr(String),
}
pub fn oobi_relative_path(prefix: &Prefix) -> PathBuf {
Path::new(".well-known")
.join("keri")
.join("oobi")
.join(prefix.as_str())
.join(OOBI_KEL_FILE)
}
pub fn export_identity_oobi(
registry: &dyn RegistryBackend,
prefix: &Prefix,
out_root: &Path,
) -> Result<PathBuf, OobiExportError> {
let events = read_kel(registry, prefix)?;
let path = out_root.join(oobi_relative_path(prefix));
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(OobiExportError::Io)?;
}
let body = serde_json::to_vec_pretty(&events).map_err(OobiExportError::Serialize)?;
std::fs::write(&path, body).map_err(OobiExportError::Io)?;
Ok(path)
}
pub fn parse_oobi_kel(body: &[u8]) -> Result<Vec<Event>, OobiExportError> {
serde_json::from_slice(body).map_err(OobiExportError::Serialize)
}
pub const OOBI_RECEIPTS_FILE: &str = "receipts.cesr";
pub type ReceiptCouple = (Prefix, Vec<u8>);
pub type EventReceiptGroup = (Said, Vec<ReceiptCouple>);
pub fn oobi_receipts_relative_path(prefix: &Prefix) -> PathBuf {
Path::new(".well-known")
.join("keri")
.join("oobi")
.join(prefix.as_str())
.join(OOBI_RECEIPTS_FILE)
}
pub fn export_receipts_oobi(
out_root: &Path,
prefix: &Prefix,
receipts: &[EventReceiptGroup],
) -> Result<PathBuf, OobiExportError> {
let mut map = serde_json::Map::new();
for (said, couples) in receipts {
let refs: Vec<(&Prefix, &[u8])> = couples.iter().map(|(w, s)| (w, s.as_slice())).collect();
let group = encode_nontrans_receipt_couples(&refs)
.map_err(|e| OobiExportError::Cesr(e.to_string()))?;
map.insert(said.as_str().to_string(), serde_json::Value::String(group));
}
let path = out_root.join(oobi_receipts_relative_path(prefix));
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(OobiExportError::Io)?;
}
let body = serde_json::to_vec_pretty(&serde_json::Value::Object(map))
.map_err(OobiExportError::Serialize)?;
std::fs::write(&path, body).map_err(OobiExportError::Io)?;
Ok(path)
}
pub fn parse_oobi_receipts(body: &[u8]) -> Result<Vec<EventReceiptGroup>, OobiExportError> {
let map: serde_json::Map<String, serde_json::Value> =
serde_json::from_slice(body).map_err(OobiExportError::Serialize)?;
let mut out = Vec::with_capacity(map.len());
for (said, group) in map {
let g = group.as_str().ok_or_else(|| {
OobiExportError::Cesr(format!("receipt group for {said} is not a string"))
})?;
let couples =
parse_nontrans_receipt_couples(g).map_err(|e| OobiExportError::Cesr(e.to_string()))?;
out.push((Said::new_unchecked(said), couples));
}
Ok(out)
}
fn read_kel(
registry: &dyn RegistryBackend,
prefix: &Prefix,
) -> Result<Vec<Event>, OobiExportError> {
let mut events = Vec::new();
match registry.visit_events(prefix, 0, &mut |e| {
events.push(e.clone());
ControlFlow::Continue(())
}) {
Ok(()) => {}
Err(RegistryError::NotFound { .. }) => {
return Err(OobiExportError::NotFound(format!("did:keri:{prefix}")));
}
Err(e) => return Err(OobiExportError::Backend(e)),
}
if events.is_empty() {
return Err(OobiExportError::NotFound(format!("did:keri:{prefix}")));
}
Ok(events)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::git::{GitRegistryBackend, RegistryConfig};
use auths_core::crypto::said::{compute_next_commitment, compute_said};
use auths_keri::{
CesrKey, IcpEvent, KeriPublicKey, KeriSequence, Said, Threshold, VersionString,
finalize_icp_event,
};
use tempfile::TempDir;
fn icp_and_prefix() -> (Event, Prefix) {
let key = KeriPublicKey::ed25519(&[9u8; 32]).unwrap();
let next = KeriPublicKey::ed25519(&[10u8; 32]).unwrap();
let icp = IcpEvent {
v: VersionString::placeholder(),
d: Said::default(),
i: Prefix::default(),
s: KeriSequence::new(0),
kt: Threshold::Simple(1),
k: vec![CesrKey::new_unchecked(key.to_qb64().unwrap())],
nt: Threshold::Simple(1),
n: vec![compute_next_commitment(&next)],
bt: Threshold::Simple(0),
b: vec![],
c: vec![],
a: vec![],
};
let finalized = finalize_icp_event(icp).unwrap();
let prefix = finalized.i.clone();
(Event::Icp(finalized), prefix)
}
fn registry_with_icp() -> (TempDir, GitRegistryBackend, Event, Prefix) {
let dir = TempDir::new().unwrap();
let backend =
GitRegistryBackend::from_config_unchecked(RegistryConfig::single_tenant(dir.path()));
backend.init_if_needed().unwrap();
let (event, prefix) = icp_and_prefix();
backend.append_event(&prefix, &event).unwrap();
(dir, backend, event, prefix)
}
#[test]
fn exports_and_round_trips_verbatim() {
let (_src, backend, event, prefix) = registry_with_icp();
let out = TempDir::new().unwrap();
let path = export_identity_oobi(&backend, &prefix, out.path()).unwrap();
assert!(path.ends_with(oobi_relative_path(&prefix)));
assert!(path.exists());
let body = std::fs::read(&path).unwrap();
let parsed = parse_oobi_kel(&body).unwrap();
assert_eq!(parsed, vec![event]);
}
#[test]
fn exported_inception_prefix_matches_aid() {
let (_src, backend, _event, prefix) = registry_with_icp();
let out = TempDir::new().unwrap();
let path = export_identity_oobi(&backend, &prefix, out.path()).unwrap();
let parsed = parse_oobi_kel(&std::fs::read(&path).unwrap()).unwrap();
let value = serde_json::to_value(&parsed[0]).unwrap();
let derived = compute_said(&value).unwrap();
assert_eq!(derived.as_str(), prefix.as_str());
}
#[test]
fn missing_identity_is_not_found() {
let (_src, backend, _event, _prefix) = registry_with_icp();
let out = TempDir::new().unwrap();
let missing =
Prefix::new_unchecked("ENotProvisioned00000000000000000000000000000".to_string());
let err = export_identity_oobi(&backend, &missing, out.path()).unwrap_err();
assert!(matches!(err, OobiExportError::NotFound(_)));
}
#[test]
fn oobi_keri_cesr_carries_receipts() {
let out = TempDir::new().unwrap();
let prefix =
Prefix::new_unchecked("EAidForReceipts00000000000000000000000000000".to_string());
let said = Said::new_unchecked("EEventReceipted0000000000000000000000000000".to_string());
let w1 = Prefix::new_unchecked(
KeriPublicKey::ed25519(&[0x11u8; 32])
.unwrap()
.to_qb64()
.unwrap(),
);
let w2 = Prefix::new_unchecked(
KeriPublicKey::ed25519(&[0x33u8; 32])
.unwrap()
.to_qb64()
.unwrap(),
);
let groups = vec![(
said.clone(),
vec![
(w1.clone(), vec![0x22u8; 64]),
(w2.clone(), vec![0x44u8; 64]),
],
)];
let path = export_receipts_oobi(out.path(), &prefix, &groups).unwrap();
assert!(path.ends_with("receipts.cesr"));
let body = std::fs::read(&path).unwrap();
let parsed = parse_oobi_receipts(&body).unwrap();
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].0, said);
assert_eq!(parsed[0].1.len(), 2);
assert_eq!(parsed[0].1[0].0.as_str(), w1.as_str());
assert_eq!(parsed[0].1[0].1, vec![0x22u8; 64]);
assert_eq!(parsed[0].1[1].0.as_str(), w2.as_str());
assert_eq!(parsed[0].1[1].1, vec![0x44u8; 64]);
}
}