eidetica/sync/protocol.rs
1//! Protocol definitions for sync communication.
2//!
3//! This module defines transport-agnostic message types that can be
4//! used across different network transports (HTTP, Iroh, Bluetooth, etc.).
5
6use serde::{Deserialize, Serialize};
7
8use super::peer_types::Address;
9use crate::{
10 auth::{
11 AuthError, Permission,
12 crypto::{
13 PrivateKey, PublicKey, create_challenge_response, generate_challenge,
14 verify_challenge_response,
15 },
16 },
17 crdt::Doc,
18 entry::{Entry, ID},
19 snapshot::Snapshot,
20};
21
22/// Handshake request sent when establishing a peer connection.
23#[allow(clippy::large_enum_variant)]
24#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
25pub struct HandshakeRequest {
26 // FIXME: device_id and public_key are functionally identical
27 /// Unique device identifier
28 pub device_id: PublicKey,
29 /// Ed25519 public key of the sender
30 pub public_key: PublicKey,
31 /// Optional human-readable display name
32 pub display_name: Option<String>,
33 /// Protocol version number
34 pub protocol_version: u32,
35 /// Random challenge bytes for signature verification
36 pub challenge: Vec<u8>,
37 /// Addresses where this peer can be reached for sync
38 pub listen_addresses: Vec<Address>,
39}
40
41/// Information about a tree available for sync
42#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
43pub struct TreeInfo {
44 /// The root ID of the tree
45 pub tree_id: ID,
46 /// Optional human-readable name for the tree
47 pub name: Option<String>,
48 /// Number of entries in the tree
49 pub entry_count: usize,
50 /// Unix timestamp of last modification
51 pub last_modified: u64,
52}
53
54/// Handshake response sent in reply to a handshake request.
55#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
56pub struct HandshakeResponse {
57 // FIXME: device_id and public_key are functionally identical
58 /// Unique device identifier
59 pub device_id: PublicKey,
60 /// Ed25519 public key of the responder
61 pub public_key: PublicKey,
62 /// Optional human-readable display name
63 pub display_name: Option<String>,
64 /// Protocol version number
65 pub protocol_version: u32,
66 /// Signed challenge from the request
67 pub challenge_response: Vec<u8>,
68 /// New challenge for mutual authentication
69 pub new_challenge: Vec<u8>,
70 /// Trees available for synchronization
71 pub available_trees: Vec<TreeInfo>,
72}
73
74/// Unified sync request for both bootstrap and incremental sync
75#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
76pub struct SyncTreeRequest {
77 /// Database ID to sync
78 pub tree_id: ID,
79 /// Our current tips (empty set signals bootstrap needed)
80 pub our_tips: Snapshot,
81 /// Device public key of the requesting peer (used for automatic tree/peer relationship tracking)
82 pub peer_pubkey: Option<PublicKey>,
83 /// Authentication key requesting access (for bootstrap).
84 ///
85 /// A request that names this key must carry matching proof in `auth` before
86 /// its approval lifecycle is read or changed. Anonymous public sync omits it.
87 pub requesting_key: Option<PublicKey>,
88 /// Key name/identifier for the requesting key
89 pub requesting_key_name: Option<String>,
90 /// Desired permission level for bootstrap
91 pub requested_permission: Option<Permission>,
92 /// Free-form context the requester attaches for the approver to inspect
93 /// when deciding whether to grant access. Carried verbatim onto the stored
94 /// `BootstrapRequest`.
95 #[serde(default)]
96 pub metadata: Option<Doc>,
97 /// Proof that the caller holds the private half of the key it is claiming.
98 ///
99 /// Required whenever `requesting_key` claims an identity, including manual
100 /// approval requests and named-key requests on public databases. Anonymous
101 /// public sync omits both fields.
102 #[serde(default)]
103 pub auth: Option<SyncRequestAuth>,
104}
105
106/// A caller's proof of key possession for one sync request.
107///
108/// The signature covers the responding server, the tree, the claimed tips, and
109/// a timestamp/nonce pair, so a captured request cannot be replayed to the same
110/// server, redirected to a different one, or reused for a different tree.
111///
112/// # What this does not defend against
113///
114/// This authenticates the *requester to the server*; it does not protect the
115/// channel. Over a plaintext transport an attacker on the network path still
116/// reads the served entries and can relay a live signed request to keep the
117/// response for itself. Confidentiality against a network attacker requires an
118/// encrypted transport (Iroh's QUIC), not this signature.
119#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
120pub struct SyncRequestAuth {
121 /// The key whose authority the caller is claiming on the tree.
122 pub key: PublicKey,
123 /// Milliseconds since the Unix epoch, from the caller's clock.
124 pub timestamp_ms: u64,
125 /// Random per-request value; makes each signature single-use.
126 pub nonce: Vec<u8>,
127 /// Signature over [`SyncRequestAuth::signing_bytes`].
128 pub signature: Vec<u8>,
129}
130
131impl SyncRequestAuth {
132 /// Sign a request to `server_pubkey` for `tree_id` at `tips`.
133 pub fn sign(
134 signing_key: &PrivateKey,
135 server_pubkey: &PublicKey,
136 tree_id: &ID,
137 tips: &Snapshot,
138 timestamp_ms: u64,
139 ) -> Self {
140 let nonce = generate_challenge();
141 let signature = create_challenge_response(
142 Self::signing_bytes(server_pubkey, tree_id, tips, timestamp_ms, &nonce),
143 signing_key,
144 );
145 Self {
146 key: signing_key.public_key(),
147 timestamp_ms,
148 nonce,
149 signature,
150 }
151 }
152
153 /// Verify the signature against the request it claims to cover.
154 ///
155 /// Freshness and single-use are the caller's responsibility — a valid
156 /// signature says nothing about when it was made.
157 pub fn verify(
158 &self,
159 server_pubkey: &PublicKey,
160 tree_id: &ID,
161 tips: &Snapshot,
162 ) -> Result<(), AuthError> {
163 verify_challenge_response(
164 Self::signing_bytes(server_pubkey, tree_id, tips, self.timestamp_ms, &self.nonce),
165 &self.signature,
166 &self.key,
167 )
168 }
169
170 /// The exact bytes covered by the signature.
171 ///
172 /// Every field is length-prefixed so that no two distinct requests can
173 /// produce the same byte string.
174 fn signing_bytes(
175 server_pubkey: &PublicKey,
176 tree_id: &ID,
177 tips: &Snapshot,
178 timestamp_ms: u64,
179 nonce: &[u8],
180 ) -> Vec<u8> {
181 let mut bytes = Vec::new();
182 let mut push = |field: &[u8]| {
183 bytes.extend_from_slice(&(field.len() as u64).to_be_bytes());
184 bytes.extend_from_slice(field);
185 };
186
187 push(SYNC_REQUEST_DOMAIN);
188 push(server_pubkey.to_string().as_bytes());
189 push(tree_id.to_string().as_bytes());
190 push(&(tips.len() as u64).to_be_bytes());
191 for tip in tips.tips() {
192 push(tip.to_string().as_bytes());
193 }
194 push(×tamp_ms.to_be_bytes());
195 push(nonce);
196 bytes
197 }
198}
199
200/// Domain separator, so a sync signature can never be mistaken for a signature
201/// over an entry or a handshake challenge.
202const SYNC_REQUEST_DOMAIN: &[u8] = b"eidetica/sync/tree-request/v1";
203
204/// Bootstrap response containing complete tree state
205#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
206pub struct BootstrapResponse {
207 /// Database ID being bootstrapped
208 pub tree_id: ID,
209 /// The root entry of the tree
210 pub root_entry: Entry,
211 /// All entries in the tree (excluding root)
212 pub all_entries: Vec<Entry>,
213 /// Whether the requesting key was approved and added
214 pub key_approved: bool,
215 /// The permission level granted (if approved)
216 pub granted_permission: Option<Permission>,
217}
218
219/// Incremental sync response for existing trees
220#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
221pub struct IncrementalResponse {
222 /// Database ID being synced
223 pub tree_id: ID,
224 /// Peer's current tips
225 pub their_tips: Vec<ID>,
226 /// Entries missing from our tree
227 pub missing_entries: Vec<Entry>,
228}
229
230/// Request messages that can be sent to a sync peer.
231#[allow(clippy::large_enum_variant)]
232#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
233pub enum SyncRequest {
234 /// Initial handshake request
235 Handshake(HandshakeRequest),
236 /// Unified tree sync request (handles both bootstrap and incremental)
237 SyncTree(SyncTreeRequest),
238 /// Send entries for synchronization (backward compatibility)
239 SendEntries(Vec<Entry>),
240}
241
242/// Response messages returned from a sync peer.
243#[allow(clippy::large_enum_variant)]
244#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
245pub enum SyncResponse {
246 /// Handshake response
247 Handshake(HandshakeResponse),
248 /// Full database bootstrap for new peers
249 Bootstrap(BootstrapResponse),
250 /// Incremental sync for existing peers
251 Incremental(IncrementalResponse),
252 /// Bootstrap request pending manual approval
253 BootstrapPending {
254 /// Unique identifier for the pending request
255 request_id: String,
256 /// Human-readable message about the pending status
257 message: String,
258 },
259 /// Bootstrap request was rejected by an administrator.
260 BootstrapRejected {
261 /// Identifier of the rejected request
262 request_id: String,
263 /// Human-readable rejection detail
264 message: String,
265 },
266 /// Acknowledgment that entries were received successfully
267 Ack,
268 /// Number of entries received (for multiple entries)
269 Count(usize),
270 /// Error response
271 Error(String),
272}
273
274/// Current protocol version - 0 indicates unstable.
275///
276/// `#[non_exhaustive]` does not protect wire compatibility: a peer on an older
277/// version fails to deserialize an unknown variant. Adding a variant to any
278/// serialized type in this protocol (handshake, sync requests/responses) is a
279/// version bump, not a backward-compatible addition. See
280/// [`crate::instance::WriteSource`] for the same rule on the service wire.
281pub const PROTOCOL_VERSION: u32 = 0;
282
283/// Context information about the incoming request.
284///
285/// This struct captures metadata about the connection that initiated
286/// the request, allowing the handler to know where the request came from.
287#[derive(Debug, Clone, Default)]
288pub struct RequestContext {
289 /// The remote address from which this request originated.
290 /// Extracted from the transport layer's connection metadata.
291 pub remote_address: Option<Address>,
292 /// The public key the peer claims for relationship tracking.
293 ///
294 /// **Unverified.** Transports copy it out of the request body; nothing
295 /// proves the sender holds the matching private key. Authorization uses
296 /// [`SyncTreeRequest::auth`], never this field.
297 pub peer_pubkey: Option<PublicKey>,
298}