atproto libraries implementation in ocaml
1(** Repository Commit for AT Protocol.
2
3 A commit is a signed snapshot of a repository. It contains the DID of the
4 repository owner, the root CID of the MST data tree, a revision TID, and a
5 signature over the commit body.
6
7 AT Protocol uses v3 commits with the following structure:
8 - did: repository DID (string)
9 - version: always 3 (int)
10 - data: CID of MST root
11 - rev: revision TID (string)
12 - prev: optional CID of previous commit
13 - sig: raw signature bytes (64 bytes for K-256) *)
14
15open Atproto_ipld
16open Atproto_crypto
17
18type error =
19 [ `Invalid_commit of string
20 | `Invalid_signature
21 | `Verification_failed
22 | `Missing_field of string
23 | `Decode_error of string ]
24
25let pp_error fmt = function
26 | `Invalid_commit msg -> Format.fprintf fmt "invalid commit: %s" msg
27 | `Invalid_signature -> Format.fprintf fmt "invalid signature"
28 | `Verification_failed -> Format.fprintf fmt "signature verification failed"
29 | `Missing_field f -> Format.fprintf fmt "missing required field: %s" f
30 | `Decode_error msg -> Format.fprintf fmt "decode error: %s" msg
31
32let error_to_string e = Format.asprintf "%a" pp_error e
33
34(** AT Protocol commit version (v3) *)
35let commit_version = 3
36
37type t = {
38 did : string; (** Repository DID *)
39 version : int; (** Commit version (always 3) *)
40 data : Cid.t; (** MST root CID *)
41 rev : string; (** Revision TID *)
42 prev : Cid.t option; (** Previous commit CID *)
43 sig_ : string; (** Signature bytes (64 bytes) *)
44}
45
46(** Encode an unsigned commit body to DAG-CBOR. The unsigned commit is used for
47 signing - it contains all fields except sig. *)
48let encode_unsigned ~did ~data ~rev ?prev () =
49 let fields =
50 [
51 ("did", Dag_cbor.String did);
52 ("data", Dag_cbor.Link data);
53 ("rev", Dag_cbor.String rev);
54 ("version", Dag_cbor.Int (Int64.of_int commit_version));
55 ]
56 in
57 let fields =
58 match prev with
59 | Some cid -> ("prev", Dag_cbor.Link cid) :: fields
60 | None -> fields
61 in
62 Dag_cbor.encode (Dag_cbor.Map fields)
63
64(** Encode a signed commit to DAG-CBOR *)
65let to_dag_cbor (commit : t) : string =
66 let fields =
67 [
68 ("did", Dag_cbor.String commit.did);
69 ("data", Dag_cbor.Link commit.data);
70 ("rev", Dag_cbor.String commit.rev);
71 ("sig", Dag_cbor.Bytes commit.sig_);
72 ("version", Dag_cbor.Int (Int64.of_int commit.version));
73 ]
74 in
75 let fields =
76 match commit.prev with
77 | Some cid -> ("prev", Dag_cbor.Link cid) :: fields
78 | None -> fields
79 in
80 Dag_cbor.encode (Dag_cbor.Map fields)
81
82(** Decode a commit from DAG-CBOR *)
83let of_dag_cbor (data : string) : (t, error) result =
84 match Dag_cbor.decode data with
85 | Error e -> Error (`Decode_error (Dag_cbor.error_to_string e))
86 | Ok value -> (
87 match value with
88 | Dag_cbor.Map pairs -> (
89 let find_string key =
90 List.find_map
91 (fun (k, v) ->
92 if k = key then
93 match v with Dag_cbor.String s -> Some s | _ -> None
94 else None)
95 pairs
96 in
97 let find_int key =
98 List.find_map
99 (fun (k, v) ->
100 if k = key then
101 match v with
102 | Dag_cbor.Int i -> Some (Int64.to_int i)
103 | _ -> None
104 else None)
105 pairs
106 in
107 let find_link key =
108 List.find_map
109 (fun (k, v) ->
110 if k = key then
111 match v with Dag_cbor.Link cid -> Some cid | _ -> None
112 else None)
113 pairs
114 in
115 let find_bytes key =
116 List.find_map
117 (fun (k, v) ->
118 if k = key then
119 match v with Dag_cbor.Bytes b -> Some b | _ -> None
120 else None)
121 pairs
122 in
123 match
124 ( find_string "did",
125 find_int "version",
126 find_link "data",
127 find_string "rev",
128 find_bytes "sig" )
129 with
130 | Some did, Some version, Some data, Some rev, Some sig_ ->
131 let prev = find_link "prev" in
132 Ok { did; version; data; rev; prev; sig_ }
133 | None, _, _, _, _ -> Error (`Missing_field "did")
134 | _, None, _, _, _ -> Error (`Missing_field "version")
135 | _, _, None, _, _ -> Error (`Missing_field "data")
136 | _, _, _, None, _ -> Error (`Missing_field "rev")
137 | _, _, _, _, None -> Error (`Missing_field "sig"))
138 | _ -> Error (`Invalid_commit "expected map"))
139
140(** Create a new signed commit using K-256 key.
141
142 The signing process: 1. Encode unsigned commit as DAG-CBOR 2. SHA-256 hash
143 the bytes 3. Sign hash with K-256 key (produces low-S signature) 4. Add
144 signature to commit *)
145let create ~did ~data ~rev ?prev ~(key : K256.private_key) () : t =
146 (* Encode unsigned commit *)
147 let unsigned = encode_unsigned ~did ~data ~rev ?prev () in
148 (* Sign the unsigned commit bytes directly (K256.sign will hash it) *)
149 let sig_ = K256.sign key unsigned in
150 { did; version = commit_version; data; rev; prev; sig_ }
151
152(** Verify a commit signature against a public key.
153
154 Returns Ok () if the signature is valid, Error otherwise. *)
155let verify (commit : t) ~(public_key : K256.public_key) : (unit, error) result =
156 (* Reconstruct the unsigned commit *)
157 let unsigned =
158 encode_unsigned ~did:commit.did ~data:commit.data ~rev:commit.rev
159 ?prev:commit.prev ()
160 in
161 (* Verify signature *)
162 match K256.verify public_key unsigned commit.sig_ with
163 | Ok () -> Ok ()
164 | Error _ -> Error `Verification_failed
165
166(** Get the CID of a commit *)
167let cid (commit : t) : Cid.t =
168 let data = to_dag_cbor commit in
169 Cid.of_dag_cbor data
170
171(** Check if a commit is valid (version check, signature length, etc.) *)
172let is_valid (commit : t) : bool =
173 commit.version = commit_version && String.length commit.sig_ = 64