use anyhow::{anyhow, Result}; use at_crypto::cid::cid_for_cbor; use at_crypto::signing::verify_dag_cbor; use cid::Cid; use k256::ecdsa::VerifyingKey; use serde_json::Value; /// A signed repository commit. /// /// `Commit` carries the canonical DAG-CBOR serialization of a signed commit /// block (including the `sig` field) together with the parsed fields. The /// `cid` is the SHA-256 DAG-CBOR content-address of `signed_bytes`. /// /// The `data` field is `Option` to support commits on empty repositories /// — an empty repo has no MST root to point at, so the JSON payload's `data` /// is serialized as `null`. #[derive(Debug, Clone)] pub struct Commit { pub cid: Cid, pub signed_bytes: Vec, pub did: String, pub rev: String, pub prev: Option, pub data: Option, } impl Commit { /// Verify the commit's signature. /// /// Delegates the actual cryptographic check to [`at_crypto::signing::verify_dag_cbor`], /// which uses the `pubkey` field embedded inside the signed commit. The /// `signing_pubkey` argument is the caller-trusted key — we additionally /// require the embedded pubkey to match it, so a malicious swap of the /// `pubkey` field (followed by a forged signature under the swapped key) /// is rejected. pub fn verify(&self, signing_pubkey: &VerifyingKey) -> Result<()> { let embedded = verify_dag_cbor(&self.signed_bytes)?; if &embedded != signing_pubkey { return Err(anyhow!( "commit embedded pubkey does not match expected signing pubkey" )); } Ok(()) } /// Parse a signed commit block out of raw DAG-CBOR bytes. /// /// This is used by `Repo::load` to reconstruct the head commit when /// re-hydrating a repository from a blockstore. pub fn from_signed_bytes(signed_bytes: Vec) -> Result { let cid = cid_for_cbor(&signed_bytes)?; let value: Value = ciborium::from_reader(&signed_bytes[..]) .map_err(|e| anyhow!("invalid commit CBOR: {e}"))?; let obj = value .as_object() .ok_or_else(|| anyhow!("commit CBOR is not an object"))?; let did = obj .get("did") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("commit missing `did`"))? .to_string(); let rev = obj .get("rev") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("commit missing `rev`"))? .to_string(); let prev = parse_optional_cid(obj.get("prev"), "prev")?; let data = parse_optional_cid(obj.get("data"), "data")?; Ok(Self { cid, signed_bytes, did, rev, prev, data, }) } } fn parse_optional_cid(value: Option<&Value>, field: &str) -> Result> { match value { None | Some(Value::Null) => Ok(None), Some(Value::String(s)) => s .parse::() .map(Some) .map_err(|e| anyhow!("commit `{field}` is not a valid CID: {e}")), Some(_) => Err(anyhow!("commit `{field}` must be null or string")), } } #[cfg(test)] mod tests { use super::*; use at_crypto::did_key::pubkey_to_multibase; use k256::ecdsa::SigningKey; use k256::SecretKey; use k256::PublicKey; fn make_test_commit( sk: &SigningKey, did: &str, rev: &str, prev: Option<&str>, data: Option<&str>, ) -> Commit { let pk: PublicKey = sk.verifying_key().into(); let mb = pubkey_to_multibase(&pk).unwrap(); let mut payload = serde_json::json!({ "did": did, "version": 3, "prev": prev.map(|s| serde_json::Value::String(s.to_string())) .unwrap_or(serde_json::Value::Null), "data": data.map(|s| serde_json::Value::String(s.to_string())) .unwrap_or(serde_json::Value::Null), "rev": rev, "pubkey": mb, }); let _ = payload.as_object_mut().unwrap().remove("sig"); let signed = at_crypto::signing::sign_dag_cbor(sk, &payload).unwrap(); Commit::from_signed_bytes(signed.signed_bytes).unwrap() } #[test] fn self_signed_commit_verifies() { let sk = SigningKey::from(SecretKey::from_slice(&[7u8; 32]).unwrap()); let commit = make_test_commit(&sk, "did:plc:abc", "0", None, None); assert!(commit.verify(&sk.verifying_key()).is_ok()); } #[test] fn wrong_key_fails_verify() { let sk = SigningKey::from(SecretKey::from_slice(&[7u8; 32]).unwrap()); let sk2 = SigningKey::from(SecretKey::from_slice(&[9u8; 32]).unwrap()); let commit = make_test_commit(&sk, "did:plc:abc", "0", None, None); assert!(commit.verify(&sk2.verifying_key()).is_err()); } #[test] fn commit_with_prev_and_data_roundtrip() { let sk = SigningKey::from(SecretKey::from_slice(&[11u8; 32]).unwrap()); let prev_cid: Cid = "bafyreig7qfkqdk5v3jy3z6xgc4n3yh6ycjxqrvt5pqjpwxgvvcxyzw7tqy" .parse() .unwrap(); let data_cid: Cid = "bafyreihzfgvyuwdq5i3qaqj2vnlv4bgw2xhcsoa2uh2pqkpcw55nuefzzi" .parse() .unwrap(); let commit = make_test_commit( &sk, "did:plc:abc", "abc123", Some(&prev_cid.to_string()), Some(&data_cid.to_string()), ); assert_eq!(commit.did, "did:plc:abc"); assert_eq!(commit.rev, "abc123"); assert_eq!(commit.prev, Some(prev_cid)); assert_eq!(commit.data, Some(data_cid)); assert!(commit.verify(&sk.verifying_key()).is_ok()); } #[test] fn tampered_signed_bytes_fail_verify() { let sk = SigningKey::from(SecretKey::from_slice(&[15u8; 32]).unwrap()); let commit = make_test_commit(&sk, "did:plc:abc", "0", None, None); // Flip a bit in `sig` (last bytes of the CBOR object). The simplest // way to land inside `sig` (a hex string of ~128 chars) is to flip a // byte near the tail of the payload. let mut tampered_bytes = commit.signed_bytes.clone(); let len = tampered_bytes.len(); tampered_bytes[len - 4] ^= 0x01; tampered_bytes[len - 3] ^= 0x01; let res = match Commit::from_signed_bytes(tampered_bytes) { Ok(c) => c.verify(&sk.verifying_key()), Err(e) => Err(e), }; assert!( res.is_err(), "tampered commit must not verify; got Ok" ); } #[test] fn tampered_field_fails_verify() { let sk = SigningKey::from(SecretKey::from_slice(&[16u8; 32]).unwrap()); let commit = make_test_commit(&sk, "did:plc:abc", "0", None, None); // Parse the signed CBOR object, swap the `did`, and re-encode. The // resulting CID is different, but the signature over the unsigned // payload is now stale — verification must fail. let mut value: Value = ciborium::from_reader(commit.signed_bytes.as_slice()).unwrap(); { let obj = value.as_object_mut().unwrap(); obj.insert("did".into(), Value::String("did:plc:imposter".into())); } let mut new_bytes = Vec::new(); ciborium::into_writer(&value, &mut new_bytes).unwrap(); let res = match Commit::from_signed_bytes(new_bytes) { Ok(c) => c.verify(&sk.verifying_key()), Err(e) => Err(e), }; assert!(res.is_err(), "did swap must invalidate signature"); } }