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