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 // Note: requesting_key is unverified. It selects which key an approval
84 // would grant; it never authorizes serving data — `auth` does that.
85 /// Authentication key requesting access (for bootstrap)
86 pub requesting_key: Option<PublicKey>,
87 /// Key name/identifier for the requesting key
88 pub requesting_key_name: Option<String>,
89 /// Desired permission level for bootstrap
90 pub requested_permission: Option<Permission>,
91 /// Free-form context the requester attaches for the approver to inspect
92 /// when deciding whether to grant access. Carried verbatim onto the stored
93 /// `BootstrapRequest`.
94 #[serde(default)]
95 pub metadata: Option<Doc>,
96 /// Proof that the caller holds the private half of the key it is claiming.
97 ///
98 /// Required before any entry is served from a database that has auth
99 /// configured. Absent on requests that only *ask* for access (the manual
100 /// approval queue), which disclose nothing.
101 #[serde(default)]
102 pub auth: Option<SyncRequestAuth>,
103}
104
105/// A caller's proof of key possession for one sync request.
106///
107/// The signature covers the responding server, the tree, the claimed tips, and
108/// a timestamp/nonce pair, so a captured request cannot be replayed to the same
109/// server, redirected to a different one, or reused for a different tree.
110///
111/// # What this does not defend against
112///
113/// This authenticates the *requester to the server*; it does not protect the
114/// channel. Over a plaintext transport an attacker on the network path still
115/// reads the served entries and can relay a live signed request to keep the
116/// response for itself. Confidentiality against a network attacker requires an
117/// encrypted transport (Iroh's QUIC), not this signature.
118#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
119pub struct SyncRequestAuth {
120 /// The key whose authority the caller is claiming on the tree.
121 pub key: PublicKey,
122 /// Milliseconds since the Unix epoch, from the caller's clock.
123 pub timestamp_ms: u64,
124 /// Random per-request value; makes each signature single-use.
125 pub nonce: Vec<u8>,
126 /// Signature over [`SyncRequestAuth::signing_bytes`].
127 pub signature: Vec<u8>,
128}
129
130impl SyncRequestAuth {
131 /// Sign a request to `server_pubkey` for `tree_id` at `tips`.
132 pub fn sign(
133 signing_key: &PrivateKey,
134 server_pubkey: &PublicKey,
135 tree_id: &ID,
136 tips: &Snapshot,
137 timestamp_ms: u64,
138 ) -> Self {
139 let nonce = generate_challenge();
140 let signature = create_challenge_response(
141 Self::signing_bytes(server_pubkey, tree_id, tips, timestamp_ms, &nonce),
142 signing_key,
143 );
144 Self {
145 key: signing_key.public_key(),
146 timestamp_ms,
147 nonce,
148 signature,
149 }
150 }
151
152 /// Verify the signature against the request it claims to cover.
153 ///
154 /// Freshness and single-use are the caller's responsibility — a valid
155 /// signature says nothing about when it was made.
156 pub fn verify(
157 &self,
158 server_pubkey: &PublicKey,
159 tree_id: &ID,
160 tips: &Snapshot,
161 ) -> Result<(), AuthError> {
162 verify_challenge_response(
163 Self::signing_bytes(server_pubkey, tree_id, tips, self.timestamp_ms, &self.nonce),
164 &self.signature,
165 &self.key,
166 )
167 }
168
169 /// The exact bytes covered by the signature.
170 ///
171 /// Every field is length-prefixed so that no two distinct requests can
172 /// produce the same byte string.
173 fn signing_bytes(
174 server_pubkey: &PublicKey,
175 tree_id: &ID,
176 tips: &Snapshot,
177 timestamp_ms: u64,
178 nonce: &[u8],
179 ) -> Vec<u8> {
180 let mut bytes = Vec::new();
181 let mut push = |field: &[u8]| {
182 bytes.extend_from_slice(&(field.len() as u64).to_be_bytes());
183 bytes.extend_from_slice(field);
184 };
185
186 push(SYNC_REQUEST_DOMAIN);
187 push(server_pubkey.to_string().as_bytes());
188 push(tree_id.to_string().as_bytes());
189 push(&(tips.len() as u64).to_be_bytes());
190 for tip in tips.tips() {
191 push(tip.to_string().as_bytes());
192 }
193 push(×tamp_ms.to_be_bytes());
194 push(nonce);
195 bytes
196 }
197}
198
199/// Domain separator, so a sync signature can never be mistaken for a signature
200/// over an entry or a handshake challenge.
201const SYNC_REQUEST_DOMAIN: &[u8] = b"eidetica/sync/tree-request/v1";
202
203/// Bootstrap response containing complete tree state
204#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
205pub struct BootstrapResponse {
206 /// Database ID being bootstrapped
207 pub tree_id: ID,
208 /// The root entry of the tree
209 pub root_entry: Entry,
210 /// All entries in the tree (excluding root)
211 pub all_entries: Vec<Entry>,
212 /// Whether the requesting key was approved and added
213 pub key_approved: bool,
214 /// The permission level granted (if approved)
215 pub granted_permission: Option<Permission>,
216}
217
218/// Incremental sync response for existing trees
219#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
220pub struct IncrementalResponse {
221 /// Database ID being synced
222 pub tree_id: ID,
223 /// Peer's current tips
224 pub their_tips: Vec<ID>,
225 /// Entries missing from our tree
226 pub missing_entries: Vec<Entry>,
227}
228
229/// Request messages that can be sent to a sync peer.
230#[allow(clippy::large_enum_variant)]
231#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
232pub enum SyncRequest {
233 /// Initial handshake request
234 Handshake(HandshakeRequest),
235 /// Unified tree sync request (handles both bootstrap and incremental)
236 SyncTree(SyncTreeRequest),
237 /// Send entries for synchronization (backward compatibility)
238 SendEntries(Vec<Entry>),
239}
240
241/// Response messages returned from a sync peer.
242#[allow(clippy::large_enum_variant)]
243#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
244pub enum SyncResponse {
245 /// Handshake response
246 Handshake(HandshakeResponse),
247 /// Full database bootstrap for new peers
248 Bootstrap(BootstrapResponse),
249 /// Incremental sync for existing peers
250 Incremental(IncrementalResponse),
251 /// Bootstrap request pending manual approval
252 BootstrapPending {
253 /// Unique identifier for the pending request
254 request_id: String,
255 /// Human-readable message about the pending status
256 message: String,
257 },
258 /// Acknowledgment that entries were received successfully
259 Ack,
260 /// Number of entries received (for multiple entries)
261 Count(usize),
262 /// Error response
263 Error(String),
264}
265
266/// Current protocol version - 0 indicates unstable
267pub const PROTOCOL_VERSION: u32 = 0;
268
269/// Context information about the incoming request.
270///
271/// This struct captures metadata about the connection that initiated
272/// the request, allowing the handler to know where the request came from.
273#[derive(Debug, Clone, Default)]
274pub struct RequestContext {
275 /// The remote address from which this request originated.
276 /// Extracted from the transport layer's connection metadata.
277 pub remote_address: Option<Address>,
278 /// The public key the peer claims for relationship tracking.
279 ///
280 /// **Unverified.** Transports copy it out of the request body; nothing
281 /// proves the sender holds the matching private key. Authorization uses
282 /// [`SyncTreeRequest::auth`], never this field.
283 pub peer_pubkey: Option<PublicKey>,
284}