1use 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#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
18pub enum ProofError {
19 #[error("the proof is not a readable car file: {0}")]
21 Car(#[from] CarError),
22 #[error("the proof declares {0} roots, not one commit")]
24 Roots(usize),
25 #[error("the proof does not hold its own root")]
27 NoCommit,
28 #[error(transparent)]
30 Commit(#[from] CommitError),
31 #[error("the proof's tree does not reach the record: {0}")]
34 Tree(#[from] StoreError),
35 #[error("the record the tree names is not in the proof: {0}")]
37 Record(String),
38}
39
40pub 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 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 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 #[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 #[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 #[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}