(** Repository Commit for AT Protocol. A commit is a signed snapshot of a repository. It contains the DID of the repository owner, the root CID of the MST data tree, a revision TID, and a signature over the commit body. AT Protocol uses v3 commits with the following structure: - did: repository DID (string) - version: always 3 (int) - data: CID of MST root - rev: revision TID (string) - prev: optional CID of previous commit - sig: raw signature bytes (64 bytes for K-256) *) open Atproto_ipld open Atproto_crypto type error = [ `Invalid_commit of string | `Invalid_signature | `Verification_failed | `Missing_field of string | `Decode_error of string ] let pp_error fmt = function | `Invalid_commit msg -> Format.fprintf fmt "invalid commit: %s" msg | `Invalid_signature -> Format.fprintf fmt "invalid signature" | `Verification_failed -> Format.fprintf fmt "signature verification failed" | `Missing_field f -> Format.fprintf fmt "missing required field: %s" f | `Decode_error msg -> Format.fprintf fmt "decode error: %s" msg let error_to_string e = Format.asprintf "%a" pp_error e (** AT Protocol commit version (v3) *) let commit_version = 3 type t = { did : string; (** Repository DID *) version : int; (** Commit version (always 3) *) data : Cid.t; (** MST root CID *) rev : string; (** Revision TID *) prev : Cid.t option; (** Previous commit CID *) sig_ : string; (** Signature bytes (64 bytes) *) } (** Encode an unsigned commit body to DAG-CBOR. The unsigned commit is used for signing - it contains all fields except sig. *) let encode_unsigned ~did ~data ~rev ?prev () = let fields = [ ("did", Dag_cbor.String did); ("data", Dag_cbor.Link data); ("rev", Dag_cbor.String rev); ("version", Dag_cbor.Int (Int64.of_int commit_version)); ] in let fields = match prev with | Some cid -> ("prev", Dag_cbor.Link cid) :: fields | None -> fields in Dag_cbor.encode (Dag_cbor.Map fields) (** Encode a signed commit to DAG-CBOR *) let to_dag_cbor (commit : t) : string = let fields = [ ("did", Dag_cbor.String commit.did); ("data", Dag_cbor.Link commit.data); ("rev", Dag_cbor.String commit.rev); ("sig", Dag_cbor.Bytes commit.sig_); ("version", Dag_cbor.Int (Int64.of_int commit.version)); ] in let fields = match commit.prev with | Some cid -> ("prev", Dag_cbor.Link cid) :: fields | None -> fields in Dag_cbor.encode (Dag_cbor.Map fields) (** Decode a commit from DAG-CBOR *) let of_dag_cbor (data : string) : (t, error) result = match Dag_cbor.decode data with | Error e -> Error (`Decode_error (Dag_cbor.error_to_string e)) | Ok value -> ( match value with | Dag_cbor.Map pairs -> ( let find_string key = List.find_map (fun (k, v) -> if k = key then match v with Dag_cbor.String s -> Some s | _ -> None else None) pairs in let find_int key = List.find_map (fun (k, v) -> if k = key then match v with | Dag_cbor.Int i -> Some (Int64.to_int i) | _ -> None else None) pairs in let find_link key = List.find_map (fun (k, v) -> if k = key then match v with Dag_cbor.Link cid -> Some cid | _ -> None else None) pairs in let find_bytes key = List.find_map (fun (k, v) -> if k = key then match v with Dag_cbor.Bytes b -> Some b | _ -> None else None) pairs in match ( find_string "did", find_int "version", find_link "data", find_string "rev", find_bytes "sig" ) with | Some did, Some version, Some data, Some rev, Some sig_ -> let prev = find_link "prev" in Ok { did; version; data; rev; prev; sig_ } | None, _, _, _, _ -> Error (`Missing_field "did") | _, None, _, _, _ -> Error (`Missing_field "version") | _, _, None, _, _ -> Error (`Missing_field "data") | _, _, _, None, _ -> Error (`Missing_field "rev") | _, _, _, _, None -> Error (`Missing_field "sig")) | _ -> Error (`Invalid_commit "expected map")) (** Create a new signed commit using K-256 key. The signing process: 1. Encode unsigned commit as DAG-CBOR 2. SHA-256 hash the bytes 3. Sign hash with K-256 key (produces low-S signature) 4. Add signature to commit *) let create ~did ~data ~rev ?prev ~(key : K256.private_key) () : t = (* Encode unsigned commit *) let unsigned = encode_unsigned ~did ~data ~rev ?prev () in (* Sign the unsigned commit bytes directly (K256.sign will hash it) *) let sig_ = K256.sign key unsigned in { did; version = commit_version; data; rev; prev; sig_ } (** Verify a commit signature against a public key. Returns Ok () if the signature is valid, Error otherwise. *) let verify (commit : t) ~(public_key : K256.public_key) : (unit, error) result = (* Reconstruct the unsigned commit *) let unsigned = encode_unsigned ~did:commit.did ~data:commit.data ~rev:commit.rev ?prev:commit.prev () in (* Verify signature *) match K256.verify public_key unsigned commit.sig_ with | Ok () -> Ok () | Error _ -> Error `Verification_failed (** Get the CID of a commit *) let cid (commit : t) : Cid.t = let data = to_dag_cbor commit in Cid.of_dag_cbor data (** Check if a commit is valid (version check, signature length, etc.) *) let is_valid (commit : t) : bool = commit.version = commit_version && String.length commit.sig_ = 64