coven/sync/
envelope.rs

1/// Changeset envelope: metadata + binary changeset packed into a single blob.
2///
3/// Wire format: `JSON bytes + \0 + changeset bytes`
4///
5/// The envelope carries enough context to understand the changeset without
6/// unpacking the binary portion (schema version, author, description).
7use serde::{Deserialize, Serialize};
8
9use crate::keys::{self, UserKeypair};
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12pub struct ChangesetEnvelope {
13    pub device_id: String,
14    pub seq: u64,
15    pub schema_version: u32,
16    pub message: String,
17    pub timestamp: String,
18    pub changeset_size: usize,
19    /// Hex-encoded Ed25519 public key of the author. None for unsigned changesets.
20    #[serde(skip_serializing_if = "Option::is_none", default)]
21    pub author_pubkey: Option<String>,
22    /// Hex-encoded detached Ed25519 signature over the envelope metadata and
23    /// changeset bytes (see `signing_payload`). None for unsigned.
24    #[serde(skip_serializing_if = "Option::is_none", default)]
25    pub signature: Option<String>,
26}
27
28/// The envelope fields the signature covers, in declaration order. Excludes
29/// `author_pubkey`/`signature` (the signature's own outputs); the changeset
30/// bytes are appended in [`signing_payload`].
31#[derive(Serialize)]
32struct SignedEnvelopeFields<'a> {
33    device_id: &'a str,
34    seq: u64,
35    schema_version: u32,
36    message: &'a str,
37    timestamp: &'a str,
38    changeset_size: usize,
39}
40
41/// Canonical bytes a changeset signature covers: the authorization-relevant
42/// envelope metadata followed by the changeset payload. Binding the metadata --
43/// not just the payload -- means a signed changeset can't be re-stamped with a
44/// forged timestamp or position to slip past pull-side membership validation.
45fn signing_payload(env: &ChangesetEnvelope, changeset_bytes: &[u8]) -> Vec<u8> {
46    let fields = SignedEnvelopeFields {
47        device_id: &env.device_id,
48        seq: env.seq,
49        schema_version: env.schema_version,
50        message: &env.message,
51        timestamp: &env.timestamp,
52        changeset_size: env.changeset_size,
53    };
54    let mut payload = serde_json::to_vec(&fields).expect("signed fields serialization cannot fail");
55    payload.push(0);
56    payload.extend_from_slice(changeset_bytes);
57    payload
58}
59
60/// Sign a changeset envelope with the user's Ed25519 keypair.
61///
62/// Sets `author_pubkey` to the hex-encoded public key and `signature` to the
63/// hex-encoded detached signature over the envelope metadata and changeset
64/// bytes (see `signing_payload`).
65pub fn sign_envelope(env: &mut ChangesetEnvelope, keypair: &UserKeypair, changeset_bytes: &[u8]) {
66    let sig = keypair.sign(&signing_payload(env, changeset_bytes));
67    env.author_pubkey = Some(hex::encode(keypair.public_key));
68    env.signature = Some(hex::encode(sig));
69}
70
71/// Verify the signature on a changeset envelope.
72///
73/// Returns true if:
74/// - No signature is present (unsigned changesets are accepted).
75/// - A valid signature is present that matches the author's public key.
76///
77/// Returns false if a signature is present but invalid (wrong key, tampered data,
78/// or malformed hex).
79pub fn verify_changeset_signature(env: &ChangesetEnvelope, changeset_bytes: &[u8]) -> bool {
80    match (&env.author_pubkey, &env.signature) {
81        (None, None) => return true, // Unsigned envelope -- accepted.
82        (Some(_), None) | (None, Some(_)) => return false, // Half-signed is invalid.
83        _ => {}
84    }
85    let (Some(pk_hex), Some(sig_hex)) = (&env.author_pubkey, &env.signature) else {
86        unreachable!()
87    };
88
89    let Ok(pk_bytes) = hex::decode(pk_hex) else {
90        return false;
91    };
92    let Ok(sig_bytes) = hex::decode(sig_hex) else {
93        return false;
94    };
95
96    let Ok(pk): Result<[u8; keys::SIGN_PUBLICKEYBYTES], _> = pk_bytes.try_into() else {
97        return false;
98    };
99    let Ok(sig): Result<[u8; keys::SIGN_BYTES], _> = sig_bytes.try_into() else {
100        return false;
101    };
102
103    keys::verify_signature(&sig, &signing_payload(env, changeset_bytes), &pk)
104}
105
106/// Pack an envelope and changeset into the wire format.
107///
108/// Layout: `[envelope JSON] \0 [changeset bytes]`
109pub fn pack(envelope: &ChangesetEnvelope, changeset: &[u8]) -> Vec<u8> {
110    let json = serde_json::to_vec(envelope).expect("envelope serialization cannot fail");
111    let mut buf = Vec::with_capacity(json.len() + 1 + changeset.len());
112    buf.extend_from_slice(&json);
113    buf.push(0);
114    buf.extend_from_slice(changeset);
115    buf
116}
117
118/// Error from unpacking a changeset envelope.
119#[derive(Debug)]
120pub enum UnpackError {
121    /// No null separator found between envelope JSON and changeset bytes.
122    NoSeparator,
123    /// The envelope portion is not valid JSON.
124    InvalidJson(serde_json::Error),
125}
126
127impl std::fmt::Display for UnpackError {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        match self {
130            UnpackError::NoSeparator => write!(f, "no null separator in envelope"),
131            UnpackError::InvalidJson(e) => write!(f, "invalid envelope JSON: {e}"),
132        }
133    }
134}
135
136impl std::error::Error for UnpackError {}
137
138/// Unpack the wire format into envelope + changeset bytes.
139pub fn unpack(data: &[u8]) -> Result<(ChangesetEnvelope, Vec<u8>), UnpackError> {
140    // Splitting on the first null byte is safe because the envelope is valid
141    // JSON, and JSON cannot contain raw 0x00 bytes -- any null characters in
142    // JSON strings must be escaped as \u0000. So the first 0x00 in the packed
143    // blob is always our separator, not part of the envelope JSON.
144    let separator = data
145        .iter()
146        .position(|&b| b == 0)
147        .ok_or(UnpackError::NoSeparator)?;
148    let json_bytes = &data[..separator];
149    let changeset_bytes = &data[separator + 1..];
150
151    let envelope: ChangesetEnvelope =
152        serde_json::from_slice(json_bytes).map_err(UnpackError::InvalidJson)?;
153    Ok((envelope, changeset_bytes.to_vec()))
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use crate::keys::KeyService;
160
161    fn test_envelope() -> ChangesetEnvelope {
162        ChangesetEnvelope {
163            device_id: "dev-abc123".into(),
164            seq: 42,
165            schema_version: 2,
166            message: "Imported Album One".into(),
167            timestamp: "2026-02-10T14:30:00Z".into(),
168            changeset_size: 4096,
169            author_pubkey: None,
170            signature: None,
171        }
172    }
173
174    #[test]
175    fn pack_unpack_roundtrip() {
176        let envelope = test_envelope();
177        let changeset = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01, 0x02];
178
179        let packed = pack(&envelope, &changeset);
180        let (unpacked_env, unpacked_cs) = unpack(&packed).expect("unpack");
181
182        assert_eq!(unpacked_env, envelope);
183        assert_eq!(unpacked_cs, changeset);
184    }
185
186    #[test]
187    fn pack_unpack_empty_changeset() {
188        let envelope = test_envelope();
189        let changeset: Vec<u8> = vec![];
190
191        let packed = pack(&envelope, &changeset);
192        let (unpacked_env, unpacked_cs) = unpack(&packed).expect("unpack");
193
194        assert_eq!(unpacked_env, envelope);
195        assert!(unpacked_cs.is_empty());
196    }
197
198    #[test]
199    fn pack_contains_null_separator() {
200        let envelope = test_envelope();
201        let changeset = vec![0xFF];
202
203        let packed = pack(&envelope, &changeset);
204
205        // Find the null byte -- it should exist exactly once between JSON and changeset.
206        let null_positions: Vec<usize> = packed
207            .iter()
208            .enumerate()
209            .filter(|&(_, &b)| b == 0)
210            .map(|(i, _)| i)
211            .collect();
212
213        // The changeset doesn't contain 0x00 in this case, so exactly one null.
214        assert_eq!(null_positions.len(), 1);
215    }
216
217    #[test]
218    fn changeset_with_embedded_nulls() {
219        let envelope = test_envelope();
220        // Changeset bytes that contain null bytes -- unpack should handle this
221        // because we split on the FIRST null (after JSON).
222        let changeset = vec![0x00, 0x00, 0xFF, 0x00];
223
224        let packed = pack(&envelope, &changeset);
225        let (unpacked_env, unpacked_cs) = unpack(&packed).expect("unpack");
226
227        assert_eq!(unpacked_env, envelope);
228        assert_eq!(unpacked_cs, changeset);
229    }
230
231    #[test]
232    fn unpack_invalid_no_separator() {
233        let data = b"hello world";
234        assert!(matches!(unpack(data), Err(UnpackError::NoSeparator)));
235    }
236
237    #[test]
238    fn unpack_invalid_bad_json() {
239        // Null separator present but JSON is invalid
240        let mut data = b"not json".to_vec();
241        data.push(0);
242        data.extend_from_slice(b"changeset");
243
244        assert!(matches!(unpack(&data), Err(UnpackError::InvalidJson(_))));
245    }
246
247    #[test]
248    fn unpack_empty_input() {
249        assert!(matches!(unpack(&[]), Err(UnpackError::NoSeparator)));
250    }
251
252    // ---- Signing tests ----
253
254    /// Combined test for signing operations. Uses a single KeyService call
255    /// because env vars are process-global and parallel tests race.
256    #[test]
257    fn changeset_signing() {
258        // Clear env to avoid interference from other tests.
259        std::env::remove_var("BAE_USER_SIGNING_KEY");
260        std::env::remove_var("BAE_USER_PUBLIC_KEY");
261
262        let ks = KeyService::new(true, "test-signing".to_string());
263        let keypair = ks.get_or_create_user_keypair().unwrap();
264
265        let changeset_bytes = b"some changeset payload";
266
267        // sign_envelope produces a valid signature.
268        let mut env = test_envelope();
269        sign_envelope(&mut env, &keypair, changeset_bytes);
270
271        assert!(env.author_pubkey.is_some());
272        assert!(env.signature.is_some());
273        assert_eq!(
274            env.author_pubkey.as_ref().unwrap(),
275            &hex::encode(keypair.public_key)
276        );
277        assert!(verify_changeset_signature(&env, changeset_bytes));
278
279        // Signed envelope round-trips through pack/unpack.
280        let packed = pack(&env, changeset_bytes);
281        let (unpacked_env, unpacked_cs) = unpack(&packed).expect("unpack");
282        assert_eq!(unpacked_env.author_pubkey, env.author_pubkey);
283        assert_eq!(unpacked_env.signature, env.signature);
284        assert!(verify_changeset_signature(&unpacked_env, &unpacked_cs));
285
286        // Tampered changeset bytes fail verification.
287        assert!(!verify_changeset_signature(&env, b"tampered payload"));
288
289        // The signature binds envelope metadata, not just the payload. Mutating
290        // an authorization-relevant field (timestamp) after signing must
291        // invalidate it -- otherwise a revoked member could backdate a signed
292        // changeset to a time they were still a member and slip past pull-side
293        // membership validation.
294        let mut backdated = env.clone();
295        backdated.timestamp = "2000-01-01T00:00:00Z".to_string();
296        assert!(!verify_changeset_signature(&backdated, changeset_bytes));
297
298        // The changeset's identity (device_id, seq) is bound too, so a signed
299        // changeset can't be replayed under a different position.
300        let mut moved = env.clone();
301        moved.seq += 1;
302        assert!(!verify_changeset_signature(&moved, changeset_bytes));
303        let mut rehomed = env.clone();
304        rehomed.device_id = "other-device".to_string();
305        assert!(!verify_changeset_signature(&rehomed, changeset_bytes));
306
307        // Unsigned envelope passes verification.
308        let unsigned_env = test_envelope();
309        assert!(verify_changeset_signature(&unsigned_env, changeset_bytes));
310
311        // Malformed hex in signature fails.
312        let mut bad_sig_env = env.clone();
313        bad_sig_env.signature = Some("not-valid-hex!!".to_string());
314        assert!(!verify_changeset_signature(&bad_sig_env, changeset_bytes));
315
316        // Wrong-length public key fails.
317        let mut bad_pk_env = env.clone();
318        bad_pk_env.author_pubkey = Some(hex::encode([0u8; 16])); // 16 bytes, not 32
319        assert!(!verify_changeset_signature(&bad_pk_env, changeset_bytes));
320
321        // Clean up
322        std::env::remove_var("BAE_USER_SIGNING_KEY");
323        std::env::remove_var("BAE_USER_PUBLIC_KEY");
324    }
325}