Skip to main content

max / makenotwork

synckit: client group transport methods (Groups phase 3, slice 2) The client half of multi-scope sync: list a user's groups, fetch their sealed GCK grant, and push/pull a group's shared changelog under its GCK. Inherent SyncKitClient methods; the SyncTransport trait extension lands with sync_now (slice 4) so its seam is driven by the loop's needs, not guessed. - ids: GroupId newtype. - types: SyncGroup (list_groups) and GroupGrant (group_grant) wire types. - client/mod: groups_base endpoint + group_grant/group_push/group_pull URL builders (mirroring the ota_base pattern). - client/helpers: encrypt_group_change_with_key / decrypt_group_change_to_pulled -- same HLC envelope as personal, but bound to (group_id, table, row_id) as AEAD associated data and sealed under the GCK. Group pull is a single-key pass (a group's key rotates server-side, re-encrypted in place, so no client-side pending-key window). - client/groups: list_groups, group_grant, group_push, group_pull_rich. - tests: group change encrypt/decrypt roundtrip preserves data + HLC; a group-A ciphertext cannot open under group-B (AAD binding); wrong GCK fails closed. Design: wiki synckit-multiscope-design. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-23 19:28 UTC
Commit: 031a46597b8adc77618056fe13e7d9a4d691bdfd
Parent: 8297ee2
5 files changed, +346 insertions, -2 deletions
@@ -0,0 +1,221 @@
1 + //! Group sync: list the caller's groups, fetch their sealed GCK grant, and
2 + //! push/pull a group's shared changelog under its Group Content Key.
3 + //!
4 + //! The GCK is supplied by the caller — the `SyncStore` resolves it from the grant
5 + //! via the identity key (a later slice); these methods only encrypt/decrypt with
6 + //! it. Group entries bind `(group_id, table, row_id)` as AEAD associated data, so
7 + //! a ciphertext cannot be relocated across groups. Design: wiki
8 + //! synckit-multiscope-design.
9 +
10 + use bytes::Bytes;
11 + use tracing::instrument;
12 + use uuid::Uuid;
13 +
14 + use crate::{
15 + error::Result,
16 + ids::{DeviceId, GroupId},
17 + types::*,
18 + };
19 +
20 + use super::SyncKitClient;
21 + use super::helpers::{Idempotency, check_response};
22 +
23 + impl SyncKitClient {
24 + /// List the groups the authenticated user belongs to within this app.
25 + #[instrument(skip(self))]
26 + pub async fn list_groups(&self) -> Result<Vec<SyncGroup>> {
27 + let token = self.require_token()?;
28 + self.retry_request_json(Idempotency::ReadOnly, || {
29 + let req = self.http.get(self.endpoints.groups()).bearer_auth(&token);
30 + async move { check_response(req.send().await?).await }
31 + })
32 + .await
33 + }
34 +
35 + /// Fetch the caller's own sealed GCK grant for a group. Open it with the
36 + /// member's identity private key to recover the GCK.
37 + #[instrument(skip(self))]
38 + pub async fn group_grant(&self, group_id: GroupId) -> Result<GroupGrant> {
39 + let token = self.require_token()?;
40 + let url = self.endpoints.group_grant(group_id);
41 + self.retry_request_json(Idempotency::ReadOnly, || {
42 + let req = self.http.get(&url).bearer_auth(&token);
43 + async move { check_response(req.send().await?).await }
44 + })
45 + .await
46 + }
47 +
48 + /// Push encrypted changes to a group's shared changelog under its GCK.
49 + /// Returns the server cursor after the push.
50 + #[instrument(skip(self, gck, changes))]
51 + pub async fn group_push(
52 + &self,
53 + group_id: GroupId,
54 + gck: &[u8; 32],
55 + device_id: DeviceId,
56 + changes: Vec<ChangeEntry>,
57 + ) -> Result<i64> {
58 + let token = self.require_token()?;
59 + let group_str = group_id.to_string();
60 + let wire_changes = changes
61 + .into_iter()
62 + .map(|c| Self::encrypt_group_change_with_key(&group_str, c, gck))
63 + .collect::<Result<Vec<_>>>()?;
64 +
65 + let body = Bytes::from(serde_json::to_vec(&WirePushRequest {
66 + device_id,
67 + batch_id: Uuid::new_v4(),
68 + changes: wire_changes,
69 + })?);
70 + let url = self.endpoints.group_push(group_id);
71 +
72 + let push_resp: PushResponse = self
73 + .retry_request_json(Idempotency::Keyed, || {
74 + let req = self
75 + .http
76 + .post(&url)
77 + .bearer_auth(&token)
78 + .header("content-type", "application/json")
79 + .body(body.clone());
80 + async move { check_response(req.send().await?).await }
81 + })
82 + .await?;
83 + Ok(push_resp.cursor)
84 + }
85 +
86 + /// Pull a group's changes since `cursor`, decrypting under its GCK. Returns
87 + /// `(changes, new_cursor, has_more)` with per-row device/seq metadata, ready
88 + /// for conflict resolution. Group pull has no master-key-rotation window (a
89 + /// group's key rotates server-side, re-encrypted in place), so the decrypt is
90 + /// a single-key pass.
91 + #[instrument(skip(self, gck))]
92 + pub async fn group_pull_rich(
93 + &self,
94 + group_id: GroupId,
95 + gck: &[u8; 32],
96 + device_id: DeviceId,
97 + cursor: i64,
98 + ) -> Result<(Vec<PulledChange>, i64, bool)> {
99 + let token = self.require_token()?;
100 + let body = Bytes::from(serde_json::to_vec(&PullRequest { device_id, cursor })?);
101 + let url = self.endpoints.group_pull(group_id);
102 +
103 + let pull_resp: PullResponse = self
104 + .retry_request_json(Idempotency::ReadOnly, || {
105 + let req = self
106 + .http
107 + .post(&url)
108 + .bearer_auth(&token)
109 + .header("content-type", "application/json")
110 + .body(body.clone());
111 + async move { check_response(req.send().await?).await }
112 + })
113 + .await?;
114 +
115 + let group_str = group_id.to_string();
116 + let changes = pull_resp
117 + .changes
118 + .into_iter()
119 + .map(|c| Self::decrypt_group_change_to_pulled(&group_str, c, gck))
120 + .collect::<Result<Vec<_>>>()?;
121 + Ok((changes, pull_resp.cursor, pull_resp.has_more))
122 + }
123 + }
124 +
125 + #[cfg(test)]
126 + mod tests {
127 + use super::*;
128 + use crate::crypto;
129 + use crate::error::SyncKitError;
130 + use crate::ids::DeviceId;
131 + use chrono::Utc;
132 +
133 + fn insert(table: &str, row: &str, data: serde_json::Value) -> ChangeEntry {
134 + ChangeEntry {
135 + table: table.into(),
136 + op: ChangeOp::Insert,
137 + row_id: row.into(),
138 + timestamp: Utc::now(),
139 + hlc: Hlc::zero(DeviceId::nil()),
140 + data: Some(data),
141 + extra: Default::default(),
142 + }
143 + }
144 +
145 + fn to_pull(wire: WireChangeEntry, device: DeviceId, seq: i64) -> PullChangeEntry {
146 + PullChangeEntry {
147 + seq,
148 + device_id: device,
149 + table: wire.table,
150 + op: wire.op,
151 + row_id: wire.row_id,
152 + timestamp: wire.timestamp,
153 + data: wire.data,
154 + key_id: None,
155 + }
156 + }
157 +
158 + #[test]
159 + fn group_change_encrypt_decrypt_roundtrip_preserves_data_and_hlc() {
160 + let gck = crypto::generate_master_key();
161 + let device = DeviceId::new(uuid::Uuid::new_v4());
162 + let hlc = Hlc {
163 + wall_ms: 7,
164 + counter: 1,
165 + node: device,
166 + };
167 + let mut e = insert("tasks", "r1", serde_json::json!({ "title": "shared" }));
168 + e.hlc = hlc;
169 +
170 + let wire = SyncKitClient::encrypt_group_change_with_key("grp-1", e, &gck).unwrap();
171 + let pulled =
172 + SyncKitClient::decrypt_group_change_to_pulled("grp-1", to_pull(wire, device, 3), &gck)
173 + .unwrap();
174 +
175 + assert_eq!(pulled.seq, 3);
176 + assert_eq!(pulled.device_id, device);
177 + assert_eq!(
178 + pulled.entry.data.unwrap(),
179 + serde_json::json!({ "title": "shared" })
180 + );
181 + assert_eq!(pulled.entry.hlc, hlc);
182 + }
183 +
184 + #[test]
185 + fn group_ciphertext_cannot_be_opened_under_another_group() {
186 + let gck = crypto::generate_master_key();
187 + let wire = SyncKitClient::encrypt_group_change_with_key(
188 + "grp-A",
189 + insert("t", "r", serde_json::json!(1)),
190 + &gck,
191 + )
192 + .unwrap();
193 + // Same GCK, but a different group id in the AAD: the open fails closed.
194 + let err = SyncKitClient::decrypt_group_change_to_pulled(
195 + "grp-B",
196 + to_pull(wire, DeviceId::nil(), 1),
197 + &gck,
198 + )
199 + .unwrap_err();
200 + assert!(matches!(err, SyncKitError::DecryptionFailed));
201 + }
202 +
203 + #[test]
204 + fn wrong_gck_fails_closed() {
205 + let gck = crypto::generate_master_key();
206 + let other = crypto::generate_master_key();
207 + let wire = SyncKitClient::encrypt_group_change_with_key(
208 + "grp-1",
209 + insert("t", "r", serde_json::json!(1)),
210 + &gck,
211 + )
212 + .unwrap();
213 + let err = SyncKitClient::decrypt_group_change_to_pulled(
214 + "grp-1",
215 + to_pull(wire, DeviceId::nil(), 1),
216 + &other,
217 + )
218 + .unwrap_err();
219 + assert!(matches!(err, SyncKitError::DecryptionFailed));
220 + }
221 + }
@@ -452,6 +452,66 @@ impl SyncKitClient {
452 452 }
453 453 }
454 454
455 + /// Encrypt a change for a **group** changelog, sealed under the group's GCK
456 + /// and bound to `(group_id, table, row_id)` as AEAD associated data. Same HLC
457 + /// envelope as the personal path; the extra `group_id` binding makes a
458 + /// ciphertext non-relocatable across groups (the p1 crypto guarantee).
459 + pub(super) fn encrypt_group_change_with_key(
460 + group_id: &str,
461 + entry: ChangeEntry,
462 + gck: &[u8; 32],
463 + ) -> Result<WireChangeEntry> {
464 + let ctx = crypto::AeadContext::group_entry(group_id, &entry.table, &entry.row_id);
465 + let envelope = Self::hlc_envelope(&entry.hlc, &entry.data);
466 + let encrypted_data = Some(crypto::encrypt_json_aad(&envelope, gck, &ctx)?);
467 +
468 + Ok(WireChangeEntry {
469 + table: entry.table,
470 + op: entry.op,
471 + row_id: entry.row_id,
472 + timestamp: entry.timestamp,
473 + data: encrypted_data,
474 + })
475 + }
476 +
477 + /// Decrypt a pulled group entry under the group's GCK, preserving `device_id`
478 + /// and `seq` in a [`PulledChange`]. The counterpart to
479 + /// [`encrypt_group_change_with_key`](Self::encrypt_group_change_with_key).
480 + /// Group pull has no master-key-rotation window (a group's key rotates
481 + /// server-side, re-encrypted in place), so this is the whole decrypt path.
482 + pub(super) fn decrypt_group_change_to_pulled(
483 + group_id: &str,
484 + entry: PullChangeEntry,
485 + gck: &[u8; 32],
486 + ) -> Result<crate::types::PulledChange> {
487 + let device_id = entry.device_id;
488 + let seq = entry.seq;
489 + let ctx = crypto::AeadContext::group_entry(group_id, &entry.table, &entry.row_id);
490 + let (hlc, data) = match entry.data {
491 + Some(ref value) => {
492 + let decrypted = crypto::decrypt_json_aad(value, gck, &ctx)?;
493 + Self::split_hlc_envelope(decrypted, device_id, entry.timestamp.timestamp_millis())?
494 + }
495 + None => (
496 + Hlc::from_legacy(entry.timestamp.timestamp_millis(), device_id),
497 + None,
498 + ),
499 + };
500 + Ok(crate::types::PulledChange {
501 + entry: ChangeEntry {
502 + table: entry.table,
503 + op: entry.op,
504 + row_id: entry.row_id,
505 + hlc,
506 + timestamp: entry.timestamp,
507 + data,
508 + extra: Default::default(),
509 + },
510 + device_id,
511 + seq,
512 + })
513 + }
514 +
455 515 /// Decrypt with a pre-loaded key. Used by `pull()` to avoid per-entry lock acquisition.
456 516 pub(super) fn decrypt_change_with_key(
457 517 entry: PullChangeEntry,
@@ -54,6 +54,7 @@
54 54
55 55 mod auth;
56 56 mod blob;
57 + mod groups;
57 58 pub use blob::BlobUploadOutcome;
58 59 mod encryption;
59 60 pub(crate) mod helpers;
@@ -76,7 +77,7 @@ use uuid::Uuid;
76 77 use crate::{
77 78 crypto,
78 79 error::{Result, SyncKitError},
79 - ids::{AppId, UserId},
80 + ids::{AppId, GroupId, UserId},
80 81 };
81 82
82 83 /// Maximum number of retry attempts for transient failures.
@@ -115,6 +116,7 @@ struct Endpoints {
115 116 subscribe: String,
116 117 status: String,
117 118 keys: String,
119 + groups_base: String,
118 120 blobs_upload: String,
119 121 blobs_confirm: String,
120 122 blobs_download: String,
@@ -162,9 +164,30 @@ impl Endpoints {
162 164 app_pricing: format!("{base}/api/v1/sync/app/pricing"),
163 165 account: format!("{base}/api/v1/sync/account"),
164 166 ota_base: format!("{base}/api/v1/sync/ota"),
167 + groups_base: format!("{base}/api/v1/sync/groups"),
165 168 }
166 169 }
167 170
171 + /// `GET`/`POST` the group collection: list the caller's groups, or create one.
172 + fn groups(&self) -> &str {
173 + &self.groups_base
174 + }
175 +
176 + /// `GET` here for the caller's own sealed GCK grant for a group.
177 + fn group_grant(&self, group_id: GroupId) -> String {
178 + format!("{}/{group_id}/grant", self.groups_base)
179 + }
180 +
181 + /// `POST` encrypted changes to a group's shared changelog.
182 + fn group_push(&self, group_id: GroupId) -> String {
183 + format!("{}/{group_id}/push", self.groups_base)
184 + }
185 +
186 + /// `POST` to pull a group's changes since a cursor.
187 + fn group_pull(&self, group_id: GroupId) -> String {
188 + format!("{}/{group_id}/pull", self.groups_base)
189 + }
190 +
168 191 /// `POST` here to create a release, or list releases, for an app.
169 192 fn ota_releases(&self, app_id: AppId) -> String {
170 193 format!("{}/apps/{app_id}/releases", self.ota_base)
@@ -77,6 +77,11 @@ id_newtype!(
77 77 /// A SyncKit application (one per product integrating the SDK).
78 78 AppId
79 79 );
80 + id_newtype!(
81 + /// A SyncKit group: a shared, end-to-end-encrypted changelog several users'
82 + /// keys can read and write.
83 + GroupId
84 + );
80 85
81 86 #[cfg(test)]
82 87 mod tests {
@@ -1,6 +1,6 @@
1 1 //! Request/response types matching the MNW SyncKit server API.
2 2
3 - use crate::ids::{AppId, DeviceId, UserId};
3 + use crate::ids::{AppId, DeviceId, GroupId, UserId};
4 4 use chrono::{DateTime, Utc};
5 5 use serde::{Deserialize, Serialize};
6 6 use std::fmt;
@@ -242,6 +242,41 @@ pub struct Device {
242 242 pub created_at: DateTime<Utc>,
243 243 }
244 244
245 + // ── Groups ──
246 +
247 + /// A group the authenticated user belongs to, as returned by
248 + /// [`SyncKitClient::list_groups`](crate::SyncKitClient::list_groups).
249 + #[derive(Debug, Clone, Deserialize)]
250 + #[non_exhaustive]
251 + pub struct SyncGroup {
252 + /// Server-assigned group id.
253 + pub id: GroupId,
254 + /// The app this group belongs to.
255 + pub app_id: AppId,
256 + /// The admin who mints the GCK and manages membership.
257 + pub admin_user_id: UserId,
258 + /// Human-readable group name.
259 + pub name: String,
260 + /// Current GCK generation. A change here means the group key rotated and a
261 + /// fresh grant must be fetched.
262 + pub gck_version: i32,
263 + /// When the group was created.
264 + pub created_at: DateTime<Utc>,
265 + }
266 +
267 + /// The caller's sealed Group Content Key grant, as returned by
268 + /// [`SyncKitClient::group_grant`](crate::SyncKitClient::group_grant). Opened with
269 + /// the member's identity private key to recover the GCK.
270 + #[derive(Debug, Clone, Deserialize)]
271 + #[non_exhaustive]
272 + pub struct GroupGrant {
273 + /// The GCK sealed to this member's identity public key (base64), opaque to
274 + /// the server.
275 + pub sealed_gck: String,
276 + /// The GCK generation this grant was sealed under.
277 + pub gck_version: i32,
278 + }
279 +
245 280 // ── Push / Pull ──
246 281
247 282 /// A change entry for pushing to the server.