Development Documentation (main branch) - For stable release docs, see docs.rs/eidetica
Skip to main content

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(&timestamp_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}