Skip to main content

didbot_repo/
commit.rs

1//! The commit: the one block in a repository that is signed.
2//!
3//! Everything else in a repository is content-addressed and therefore
4//! self-checking — a block either hashes to the CID that points at it or it
5//! does not. None of that says who wrote it. The commit is where an identity
6//! makes a claim: *this DID says its repository is the tree rooted here, as of
7//! this revision*. A reader resolves the DID, takes the public key out of the
8//! document, and checks the signature over the commit's other fields. That
9//! chain — document to key to signature to root to records — is the whole of
10//! what makes an `at://` URI mean anything to somebody who does not trust the
11//! server it came from.
12
13use std::collections::BTreeMap;
14
15use didbot_data::{dag_cbor, Cid, Value};
16use didbot_key::{secp256r1, KeyError, Signature, SigningKey, VerifyingKey};
17
18/// The repository format this crate writes.
19///
20/// Version 3 is the current one and the only one atproto's relays accept. It
21/// is written into every commit rather than assumed, because a reader has to
22/// be able to refuse a version it does not know rather than misread it.
23pub const REPO_VERSION: i64 = 3;
24
25/// Why a commit could not be trusted.
26#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
27pub enum CommitError {
28    /// The signature is not 64 bytes, or is not a pair the key's curve has.
29    #[error("the commit's signature is not a usable signature: {0}")]
30    Malformed(String),
31    /// The block is not a commit: not the six fields a version 3 commit
32    /// has, or a field of the wrong type.
33    #[error("the block is not a commit: {0}")]
34    NotACommit(String),
35    /// The signature does not verify against the key it was checked with.
36    ///
37    /// This is the interesting one, and it does not distinguish between the
38    /// three ways it happens — a tampered commit, a substituted key, or a
39    /// signature from somebody else — because a verifier cannot tell them
40    /// apart and a message that guessed would be a message that misled.
41    #[error("the commit's signature does not verify against this key")]
42    Unverified,
43    /// The commit names a repository format this crate does not write.
44    #[error("commit declares repository version {found}, not {REPO_VERSION}")]
45    Version {
46        /// What it declared.
47        found: i64,
48    },
49    /// The commit's `did` is not the repository that was asked about.
50    #[error("commit is for `{found}`, not `{expected}`")]
51    WrongRepo {
52        /// The DID inside the commit.
53        found: String,
54        /// The DID the caller was verifying for.
55        expected: String,
56    },
57}
58
59/// A commit before it is signed.
60///
61/// The field names are the protocol's, and every one of them is signed: a
62/// reader that trusted `did` from anywhere but inside the signature would
63/// accept one account's commit as another's.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct Commit {
66    /// The repository this is a commit for.
67    pub did: String,
68    /// The repository format. Always [`REPO_VERSION`] here.
69    pub version: i64,
70    /// The root of the Merkle search tree over every record.
71    pub data: Cid,
72    /// A TID that increases with every commit to this repository.
73    ///
74    /// It is what lets a consumer order two commits it received out of order,
75    /// and what a `since` cursor is compared against. It is a string here
76    /// rather than a parsed TID because this crate does not mint one — the
77    /// caller does, out of the same clock its record keys come from.
78    pub rev: String,
79    /// The commit this one replaces, or `None` for the first.
80    pub prev: Option<Cid>,
81}
82
83impl Commit {
84    /// The bytes a signature is made over: every field except `sig`.
85    ///
86    /// A signature over the *signed* commit would be a signature over itself,
87    /// so the field is absent rather than null or empty. That means the block
88    /// that is hashed for the CID and the block that is signed are two
89    /// different encodings of the same commit, which is the one thing about
90    /// this format that reliably surprises people, and why it is a named
91    /// method rather than a step inside `sign`.
92    #[must_use]
93    pub fn to_sign(&self) -> Vec<u8> {
94        dag_cbor::encode(&self.to_value(None))
95    }
96
97    /// Signs the commit.
98    ///
99    /// The signature is deterministic — `k256` uses RFC 6979 nonces — so the
100    /// same repository state signed by the same key twice gives the same
101    /// bytes and the same commit CID. That is what makes an export
102    /// reproducible, and it is a property of the key crate rather than of
103    /// anything decided here.
104    #[must_use]
105    pub fn sign(self, key: &SigningKey) -> SignedCommit {
106        let signature = key.sign(&self.to_sign());
107        SignedCommit {
108            commit: self,
109            signature: signature.to_bytes(),
110        }
111    }
112
113    /// The commit as a data-model value, with or without its signature.
114    fn to_value(&self, signature: Option<&[u8; 64]>) -> Value {
115        let mut fields = BTreeMap::from([
116            ("did".to_owned(), Value::String(self.did.clone())),
117            ("version".to_owned(), Value::Integer(self.version)),
118            ("data".to_owned(), Value::Link(self.data.clone())),
119            ("rev".to_owned(), Value::String(self.rev.clone())),
120            (
121                "prev".to_owned(),
122                self.prev
123                    .as_ref()
124                    .map_or(Value::Null, |cid| Value::Link(cid.clone())),
125            ),
126        ]);
127        if let Some(signature) = signature {
128            fields.insert("sig".to_owned(), Value::Bytes(signature.to_vec()));
129        }
130        Value::Map(fields)
131    }
132}
133
134/// A key another repository's commits are signed with, as its DID document
135/// publishes it. atproto signs with either of two curves; this crate signs
136/// with secp256k1 only and reads both.
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum CommitKey {
139    /// A secp256k1 key.
140    Secp256k1(VerifyingKey),
141    /// A P-256 key.
142    P256(secp256r1::VerifyingKey),
143}
144
145impl CommitKey {
146    /// Reads a `publicKeyMultibase` naming a key of either curve.
147    pub fn from_multibase(multibase: &str) -> Result<Self, KeyError> {
148        match VerifyingKey::from_multibase(multibase) {
149            Ok(key) => Ok(Self::Secp256k1(key)),
150            Err(KeyError::WrongCurve { .. }) => {
151                secp256r1::VerifyingKey::from_multibase(multibase).map(Self::P256)
152            }
153            Err(error) => Err(error),
154        }
155    }
156}
157
158/// A commit and the signature over it.
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct SignedCommit {
161    commit: Commit,
162    signature: [u8; 64],
163}
164
165impl SignedCommit {
166    /// Reassembles a commit and a signature that were stored apart.
167    #[must_use]
168    pub fn new(commit: Commit, signature: [u8; 64]) -> Self {
169        Self { commit, signature }
170    }
171
172    /// Reads a commit block another server wrote.
173    ///
174    /// The block holds exactly the six fields a version 3 commit has, `prev`
175    /// included even when it is null. Anything else is refused here. A
176    /// field this left out of [`Commit::to_sign`] would fail the signature
177    /// anyway, so refusing it first says why.
178    pub fn decode(block: &[u8]) -> Result<Self, CommitError> {
179        let refuse = |why: &str| CommitError::NotACommit(why.to_owned());
180        let value = dag_cbor::decode(block).map_err(|error| refuse(&error.to_string()))?;
181        let Value::Map(mut fields) = value else {
182            return Err(refuse("it is not a map"));
183        };
184        let mut take = |name: &str| {
185            fields
186                .remove(name)
187                .ok_or_else(|| refuse(&format!("it has no `{name}`")))
188        };
189        let (Value::String(did), Value::Integer(version), Value::Link(data), Value::String(rev)) =
190            (take("did")?, take("version")?, take("data")?, take("rev")?)
191        else {
192            return Err(refuse(
193                "`did`, `version`, `data` or `rev` has the wrong type",
194            ));
195        };
196        let prev = match take("prev")? {
197            Value::Null => None,
198            Value::Link(cid) => Some(cid),
199            _ => return Err(refuse("`prev` is neither a link nor null")),
200        };
201        let Value::Bytes(signature) = take("sig")? else {
202            return Err(refuse("`sig` is not bytes"));
203        };
204        let signature: [u8; 64] = signature
205            .try_into()
206            .map_err(|_| refuse("`sig` is not 64 bytes"))?;
207        if let Some(extra) = fields.keys().next() {
208            return Err(refuse(&format!("it has a `{extra}` field")));
209        }
210        Ok(Self::new(
211            Commit {
212                did,
213                version,
214                data,
215                rev,
216                prev,
217            },
218            signature,
219        ))
220    }
221
222    /// What was signed.
223    #[must_use]
224    pub fn commit(&self) -> &Commit {
225        &self.commit
226    }
227
228    /// The 64-byte compact signature, `r` then `s`.
229    #[must_use]
230    pub fn signature(&self) -> &[u8; 64] {
231        &self.signature
232    }
233
234    /// The block a CAR file carries and a CID names.
235    #[must_use]
236    pub fn block(&self) -> Vec<u8> {
237        dag_cbor::encode(&self.commit.to_value(Some(&self.signature)))
238    }
239
240    /// The CID of that block, which is a CAR export's root.
241    #[must_use]
242    pub fn cid(&self) -> Cid {
243        Cid::of_dag_cbor(&self.block())
244    }
245
246    /// Checks the signature against a public key.
247    ///
248    /// The key must come from somewhere other than this object for the answer
249    /// to mean anything — a DID document, in practice. Verifying with the key
250    /// that signed proves the signing code is self-consistent and nothing
251    /// else; see `tests/did_document.rs`, which takes the key out of a
252    /// serialised document instead.
253    ///
254    /// A high-S signature fails, because `k256` refuses one internally and
255    /// atproto requires low-S. That is a known jacquard defect not applying to us,
256    /// rather than something handled here.
257    pub fn verify(&self, key: &VerifyingKey) -> Result<(), CommitError> {
258        self.check_version()?;
259        let signature = Signature::from_bytes(&self.signature)
260            .map_err(|error| CommitError::Malformed(error.to_string()))?;
261        key.verify(&self.commit.to_sign(), &signature)
262            .map_err(|_| CommitError::Unverified)
263    }
264
265    /// Checks the signature, and that the commit is for the repository asked
266    /// about.
267    ///
268    /// The two go together far more often than either goes alone: a
269    /// signature that verifies against the key in `did:web:a`'s document, on
270    /// a commit whose `did` is `did:web:b`, is a valid signature and the
271    /// wrong answer.
272    pub fn verify_for(&self, did: &str, key: &VerifyingKey) -> Result<(), CommitError> {
273        self.check_repo(did)?;
274        self.verify(key)
275    }
276
277    /// [`SignedCommit::verify_for`], with a key of either curve.
278    ///
279    /// A P-256 signature with a high `s` fails, as a secp256k1 one does:
280    /// [`secp256r1::VerifyingKey::verify`] refuses it.
281    pub fn verify_with(&self, did: &str, key: &CommitKey) -> Result<(), CommitError> {
282        let key = match key {
283            CommitKey::Secp256k1(key) => return self.verify_for(did, key),
284            CommitKey::P256(key) => key,
285        };
286        self.check_repo(did)?;
287        self.check_version()?;
288        let signature = secp256r1::Signature::from_bytes(&self.signature)
289            .map_err(|error| CommitError::Malformed(error.to_string()))?;
290        key.verify(&self.commit.to_sign(), &signature)
291            .map_err(|_| CommitError::Unverified)
292    }
293
294    /// Refuses a commit for any repository but `did`'s.
295    fn check_repo(&self, did: &str) -> Result<(), CommitError> {
296        if self.commit.did != did {
297            return Err(CommitError::WrongRepo {
298                found: self.commit.did.clone(),
299                expected: did.to_owned(),
300            });
301        }
302        Ok(())
303    }
304
305    /// Refuses a repository format this crate does not write.
306    fn check_version(&self) -> Result<(), CommitError> {
307        if self.commit.version != REPO_VERSION {
308            return Err(CommitError::Version {
309                found: self.commit.version,
310            });
311        }
312        Ok(())
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    fn commit() -> Commit {
321        Commit {
322            did: "did:web:kestrel.agents.localhost%3A4100".to_owned(),
323            version: REPO_VERSION,
324            data: Cid::of_dag_cbor(b"a tree root"),
325            rev: "3lqmnopqrstuv".to_owned(),
326            prev: None,
327        }
328    }
329
330    #[test]
331    fn a_signed_commit_verifies_against_its_own_public_half() {
332        let key = SigningKey::generate();
333        let signed = commit().sign(&key);
334        assert!(signed.verify(&key.verifying_key()).is_ok());
335    }
336
337    /// The block that is hashed carries `sig`; the bytes that are signed do
338    /// not. Getting this backwards produces a commit that no other
339    /// implementation can verify and that verifies fine against itself.
340    #[test]
341    fn the_signed_bytes_are_not_the_block() {
342        let signed = commit().sign(&SigningKey::generate());
343        assert_ne!(signed.commit().to_sign(), signed.block());
344        assert!(signed.block().len() > signed.commit().to_sign().len());
345    }
346
347    #[test]
348    fn another_key_does_not_verify_it() {
349        let signed = commit().sign(&SigningKey::generate());
350        let stranger = SigningKey::generate().verifying_key();
351        assert_eq!(signed.verify(&stranger), Err(CommitError::Unverified));
352    }
353
354    /// Every field is inside the signature. Moving any one of them has to
355    /// break it, or a server could hand out one account's records under
356    /// another account's name.
357    #[test]
358    fn no_field_can_be_moved_without_breaking_the_signature() {
359        let key = SigningKey::generate();
360        let public = key.verifying_key();
361        let signed = commit().sign(&key);
362
363        let mut elsewhere = commit();
364        elsewhere.did = "did:web:heron.agents.localhost%3A4100".to_owned();
365        assert!(SignedCommit::new(elsewhere, *signed.signature())
366            .verify(&public)
367            .is_err());
368
369        let mut retree = commit();
370        retree.data = Cid::of_dag_cbor(b"a different tree root");
371        assert!(SignedCommit::new(retree, *signed.signature())
372            .verify(&public)
373            .is_err());
374
375        let mut rewound = commit();
376        rewound.rev = "3lqmnopqrstuu".to_owned();
377        assert!(SignedCommit::new(rewound, *signed.signature())
378            .verify(&public)
379            .is_err());
380
381        let mut chained = commit();
382        chained.prev = Some(Cid::of_dag_cbor(b"an earlier commit"));
383        assert!(SignedCommit::new(chained, *signed.signature())
384            .verify(&public)
385            .is_err());
386    }
387
388    /// A verifier that only checked the signature would accept this.
389    #[test]
390    fn a_valid_signature_for_the_wrong_repository_is_refused() {
391        let key = SigningKey::generate();
392        let signed = commit().sign(&key);
393        assert_eq!(
394            signed.verify_for(
395                "did:web:heron.agents.localhost%3A4100",
396                &key.verifying_key()
397            ),
398            Err(CommitError::WrongRepo {
399                found: commit().did,
400                expected: "did:web:heron.agents.localhost%3A4100".to_owned(),
401            })
402        );
403        assert!(signed
404            .verify_for(&commit().did, &key.verifying_key())
405            .is_ok());
406    }
407
408    #[test]
409    fn a_version_this_crate_does_not_write_is_refused_before_the_signature() {
410        let key = SigningKey::generate();
411        let mut future = commit();
412        future.version = 4;
413        let signed = future.sign(&key);
414        assert_eq!(
415            signed.verify(&key.verifying_key()),
416            Err(CommitError::Version { found: 4 })
417        );
418    }
419
420    /// Signing is deterministic, so an export of unchanged state is
421    /// byte-identical and a client can cache one.
422    #[test]
423    fn signing_the_same_state_twice_gives_the_same_commit() {
424        let key = SigningKey::generate();
425        assert_eq!(commit().sign(&key).cid(), commit().sign(&key).cid());
426    }
427}