1use techne_lexicon_check::chain;
35use techne_lexicon_check::{parse_at_uri, same_name, same_uri};
36use techne_lexicon_check::{dag_cbor, decide, Decision, Endorsements, Role, Vouch};
37use serde::{Deserialize, Serialize};
38use serde_json::Value;
39
40const OPERATOR: &str = "pub.techne.draft.operator";
42
43#[derive(Debug, Clone, Deserialize)]
45pub struct Record {
46 pub uri: String,
47 pub cid: String,
48 pub value: Value,
49 #[serde(default, rename = "firstSeen")]
53 pub first_seen: Option<String>,
54}
55
56impl Record {
57 fn as_chain(&self) -> chain::Record<'_> {
58 chain::Record { uri: &self.uri, cid: &self.cid, body: &self.value }
59 }
60
61 pub fn intact(&self) -> bool {
63 dag_cbor::cid_for_json(&self.value).is_ok_and(|c| c.to_string() == self.cid)
64 }
65}
66
67#[derive(Debug, Clone, Deserialize)]
69pub struct KnownRole {
70 pub member: String,
71 pub collective: String,
72 pub role: Option<i16>,
74}
75
76#[derive(Debug, Clone, Deserialize)]
78pub struct KnownAdmission {
79 pub author: String,
80 pub space: String,
81 pub admitted: bool,
82}
83
84#[derive(Debug, Clone, Default, Deserialize)]
86#[serde(default)]
87pub struct Claims {
88 pub root: Option<String>,
90 pub records: Vec<Record>,
94 pub roles: Vec<KnownRole>,
95 pub admissions: Vec<KnownAdmission>,
96}
97
98impl Claims {
99 fn intact(&self) -> impl Iterator<Item = &Record> {
100 self.records.iter().filter(|r| r.intact())
101 }
102
103 fn issuer_of<'a>(&'a self, cred: &'a Record) -> Option<&'a str> {
106 let root = self.root.as_deref()?;
107 let (repo, _, _) = parse_at_uri(&cred.uri)?;
108 if repo == root {
109 return Some(root);
110 }
111 let seen = cred.first_seen.as_deref();
112 self.intact().any(|o| names_operator(root, o, repo, seen)).then_some(repo)
113 }
114
115 fn revoked(&self, cred: &Record) -> bool {
117 let Some(at) = cred.value.get("revocation").and_then(Value::as_str) else { return false };
121 let at_root = match (self.root.as_deref(), parse_at_uri(at)) {
122 (Some(root), Some((_, collection, rkey))) => Some(format!("at://{root}/{collection}/{rkey}")),
123 _ => None,
124 };
125 self.records.iter().any(|r| same_uri(&r.uri, at) || at_root.as_deref().is_some_and(|a| same_uri(&r.uri, a)))
126 }
127
128 fn held_vouch(&self, did: &str) -> Option<Vouch<'_>> {
131 let pairs: Vec<Vouch<'_>> = self
132 .intact()
133 .filter(|decl| parse_at_uri(&decl.uri).is_some_and(|(repo, _, _)| repo == did))
134 .flat_map(|decl| {
135 self.intact()
136 .filter(|cred| cred.value.get("record").and_then(Value::as_str).is_some_and(|r| same_uri(r, &decl.uri)))
137 .map(move |cred| Vouch { declaration: decl.as_chain(), credential: cred.as_chain(), revocation_present: self.revoked(cred) })
138 })
139 .collect();
140 pairs.iter().find(|v| self.vouches(v)).or(pairs.first()).copied()
141 }
142
143 fn vouches(&self, v: &Vouch<'_>) -> bool {
146 self.credential_issuer(v.credential.uri).is_some_and(|issuer| chain::vouched(issuer, v.credential, v.declaration, v.revocation_present))
147 }
148
149 fn vouched_declaration(&self, did: &str) -> Option<&Value> {
151 self.held_vouch(did).filter(|v| self.vouches(v)).map(|v| v.declaration.body)
152 }
153}
154
155fn names_operator(root: &str, o: &Record, did: &str, seen: Option<&str>) -> bool {
159 let Some((repo, collection, rkey)) = parse_at_uri(&o.uri) else { return false };
160 let named = repo == root
161 && same_name(collection, OPERATOR)
162 && o.value.get("subject").and_then(Value::as_str) == Some(did)
163 && (rkey == did || rkey.strip_prefix(did).is_some_and(|rest| rest.starts_with('-')));
164 let instant = |t: &str| chrono::DateTime::parse_from_rfc3339(t).ok();
165 let at = seen.and_then(instant);
166 let end = |key: &str, holds: fn(i64, i64) -> bool| match o.value.get(key) {
168 None => true,
169 Some(t) => match (t.as_str().and_then(instant), at) {
170 (Some(t), Some(at)) => holds(t.timestamp_micros(), at.timestamp_micros()),
171 _ => false,
172 },
173 };
174 named && end("validFrom", |from, at| from <= at) && end("validUntil", |until, at| until > at)
175}
176
177impl Endorsements for Claims {
178 fn root(&self) -> Option<&str> {
179 self.root.as_deref()
180 }
181 fn endorses(&self, did: &str, collection: &str) -> bool {
182 self.vouched_declaration(did).is_some_and(|decl| chain::writer(decl, collection))
183 }
184 fn credential_issuer<'a>(&'a self, credential_uri: &'a str) -> Option<&'a str> {
187 let mut issuers = self.intact().filter(|r| r.uri == credential_uri).map(|cred| self.issuer_of(cred));
189 let first = issuers.next()??;
190 issuers.all(|i| i.is_some()).then_some(first)
191 }
192}
193
194impl techne_lexicon_check::Claims for Claims {
195 fn role_at(&self, member: &str, collective: &str, _at: &str) -> Role {
196 match self.roles.iter().find(|r| r.member == member && r.collective == collective) {
197 Some(KnownRole { role: Some(n), .. }) => Role::Held(*n),
198 Some(_) => Role::NotHeld,
199 None => Role::Unknown,
200 }
201 }
202 fn admitted(&self, author: &str, space: &str, _at: &str) -> Option<bool> {
203 self.admissions.iter().find(|a| a.author == author && a.space == space).map(|a| a.admitted)
204 }
205 fn vouch(&self, author: &str) -> Option<Vouch<'_>> {
206 self.held_vouch(author)
207 }
208}
209
210#[derive(Debug, Clone, PartialEq, Serialize)]
212#[serde(rename_all = "camelCase")]
213pub struct Verification {
214 pub valid: bool,
215 pub signer: String,
216 pub chain_verified: bool,
217 pub problems: Vec<String>,
219}
220
221pub fn verify(record: &Record, claims: &Claims) -> Verification {
223 let Some((signer, collection, rkey)) = parse_at_uri(&record.uri) else {
224 return Verification { valid: false, signer: String::new(), chain_verified: false, problems: vec!["uri: not an AT-URI".into()] };
225 };
226 let intact = record.intact();
227 let mut problems = vec![];
228 if !intact {
229 problems.push(format!("cid: the body does not hash to {}", record.cid));
230 }
231 let version = techne_lexicon_check::Version { collection, author: signer, rkey, body: &record.value, space: None };
232 match decide(&version, "", claims) {
233 Decision::Valid => {}
234 Decision::Pending => problems.push("pending: a claim it needs is not known".into()),
235 Decision::Invalid { reason } => problems.push(reason),
236 }
237 Verification {
238 valid: problems.is_empty(),
239 signer: signer.to_string(),
240 chain_verified: intact && claims.endorses(signer, collection),
242 problems,
243 }
244}