Skip to main content

max / makenotwork

Expose GCK rotation, keyed per generation so history survives it Removing a group member revoked server-side access and left the Group Content Key alone, so a removed member holding a copy could still read anything written afterwards. rotate_group_gck existed in the db layer with no route calling it. Add POST /groups/{id}/rotate, admin only. The grant set the admin posts becomes the new membership, so removal and re-key commit together and no window exists where a removed member's key is still current. Reject a generation that does not advance, a batch missing the admin's own grant, a grant naming a non-member, and duplicates. Rotation cannot re-encrypt the log: the server never sees plaintext. But sync_group_log carried no generation and grants were one row per member, overwritten on rotation, so the first rotation would have orphaned every earlier entry. Migration 183 fixes both halves: entries record the generation they were sealed under, and sync_group_grants keys grants by generation so a member keeps every generation they were a member during. GET /grant takes an optional ?version=. Consequences: a member sees the group from when they joined; a removed member loses every generation; rotation buys forward secrecy, not retroactive secrecy.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-03 18:46 UTC
Signed with PGP, not checked
Commit: 9a8bf2b314ed2e281e1b81c2250a27e01c20843d
Parent: 6e6485b
9 files changed, +886 insertions, -55 deletions
@@ -106,6 +106,7 @@
106 106 mod synckit_adversarial;
107 107 mod synckit_billing;
108 108 mod synckit_blob_multipart;
109 + mod synckit_group_rotation;
109 110 mod synckit_groups_billing;
110 111 mod synckit_paid_sync;
111 112 mod synckit_per_key_storage;
@@ -124,7 +124,9 @@
124 124 pub created_at: DateTime<Utc>,
125 125 }
126 126
127 - /// One member of a group, carrying that member's sealed GCK grant.
127 + /// One member of a group. Grants live in `sync_group_grants`, one row per
128 + /// generation, so a member keeps the keys for every generation they were a member
129 + /// during; membership here is role and provenance only.
128 130 #[derive(Debug, Clone, FromRow, Serialize)]
129 131 pub struct DbSyncGroupMember {
130 132 /// The group this membership belongs to.
@@ -137,12 +139,6 @@
137 139 /// `admin` | `member`. Reserved for the later per-key permission system; MVP
138 140 /// treats every member as a reader and writer.
139 141 pub role: String,
140 - /// The GCK sealed to this member's X25519 public key (base64), opaque to the
141 - /// server. The member opens it with their identity private key.
142 - pub sealed_gck: String,
143 - /// The GCK generation `sealed_gck` was sealed under; a value below the
144 - /// group's current `gck_version` marks a stale grant.
145 - pub gck_version: i32,
146 142 /// When this member was added.
147 143 pub added_at: DateTime<Utc>,
148 144 }
@@ -152,7 +148,7 @@
152 148 /// The group changelog is a separate table from the personal [`DbSyncLogEntry`]
153 149 /// so personal-scope queries can never see group rows (which are sealed under the
154 150 /// group's GCK, not the per-user key). There is deliberately no `key_id`: a group
155 - /// entry's key generation is the group's `gck_version`, not `sync_keys.key_id`.
151 + /// entry's key generation is its own `gck_version`, not `sync_keys.key_id`.
156 152 #[derive(Debug, Clone, FromRow, Serialize)]
157 153 pub struct DbSyncGroupLogEntry {
158 154 /// Server-assigned monotonic sequence number; the group pull cursor.
@@ -175,6 +171,11 @@
175 171 pub client_timestamp: DateTime<Utc>,
176 172 /// Encrypted row data (sealed under the group GCK). Null for deletes.
177 173 pub data: Option<serde_json::Value>,
174 + /// The GCK generation `data` is sealed under, stamped at push. A rotation
175 + /// bumps the group's generation without touching existing rows, so this is
176 + /// what lets a member decrypt entries that predate a rotation they lived
177 + /// through.
178 + pub gck_version: i32,
178 179 /// When the server received and recorded this entry.
179 180 pub created_at: DateTime<Utc>,
180 181 }
@@ -49,18 +49,29 @@
49 49
50 50 sqlx::query(
51 51 r"
52 - INSERT INTO sync_group_members (group_id, user_id, role, sealed_gck, gck_version, member_pubkey)
53 - VALUES ($1, $2, 'admin', $3, $4, $5)
52 + INSERT INTO sync_group_members (group_id, user_id, role, member_pubkey)
53 + VALUES ($1, $2, 'admin', $3)
54 54 ",
55 55 )
56 56 .bind(group.id)
57 57 .bind(admin_user_id)
58 - .bind(admin_sealed_gck)
59 - .bind(group.gck_version)
60 58 .bind(admin_pubkey)
61 59 .execute(&mut *tx)
62 60 .await?;
63 61
62 + sqlx::query(
63 + r"
64 + INSERT INTO sync_group_grants (group_id, user_id, gck_version, sealed_gck)
65 + VALUES ($1, $2, $3, $4)
66 + ",
67 + )
68 + .bind(group.id)
69 + .bind(admin_user_id)
70 + .bind(group.gck_version)
71 + .bind(admin_sealed_gck)
72 + .execute(&mut *tx)
73 + .await?;
74 +
64 75 tx.commit().await?;
65 76 Ok(group)
66 77 }
@@ -115,7 +126,7 @@
115 126 pub async fn list_members(pool: &PgPool, group_id: SyncGroupId) -> Result<Vec<DbSyncGroupMember>> {
116 127 let members = sqlx::query_as::<_, DbSyncGroupMember>(
117 128 r"
118 - SELECT m.group_id, m.user_id, u.email, m.role, m.sealed_gck, m.gck_version, m.added_at
129 + SELECT m.group_id, m.user_id, u.email, m.role, m.added_at
119 130 FROM sync_group_members m
120 131 JOIN users u ON u.id = m.user_id
121 132 WHERE m.group_id = $1
@@ -180,9 +191,12 @@
180 191 Ok(exists)
181 192 }
182 193
183 - /// Fetch a member's current sealed GCK grant `(sealed_gck, gck_version)`, so the
184 - /// member's device can open the GCK and read the group changelog. `None` if the
185 - /// user is not a member.
194 + /// Fetch a member's newest sealed GCK grant `(sealed_gck, gck_version)`, so the
195 + /// member's device can open the current GCK. `None` if the user holds no grant.
196 + ///
197 + /// Newest, not "the group's current generation": a member added before a rotation
198 + /// they were not part of would have no row at the current version, and returning
199 + /// nothing would be indistinguishable from not being a member.
186 200 #[tracing::instrument(skip_all)]
187 201 pub async fn get_member_grant(
188 202 pool: &PgPool,
@@ -190,7 +204,12 @@
190 204 user_id: UserId,
191 205 ) -> Result<Option<(String, i32)>> {
192 206 let grant: Option<(String, i32)> = sqlx::query_as(
193 - "SELECT sealed_gck, gck_version FROM sync_group_members WHERE group_id = $1 AND user_id = $2",
207 + r"
208 + SELECT sealed_gck, gck_version FROM sync_group_grants
209 + WHERE group_id = $1 AND user_id = $2
210 + ORDER BY gck_version DESC
211 + LIMIT 1
212 + ",
194 213 )
195 214 .bind(group_id)
196 215 .bind(user_id)
@@ -199,11 +218,36 @@
199 218 Ok(grant)
200 219 }
201 220
221 + /// Fetch a member's grant for one specific generation, so a device can decrypt
222 + /// entries written before a rotation it lived through. `None` if the member never
223 + /// held that generation.
224 + #[tracing::instrument(skip_all)]
225 + pub async fn get_member_grant_at(
226 + pool: &PgPool,
227 + group_id: SyncGroupId,
228 + user_id: UserId,
229 + gck_version: i32,
230 + ) -> Result<Option<String>> {
231 + let sealed: Option<String> = sqlx::query_scalar(
232 + "SELECT sealed_gck FROM sync_group_grants WHERE group_id = $1 AND user_id = $2 AND gck_version = $3",
233 + )
234 + .bind(group_id)
235 + .bind(user_id)
236 + .bind(gck_version)
237 + .fetch_optional(pool)
238 + .await?;
239 + Ok(sealed)
240 + }
241 +
202 242 /// Add a member, or replace an existing member's grant (idempotent upsert).
203 243 ///
204 244 /// The admin calls this with a grant it sealed to the member's public key at the
205 - /// group's current `gck_version`. Re-adding an existing member updates their
206 - /// grant and role in place.
245 + /// group's current `gck_version`. Re-adding an existing member updates their role
246 + /// and public key, and records their grant for that generation.
247 + ///
248 + /// A new member is granted the current generation only. Entries written under
249 + /// earlier generations stay unreadable to them, which is the intended shape: a
250 + /// member sees the group from when they joined, not before.
207 251 #[tracing::instrument(skip_all)]
208 252 pub async fn add_or_update_member(
209 253 pool: &PgPool,
@@ -214,25 +258,40 @@
214 258 gck_version: i32,
215 259 member_pubkey: &str,
216 260 ) -> Result<()> {
261 + let mut tx = pool.begin().await?;
262 +
217 263 sqlx::query(
218 264 r"
219 - INSERT INTO sync_group_members (group_id, user_id, role, sealed_gck, gck_version, member_pubkey)
220 - VALUES ($1, $2, $3, $4, $5, $6)
265 + INSERT INTO sync_group_members (group_id, user_id, role, member_pubkey)
266 + VALUES ($1, $2, $3, $4)
221 267 ON CONFLICT (group_id, user_id)
222 268 DO UPDATE SET role = EXCLUDED.role,
223 - sealed_gck = EXCLUDED.sealed_gck,
224 - gck_version = EXCLUDED.gck_version,
225 269 member_pubkey = EXCLUDED.member_pubkey
226 270 ",
227 271 )
228 272 .bind(group_id)
229 273 .bind(user_id)
230 274 .bind(role)
231 - .bind(sealed_gck)
232 - .bind(gck_version)
233 275 .bind(member_pubkey)
234 - .execute(pool)
276 + .execute(&mut *tx)
235 277 .await?;
278 +
279 + sqlx::query(
280 + r"
281 + INSERT INTO sync_group_grants (group_id, user_id, gck_version, sealed_gck)
282 + VALUES ($1, $2, $3, $4)
283 + ON CONFLICT (group_id, user_id, gck_version)
284 + DO UPDATE SET sealed_gck = EXCLUDED.sealed_gck
285 + ",
286 + )
287 + .bind(group_id)
288 + .bind(user_id)
289 + .bind(gck_version)
290 + .bind(sealed_gck)
291 + .execute(&mut *tx)
292 + .await?;
293 +
294 + tx.commit().await?;
236 295 Ok(())
237 296 }
238 297
@@ -252,14 +311,19 @@
252 311 Ok(result.rows_affected() > 0)
253 312 }
254 313
255 - /// Rotate the group's GCK to `new_version`, atomically replacing every member's
256 - /// grant with one sealed under the new key.
314 + /// Rotate the group's GCK to `new_version`, granting the new key to everyone in
315 + /// `grants` and removing everyone else.
257 316 ///
258 317 /// The admin mints a fresh GCK, seals it to the remaining members' stored public
259 318 /// keys, and passes `(user_id, sealed_gck)` for each. In one transaction this
260 319 /// bumps `sync_groups.gck_version`, deletes any member not in `grants` (the
261 - /// removed set), and upserts each provided grant at `new_version`. Callers must
262 - /// include the admin's own re-sealed grant.
320 + /// removed set) along with every grant they held, and records each provided grant
321 + /// at `new_version`. Callers must include the admin's own re-sealed grant.
322 + ///
323 + /// Grants **accumulate**: a remaining member keeps their rows for earlier
324 + /// generations, which is what lets them still read entries written before this
325 + /// rotation. Only the removed set loses history, and only server-side; whatever
326 + /// they already pulled is in their hands.
263 327 #[tracing::instrument(skip_all)]
264 328 pub async fn rotate_group_gck(
265 329 pool: &PgPool,
@@ -275,26 +339,33 @@
275 339 .execute(&mut *tx)
276 340 .await?;
277 341
278 - // Anyone not in the new grant set is removed by the rotation.
342 + // Anyone not in the new grant set is removed by the rotation, and loses every
343 + // generation they held rather than just the new one.
279 344 let keep: Vec<UserId> = grants.iter().map(|(u, _)| *u).collect();
280 345 sqlx::query("DELETE FROM sync_group_members WHERE group_id = $1 AND user_id <> ALL($2)")
281 346 .bind(group_id)
282 347 .bind(&keep)
283 348 .execute(&mut *tx)
284 349 .await?;
350 + sqlx::query("DELETE FROM sync_group_grants WHERE group_id = $1 AND user_id <> ALL($2)")
351 + .bind(group_id)
352 + .bind(&keep)
353 + .execute(&mut *tx)
354 + .await?;
285 355
286 356 for (user_id, sealed_gck) in grants {
287 357 sqlx::query(
288 358 r"
289 - UPDATE sync_group_members
290 - SET sealed_gck = $3, gck_version = $4
291 - WHERE group_id = $1 AND user_id = $2
359 + INSERT INTO sync_group_grants (group_id, user_id, gck_version, sealed_gck)
360 + VALUES ($1, $2, $3, $4)
361 + ON CONFLICT (group_id, user_id, gck_version)
362 + DO UPDATE SET sealed_gck = EXCLUDED.sealed_gck
292 363 ",
293 364 )
294 365 .bind(group_id)
295 366 .bind(user_id)
296 - .bind(sealed_gck)
297 367 .bind(new_version)
368 + .bind(sealed_gck)
298 369 .execute(&mut *tx)
299 370 .await?;
300 371 }
@@ -135,10 +135,11 @@
135 135 ///
136 136 /// Mirrors [`push_sync_changes`] but writes the separate `sync_group_log` table
137 137 /// and scopes idempotency by `(app_id, group_id, batch_id)`. Group entries carry
138 - /// no `key_id`: their key generation is the group's `gck_version`, not the
139 - /// per-user `sync_keys.key_id`. The dedicated table is what keeps group rows out
140 - /// of every personal-scope query with no `group_id IS NULL` guard to remember.
141 - /// Membership is enforced by the caller.
138 + /// no `key_id`: their key generation is the group's `gck_version`, stamped onto
139 + /// each row at insert so a rotation does not orphan what came before it. The
140 + /// dedicated table is what keeps group rows out of every personal-scope query
141 + /// with no `group_id IS NULL` guard to remember. Membership is enforced by the
142 + /// caller.
142 143 #[allow(clippy::type_complexity)]
143 144 #[tracing::instrument(skip_all)]
144 145 pub async fn push_group_changes(
@@ -202,10 +203,13 @@
202 203 return Ok(max_seq);
203 204 }
204 205
206 + // Stamp the generation the pusher's ciphertext is sealed under, read inside
207 + // this transaction so a rotation committing concurrently cannot leave a row
208 + // labelled with a generation it was not encrypted under.
205 209 let seqs: Vec<i64> = sqlx::query_scalar(
206 210 r"
207 - INSERT INTO sync_group_log (app_id, user_id, device_id, group_id, batch_id, table_name, operation, row_id, client_timestamp, data)
208 - SELECT $1, $2, $3, $4, $5, t.*
211 + INSERT INTO sync_group_log (app_id, user_id, device_id, group_id, batch_id, table_name, operation, row_id, client_timestamp, data, gck_version)
212 + SELECT $1, $2, $3, $4, $5, t.*, (SELECT gck_version FROM sync_groups WHERE id = $4)
209 213 FROM UNNEST($6::text[], $7::text[], $8::text[], $9::timestamptz[], $10::jsonb[]) AS t
210 214 RETURNING seq
211 215 ",
@@ -6,13 +6,17 @@
6 6 //! the GCK or any plaintext. Group push/pull are gated on membership; management
7 7 //! actions (add/remove member) are gated on being the group admin.
8 8 //!
9 + //! Member public keys are stored on add, and GCK rotation is exposed at
10 + //! `/groups/{id}/rotate`: the grant set the admin posts becomes the new
11 + //! membership, so removing a member and re-keying the group are one transaction.
12 + //!
9 13 //! Deferred to later slices (noted where they'd hook in): the paid-write gate and
10 - //! SSE push notifications for groups (p2-billing), member public-key storage and
11 - //! GCK rotation (p3). Design: wiki synckit-groups-design.
14 + //! SSE push notifications for groups (p2-billing). Design: wiki
15 + //! synckit-groups-design.
12 16
13 17 use axum::{
14 18 Json,
15 - extract::{Path, State},
19 + extract::{Path, Query, State},
16 20 http::StatusCode,
17 21 response::{IntoResponse, Response},
18 22 };
@@ -28,9 +32,9 @@
28 32 };
29 33
30 34 use super::{
31 - AddMemberRequest, CreateGroupRequest, GroupGrantResponse, GroupMemberPubkey,
35 + AddMemberRequest, CreateGroupRequest, GrantQuery, GroupGrantResponse, GroupMemberPubkey,
32 36 GroupMemberResponse, GroupResponse, PullChangeEntry, PullRequest, PullResponse, PushRequest,
33 - PushResponse,
37 + PushResponse, RotateGroupKeyRequest,
34 38 };
35 39
36 40 /// Fetch a group scoped to the caller's app, or 404. Guards every group handler
@@ -200,11 +204,125 @@
200 204 Ok(Json(response))
201 205 }
202 206
207 + /// Rotate the group's Group Content Key. Admin only.
208 + ///
209 + /// The admin mints a fresh GCK client-side, seals it to each remaining member's
210 + /// stored public key (from `/pubkeys`), and posts the batch. The server bumps the
211 + /// generation, drops anyone absent from the batch, and stores the new grants in
212 + /// one transaction.
213 + ///
214 + /// The grant set IS the new membership, which is what makes removal and re-key
215 + /// atomic: there is no window in which a removed member's key is still current.
216 + /// A member added between the admin's `/pubkeys` read and this call would be
217 + /// absent from the batch and dropped, so an admin racing itself loses a member
218 + /// rather than leaking a key. Re-adding is one call; the alternative failure is
219 + /// silent.
220 + #[utoipa::path(post, path = "/api/v1/sync/groups/{id}/rotate", tag = "SyncKit",
221 + params(("id" = String, Path, description = "Group ID")),
222 + request_body = RotateGroupKeyRequest,
223 + responses(
224 + (status = 204, description = "Key rotated"),
225 + (status = 400, description = "Stale generation, or a grant set the server will not act on"),
226 + (status = 403, description = "Not the group admin"),
227 + ),
228 + security(("bearer" = [])),
229 + )]
230 + #[tracing::instrument(skip_all, name = "synckit::rotate_group_key")]
231 + pub(super) async fn rotate_key(
232 + State(db): State<PgPool>,
233 + sync_user: SyncUser,
234 + Path(group_id): Path<SyncGroupId>,
235 + Json(req): Json<RotateGroupKeyRequest>,
236 + ) -> Result<impl IntoResponse> {
237 + let group = require_group(&db, sync_user.app_id, group_id).await?;
238 + require_admin(&db, group_id, sync_user.user_id).await?;
239 +
240 + // A generation that does not advance would re-point every member at a key
241 + // some previously-removed member may still hold.
242 + if req.gck_version <= group.gck_version {
243 + return Err(AppError::BadRequest(format!(
244 + "gck_version must be greater than the current generation ({})",
245 + group.gck_version
246 + )));
247 + }
248 +
249 + if req.grants.is_empty() {
250 + return Err(AppError::BadRequest(
251 + "A rotation must carry at least the admin's own grant".to_string(),
252 + ));
253 + }
254 + if req
255 + .grants
256 + .iter()
257 + .any(|g| g.sealed_gck.len() > constants::SYNCKIT_MAX_KEY_ENVELOPE_BYTES)
258 + {
259 + return Err(AppError::BadRequest(
260 + "Sealed key exceeds size limit".to_string(),
261 + ));
262 + }
263 +
264 + // The admin must be able to read the group afterwards. `rotate_group_gck`
265 + // deletes everyone outside the batch, so omitting the admin's own grant
266 + // orphans the group; the db layer states this as a precondition and nothing
267 + // enforced it.
268 + if !req.grants.iter().any(|g| g.user_id == group.admin_user_id) {
269 + return Err(AppError::BadRequest(
270 + "The rotation must include the admin's own re-sealed grant".to_string(),
271 + ));
272 + }
273 +
274 + // Rotation re-seals; it does not recruit. A grant for a non-member would be a
275 + // silent no-op in the db layer's UPDATE, so reject it here rather than let an
276 + // admin believe someone was added.
277 + let members: std::collections::HashSet<UserId> =
278 + db::synckit::list_member_pubkeys(&db, group_id)
279 + .await?
280 + .into_iter()
281 + .map(|(user_id, _)| user_id)
282 + .collect();
283 + let mut seen = std::collections::HashSet::with_capacity(req.grants.len());
284 + for grant in &req.grants {
285 + if !members.contains(&grant.user_id) {
286 + return Err(AppError::BadRequest(
287 + "A rotation grant names someone who is not a member; add members separately"
288 + .to_string(),
289 + ));
290 + }
291 + if !seen.insert(grant.user_id) {
292 + return Err(AppError::BadRequest(
293 + "Duplicate grant for the same member".to_string(),
294 + ));
295 + }
296 + }
297 +
298 + let grants: Vec<(UserId, String)> = req
299 + .grants
300 + .into_iter()
301 + .map(|g| (g.user_id, g.sealed_gck))
302 + .collect();
303 + let removed = members.len().saturating_sub(grants.len());
304 + db::synckit::rotate_group_gck(&db, group_id, req.gck_version, &grants).await?;
305 +
306 + tracing::info!(
307 + %group_id,
308 + gck_version = req.gck_version,
309 + remaining = grants.len(),
310 + removed,
311 + "rotated group content key"
312 + );
313 +
314 + Ok(StatusCode::NO_CONTENT)
315 + }
316 +
203 317 /// Remove a member from a group. Admin only.
204 318 ///
205 - /// This drops the member from future group writes. Forward secrecy for writes
206 - /// after removal comes from the admin then rotating the GCK (p3); data the member
207 - /// already pulled is already in their hands.
319 + /// Revocation only: it drops the member from future group writes but leaves the
320 + /// GCK generation alone, so a member who kept a copy of the key can still read
321 + /// any group ciphertext they can obtain. Forward secrecy comes from
322 + /// [`rotate_key`], which removes and re-keys in one transaction and is what
323 + /// `SyncKitClient::remove_member` drives. This endpoint remains for a caller that
324 + /// wants revocation without a re-key. Data the member already pulled is already
325 + /// in their hands either way.
208 326 #[utoipa::path(delete, path = "/api/v1/sync/groups/{id}/members/{user_id}", tag = "SyncKit",
209 327 params(
210 328 ("id" = String, Path, description = "Group ID"),
@@ -268,11 +386,21 @@
268 386
269 387 /// Fetch the caller's own sealed GCK grant for a group, so their device can open
270 388 /// the GCK and read the group changelog. Members only.
389 + ///
390 + /// Without `version`, returns the newest grant the caller holds, which is what a
391 + /// device wants in order to write. With `version`, returns the grant for that
392 + /// generation, which is what a device wants in order to read an entry pushed
393 + /// before a rotation. A generation the caller never held is 403, the same answer
394 + /// as not being a member: a non-member must not be able to probe which
395 + /// generations exist.
271 396 #[utoipa::path(get, path = "/api/v1/sync/groups/{id}/grant", tag = "SyncKit",
272 - params(("id" = String, Path, description = "Group ID")),
397 + params(
398 + ("id" = String, Path, description = "Group ID"),
399 + ("version" = Option<i32>, Query, description = "GCK generation; omit for the newest"),
400 + ),
273 401 responses(
274 402 (status = 200, description = "The caller's sealed grant", body = GroupGrantResponse),
275 - (status = 403, description = "Not a member"),
403 + (status = 403, description = "Not a member, or never held that generation"),
276 404 ),
277 405 security(("bearer" = [])),
278 406 )]
@@ -281,12 +409,22 @@
281 409 State(db): State<PgPool>,
282 410 sync_user: SyncUser,
283 411 Path(group_id): Path<SyncGroupId>,
412 + Query(query): Query<GrantQuery>,
284 413 ) -> Result<impl IntoResponse> {
285 414 require_group(&db, sync_user.app_id, group_id).await?;
286 415
287 - let (sealed_gck, gck_version) = db::synckit::get_member_grant(&db, group_id, sync_user.user_id)
288 - .await?
289 - .ok_or(AppError::Forbidden)?;
416 + let (sealed_gck, gck_version) = match query.version {
417 + Some(version) => {
418 + let sealed =
419 + db::synckit::get_member_grant_at(&db, group_id, sync_user.user_id, version)
420 + .await?
421 + .ok_or(AppError::Forbidden)?;
422 + (sealed, version)
423 + }
424 + None => db::synckit::get_member_grant(&db, group_id, sync_user.user_id)
425 + .await?
426 + .ok_or(AppError::Forbidden)?,
427 + };
290 428
291 429 Ok(Json(GroupGrantResponse {
292 430 sealed_gck,
@@ -446,8 +584,10 @@
446 584 row_id: e.row_id,
447 585 timestamp: e.client_timestamp,
448 586 data: e.data,
449 - // Group entries key off the group's GCK, not a per-user key_id.
587 + // Group entries key off the GCK generation stamped on the row, not a
588 + // per-user key_id.
450 589 key_id: None,
590 + gck_version: Some(e.gck_version),
451 591 })
452 592 .collect();
453 593
@@ -170,6 +170,12 @@
170 170 /// Which encryption key was used. Null means key_id 1 (pre-rotation).
171 171 #[serde(skip_serializing_if = "Option::is_none")]
172 172 key_id: Option<i32>,
173 + /// For a group entry, the GCK generation its ciphertext is sealed under. The
174 + /// member resolves that generation's grant to decrypt it, which is how entries
175 + /// written before a rotation stay readable. Absent on personal entries, which
176 + /// key off `key_id` instead.
177 + #[serde(skip_serializing_if = "Option::is_none")]
178 + gck_version: Option<i32>,
173 179 }
174 180
175 181 #[derive(Serialize, utoipa::ToSchema)]
@@ -295,6 +301,39 @@
295 301 pubkey: String,
296 302 }
297 303
304 + /// Query for `GET /groups/{id}/grant`: which GCK generation to fetch.
305 + #[derive(Deserialize, utoipa::ToSchema)]
306 + pub(crate) struct GrantQuery {
307 + /// The generation wanted. Omitted means the newest the caller holds.
308 + #[serde(default)]
309 + pub version: Option<i32>,
310 + }
311 +
312 + /// One member's re-sealed grant in a rotation batch.
313 + #[derive(Deserialize, utoipa::ToSchema)]
314 + pub(crate) struct RotateGrant {
315 + #[schema(value_type = String)]
316 + pub user_id: UserId,
317 + /// The new GCK sealed to this member's stored identity public key (base64).
318 + /// Opaque to the server.
319 + pub sealed_gck: String,
320 + }
321 +
322 + /// Rotate a group's GCK, from `POST /groups/{id}/rotate`.
323 + ///
324 + /// The grant set is the new membership: anyone holding a grant today and absent
325 + /// here is removed by the rotation. That is what makes removal and re-key one
326 + /// transaction rather than two calls with a window between them.
327 + #[derive(Deserialize, utoipa::ToSchema)]
328 + pub(crate) struct RotateGroupKeyRequest {
329 + /// The new GCK generation. Must be greater than the group's current one, so
330 + /// a replayed or stale rotation cannot roll the group back onto a key a
331 + /// removed member still holds.
332 + pub gck_version: i32,
333 + /// Every remaining member's re-sealed grant, including the admin's own.
334 + pub grants: Vec<RotateGrant>,
335 + }
336 +
298 337 #[derive(Serialize, utoipa::ToSchema)]
299 338 pub(crate) struct GroupMemberResponse {
300 339 #[schema(value_type = String)]
@@ -883,6 +922,14 @@
883 922 "/api/v1/sync/groups/{id}/pubkeys",
884 923 get(groups::list_pubkeys),
885 924 )
925 + .route(
926 + "/api/sync/groups/{id}/rotate",
927 + post_csrf_skip(SYNCKIT_JWT_SKIP, groups::rotate_key),
928 + )
929 + .route(
930 + "/api/v1/sync/groups/{id}/rotate",
931 + post_csrf_skip(SYNCKIT_JWT_SKIP, groups::rotate_key),
932 + )
886 933 .route(
887 934 "/api/sync/groups/{id}/push",
888 935 post_csrf_skip(SYNCKIT_JWT_SKIP, groups::group_push),
@@ -189,6 +189,8 @@
189 189 timestamp: e.client_timestamp,
190 190 data: e.data,
191 191 key_id: e.key_id,
192 + // Personal entries key off `key_id`; GCK generations are group-only.
193 + gck_version: None,
192 194 })
193 195 .collect();
194 196
@@ -1,0 +1,65 @@
1 + -- SyncKit Groups: keep every GCK generation, not just the current one.
2 + --
3 + -- Rotation (member removal, or a re-key after a suspected compromise) mints a new
4 + -- Group Content Key. Until now each member had exactly one grant row, overwritten
5 + -- on rotation, and sync_group_log carried no generation at all: its schema comment
6 + -- asserted "generation = sync_groups.gck_version", which only holds if every
7 + -- existing entry is re-encrypted under the new key at rotation time. The server
8 + -- cannot do that, because it never sees plaintext. So a rotation would have left
9 + -- every pre-rotation entry sealed under a key no member could obtain any more.
10 + --
11 + -- Two changes make history survive a rotation:
12 + --
13 + -- 1. Entries record the generation they were sealed under. A pull returns it and
14 + -- the client resolves that generation's key.
15 + -- 2. Grants are keyed by generation, so a member keeps the grants for every
16 + -- generation they were a member during. Rotation adds rows rather than
17 + -- replacing them.
18 + --
19 + -- A removed member's grants are deleted outright: they lose access to everything,
20 + -- including entries they could have read before. Whatever they already pulled is
21 + -- in their hands regardless, which is the standard, documented limitation.
22 + --
23 + -- Design: wiki synckit-groups-design.
24 +
25 + -- Grants, one row per (group, member, generation).
26 + CREATE TABLE IF NOT EXISTS sync_group_grants (
27 + group_id UUID NOT NULL REFERENCES sync_groups(id) ON DELETE CASCADE,
28 + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
29 + -- The GCK generation this grant opens. Not a foreign key: generations are a
30 + -- counter on sync_groups, not rows.
31 + gck_version INT NOT NULL,
32 + -- The GCK sealed to this member's X25519 public key (base64), opaque to the
33 + -- server. Produced by the admin via seal_gck_to_member (synckit-client).
34 + sealed_gck TEXT NOT NULL,
35 + granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
36 + PRIMARY KEY (group_id, user_id, gck_version)
37 + );
38 +
39 + -- Carry the existing single grant per member across as that member's grant for
40 + -- the generation it was sealed under.
41 + INSERT INTO sync_group_grants (group_id, user_id, gck_version, sealed_gck, granted_at)
42 + SELECT group_id, user_id, gck_version, sealed_gck, added_at
43 + FROM sync_group_members
44 + ON CONFLICT (group_id, user_id, gck_version) DO NOTHING;
45 +
46 + -- sync_group_members keeps membership: role, the public key rotation re-seals to,
47 + -- and when they joined. The grant columns now live in sync_group_grants, which is
48 + -- the only place a generation's sealed key is recorded.
49 + ALTER TABLE sync_group_members DROP COLUMN IF EXISTS sealed_gck;
50 + ALTER TABLE sync_group_members DROP COLUMN IF EXISTS gck_version;
51 +
52 + -- Every group entry records the generation its ciphertext was sealed under, so a
53 + -- pull can hand the client the right key generation per row. Existing rows
54 + -- predate any rotation, so they belong to their group's current generation.
55 + ALTER TABLE sync_group_log ADD COLUMN IF NOT EXISTS gck_version INT;
56 +
57 + UPDATE sync_group_log l
58 + SET gck_version = g.gck_version
59 + FROM sync_groups g
60 + WHERE l.group_id = g.id AND l.gck_version IS NULL;
61 +
62 + -- Groups with no entries leave nothing to backfill; the default covers a row
63 + -- inserted by an older binary mid-deploy.
64 + ALTER TABLE sync_group_log ALTER COLUMN gck_version SET DEFAULT 1;
65 + ALTER TABLE sync_group_log ALTER COLUMN gck_version SET NOT NULL;
@@ -1,0 +1,584 @@
1 + //! HTTP tests: rotating a group's Group Content Key.
2 + //!
3 + //! The property under test is that removal and re-key are one transaction. The
4 + //! grant set an admin posts *is* the new membership, so there is no window in
5 + //! which a removed member's key is still the group's current one. Everything the
6 + //! server rejects here (a generation that does not advance, a batch missing the
7 + //! admin, a grant for a non-member) exists to stop a rotation that would leave
8 + //! the group readable by someone it just removed, or unreadable by its admin.
9 + //!
10 + //! Design: wiki synckit-groups-design.
11 +
12 + use serde_json::json;
13 +
14 + use super::synckit_paid_sync::{
15 + auth_as, create_internal_app, harness_with_blobs, seed_subscription,
16 + };
17 + use crate::harness::TestHarness;
18 +
19 + const GIB: i64 = 1024 * 1024 * 1024;
20 +
21 + async fn create_group(h: &mut TestHarness, name: &str) -> String {
22 + let resp = h
23 + .client
24 + .post_json(
25 + "/api/sync/groups",
26 + &json!({ "id": uuid::Uuid::new_v4().to_string(), "name": name, "admin_sealed_gck": "sealed_admin_v1", "admin_pubkey": "pk_admin" }).to_string(),
27 + )
28 + .await;
29 + assert_eq!(resp.status, 200, "create group: {}", resp.text);
30 + resp.json::<serde_json::Value>()["id"]
31 + .as_str()
32 + .expect("group id")
33 + .to_string()
34 + }
35 +
36 + /// Seed a verified account and add it to `group_id` as a member.
37 + async fn add_member(
38 + h: &mut TestHarness,
39 + group_id: &str,
40 + username: &str,
41 + email: &str,
42 + ) -> makenotwork::db::UserId {
43 + let user = h.signup(username, email, "Password1!").await;
44 + sqlx::query("UPDATE users SET email_verified = true WHERE id = $1")
45 + .bind(user)
46 + .execute(&h.db)
47 + .await
48 + .expect("verify member");
49 +
50 + let resp = h
51 + .client
52 + .post_json(
53 + &format!("/api/sync/groups/{group_id}/members"),
54 + &json!({
55 + "member_email": email,
56 + "sealed_gck": format!("sealed_{username}_v1"),
57 + "member_pubkey": format!("pk_{username}"),
58 + })
59 + .to_string(),
60 + )
61 + .await;
62 + assert_eq!(resp.status, 204, "add member: {}", resp.text);
63 + user
64 + }
65 +
66 + /// The caller's own grant and the generation it was sealed under.
67 + async fn grant(h: &mut TestHarness, group_id: &str) -> (String, i64) {
68 + let resp = h
69 + .client
70 + .get(&format!("/api/sync/groups/{group_id}/grant"))
71 + .await;
72 + assert_eq!(resp.status, 200, "get grant: {}", resp.text);
73 + let body: serde_json::Value = resp.json();
74 + (
75 + body["sealed_gck"].as_str().expect("sealed_gck").to_string(),
76 + body["gck_version"].as_i64().expect("gck_version"),
77 + )
78 + }
79 +
80 + async fn member_ids(h: &mut TestHarness, group_id: &str) -> Vec<String> {
81 + let resp = h
82 + .client
83 + .get(&format!("/api/sync/groups/{group_id}/members"))
84 + .await;
85 + assert_eq!(resp.status, 200, "list members: {}", resp.text);
86 + resp.json::<serde_json::Value>()
87 + .as_array()
88 + .expect("member array")
89 + .iter()
90 + .map(|m| m["user_id"].as_str().expect("user_id").to_string())
91 + .collect()
92 + }
93 +
94 + async fn register_device(h: &mut TestHarness, name: &str) -> String {
95 + let resp = h
96 + .client
97 + .post_json(
98 + "/api/sync/devices",
99 + &json!({ "device_name": name, "platform": "macos" }).to_string(),
100 + )
101 + .await;
102 + assert_eq!(resp.status, 200, "register device: {}", resp.text);
103 + resp.json::<serde_json::Value>()["id"]
104 + .as_str()
105 + .expect("device id")
106 + .to_string()
107 + }
108 +
109 + /// Push one group entry, returning nothing: these tests care about what a later
110 + /// pull says the entry was sealed under, not about the cursor.
111 + async fn push_entry(h: &mut TestHarness, group_id: &str, device_id: &str, row: &str) {
112 + let resp = h
113 + .client
114 + .post_json(
115 + &format!("/api/sync/groups/{group_id}/push"),
116 + &json!({
117 + "device_id": device_id,
118 + "batch_id": uuid::Uuid::new_v4(),
119 + "changes": [{
120 + "table": "tasks",
121 + "op": "INSERT",
122 + "row_id": row,
123 + "timestamp": "2026-01-01T00:00:00Z",
124 + "data": { "ciphertext": format!("sealed-{row}") },
125 + }],
126 + })
127 + .to_string(),
128 + )
129 + .await;
130 + assert_eq!(resp.status, 200, "group push: {}", resp.text);
131 + }
132 +
133 + async fn pull_entries(
134 + h: &mut TestHarness,
135 + group_id: &str,
136 + device_id: &str,
137 + ) -> Vec<serde_json::Value> {
138 + let resp = h
139 + .client
140 + .post_json(
141 + &format!("/api/sync/groups/{group_id}/pull"),
142 + &json!({ "device_id": device_id, "cursor": 0 }).to_string(),
143 + )
144 + .await;
145 + assert_eq!(resp.status, 200, "group pull: {}", resp.text);
146 + resp.json::<serde_json::Value>()["changes"]
147 + .as_array()
148 + .expect("changes")
149 + .clone()
150 + }
151 +
152 + /// A rotation that drops one member: the generation advances, everyone kept is
153 + /// re-granted under it, and the removed member is gone from the group in the same
154 + /// operation.
155 + #[tokio::test]
156 + async fn rotation_advances_the_generation_regrants_and_drops_the_removed_member() {
157 + let (mut h, _blobs) = harness_with_blobs().await;
158 + let admin = h
159 + .signup("gr_admin", "gr_admin@example.com", "Password1!")
160 + .await;
161 + let (app, _key) = create_internal_app(&h.db, admin).await;
162 + auth_as(&mut h, admin, app, "admin-key");
163 + seed_subscription(&h.db, admin, app, "active", 10 * GIB).await;
164 +
165 + let group_id = create_group(&mut h, "Team").await;
166 + let bob = add_member(&mut h, &group_id, "gr_bob", "gr_bob@example.com").await;
167 + let carol = add_member(&mut h, &group_id, "gr_carol", "gr_carol@example.com").await;
168 +
169 + auth_as(&mut h, admin, app, "admin-key");
170 + let (_, before) = grant(&mut h, &group_id).await;
171 + assert_eq!(before, 1, "groups start at generation 1");
172 +
173 + // Drop Carol: her grant is simply absent from the batch.
174 + let resp = h
175 + .client
176 + .post_json(
177 + &format!("/api/sync/groups/{group_id}/rotate"),
178 + &json!({
179 + "gck_version": 2,
180 + "grants": [
181 + { "user_id": admin.to_string(), "sealed_gck": "sealed_admin_v2" },
182 + { "user_id": bob.to_string(), "sealed_gck": "sealed_bob_v2" },
183 + ],
184 + })
185 + .to_string(),
186 + )
187 + .await;
188 + assert_eq!(resp.status, 204, "rotate: {}", resp.text);
189 +
190 + let (admin_grant, admin_version) = grant(&mut h, &group_id).await;
191 + assert_eq!(
192 + admin_version, 2,
193 + "admin is re-granted at the new generation"
194 + );
195 + assert_eq!(admin_grant, "sealed_admin_v2");
196 +
197 + let remaining = member_ids(&mut h, &group_id).await;
198 + assert_eq!(remaining.len(), 2, "carol is gone: {remaining:?}");
199 + assert!(remaining.contains(&admin.to_string()));
200 + assert!(remaining.contains(&bob.to_string()));
201 + assert!(!remaining.contains(&carol.to_string()));
202 +
203 + // Bob kept his access and holds the new generation.
204 + auth_as(&mut h, bob, app, "bob-key");
205 + let (bob_grant, bob_version) = grant(&mut h, &group_id).await;
206 + assert_eq!(bob_version, 2);
207 + assert_eq!(bob_grant, "sealed_bob_v2");
208 +
209 + // Carol is no longer a member, so she cannot read the group at all.
210 + auth_as(&mut h, carol, app, "carol-key");
211 + let resp = h
212 + .client
213 + .get(&format!("/api/sync/groups/{group_id}/grant"))
214 + .await;
215 + assert_eq!(
216 + resp.status, 403,
217 + "a removed member must lose group access: {}",
218 + resp.text
219 + );
220 + }
221 +
222 + /// A generation that does not advance is refused. Accepting one would re-point
223 + /// every member at a key a previously-removed member may still hold, and a
224 + /// replayed rotation would silently roll the group back.
225 + #[tokio::test]
226 + async fn rotation_requires_the_generation_to_advance() {
227 + let (mut h, _blobs) = harness_with_blobs().await;
228 + let admin = h
229 + .signup("gr2_admin", "gr2_admin@example.com", "Password1!")
230 + .await;
231 + let (app, _key) = create_internal_app(&h.db, admin).await;
232 + auth_as(&mut h, admin, app, "admin-key");
233 + let group_id = create_group(&mut h, "Team").await;
234 +
235 + for stale in [1, 0, -5] {
236 + let resp = h
237 + .client
238 + .post_json(
239 + &format!("/api/sync/groups/{group_id}/rotate"),
240 + &json!({
241 + "gck_version": stale,
242 + "grants": [{ "user_id": admin.to_string(), "sealed_gck": "sealed_admin_replay" }],
243 + })
244 + .to_string(),
245 + )
246 + .await;
247 + assert_eq!(resp.status, 400, "stale generation {stale}: {}", resp.text);
248 + }
249 +
250 + // A committed rotation cannot be replayed at its own generation.
251 + let resp = h
252 + .client
253 + .post_json(
254 + &format!("/api/sync/groups/{group_id}/rotate"),
255 + &json!({
256 + "gck_version": 2,
257 + "grants": [{ "user_id": admin.to_string(), "sealed_gck": "sealed_admin_v2" }],
258 + })
259 + .to_string(),
260 + )
261 + .await;
262 + assert_eq!(resp.status, 204, "first rotation: {}", resp.text);
263 +
264 + let resp = h
265 + .client
266 + .post_json(
267 + &format!("/api/sync/groups/{group_id}/rotate"),
268 + &json!({
269 + "gck_version": 2,
270 + "grants": [{ "user_id": admin.to_string(), "sealed_gck": "sealed_admin_replay" }],
271 + })
272 + .to_string(),
273 + )
274 + .await;
275 + assert_eq!(resp.status, 400, "replayed rotation: {}", resp.text);
276 +
277 + let (sealed, version) = grant(&mut h, &group_id).await;
278 + assert_eq!(version, 2, "the replay changed nothing");
279 + assert_eq!(sealed, "sealed_admin_v2");
280 + }
281 +
282 + /// The batch must carry the admin's own re-sealed grant. Without it the rotation
283 + /// would delete the admin along with everyone else omitted, orphaning the group.
284 + #[tokio::test]
285 + async fn rotation_without_the_admins_own_grant_is_refused() {
286 + let (mut h, _blobs) = harness_with_blobs().await;
287 + let admin = h
288 + .signup("gr3_admin", "gr3_admin@example.com", "Password1!")
289 + .await;
290 + let (app, _key) = create_internal_app(&h.db, admin).await;
291 + auth_as(&mut h, admin, app, "admin-key");
292 + let group_id = create_group(&mut h, "Team").await;
293 + let bob = add_member(&mut h, &group_id, "gr3_bob", "gr3_bob@example.com").await;
294 +
295 + auth_as(&mut h, admin, app, "admin-key");
296 + let resp = h
297 + .client
298 + .post_json(
299 + &format!("/api/sync/groups/{group_id}/rotate"),
300 + &json!({
301 + "gck_version": 2,
302 + "grants": [{ "user_id": bob.to_string(), "sealed_gck": "sealed_bob_v2" }],
303 + })
304 + .to_string(),
305 + )
306 + .await;
307 + assert_eq!(resp.status, 400, "admin omitted: {}", resp.text);
308 +
309 + // An empty batch is the same mistake with nothing left standing.
310 + let resp = h
311 + .client
312 + .post_json(
313 + &format!("/api/sync/groups/{group_id}/rotate"),
314 + &json!({ "gck_version": 2, "grants": [] }).to_string(),
315 + )
316 + .await;
317 + assert_eq!(resp.status, 400, "empty batch: {}", resp.text);
318 +
319 + let (_, version) = grant(&mut h, &group_id).await;
320 + assert_eq!(version, 1, "no rejected rotation touched the generation");
321 + assert_eq!(member_ids(&mut h, &group_id).await.len(), 2);
322 + }
323 +
324 + /// Rotation re-seals; it does not recruit. A grant naming a non-member would be a
325 + /// silent no-op in the db layer's UPDATE, so the admin must not be able to believe
326 + /// someone was added by rotating.
327 + #[tokio::test]
328 + async fn rotation_grant_for_a_non_member_is_refused() {
329 + let (mut h, _blobs) = harness_with_blobs().await;
330 + let admin = h
331 + .signup("gr4_admin", "gr4_admin@example.com", "Password1!")
332 + .await;
333 + let (app, _key) = create_internal_app(&h.db, admin).await;
334 + auth_as(&mut h, admin, app, "admin-key");
335 + let group_id = create_group(&mut h, "Team").await;
336 +
337 + let outsider = h
338 + .signup("gr4_dave", "gr4_dave@example.com", "Password1!")
339 + .await;
340 + auth_as(&mut h, admin, app, "admin-key");
341 +
342 + let resp = h
343 + .client
344 + .post_json(
345 + &format!("/api/sync/groups/{group_id}/rotate"),
346 + &json!({
347 + "gck_version": 2,
348 + "grants": [
349 + { "user_id": admin.to_string(), "sealed_gck": "sealed_admin_v2" },
350 + { "user_id": outsider.to_string(), "sealed_gck": "sealed_dave_v2" },
351 + ],
352 + })
353 + .to_string(),
354 + )
355 + .await;
356 + assert_eq!(resp.status, 400, "grant for a non-member: {}", resp.text);
357 +
358 + // A duplicate grant for the same member is refused for the same reason: the
359 + // batch would not mean what it appears to.
360 + let resp = h
361 + .client
362 + .post_json(
363 + &format!("/api/sync/groups/{group_id}/rotate"),
364 + &json!({
365 + "gck_version": 2,
366 + "grants": [
367 + { "user_id": admin.to_string(), "sealed_gck": "sealed_admin_v2" },
368 + { "user_id": admin.to_string(), "sealed_gck": "sealed_admin_v2_again" },
369 + ],
370 + })
371 + .to_string(),
372 + )
373 + .await;
374 + assert_eq!(resp.status, 400, "duplicate grant: {}", resp.text);
375 + }
376 +
377 + /// Only the admin may rotate. A member holding a valid grant must not be able to
378 + /// re-key the group, which would let them lock the admin out.
379 + #[tokio::test]
380 + async fn only_the_admin_may_rotate() {
381 + let (mut h, _blobs) = harness_with_blobs().await;
382 + let admin = h
383 + .signup("gr5_admin", "gr5_admin@example.com", "Password1!")
384 + .await;
385 + let (app, _key) = create_internal_app(&h.db, admin).await;
386 + auth_as(&mut h, admin, app, "admin-key");
387 + let group_id = create_group(&mut h, "Team").await;
388 + let bob = add_member(&mut h, &group_id, "gr5_bob", "gr5_bob@example.com").await;
389 +
390 + auth_as(&mut h, bob, app, "bob-key");
391 + let resp = h
392 + .client
393 + .post_json(
394 + &format!("/api/sync/groups/{group_id}/rotate"),
395 + &json!({
396 + "gck_version": 2,
397 + "grants": [{ "user_id": bob.to_string(), "sealed_gck": "sealed_bob_v2" }],
398 + })
399 + .to_string(),
400 + )
401 + .await;
402 + assert_eq!(resp.status, 403, "member rotating: {}", resp.text);
403 +
404 + // A non-member gets the same answer, and learns nothing about the group.
405 + let carol = h
406 + .signup("gr5_carol", "gr5_carol@example.com", "Password1!")
407 + .await;
408 + auth_as(&mut h, carol, app, "carol-key");
409 + let resp = h
410 + .client
411 + .post_json(
412 + &format!("/api/sync/groups/{group_id}/rotate"),
413 + &json!({
414 + "gck_version": 2,
415 + "grants": [{ "user_id": carol.to_string(), "sealed_gck": "sealed_carol_v2" }],
416 + })
417 + .to_string(),
418 + )
419 + .await;
420 + assert_eq!(resp.status, 403, "non-member rotating: {}", resp.text);
421 + }
422 +
423 + /// The property the whole per-generation design exists for: entries written
424 + /// before a rotation stay readable afterwards.
425 + ///
426 + /// A rotation bumps the generation without re-encrypting the log (the server
427 + /// cannot, it never sees plaintext), so every entry records the generation it was
428 + /// sealed under and members keep the grant for every generation they lived
429 + /// through. Without both halves a single removal would orphan the group's whole
430 + /// history.
431 + #[tokio::test]
432 + async fn entries_written_before_a_rotation_stay_readable_after_it() {
433 + let (mut h, _blobs) = harness_with_blobs().await;
434 + let admin = h
435 + .signup("gr6_admin", "gr6_admin@example.com", "Password1!")
436 + .await;
437 + let (app, _key) = create_internal_app(&h.db, admin).await;
438 + auth_as(&mut h, admin, app, "admin-key");
439 + seed_subscription(&h.db, admin, app, "active", 10 * GIB).await;
440 +
441 + let group_id = create_group(&mut h, "Team").await;
442 + let bob = add_member(&mut h, &group_id, "gr6_bob", "gr6_bob@example.com").await;
443 + let carol = add_member(&mut h, &group_id, "gr6_carol", "gr6_carol@example.com").await;
444 +
445 + auth_as(&mut h, admin, app, "admin-key");
446 + let device = register_device(&mut h, "admin-dev").await;
447 + push_entry(&mut h, &group_id, &device, "before-rotation").await;
448 +
449 + let resp = h
450 + .client
451 + .post_json(
452 + &format!("/api/sync/groups/{group_id}/rotate"),
453 + &json!({
454 + "gck_version": 2,
455 + "grants": [
456 + { "user_id": admin.to_string(), "sealed_gck": "sealed_admin_v2" },
457 + { "user_id": bob.to_string(), "sealed_gck": "sealed_bob_v2" },
458 + ],
459 + })
460 + .to_string(),
461 + )
462 + .await;
463 + assert_eq!(resp.status, 204, "rotate: {}", resp.text);
464 +
465 + push_entry(&mut h, &group_id, &device, "after-rotation").await;
466 +
467 + // A pull spans both generations, and each entry says which key opens it.
468 + let changes = pull_entries(&mut h, &group_id, &device).await;
469 + assert_eq!(changes.len(), 2, "both entries pull: {changes:?}");
470 + let by_row = |row: &str| -> i64 {
471 + changes
472 + .iter()
473 + .find(|c| c["row_id"] == row)
474 + .unwrap_or_else(|| panic!("{row} missing from {changes:?}"))["gck_version"]
475 + .as_i64()
476 + .expect("gck_version on a group entry")
477 + };
478 + assert_eq!(by_row("before-rotation"), 1, "the old entry keeps its key");
479 + assert_eq!(
480 + by_row("after-rotation"),
481 + 2,
482 + "the new entry uses the new key"
483 + );
484 +
485 + // A member who lived through the rotation can still fetch the old generation's
486 + // grant, which is what makes the old entry decryptable rather than merely
487 + // present.
488 + auth_as(&mut h, bob, app, "bob-key");
489 + let resp = h
490 + .client
491 + .get(&format!("/api/sync/groups/{group_id}/grant?version=1"))
492 + .await;
493 + assert_eq!(resp.status, 200, "bob's generation-1 grant: {}", resp.text);
494 + assert_eq!(
495 + resp.json::<serde_json::Value>()["sealed_gck"],
496 + "sealed_gr6_bob_v1"
497 + );
498 +
499 + let resp = h
500 + .client
Lines truncated