Skip to main content

didbot_repo/
verify.rs

1//! Reading one record out of another server's repository, from the proof
2//! that server answers `com.atproto.sync.getRecord` with.
3//!
4//! The steps are the ones [`Proof`](crate::Proof) lists for a verifier, and
5//! each fails closed. Every block must hash to its name. The commit must be
6//! the repository's own and verify against its key. The walk from the
7//! commit's tree root must reach the key through blocks the file holds.
8
9use didbot_data::dag_cbor;
10use didbot_data::mst::{Store, StoreError};
11
12use crate::car::{self, CarError};
13use crate::commit::{CommitError, CommitKey, SignedCommit};
14use crate::repository::tree_key;
15
16/// Why a proof establishes nothing about a record.
17#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
18pub enum ProofError {
19    /// The file is not a CAR file whose blocks hash to their names.
20    #[error("the proof is not a readable car file: {0}")]
21    Car(#[from] CarError),
22    /// The file roots at something other than one commit.
23    #[error("the proof declares {0} roots, not one commit")]
24    Roots(usize),
25    /// The root's block is not in the file.
26    #[error("the proof does not hold its own root")]
27    NoCommit,
28    /// The root is not a commit the repository signed with this key.
29    #[error(transparent)]
30    Commit(#[from] CommitError),
31    /// The walk from the tree root needs a block the file does not hold, or
32    /// the tree is malformed.
33    #[error("the proof's tree does not reach the record: {0}")]
34    Tree(#[from] StoreError),
35    /// The tree names a record, and the file does not hold it as one.
36    #[error("the record the tree names is not in the proof: {0}")]
37    Record(String),
38}
39
40/// The record at `collection`/`rkey` in `did`'s repository, as `proof`
41/// shows it, in its JSON form. `None` when the proof shows the repository
42/// holds nothing there.
43///
44/// `key` must come from `did`'s own DID document; see
45/// [`SignedCommit::verify`]. The record's `$type` must be `collection`.
46pub fn read_record(
47    proof: &[u8],
48    did: &str,
49    key: &CommitKey,
50    collection: &str,
51    rkey: &str,
52) -> Result<Option<serde_json::Value>, ProofError> {
53    let car = car::read(proof)?;
54    let [root] = car.roots.as_slice() else {
55        return Err(ProofError::Roots(car.roots.len()));
56    };
57    let commit = SignedCommit::decode(car.blocks.get(root).ok_or(ProofError::NoCommit)?)?;
58    commit.verify_with(did, key)?;
59
60    // A block that does not read as a tree node is left out: the record's
61    // own block is one, and the walk reaches it by the CID the tree names.
62    // A node the walk needs and this left out fails the walk.
63    let mut tree = Store::default();
64    for (cid, block) in &car.blocks {
65        if cid != root && tree.insert(cid, block.clone()).is_err() {
66            continue;
67        }
68    }
69    let Some(record) = tree
70        .find(&commit.commit().data, &tree_key(collection, rkey))?
71        .value
72    else {
73        return Ok(None);
74    };
75    let block = car
76        .blocks
77        .get(&record)
78        .ok_or_else(|| ProofError::Record(format!("{record} is not in the file")))?;
79    let value = dag_cbor::decode(block)
80        .map_err(|error| ProofError::Record(error.to_string()))?
81        .to_json();
82    if value.get("$type").and_then(serde_json::Value::as_str) != Some(collection) {
83        return Err(ProofError::Record(format!(
84            "{record} is not a `{collection}` record"
85        )));
86    }
87    Ok(Some(value))
88}
89
90#[cfg(test)]
91mod tests {
92    use didbot_key::SigningKey;
93    use serde_json::{json, Value};
94
95    use super::*;
96    use crate::{Builder, Repository};
97
98    const DID: &str = "did:web:lexicons.example";
99    const SCHEMA: &str = "com.atproto.lexicon.schema";
100    /// The multicodec a P-256 public key is published under.
101    const P256_MULTICODEC: u64 = 0x1200;
102
103    fn schema(nsid: &str) -> Value {
104        json!({ "$type": SCHEMA, "lexicon": 1, "id": nsid, "defs": {} })
105    }
106
107    fn repository(key: &SigningKey) -> Repository {
108        let mut repo = Builder::new();
109        for nsid in [
110            "com.example.authFull",
111            "com.example.authBasic",
112            "org.example.authAll",
113        ] {
114            repo.insert(SCHEMA, nsid, &schema(nsid)).expect("a record");
115        }
116        repo.commit(DID, "3lqmnopqrstuv", None, key)
117    }
118
119    fn secp256k1(key: &SigningKey) -> CommitKey {
120        CommitKey::Secp256k1(key.verifying_key())
121    }
122
123    #[test]
124    fn a_record_is_read_out_of_its_proof() {
125        let key = SigningKey::generate();
126        let proof = repository(&key)
127            .proof(SCHEMA, "com.example.authFull")
128            .to_car();
129        assert_eq!(
130            read_record(
131                &proof,
132                DID,
133                &secp256k1(&key),
134                SCHEMA,
135                "com.example.authFull"
136            ),
137            Ok(Some(schema("com.example.authFull")))
138        );
139    }
140
141    #[test]
142    fn a_proof_of_absence_reads_as_nothing() {
143        let key = SigningKey::generate();
144        let proof = repository(&key)
145            .proof(SCHEMA, "com.example.authNone")
146            .to_car();
147        assert_eq!(
148            read_record(
149                &proof,
150                DID,
151                &secp256k1(&key),
152                SCHEMA,
153                "com.example.authNone"
154            ),
155            Ok(None)
156        );
157    }
158
159    /// The commit is what fixes the tree, so a proof is only worth the
160    /// commit's signature, checked with the repository's own key.
161    #[test]
162    fn a_proof_under_another_key_or_repository_is_refused() {
163        let key = SigningKey::generate();
164        let proof = repository(&key)
165            .proof(SCHEMA, "com.example.authFull")
166            .to_car();
167        let stranger = secp256k1(&SigningKey::generate());
168        assert_eq!(
169            read_record(&proof, DID, &stranger, SCHEMA, "com.example.authFull"),
170            Err(ProofError::Commit(CommitError::Unverified))
171        );
172        assert!(matches!(
173            read_record(
174                &proof,
175                "did:web:other.example",
176                &secp256k1(&key),
177                SCHEMA,
178                "com.example.authFull"
179            ),
180            Err(ProofError::Commit(CommitError::WrongRepo { .. }))
181        ));
182    }
183
184    /// A proof carries one record and the path to it. Read for another
185    /// record the repository holds, it answers neither with that record nor
186    /// with "nothing there".
187    #[test]
188    fn a_proof_answers_only_for_its_own_record() {
189        let key = SigningKey::generate();
190        let proof = repository(&key)
191            .proof(SCHEMA, "com.example.authFull")
192            .to_car();
193        for held in ["com.example.authBasic", "org.example.authAll"] {
194            assert!(
195                read_record(&proof, DID, &secp256k1(&key), SCHEMA, held).is_err(),
196                "{held}"
197            );
198        }
199    }
200
201    #[test]
202    fn a_record_of_another_type_is_refused() {
203        let key = SigningKey::generate();
204        let mut repo = Builder::new();
205        repo.insert(
206            SCHEMA,
207            "com.example.authFull",
208            &json!({ "$type": "com.example.other", "text": "not a schema" }),
209        )
210        .expect("a record");
211        let proof = repo
212            .commit(DID, "3lqmnopqrstuv", None, &key)
213            .proof(SCHEMA, "com.example.authFull")
214            .to_car();
215        assert!(matches!(
216            read_record(
217                &proof,
218                DID,
219                &secp256k1(&key),
220                SCHEMA,
221                "com.example.authFull"
222            ),
223            Err(ProofError::Record(_))
224        ));
225    }
226
227    /// atproto signs with P-256 as well as secp256k1. This crate never signs
228    /// with P-256, so the test does.
229    #[test]
230    fn a_commit_signed_with_p256_is_read() {
231        use p256::ecdsa::signature::Signer;
232
233        let repo = repository(&SigningKey::generate());
234        let p256 = p256::ecdsa::SigningKey::from_slice(&[7u8; 32]).expect("a scalar");
235        let commit = repo.commit().commit().clone();
236        let signature: p256::ecdsa::Signature = p256.sign(&commit.to_sign());
237        let signature = signature.normalize_s().unwrap_or(signature);
238        let mut bytes = [0u8; 64];
239        bytes.copy_from_slice(&signature.to_bytes());
240        let signed = SignedCommit::new(commit, bytes);
241
242        let proof = repo.proof(SCHEMA, "com.example.authFull");
243        let tree = proof
244            .blocks()
245            .iter()
246            .filter(|(cid, _)| *cid != proof.root())
247            .map(|(cid, block)| (cid.clone(), block.clone()));
248        let file = car::write(&signed.cid(), tree.chain([(signed.cid(), signed.block())]));
249
250        let multikey = jacquard_common::types::crypto::multikey(
251            P256_MULTICODEC,
252            p256.verifying_key().to_encoded_point(true).as_bytes(),
253        );
254        let key = CommitKey::from_multibase(&multikey).expect("a P-256 key");
255        assert!(matches!(key, CommitKey::P256(_)));
256        assert_eq!(
257            read_record(&file, DID, &key, SCHEMA, "com.example.authFull"),
258            Ok(Some(schema("com.example.authFull")))
259        );
260    }
261}