max / makenotwork
10 files changed,
+227 insertions,
-44 deletions
| @@ -113,10 +113,10 @@ pub async fn get_rotation_entries( | |||
| 113 | 113 | new_key_id: i32, | |
| 114 | 114 | after_seq: i64, | |
| 115 | 115 | limit: i64, | |
| 116 | - | ) -> Result<Vec<(i64, Option<JsonValue>)>> { | |
| 117 | - | let entries: Vec<(i64, Option<JsonValue>)> = sqlx::query_as( | |
| 116 | + | ) -> Result<Vec<(i64, String, String, Option<JsonValue>)>> { | |
| 117 | + | let entries: Vec<(i64, String, String, Option<JsonValue>)> = sqlx::query_as( | |
| 118 | 118 | r#" | |
| 119 | - | SELECT seq, data FROM sync_log | |
| 119 | + | SELECT seq, table_name, row_id, data FROM sync_log | |
| 120 | 120 | WHERE app_id = $1 AND user_id = $2 | |
| 121 | 121 | AND seq > $3 | |
| 122 | 122 | AND (key_id IS NULL OR key_id != $5) |
| @@ -320,6 +320,10 @@ pub(crate) struct RotationEntriesResponse { | |||
| 320 | 320 | #[derive(Serialize, utoipa::ToSchema)] | |
| 321 | 321 | pub(crate) struct RotationEntry { | |
| 322 | 322 | seq: i64, | |
| 323 | + | /// Source table and row id, echoed so the client can recompute the entry's | |
| 324 | + | /// AEAD associated data when re-encrypting under the new key. | |
| 325 | + | table: String, | |
| 326 | + | row_id: String, | |
| 323 | 327 | data: Option<serde_json::Value>, | |
| 324 | 328 | } | |
| 325 | 329 |
| @@ -714,7 +714,7 @@ pub(super) async fn rotation_entries( | |||
| 714 | 714 | let has_more = raw_entries.len() as i64 == page_size; | |
| 715 | 715 | let entries: Vec<RotationEntry> = raw_entries | |
| 716 | 716 | .into_iter() | |
| 717 | - | .map(|(seq, data)| RotationEntry { seq, data }) | |
| 717 | + | .map(|(seq, table, row_id, data)| RotationEntry { seq, table, row_id, data }) | |
| 718 | 718 | .collect(); | |
| 719 | 719 | ||
| 720 | 720 | Ok(Json(RotationEntriesResponse { entries, has_more })) |
| @@ -7,7 +7,7 @@ end-to-end encrypted cloud sync against the MNW SyncKit server. Consumer apps | |||
| 7 | 7 | (GoingsOn, Balanced Breakfast, audiofiles) use this crate to push and pull | |
| 8 | 8 | changelog entries without the server ever seeing plaintext data. | |
| 9 | 9 | ||
| 10 | - | Version: 0.3.1. | |
| 10 | + | Version: 0.4.0. | |
| 11 | 11 | ||
| 12 | 12 | ## Crate structure | |
| 13 | 13 | ||
| @@ -122,12 +122,46 @@ from the OS keychain without any server interaction or password prompt. | |||
| 122 | 122 | ||
| 123 | 123 | ## Push/Pull protocol | |
| 124 | 124 | ||
| 125 | + | ### The HLC envelope | |
| 126 | + | ||
| 127 | + | Every change — **including Deletes** — is encrypted as a JSON *envelope*, not as | |
| 128 | + | the bare row data: | |
| 129 | + | ||
| 130 | + | ```json | |
| 131 | + | { "__skver": 2, "__skhlc": { "wall_ms": …, "counter": …, "node": "…" }, "data": <row|null> } | |
| 132 | + | ``` | |
| 133 | + | ||
| 134 | + | Encrypting the envelope (rather than just the row) is what carries the hybrid | |
| 135 | + | logical clock (HLC) inside the E2E ciphertext, so the server can order entries by | |
| 136 | + | `seq` but never sees or orders by the clock. A Delete has `data: null` but still | |
| 137 | + | seals an envelope, so its HLC travels too. On decrypt, a payload whose embedded | |
| 138 | + | `__skhlc` parses as an `Hlc` is treated as an envelope; anything else is a legacy | |
| 139 | + | bare row whose HLC is synthesized from `node` + the entry's `client_timestamp`. | |
| 140 | + | ||
| 141 | + | ### Wire version tag and AAD binding | |
| 142 | + | ||
| 143 | + | The encrypted `data` string is **position-bound**. v2 ciphertext is prefixed | |
| 144 | + | `sk2:` and sealed with AEAD associated data `aad_for_entry(table, row_id)` = | |
| 145 | + | `table` `0x1f` `row_id`. Because the tag is read before decryption, the reader | |
| 146 | + | always knows which AAD to apply: | |
| 147 | + | ||
| 148 | + | - `sk2:`-tagged → open with the entry's `(table, row_id)` AAD. | |
| 149 | + | - untagged (legacy, pre-v2) → open with empty AAD. | |
| 150 | + | ||
| 151 | + | This means a malicious or compromised server cannot relocate a valid ciphertext | |
| 152 | + | to a different row or table: the AAD no longer matches and the open fails closed. | |
| 153 | + | Legacy and v2 ciphertext coexist with no flag-day; key rotation re-encrypts | |
| 154 | + | legacy entries into the v2 form opportunistically. | |
| 155 | + | ||
| 125 | 156 | ### Push | |
| 126 | 157 | ||
| 127 | - | 1. Caller provides a `Vec<ChangeEntry>` with plaintext `data` fields. | |
| 128 | - | 2. Client serializes each `data` field to JSON bytes, encrypts with the master | |
| 129 | - | key (XChaCha20-Poly1305, random nonce), and base64-encodes the result. | |
| 130 | - | 3. The encrypted payload is POSTed to `/api/sync/push` with the device ID. | |
| 158 | + | 1. Caller provides a `Vec<ChangeEntry>` with plaintext `data` fields and an HLC. | |
| 159 | + | 2. Client wraps each `(hlc, data)` in an envelope, encrypts it with the master | |
| 160 | + | key (XChaCha20-Poly1305, random nonce, `(table, row_id)` AAD), and emits the | |
| 161 | + | `sk2:`-tagged base64 string. | |
| 162 | + | 3. The encrypted payload is POSTed to `/api/sync/push` with the device ID and a | |
| 163 | + | client-generated `batch_id` (idempotent push: a replayed batch returns the | |
| 164 | + | existing cursor). | |
| 131 | 165 | 4. Server appends entries to the changelog and returns the new cursor (i64). | |
| 132 | 166 | 5. Retries on transient failures (see below). | |
| 133 | 167 | ||
| @@ -136,10 +170,27 @@ from the OS keychain without any server interaction or password prompt. | |||
| 136 | 170 | 1. Client POSTs to `/api/sync/pull` with the device ID and last-known cursor. | |
| 137 | 171 | 2. Server returns changelog entries since that cursor, a new cursor, and a | |
| 138 | 172 | `has_more` flag for pagination. | |
| 139 | - | 3. Client decrypts each entry's `data` field and returns plaintext `ChangeEntry` | |
| 140 | - | values to the caller. | |
| 173 | + | 3. Client decrypts each entry's envelope (auto-detecting the wire version) and | |
| 174 | + | returns plaintext `ChangeEntry` values, HLC included, to the caller. | |
| 141 | 175 | 4. Retries on transient failures. | |
| 142 | 176 | ||
| 177 | + | **Caller contract.** The SDK does not persist the cursor or dedup re-delivered | |
| 178 | + | changes. Persist the returned cursor only *after* applying the returned changes | |
| 179 | + | (same transaction where possible), and make apply idempotent per | |
| 180 | + | `(table, row_id, hlc)` — a reconnect can re-deliver a batch. | |
| 181 | + | ||
| 182 | + | ### Conflict resolution | |
| 183 | + | ||
| 184 | + | Conflicts are resolved client-side (the server cannot read plaintext). | |
| 185 | + | `resolve_lww` orders by HLC, operation-agnostically: the higher `(wall_ms, | |
| 186 | + | counter, node)` wins, so a newer edit beats an older delete and vice-versa, and | |
| 187 | + | every device converges on the same winner. An *exactly* equal HLC normally means | |
| 188 | + | a same-device echo; if two installs share a `node` UUID (cloned config), the tie | |
| 189 | + | is broken deterministically on the canonical payload bytes so both devices still | |
| 190 | + | converge. `detect_conflicts` only flags rows with an *un-pushed* local edit; | |
| 191 | + | "clean" changes must still be HLC-gated by the caller against committed local | |
| 192 | + | state before applying. | |
| 193 | + | ||
| 143 | 194 | ### Sequence numbers | |
| 144 | 195 | ||
| 145 | 196 | The server assigns a monotonically increasing sequence number (`seq`) to each | |
| @@ -232,5 +283,10 @@ storing long-lived credentials in memory. | |||
| 232 | 283 | loaded) but never log key bytes or ciphertext. | |
| 233 | 284 | - **Minimum ciphertext size**: Decryption rejects inputs shorter than 40 bytes | |
| 234 | 285 | (24-byte nonce + 16-byte tag), preventing trivial malformed-input attacks. | |
| 286 | + | - **Ciphertext position binding (AAD)**: v2 entry ciphertext is sealed with | |
| 287 | + | `(table, row_id)` as AEAD associated data, so the untrusted server cannot | |
| 288 | + | relocate a valid ciphertext to another row/table without the open failing | |
| 289 | + | closed. See "Wire version tag and AAD binding". | |
| 235 | 290 | - **Envelope versioning**: The key envelope includes a version field (`v: 1`) | |
| 236 | - | to support future algorithm upgrades without breaking existing envelopes. | |
| 291 | + | and travels with its Argon2 cost parameters, so the work factor can be raised | |
| 292 | + | later without breaking existing envelopes. |
| @@ -136,20 +136,26 @@ impl SyncKitClient { | |||
| 136 | 136 | /// envelope (rather than the bare row payload) is what carries the HLC inside | |
| 137 | 137 | /// the E2E ciphertext — including for Deletes, which have no row payload. The | |
| 138 | 138 | /// server stores the ciphertext opaquely and never sees the clock. | |
| 139 | + | /// | |
| 140 | + | /// `__skver: 2` is a positive version marker that, together with the `sk2:` | |
| 141 | + | /// wire tag on the ciphertext, removes the need to structurally guess whether | |
| 142 | + | /// a decrypted value is an envelope or a bare legacy row. | |
| 139 | 143 | fn hlc_envelope(hlc: &Hlc, data: &Option<serde_json::Value>) -> serde_json::Value { | |
| 140 | - | serde_json::json!({ "__skhlc": hlc, "data": data }) | |
| 144 | + | serde_json::json!({ "__skver": 2, "__skhlc": hlc, "data": data }) | |
| 141 | 145 | } | |
| 142 | 146 | ||
| 143 | - | /// Split a decrypted payload back into `(hlc, data)`. Handles both the HLC | |
| 144 | - | /// envelope and legacy bare-row payloads (entries pushed before HLC support), | |
| 145 | - | /// for which the HLC is synthesized from `node` + `timestamp_ms`. | |
| 147 | + | /// Split a decrypted payload back into `(hlc, data)`. A payload carrying an | |
| 148 | + | /// embedded `__skhlc` that parses as an [`Hlc`] is an envelope (gen1/gen2); | |
| 149 | + | /// anything else is a legacy bare-row payload whose HLC is synthesized from | |
| 150 | + | /// `node` + `timestamp_ms`. Detection keys on the embedded HLC's presence — | |
| 151 | + | /// not on a generic `data` key — so an app row that merely happens to contain | |
| 152 | + | /// a `data` field is never misread as an envelope. | |
| 146 | 153 | fn split_hlc_envelope( | |
| 147 | 154 | decrypted: serde_json::Value, | |
| 148 | 155 | node: uuid::Uuid, | |
| 149 | 156 | timestamp_ms: i64, | |
| 150 | 157 | ) -> (Hlc, Option<serde_json::Value>) { | |
| 151 | 158 | if let Some(obj) = decrypted.as_object() | |
| 152 | - | && obj.contains_key("data") | |
| 153 | 159 | && let Some(hlc) = obj | |
| 154 | 160 | .get("__skhlc") | |
| 155 | 161 | .and_then(|v| serde_json::from_value::<Hlc>(v.clone()).ok()) | |
| @@ -163,10 +169,13 @@ impl SyncKitClient { | |||
| 163 | 169 | ||
| 164 | 170 | /// Encrypt with a pre-loaded key. Used by `push()` to avoid per-entry lock | |
| 165 | 171 | /// acquisition. Every change (including Deletes) is encrypted, because the HLC | |
| 166 | - | /// envelope always needs sealing. | |
| 172 | + | /// envelope always needs sealing. The ciphertext is bound to its | |
| 173 | + | /// `(table, row_id)` address via AEAD associated data, so a server cannot | |
| 174 | + | /// relocate it to another row/table without the open failing closed. | |
| 167 | 175 | pub(super) fn encrypt_change_with_key(entry: ChangeEntry, master_key: &[u8; 32]) -> Result<WireChangeEntry> { | |
| 176 | + | let aad = crypto::aad_for_entry(&entry.table, &entry.row_id); | |
| 168 | 177 | let envelope = Self::hlc_envelope(&entry.hlc, &entry.data); | |
| 169 | - | let encrypted_data = Some(crypto::encrypt_json(&envelope, master_key)?); | |
| 178 | + | let encrypted_data = Some(crypto::encrypt_json_aad(&envelope, master_key, &aad)?); | |
| 170 | 179 | ||
| 171 | 180 | Ok(WireChangeEntry { | |
| 172 | 181 | table: entry.table, | |
| @@ -230,9 +239,10 @@ impl SyncKitClient { | |||
| 230 | 239 | /// Inner decryption that borrows the entry (for fallback logic). | |
| 231 | 240 | #[allow(dead_code)] | |
| 232 | 241 | fn decrypt_change_with_key_inner(entry: &PullChangeEntry, master_key: &[u8; 32]) -> Result<ChangeEntry> { | |
| 242 | + | let aad = crypto::aad_for_entry(&entry.table, &entry.row_id); | |
| 233 | 243 | let (hlc, data) = match entry.data { | |
| 234 | 244 | Some(ref value) => { | |
| 235 | - | let decrypted = crypto::decrypt_json(value, master_key)?; | |
| 245 | + | let decrypted = crypto::decrypt_json_aad(value, master_key, &aad)?; | |
| 236 | 246 | Self::split_hlc_envelope(decrypted, entry.device_id, entry.timestamp.timestamp_millis()) | |
| 237 | 247 | } | |
| 238 | 248 | None => ( | |
| @@ -253,9 +263,10 @@ impl SyncKitClient { | |||
| 253 | 263 | ||
| 254 | 264 | /// Decrypt with a pre-loaded key. Used by `pull()` to avoid per-entry lock acquisition. | |
| 255 | 265 | pub(super) fn decrypt_change_with_key(entry: PullChangeEntry, master_key: &[u8; 32]) -> Result<ChangeEntry> { | |
| 266 | + | let aad = crypto::aad_for_entry(&entry.table, &entry.row_id); | |
| 256 | 267 | let (hlc, data) = match entry.data { | |
| 257 | 268 | Some(ref value) => { | |
| 258 | - | let decrypted = crypto::decrypt_json(value, master_key)?; | |
| 269 | + | let decrypted = crypto::decrypt_json_aad(value, master_key, &aad)?; | |
| 259 | 270 | Self::split_hlc_envelope(decrypted, entry.device_id, entry.timestamp.timestamp_millis()) | |
| 260 | 271 | } | |
| 261 | 272 | None => ( |
| @@ -155,9 +155,13 @@ impl SyncKitClient { | |||
| 155 | 155 | .map(|entry| { | |
| 156 | 156 | let new_data = match entry.data { | |
| 157 | 157 | Some(ref encrypted_value) => { | |
| 158 | - | // Decrypt with old key, re-encrypt with new key | |
| 159 | - | let plaintext = crypto::decrypt_json(encrypted_value, old_key)?; | |
| 160 | - | let reencrypted = crypto::encrypt_json(&plaintext, new_key)?; | |
| 158 | + | // Decrypt with old key, re-encrypt with new key — binding the | |
| 159 | + | // same (table, row_id) AAD on both ends. A legacy untagged | |
| 160 | + | // entry decrypts with empty AAD and re-emits as a v2 tagged, | |
| 161 | + | // position-bound payload (opportunistic upgrade). | |
| 162 | + | let aad = crypto::aad_for_entry(&entry.table, &entry.row_id); | |
| 163 | + | let plaintext = crypto::decrypt_json_aad(encrypted_value, old_key, &aad)?; | |
| 164 | + | let reencrypted = crypto::encrypt_json_aad(&plaintext, new_key, &aad)?; | |
| 161 | 165 | Some(reencrypted) | |
| 162 | 166 | } | |
| 163 | 167 | None => None, // DELETE entries have no data | |
| @@ -385,11 +389,13 @@ mod tests { | |||
| 385 | 389 | assert_eq!(resp.target_seq, 100); | |
| 386 | 390 | assert_eq!(resp.new_key_id, 2); | |
| 387 | 391 | ||
| 388 | - | let json = r#"{"entries": [{"seq": 1, "data": "encrypted"}, {"seq": 2, "data": null}], "has_more": true}"#; | |
| 392 | + | let json = r#"{"entries": [{"seq": 1, "table": "tasks", "row_id": "r1", "data": "encrypted"}, {"seq": 2, "table": "tasks", "row_id": "r2", "data": null}], "has_more": true}"#; | |
| 389 | 393 | let resp: RotationEntriesResponse = serde_json::from_str(json).unwrap(); | |
| 390 | 394 | assert_eq!(resp.entries.len(), 2); | |
| 391 | 395 | assert!(resp.has_more); | |
| 392 | 396 | assert!(resp.entries[0].data.is_some()); | |
| 397 | + | assert_eq!(resp.entries[0].table, "tasks"); | |
| 398 | + | assert_eq!(resp.entries[0].row_id, "r1"); | |
| 393 | 399 | assert!(resp.entries[1].data.is_none()); | |
| 394 | 400 | } | |
| 395 | 401 |
| @@ -87,10 +87,10 @@ fn parse_sse_block_is_changed(block: &str) -> bool { | |||
| 87 | 87 | if trimmed.starts_with(':') { | |
| 88 | 88 | continue; | |
| 89 | 89 | } | |
| 90 | - | if let Some(value) = trimmed.strip_prefix("event:") { | |
| 91 | - | if value.trim() == "changed" { | |
| 92 | - | return true; | |
| 93 | - | } | |
| 90 | + | if let Some(value) = trimmed.strip_prefix("event:") | |
| 91 | + | && value.trim() == "changed" | |
| 92 | + | { | |
| 93 | + | return true; | |
| 94 | 94 | } | |
| 95 | 95 | } | |
| 96 | 96 | false |
| @@ -108,6 +108,13 @@ impl SyncKitClient { | |||
| 108 | 108 | /// Returns (changes, new_cursor, has_more). | |
| 109 | 109 | /// | |
| 110 | 110 | /// Retries on transient failures (network errors, 5xx, 429) with exponential backoff. | |
| 111 | + | /// | |
| 112 | + | /// **Contract — persist the cursor only after applying.** The returned cursor | |
| 113 | + | /// must be written to durable storage *after* the returned changes are | |
| 114 | + | /// applied, in the same transaction where possible. Persisting the cursor | |
| 115 | + | /// first and crashing before apply silently drops a batch; the SDK does not | |
| 116 | + | /// dedup re-delivered changes, so apply must also be idempotent per | |
| 117 | + | /// `(table, row_id, hlc)`. | |
| 111 | 118 | #[instrument(skip(self))] | |
| 112 | 119 | pub async fn pull( | |
| 113 | 120 | &self, |
| @@ -69,7 +69,16 @@ pub trait ConflictResolver: Send + Sync { | |||
| 69 | 69 | /// each produces a separate `ConflictPair` with a clone of the same local | |
| 70 | 70 | /// entry. The caller must resolve them in order (by `seq`). | |
| 71 | 71 | /// | |
| 72 | - | /// Returns `(clean, conflicts)` where `clean` changes can be applied directly. | |
| 72 | + | /// Returns `(clean, conflicts)` where `clean` changes have no *un-pushed* local | |
| 73 | + | /// edit to conflict with. | |
| 74 | + | /// | |
| 75 | + | /// **Contract — clean changes still need HLC gating.** "Clean" means only that | |
| 76 | + | /// no local *pending* edit contests the row. It does **not** mean the remote | |
| 77 | + | /// change is newer than what is already committed locally: a device that has | |
| 78 | + | /// applied and pushed a newer edit, then pulls an older remote edit for the same | |
| 79 | + | /// row, will see that older edit in `clean`. The caller must compare each clean | |
| 80 | + | /// change's [`Hlc`](crate::types::Hlc) against the row's committed HLC and skip | |
| 81 | + | /// it if older. Applying clean changes blindly can clobber a newer local value. | |
| 73 | 82 | pub fn detect_conflicts( | |
| 74 | 83 | remote: Vec<PulledChange>, | |
| 75 | 84 | local_pending: &[ChangeEntry], | |
| @@ -117,14 +126,35 @@ pub fn detect_conflicts( | |||
| 117 | 126 | /// This replaces the prior wall-clock + "delete always wins" rule. Delete-wins | |
| 118 | 127 | /// caused permanent loss when a delete on one device beat a strictly-newer edit on | |
| 119 | 128 | /// another (ultra-fuzz Run #28); under HLC ordering a newer edit beats an older | |
| 120 | - | /// delete and vice-versa, by clock value alone. `>=` only ties to local for an | |
| 121 | - | /// identical HLC, which (distinct `node`) means a same-device echo, not a real | |
| 122 | - | /// cross-device conflict. | |
| 129 | + | /// delete and vice-versa, by clock value alone. | |
| 130 | + | /// | |
| 131 | + | /// An *exactly* equal HLC normally means a same-device echo, since a distinct | |
| 132 | + | /// `node` makes the value unique. It can only collide across devices if two | |
| 133 | + | /// installs share a `node` UUID (a cloned config dir or a restored backup). To | |
| 134 | + | /// stay convergent even then, an exact tie is broken on the canonical payload | |
| 135 | + | /// bytes — a deterministic order both devices compute identically — rather than | |
| 136 | + | /// unconditionally keeping local (which would let A keep `a` while B keeps `b`). | |
| 123 | 137 | pub fn resolve_lww(local: &ChangeEntry, remote: &PulledChange) -> Resolution { | |
| 124 | - | if local.hlc >= remote.entry.hlc { | |
| 125 | - | Resolution::KeepLocal | |
| 126 | - | } else { | |
| 127 | - | Resolution::KeepRemote | |
| 138 | + | match local.hlc.cmp(&remote.entry.hlc) { | |
| 139 | + | std::cmp::Ordering::Greater => Resolution::KeepLocal, | |
| 140 | + | std::cmp::Ordering::Less => Resolution::KeepRemote, | |
| 141 | + | std::cmp::Ordering::Equal => { | |
| 142 | + | if canonical_payload(&local.data) >= canonical_payload(&remote.entry.data) { | |
| 143 | + | Resolution::KeepLocal | |
| 144 | + | } else { | |
| 145 | + | Resolution::KeepRemote | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | /// Deterministic byte encoding of a row payload for the exact-HLC tiebreak. | |
| 152 | + | /// `serde_json`'s default `Map` is sorted, so equal values always serialize to | |
| 153 | + | /// equal bytes on every device — the property the tiebreak relies on. | |
| 154 | + | fn canonical_payload(data: &Option<serde_json::Value>) -> Vec<u8> { | |
| 155 | + | match data { | |
| 156 | + | Some(v) => serde_json::to_vec(v).unwrap_or_default(), | |
| 157 | + | None => Vec::new(), | |
| 128 | 158 | } | |
| 129 | 159 | } | |
| 130 | 160 | ||
| @@ -467,6 +497,34 @@ mod tests { | |||
| 467 | 497 | } | |
| 468 | 498 | ||
| 469 | 499 | #[test] | |
| 500 | + | fn lww_identical_hlc_node_collision_converges_on_payload() { | |
| 501 | + | // Pathological case: two installs share a node UUID (cloned config), so | |
| 502 | + | // two genuinely different edits produce a byte-identical HLC. The payload | |
| 503 | + | // tiebreak must still make both devices land on the same value. | |
| 504 | + | let shared = Uuid::from_u128(7); | |
| 505 | + | let hlc = Hlc { wall_ms: 1000, counter: 3, node: shared }; | |
| 506 | + | let val_a = json!({"v": "aaa"}); | |
| 507 | + | let val_b = json!({"v": "bbb"}); // canonically greater than val_a | |
| 508 | + | ||
| 509 | + | // Device A: local = a, remote = b. | |
| 510 | + | let mut local_a = make_entry("tasks", "r1", ChangeOp::Update, Utc::now()); | |
| 511 | + | local_a.hlc = hlc; | |
| 512 | + | local_a.data = Some(val_a.clone()); | |
| 513 | + | let mut remote_b = pulled_with_hlc("r1", ChangeOp::Update, hlc, shared); | |
| 514 | + | remote_b.entry.data = Some(val_b.clone()); | |
| 515 | + | // b's payload sorts higher, so A drops local and keeps remote (b). | |
| 516 | + | assert!(matches!(resolve_lww(&local_a, &remote_b), Resolution::KeepRemote)); | |
| 517 | + | ||
| 518 | + | // Device B: local = b, remote = a — must keep local (b). Both converge on b. | |
| 519 | + | let mut local_b = make_entry("tasks", "r1", ChangeOp::Update, Utc::now()); | |
| 520 | + | local_b.hlc = hlc; | |
| 521 | + | local_b.data = Some(val_b); | |
| 522 | + | let mut remote_a = pulled_with_hlc("r1", ChangeOp::Update, hlc, shared); | |
| 523 | + | remote_a.entry.data = Some(val_a); | |
| 524 | + | assert!(matches!(resolve_lww(&local_b, &remote_a), Resolution::KeepLocal)); | |
| 525 | + | } | |
| 526 | + | ||
| 527 | + | #[test] | |
| 470 | 528 | fn lww_newer_update_beats_older_delete() { | |
| 471 | 529 | // The data-loss fix: a strictly-newer UPDATE must beat an older DELETE. | |
| 472 | 530 | // Under the old "delete always wins" rule this edit was silently lost. |
| @@ -97,7 +97,7 @@ impl Hlc { | |||
| 97 | 97 | if now_ms > prev.wall_ms { | |
| 98 | 98 | Hlc { wall_ms: now_ms, counter: 0, node } | |
| 99 | 99 | } else { | |
| 100 | - | Hlc { wall_ms: prev.wall_ms, counter: prev.counter + 1, node } | |
| 100 | + | bump_counter(prev.wall_ms, prev.counter, node) | |
| 101 | 101 | } | |
| 102 | 102 | } | |
| 103 | 103 | ||
| @@ -111,16 +111,28 @@ impl Hlc { | |||
| 111 | 111 | /// causally follow the remote change. | |
| 112 | 112 | pub fn observe(prev: Hlc, remote: Hlc, now_ms: i64, node: Uuid) -> Hlc { | |
| 113 | 113 | let wall = prev.wall_ms.max(remote.wall_ms).max(now_ms); | |
| 114 | - | let counter = if wall == prev.wall_ms && wall == remote.wall_ms { | |
| 115 | - | prev.counter.max(remote.counter) + 1 | |
| 114 | + | let base = if wall == prev.wall_ms && wall == remote.wall_ms { | |
| 115 | + | prev.counter.max(remote.counter) | |
| 116 | 116 | } else if wall == prev.wall_ms { | |
| 117 | - | prev.counter + 1 | |
| 117 | + | prev.counter | |
| 118 | 118 | } else if wall == remote.wall_ms { | |
| 119 | - | remote.counter + 1 | |
| 119 | + | remote.counter | |
| 120 | 120 | } else { | |
| 121 | - | 0 | |
| 121 | + | // Physical time strictly leads both clocks: counter resets cleanly. | |
| 122 | + | return Hlc { wall_ms: wall, counter: 0, node }; | |
| 122 | 123 | }; | |
| 123 | - | Hlc { wall_ms: wall, counter, node } | |
| 124 | + | bump_counter(wall, base, node) | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | /// Increment a counter at `wall_ms`, rolling into the wall component on the | |
| 129 | + | /// (astronomically unlikely, but attacker-reachable via a hostile peer's | |
| 130 | + | /// `counter: u32::MAX`) overflow instead of wrapping to 0 — which would send the | |
| 131 | + | /// clock *backwards* and break the monotonicity HLC exists to guarantee. | |
| 132 | + | fn bump_counter(wall_ms: i64, counter: u32, node: Uuid) -> Hlc { | |
| 133 | + | match counter.checked_add(1) { | |
| 134 | + | Some(next) => Hlc { wall_ms, counter: next, node }, | |
| 135 | + | None => Hlc { wall_ms: wall_ms.saturating_add(1), counter: 0, node }, | |
| 124 | 136 | } | |
| 125 | 137 | } | |
| 126 | 138 | ||
| @@ -374,6 +386,11 @@ pub(crate) struct RotationEntriesResponse { | |||
| 374 | 386 | #[derive(Deserialize)] | |
| 375 | 387 | pub(crate) struct RotationEntry { | |
| 376 | 388 | pub seq: i64, | |
| 389 | + | /// Source table — echoed by the server so re-encryption can recompute the | |
| 390 | + | /// entry's AEAD associated data (`aad_for_entry(table, row_id)`). | |
| 391 | + | pub table: String, | |
| 392 | + | /// App-assigned row identifier — see [`Self::table`]. | |
| 393 | + | pub row_id: String, | |
| 377 | 394 | pub data: Option<serde_json::Value>, | |
| 378 | 395 | } | |
| 379 | 396 | ||
| @@ -727,6 +744,30 @@ mod tests { | |||
| 727 | 744 | } | |
| 728 | 745 | ||
| 729 | 746 | #[test] | |
| 747 | + | fn hlc_counter_overflow_rolls_into_wall_not_backwards() { | |
| 748 | + | let me = Uuid::from_u128(1); | |
| 749 | + | // A local tick at a saturated counter must not wrap to 0 (which would | |
| 750 | + | // move the clock backwards); it rolls the wall component forward instead. | |
| 751 | + | let prev = Hlc { wall_ms: 1000, counter: u32::MAX, node: me }; | |
| 752 | + | let next = Hlc::tick(prev, 1000, me); | |
| 753 | + | assert!(next > prev, "tick at counter::MAX must stay strictly increasing"); | |
| 754 | + | assert_eq!(next, Hlc { wall_ms: 1001, counter: 0, node: me }); | |
| 755 | + | } | |
| 756 | + | ||
| 757 | + | #[test] | |
| 758 | + | fn hlc_observe_hostile_max_counter_stays_monotonic() { | |
| 759 | + | let me = Uuid::from_u128(1); | |
| 760 | + | let them = Uuid::from_u128(2); | |
| 761 | + | // A hostile/buggy peer sends counter: u32::MAX at our exact wall_ms. | |
| 762 | + | let local = Hlc { wall_ms: 5000, counter: 3, node: me }; | |
| 763 | + | let remote = Hlc { wall_ms: 5000, counter: u32::MAX, node: them }; | |
| 764 | + | let merged = Hlc::observe(local, remote, 5000, me); | |
| 765 | + | assert!(merged > local, "observe must not wrap backwards on a MAX remote counter"); | |
| 766 | + | assert!(merged > remote); | |
| 767 | + | assert_eq!(merged, Hlc { wall_ms: 5001, counter: 0, node: me }); | |
| 768 | + | } | |
| 769 | + | ||
| 770 | + | #[test] | |
| 730 | 771 | fn hlc_serde_roundtrip() { | |
| 731 | 772 | let h = Hlc { wall_ms: 123, counter: 4, node: Uuid::from_u128(7) }; | |
| 732 | 773 | let j = serde_json::to_value(h).unwrap(); |