Skip to main content

techne_sdk/
verify.rs

1//! What an app can check for itself about a record it read (P13.5): the pure check from
2//! `techne-lexicon-check`, over records the app holds, with nothing asked of the platform.
3//!
4//! [`verify`] answers three things about one record:
5//! - **valid**: its body hashes to the CID it was served with (a tampered body does not), and
6//!   [`decide`] calls the version valid (lexicon, placement, provenance) over the claims the app holds;
7//! - **signer**: the repo it is from (the DID in its AT-URI). atproto signs a repo's commits, not each
8//!   record, so this is whose repo the record claims to be in; proving the commit signature needs the
9//!   commit and its MST path, which a read does not carry;
10//! - **chainVerified**: an admin writer vouches for the signer's `publisher/self` declaration and the
11//!   declaration covers the record's collection ([`chain::vouched`], [`chain::writer`]), checked
12//!   offline: the credential pins the declaration by CID, and every record used is first checked
13//!   against its own CID (which proves a body matches the CID it came with, not which repo served it:
14//!   the credential's own place is its AT-URI in its issuer's repo, as `chain::vouched` requires). False
15//!   whenever the record itself is not intact.
16//!
17//! The admin writers are the platform's (provenancePolicy#vouchedWriter, "option (b)"): the commons
18//! root, and an operator the root names in a `pub.techne.draft.operator` record (in the root's repo, at
19//! rkey `{did}` or `{did}-{millis}`, `subject` = that DID) whose period (`validFrom` inclusive,
20//! `validUntil` exclusive, an absent end open) holds when the app first saw the credential
21//! ([`Record::first_seen`]). A credential is revoked by a record at its own `revocation` address (in the
22//! issuer's repo) or at the same rkey in the root's repo, so the root can revoke an operator's credential.
23//!
24//! The caller holds one declaration per account, its current one: an edited declaration is untrusted
25//! until an admin writer re-issues, and an app that kept the old one beside an unrevoked credential would
26//! still count it. Likewise one version per credential: when it holds several at one address, the
27//! credential counts only if every one of them was first seen while its issuer was an admin writer.
28//! Roles and admissions are as of the version's first-seen time, as the caller asserts.
29//!
30//! [`Claims`] is the data form of `techne_lexicon_check::Claims`: the root, the records the app has read
31//! (credentials, declarations, revocations, the root's operator records), and any roles and admissions
32//! it knows.
33
34use 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
40/// The root's statement that an account is an operator for a period.
41const OPERATOR: &str = "pub.techne.draft.operator";
42
43/// One record as read: its address, the CID it was served with, and its body.
44#[derive(Debug, Clone, Deserialize)]
45pub struct Record {
46    pub uri: String,
47    pub cid: String,
48    pub value: Value,
49    /// When the app first saw this version (RFC 3339). Read for a credential an operator issued: it
50    /// counts only if the root names the operator for a period holding then. Without it, only a period
51    /// open at both ends holds.
52    #[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    /// The body hashes to `cid`.
62    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/// A member's role in a collective, as the app knows it.
68#[derive(Debug, Clone, Deserialize)]
69pub struct KnownRole {
70    pub member: String,
71    pub collective: String,
72    /// The role held; `None`: a membership is known but none covered the time.
73    pub role: Option<i16>,
74}
75
76/// Whether an author passed a space's write policy, as the app knows it.
77#[derive(Debug, Clone, Deserialize)]
78pub struct KnownAdmission {
79    pub author: String,
80    pub space: String,
81    pub admitted: bool,
82}
83
84/// What the app holds to check records against.
85#[derive(Debug, Clone, Default, Deserialize)]
86#[serde(default)]
87pub struct Claims {
88    /// The commons root DID.
89    pub root: Option<String>,
90    /// Records the app has read: credentials and revocations (the root's and its operators'), the root's
91    /// operator records, accounts' declarations.
92    /// A record whose body does not hash to its CID is ignored.
93    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    /// The admin writer that issued `cred`, if its repo is one for it: the root, or an operator one of
104    /// the root's intact operator records names for a period holding at `cred`'s first-seen time.
105    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    /// A record is held at `cred`'s `revocation` address, or at the same rkey in the root's repo.
116    fn revoked(&self, cred: &Record) -> bool {
117        // A revocation counts wherever it is read: its presence is what revokes. A credential keeps the
118        // spelling it was issued under, so its `revocation` matches a record read under any spelling of
119        // the collection (`same_uri`: same repo and rkey, a renamed collection's old or new name).
120        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    /// An admin writer's credential over `did`'s declaration, both held and intact: the pair that vouches
129    /// now if there is one, else the first pair that names it (so `decide` can say why it does not).
130    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    /// `v` vouches now: its credential's issuer is an admin writer for it ([`Self::credential_issuer`])
144    /// and the chain holds.
145    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    /// `did`'s `publisher/self` declaration, when an admin writer vouches for it now.
150    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
155/// `o` is one of `root`'s operator records naming `did` (`subject` = `did`, at rkey `did` or
156/// `{did}-...`) for a period holding at `seen` (`validFrom <= seen < validUntil`; an absent end is open,
157/// a malformed time holds at no time, and with no `seen` only a period open at both ends holds).
158fn 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    // Compared at microseconds, the platform's timestamptz precision.
167    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    /// The repo holding the credential at `credential_uri`, when it is an admin writer for every intact
185    /// version held there ([`Claims::issuer_of`]); `None` when none is held.
186    fn credential_issuer<'a>(&'a self, credential_uri: &'a str) -> Option<&'a str> {
187        // Every version held at one address is in one repo, so they share an issuer when they have one.
188        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/// What an app shows for one record.
211#[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    /// Why it is not valid (empty when it is).
218    pub problems: Vec<String>,
219}
220
221/// Verify one record against what the app holds. See the module docs.
222pub 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        // A tampered body says nothing about its signer's chain either: fail closed.
241        chain_verified: intact && claims.endorses(signer, collection),
242        problems,
243    }
244}