max / makenotwork
1 file changed,
+110 insertions,
-19 deletions
| @@ -26,6 +26,32 @@ pub(super) enum Idempotency { | |||
| 26 | 26 | Keyed, | |
| 27 | 27 | } | |
| 28 | 28 | ||
| 29 | + | /// Current on-wire HLC-envelope version, written into the `__skver` tag. | |
| 30 | + | const ENVELOPE_VERSION: u64 = 2; | |
| 31 | + | ||
| 32 | + | /// The on-wire HLC-envelope version, parsed from the `__skver` tag. | |
| 33 | + | /// | |
| 34 | + | /// Parsing is the explicit dispatch point: an unknown (future) version is a | |
| 35 | + | /// hard error, never a silent fallback. That closes the X2 hazard — a protocol | |
| 36 | + | /// bump that an older client cannot understand fails loudly instead of being | |
| 37 | + | /// mis-decoded as a bare row and corrupting the clock. | |
| 38 | + | #[derive(Clone, Copy, PartialEq, Eq, Debug)] | |
| 39 | + | enum WireVersion { | |
| 40 | + | /// The current envelope: `{ __skver: 2, __skhlc, data }`. | |
| 41 | + | V2, | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | impl WireVersion { | |
| 45 | + | fn parse(tag: u64) -> Result<Self> { | |
| 46 | + | match tag { | |
| 47 | + | 2 => Ok(WireVersion::V2), | |
| 48 | + | other => Err(SyncKitError::Crypto(format!( | |
| 49 | + | "unknown sync envelope version {other}; this client is too old to read it" | |
| 50 | + | ))), | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 29 | 55 | impl SyncKitClient { | |
| 30 | 56 | /// Retry an async HTTP operation with exponential backoff. | |
| 31 | 57 | /// | |
| @@ -169,34 +195,59 @@ impl SyncKitClient { | |||
| 169 | 195 | /// the E2E ciphertext — including for Deletes, which have no row payload. The | |
| 170 | 196 | /// server stores the ciphertext opaquely and never sees the clock. | |
| 171 | 197 | /// | |
| 172 | - | /// `__skver: 2` is a positive version marker that, together with the `sk2:` | |
| 173 | - | /// wire tag on the ciphertext, removes the need to structurally guess whether | |
| 174 | - | /// a decrypted value is an envelope or a bare legacy row. | |
| 198 | + | /// The `__skver` tag is a positive, explicit version marker. A reader | |
| 199 | + | /// dispatches on it (see [`WireVersion`]) rather than structurally guessing, | |
| 200 | + | /// so a future format bump is rejected loudly instead of silently misread. | |
| 175 | 201 | fn hlc_envelope(hlc: &Hlc, data: &Option<serde_json::Value>) -> serde_json::Value { | |
| 176 | - | serde_json::json!({ "__skver": 2, "__skhlc": hlc, "data": data }) | |
| 202 | + | serde_json::json!({ "__skver": ENVELOPE_VERSION, "__skhlc": hlc, "data": data }) | |
| 177 | 203 | } | |
| 178 | 204 | ||
| 179 | - | /// Split a decrypted payload back into `(hlc, data)`. A payload carrying an | |
| 180 | - | /// embedded `__skhlc` that parses as an [`Hlc`] is an envelope (gen1/gen2); | |
| 181 | - | /// anything else is a legacy bare-row payload whose HLC is synthesized from | |
| 182 | - | /// `node` + `timestamp_ms`. Detection keys on the embedded HLC's presence — | |
| 183 | - | /// not on a generic `data` key — so an app row that merely happens to contain | |
| 184 | - | /// a `data` field is never misread as an envelope. | |
| 205 | + | /// Split a decrypted payload back into `(hlc, data)`. | |
| 206 | + | /// | |
| 207 | + | /// Dispatch is explicit, not structural: | |
| 208 | + | /// - A `__skver` tag means a versioned envelope; the version is parsed via | |
| 209 | + | /// [`WireVersion::parse`], which **errors loudly** on an unknown future | |
| 210 | + | /// version rather than silently falling back to a bare-row read (which | |
| 211 | + | /// would corrupt the clock — the X2 hazard). | |
| 212 | + | /// - No `__skver` but an embedded `__skhlc` that parses is a gen-1 envelope | |
| 213 | + | /// (predates the version tag). | |
| 214 | + | /// - Anything else is a legacy bare-row payload whose HLC is synthesized | |
| 215 | + | /// from `node` + `timestamp_ms`. | |
| 185 | 216 | fn split_hlc_envelope( | |
| 186 | 217 | decrypted: serde_json::Value, | |
| 187 | 218 | node: uuid::Uuid, | |
| 188 | 219 | timestamp_ms: i64, | |
| 189 | - | ) -> (Hlc, Option<serde_json::Value>) { | |
| 190 | - | if let Some(obj) = decrypted.as_object() | |
| 191 | - | && let Some(hlc) = obj | |
| 220 | + | ) -> Result<(Hlc, Option<serde_json::Value>)> { | |
| 221 | + | if let Some(obj) = decrypted.as_object() { | |
| 222 | + | if let Some(tag) = obj.get("__skver") { | |
| 223 | + | // Explicit version present: dispatch, rejecting unknown loudly. | |
| 224 | + | let tag = tag.as_u64().ok_or_else(|| { | |
| 225 | + | SyncKitError::Crypto("envelope __skver tag is not an integer".into()) | |
| 226 | + | })?; | |
| 227 | + | return match WireVersion::parse(tag)? { | |
| 228 | + | WireVersion::V2 => { | |
| 229 | + | let hlc = obj | |
| 230 | + | .get("__skhlc") | |
| 231 | + | .and_then(|v| serde_json::from_value::<Hlc>(v.clone()).ok()) | |
| 232 | + | .ok_or_else(|| { | |
| 233 | + | SyncKitError::Crypto("v2 envelope missing __skhlc".into()) | |
| 234 | + | })?; | |
| 235 | + | let data = obj.get("data").cloned().filter(|v| !v.is_null()); | |
| 236 | + | Ok((hlc, data)) | |
| 237 | + | } | |
| 238 | + | }; | |
| 239 | + | } | |
| 240 | + | // gen-1 envelope: embedded HLC, no version tag. | |
| 241 | + | if let Some(hlc) = obj | |
| 192 | 242 | .get("__skhlc") | |
| 193 | 243 | .and_then(|v| serde_json::from_value::<Hlc>(v.clone()).ok()) | |
| 194 | - | { | |
| 195 | - | let data = obj.get("data").cloned().filter(|v| !v.is_null()); | |
| 196 | - | return (hlc, data); | |
| 244 | + | { | |
| 245 | + | let data = obj.get("data").cloned().filter(|v| !v.is_null()); | |
| 246 | + | return Ok((hlc, data)); | |
| 247 | + | } | |
| 197 | 248 | } | |
| 198 | 249 | // Legacy bare-row payload: the decrypted value is the row data itself. | |
| 199 | - | (Hlc::from_legacy(timestamp_ms, node), Some(decrypted)) | |
| 250 | + | Ok((Hlc::from_legacy(timestamp_ms, node), Some(decrypted))) | |
| 200 | 251 | } | |
| 201 | 252 | ||
| 202 | 253 | /// Encrypt with a pre-loaded key. Used by `push()` to avoid per-entry lock | |
| @@ -275,7 +326,7 @@ impl SyncKitClient { | |||
| 275 | 326 | let (hlc, data) = match entry.data { | |
| 276 | 327 | Some(ref value) => { | |
| 277 | 328 | let decrypted = crypto::decrypt_json_aad(value, master_key, &ctx)?; | |
| 278 | - | Self::split_hlc_envelope(decrypted, entry.device_id.as_uuid(), entry.timestamp.timestamp_millis()) | |
| 329 | + | Self::split_hlc_envelope(decrypted, entry.device_id.as_uuid(), entry.timestamp.timestamp_millis())? | |
| 279 | 330 | } | |
| 280 | 331 | None => ( | |
| 281 | 332 | Hlc::from_legacy(entry.timestamp.timestamp_millis(), entry.device_id.as_uuid()), | |
| @@ -299,7 +350,7 @@ impl SyncKitClient { | |||
| 299 | 350 | let (hlc, data) = match entry.data { | |
| 300 | 351 | Some(ref value) => { | |
| 301 | 352 | let decrypted = crypto::decrypt_json_aad(value, master_key, &ctx)?; | |
| 302 | - | Self::split_hlc_envelope(decrypted, entry.device_id.as_uuid(), entry.timestamp.timestamp_millis()) | |
| 353 | + | Self::split_hlc_envelope(decrypted, entry.device_id.as_uuid(), entry.timestamp.timestamp_millis())? | |
| 303 | 354 | } | |
| 304 | 355 | None => ( | |
| 305 | 356 | Hlc::from_legacy(entry.timestamp.timestamp_millis(), entry.device_id.as_uuid()), | |
| @@ -458,6 +509,46 @@ mod tests { | |||
| 458 | 509 | format!("{header}.{payload}.{signature}") | |
| 459 | 510 | } | |
| 460 | 511 | ||
| 512 | + | // ── wire-version envelope dispatch ── | |
| 513 | + | ||
| 514 | + | #[test] | |
| 515 | + | fn split_envelope_dispatches_on_explicit_version() { | |
| 516 | + | let node = uuid::Uuid::from_u128(1); | |
| 517 | + | let hlc = Hlc { wall_ms: 5, counter: 2, node }; | |
| 518 | + | ||
| 519 | + | // v2 envelope: explicit __skver, parsed by version. | |
| 520 | + | let v2 = serde_json::json!({ "__skver": 2, "__skhlc": hlc, "data": {"k": "v"} }); | |
| 521 | + | let (got, data) = SyncKitClient::split_hlc_envelope(v2, node, 0).unwrap(); | |
| 522 | + | assert_eq!(got, hlc); | |
| 523 | + | assert_eq!(data, Some(serde_json::json!({"k": "v"}))); | |
| 524 | + | ||
| 525 | + | // gen-1 envelope: __skhlc present, no version tag. | |
| 526 | + | let gen1 = serde_json::json!({ "__skhlc": hlc, "data": null }); | |
| 527 | + | let (got, data) = SyncKitClient::split_hlc_envelope(gen1, node, 0).unwrap(); | |
| 528 | + | assert_eq!(got, hlc); | |
| 529 | + | assert_eq!(data, None); | |
| 530 | + | ||
| 531 | + | // Bare legacy row: HLC synthesized from node + timestamp. | |
| 532 | + | let bare = serde_json::json!({ "title": "buy milk" }); | |
| 533 | + | let (got, data) = SyncKitClient::split_hlc_envelope(bare.clone(), node, 1234).unwrap(); | |
| 534 | + | assert_eq!(got, Hlc::from_legacy(1234, node)); | |
| 535 | + | assert_eq!(data, Some(bare)); | |
| 536 | + | } | |
| 537 | + | ||
| 538 | + | #[test] | |
| 539 | + | fn split_envelope_rejects_unknown_version_loudly() { | |
| 540 | + | // The X2 hazard: a future envelope version must error, not silently | |
| 541 | + | // fall back to a bare-row read (which would corrupt the clock). | |
| 542 | + | let node = uuid::Uuid::from_u128(1); | |
| 543 | + | let hlc = Hlc { wall_ms: 5, counter: 0, node }; | |
| 544 | + | let future = serde_json::json!({ "__skver": 3, "__skhlc": hlc, "data": null }); | |
| 545 | + | let err = SyncKitClient::split_hlc_envelope(future, node, 0).unwrap_err(); | |
| 546 | + | assert!( | |
| 547 | + | matches!(err, SyncKitError::Crypto(ref m) if m.contains("envelope version 3")), | |
| 548 | + | "unexpected error: {err:?}" | |
| 549 | + | ); | |
| 550 | + | } | |
| 551 | + | ||
| 461 | 552 | // ── encrypt_change / decrypt_change ── | |
| 462 | 553 | ||
| 463 | 554 | #[test] |