Skip to main content

max / makenotwork

synckit-server: store member identity public keys (Groups phase 3, p3-rotation foundation) Member public-key storage, the prerequisite for non-interactive GCK rotation: when a member is removed, the admin re-seals the new GCK to the remaining members' stored public keys without re-collecting them out of band. A pubkey is non-secret metadata (the server already knows group membership) and is never used to decrypt anything server-side. - migration 173: sync_group_members.member_pubkey (nullable, ADD COLUMN IF NOT EXISTS for re-run safety). - db::synckit::groups: create_group and add_or_update_member store the pubkey; new list_member_pubkeys(group) returns (user_id, pubkey) for every member with a stored key. - routes: CreateGroupRequest.admin_pubkey and AddMemberRequest.member_pubkey; new admin-only GET /groups/{id}/pubkeys returning [{user_id, pubkey}]. - tests: db list_member_pubkeys returns admin + member keys; existing group db and HTTP tests updated for the new fields. Next: the group GCK-rotation routes (begin/entries/batch/complete re-encrypting sync_group_log under the new key) -- needs a per-entry gck_version column, so I'll check in on that design before building it. Design: wiki synckit-groups-design. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-23 20:30 UTC
Commit: c31fa9705f8510af8485876837906cc338f8bb7a
Parent: cb94e5e
6 files changed, +148 insertions, -29 deletions
@@ -0,0 +1,11 @@
1 + -- SyncKit Groups: store each member's identity public key.
2 + --
3 + -- Needed for non-interactive GCK rotation (p3-rotation): when a member is
4 + -- removed, the admin mints a new GCK and re-seals it to the *remaining* members'
5 + -- public keys without re-collecting them out of band. The key is non-secret
6 + -- metadata (the server already knows group membership); it is never used to
7 + -- decrypt anything server-side. Nullable so a membership row can predate its
8 + -- pubkey; the client always sends it with the grant. Design: wiki
9 + -- synckit-groups-design.
10 +
11 + ALTER TABLE sync_group_members ADD COLUMN IF NOT EXISTS member_pubkey TEXT;
@@ -26,6 +26,7 @@ pub async fn create_group(
26 26 admin_user_id: UserId,
27 27 name: &str,
28 28 admin_sealed_gck: &str,
29 + admin_pubkey: &str,
29 30 ) -> Result<DbSyncGroup> {
30 31 let mut tx = pool.begin().await?;
31 32
@@ -44,14 +45,15 @@ pub async fn create_group(
44 45
45 46 sqlx::query(
46 47 r#"
47 - INSERT INTO sync_group_members (group_id, user_id, role, sealed_gck, gck_version)
48 - VALUES ($1, $2, 'admin', $3, $4)
48 + INSERT INTO sync_group_members (group_id, user_id, role, sealed_gck, gck_version, member_pubkey)
49 + VALUES ($1, $2, 'admin', $3, $4, $5)
49 50 "#,
50 51 )
51 52 .bind(group.id)
52 53 .bind(admin_user_id)
53 54 .bind(admin_sealed_gck)
54 55 .bind(group.gck_version)
56 + .bind(admin_pubkey)
55 57 .execute(&mut *tx)
56 58 .await?;
57 59
@@ -121,6 +123,26 @@ pub async fn list_members(pool: &PgPool, group_id: SyncGroupId) -> Result<Vec<Db
121 123 Ok(members)
122 124 }
123 125
126 + /// The `(user_id, member_pubkey)` of every member with a stored public key. The
127 + /// admin fetches this to re-seal a rotated GCK to the remaining members without
128 + /// re-collecting their keys out of band. Members without a stored key (legacy
129 + /// rows) are omitted.
130 + #[tracing::instrument(skip_all)]
131 + pub async fn list_member_pubkeys(
132 + pool: &PgPool,
133 + group_id: SyncGroupId,
134 + ) -> Result<Vec<(UserId, String)>> {
135 + let rows: Vec<(UserId, String)> = sqlx::query_as(
136 + "SELECT user_id, member_pubkey FROM sync_group_members \
137 + WHERE group_id = $1 AND member_pubkey IS NOT NULL \
138 + ORDER BY added_at ASC",
139 + )
140 + .bind(group_id)
141 + .fetch_all(pool)
142 + .await?;
143 + Ok(rows)
144 + }
145 +
124 146 /// Whether `user_id` is a member of `group_id`. The membership gate for
125 147 /// group-scoped push/pull.
126 148 #[tracing::instrument(skip_all)]
@@ -185,15 +207,17 @@ pub async fn add_or_update_member(
185 207 role: &str,
186 208 sealed_gck: &str,
187 209 gck_version: i32,
210 + member_pubkey: &str,
188 211 ) -> Result<()> {
189 212 sqlx::query(
190 213 r#"
191 - INSERT INTO sync_group_members (group_id, user_id, role, sealed_gck, gck_version)
192 - VALUES ($1, $2, $3, $4, $5)
214 + INSERT INTO sync_group_members (group_id, user_id, role, sealed_gck, gck_version, member_pubkey)
215 + VALUES ($1, $2, $3, $4, $5, $6)
193 216 ON CONFLICT (group_id, user_id)
194 217 DO UPDATE SET role = EXCLUDED.role,
195 218 sealed_gck = EXCLUDED.sealed_gck,
196 - gck_version = EXCLUDED.gck_version
219 + gck_version = EXCLUDED.gck_version,
220 + member_pubkey = EXCLUDED.member_pubkey
197 221 "#,
198 222 )
199 223 .bind(group_id)
@@ -201,6 +225,7 @@ pub async fn add_or_update_member(
201 225 .bind(role)
202 226 .bind(sealed_gck)
203 227 .bind(gck_version)
228 + .bind(member_pubkey)
204 229 .execute(pool)
205 230 .await?;
206 231 Ok(())
@@ -28,8 +28,9 @@ use crate::{
28 28 };
29 29
30 30 use super::{
31 - AddMemberRequest, CreateGroupRequest, GroupGrantResponse, GroupMemberResponse, GroupResponse,
32 - PullChangeEntry, PullRequest, PullResponse, PushRequest, PushResponse,
31 + AddMemberRequest, CreateGroupRequest, GroupGrantResponse, GroupMemberPubkey,
32 + GroupMemberResponse, GroupResponse, PullChangeEntry, PullRequest, PullResponse, PushRequest,
33 + PushResponse,
33 34 };
34 35
35 36 /// Fetch a group scoped to the caller's app, or 404. Guards every group handler
@@ -78,7 +79,9 @@ pub(super) async fn create_group(
78 79 Json(req): Json<CreateGroupRequest>,
79 80 ) -> Result<impl IntoResponse> {
80 81 validation::validate_sync_group_name(&req.name)?;
81 - if req.admin_sealed_gck.len() > constants::SYNCKIT_MAX_KEY_ENVELOPE_BYTES {
82 + if req.admin_sealed_gck.len() > constants::SYNCKIT_MAX_KEY_ENVELOPE_BYTES
83 + || req.admin_pubkey.len() > constants::SYNCKIT_MAX_KEY_ENVELOPE_BYTES
84 + {
82 85 return Err(AppError::BadRequest(
83 86 "Sealed key exceeds size limit".to_string(),
84 87 ));
@@ -90,6 +93,7 @@ pub(super) async fn create_group(
90 93 sync_user.user_id,
91 94 &req.name,
92 95 &req.admin_sealed_gck,
96 + &req.admin_pubkey,
93 97 )
94 98 .await?;
95 99
@@ -134,7 +138,9 @@ pub(super) async fn add_member(
134 138 let group = require_group(&db, sync_user.app_id, group_id).await?;
135 139 require_admin(&db, group_id, sync_user.user_id).await?;
136 140
137 - if req.sealed_gck.len() > constants::SYNCKIT_MAX_KEY_ENVELOPE_BYTES {
141 + if req.sealed_gck.len() > constants::SYNCKIT_MAX_KEY_ENVELOPE_BYTES
142 + || req.member_pubkey.len() > constants::SYNCKIT_MAX_KEY_ENVELOPE_BYTES
143 + {
138 144 return Err(AppError::BadRequest(
139 145 "Sealed key exceeds size limit".to_string(),
140 146 ));
@@ -153,7 +159,8 @@ pub(super) async fn add_member(
153 159 .ok_or_else(|| AppError::BadRequest("No verified account for that email".to_string()))?;
154 160
155 161 // The grant the admin sends is sealed under the group's current GCK, so it is
156 - // stored at that generation.
162 + // stored at that generation, along with the member's public key (for re-seal
163 + // on a later rotation).
157 164 db::synckit::add_or_update_member(
158 165 &db,
159 166 group_id,
@@ -161,12 +168,37 @@ pub(super) async fn add_member(
161 168 role,
162 169 &req.sealed_gck,
163 170 group.gck_version,
171 + &req.member_pubkey,
164 172 )
165 173 .await?;
166 174
167 175 Ok(StatusCode::NO_CONTENT)
168 176 }
169 177
178 + /// List every member's identity public key. Admin only — the admin re-seals a
179 + /// rotated GCK to each of these on member removal.
180 + #[utoipa::path(get, path = "/api/v1/sync/groups/{id}/pubkeys", tag = "SyncKit",
181 + params(("id" = String, Path, description = "Group ID")),
182 + responses((status = 200, description = "Member public keys", body = Vec<GroupMemberPubkey>)),
183 + security(("bearer" = [])),
184 + )]
185 + #[tracing::instrument(skip_all, name = "synckit::list_group_pubkeys")]
186 + pub(super) async fn list_pubkeys(
187 + State(db): State<PgPool>,
188 + sync_user: SyncUser,
189 + Path(group_id): Path<SyncGroupId>,
190 + ) -> Result<impl IntoResponse> {
191 + require_group(&db, sync_user.app_id, group_id).await?;
192 + require_admin(&db, group_id, sync_user.user_id).await?;
193 +
194 + let pubkeys = db::synckit::list_member_pubkeys(&db, group_id).await?;
195 + let response: Vec<GroupMemberPubkey> = pubkeys
196 + .into_iter()
197 + .map(|(user_id, pubkey)| GroupMemberPubkey { user_id, pubkey })
198 + .collect();
199 + Ok(Json(response))
200 + }
201 +
170 202 /// Remove a member from a group. Admin only.
171 203 ///
172 204 /// This drops the member from future group writes. Forward secrecy for writes
@@ -200,6 +200,9 @@ pub(crate) struct CreateGroupRequest {
200 200 /// The Group Content Key sealed to the creating admin's own identity public
201 201 /// key (base64), opaque to the server.
202 202 pub admin_sealed_gck: String,
203 + /// The admin's own identity public key (base64), stored so the GCK can be
204 + /// re-sealed to the admin on a later rotation.
205 + pub admin_pubkey: String,
203 206 }
204 207
205 208 #[derive(Serialize, utoipa::ToSchema)]
@@ -236,11 +239,23 @@ pub(crate) struct AddMemberRequest {
236 239 /// The GCK sealed to the member's identity public key (base64), produced by
237 240 /// the admin with the group's current GCK. Opaque to the server.
238 241 pub sealed_gck: String,
242 + /// The member's identity public key (base64), stored so the GCK can be
243 + /// re-sealed to them on a later rotation.
244 + pub member_pubkey: String,
239 245 /// Optional role: "member" (default) or "admin".
240 246 #[serde(default)]
241 247 pub role: Option<String>,
242 248 }
243 249
250 + /// One member's identity public key, from `GET /groups/{id}/pubkeys` — the admin
251 + /// re-seals a rotated GCK to each of these.
252 + #[derive(Serialize, utoipa::ToSchema)]
253 + pub(crate) struct GroupMemberPubkey {
254 + #[schema(value_type = String)]
255 + user_id: UserId,
256 + pubkey: String,
257 + }
258 +
244 259 #[derive(Serialize, utoipa::ToSchema)]
245 260 pub(crate) struct GroupMemberResponse {
246 261 #[schema(value_type = String)]
@@ -801,6 +816,11 @@ pub fn synckit_routes(synckit_jwt_secret: Option<std::sync::Arc<String>>) -> Csr
801 816 )
802 817 .route_get("/api/sync/groups/{id}/grant", get(groups::get_grant))
803 818 .route_get("/api/v1/sync/groups/{id}/grant", get(groups::get_grant))
819 + .route_get("/api/sync/groups/{id}/pubkeys", get(groups::list_pubkeys))
820 + .route_get(
821 + "/api/v1/sync/groups/{id}/pubkeys",
822 + get(groups::list_pubkeys),
823 + )
804 824 .route(
805 825 "/api/sync/groups/{id}/push",
806 826 post_csrf_skip(SYNCKIT_JWT_SKIP, groups::group_push),
@@ -90,7 +90,7 @@ async fn create_group_enrolls_admin_as_member_and_admin() {
90 90 let admin = seed_user(&db.pool, "grp_admin").await;
91 91 let app = seed_app(&db.pool, admin, "grpcreate").await;
92 92
93 - let group = synckit::create_group(&db.pool, app, admin, "Team", "sealed_admin")
93 + let group = synckit::create_group(&db.pool, app, admin, "Team", "sealed_admin", "pk")
94 94 .await
95 95 .unwrap();
96 96
@@ -118,7 +118,7 @@ async fn add_member_stores_grant_and_gates_admin() {
118 118 let db = TestDb::new().await;
119 119 let admin = seed_user(&db.pool, "gm_admin").await;
120 120 let app = seed_app(&db.pool, admin, "gmadd").await;
121 - let group = synckit::create_group(&db.pool, app, admin, "Team", "sealed_admin")
121 + let group = synckit::create_group(&db.pool, app, admin, "Team", "sealed_admin", "pk")
122 122 .await
123 123 .unwrap();
124 124
@@ -130,6 +130,7 @@ async fn add_member_stores_grant_and_gates_admin() {
130 130 "member",
131 131 "sealed_bob",
132 132 group.gck_version,
133 + "pk_bob",
133 134 )
134 135 .await
135 136 .unwrap();
@@ -171,16 +172,16 @@ async fn add_or_update_member_is_idempotent_upsert() {
171 172 let db = TestDb::new().await;
172 173 let admin = seed_user(&db.pool, "up_admin").await;
173 174 let app = seed_app(&db.pool, admin, "upsert").await;
174 - let group = synckit::create_group(&db.pool, app, admin, "Team", "sealed_admin")
175 + let group = synckit::create_group(&db.pool, app, admin, "Team", "sealed_admin", "pk")
175 176 .await
176 177 .unwrap();
177 178 let bob = seed_user(&db.pool, "up_bob").await;
178 179
179 - synckit::add_or_update_member(&db.pool, group.id, bob, "member", "grant_v1", 1)
180 + synckit::add_or_update_member(&db.pool, group.id, bob, "member", "grant_v1", 1, "pk")
180 181 .await
181 182 .unwrap();
182 183 // Re-adding replaces the grant in place, not a second row.
183 - synckit::add_or_update_member(&db.pool, group.id, bob, "member", "grant_v2", 1)
184 + synckit::add_or_update_member(&db.pool, group.id, bob, "member", "grant_v2", 1, "pk")
184 185 .await
185 186 .unwrap();
186 187
@@ -205,15 +206,15 @@ async fn list_groups_for_user_is_membership_scoped() {
205 206 let db = TestDb::new().await;
206 207 let admin = seed_user(&db.pool, "lg_admin").await;
207 208 let app = seed_app(&db.pool, admin, "listgrp").await;
208 - let g1 = synckit::create_group(&db.pool, app, admin, "One", "s")
209 + let g1 = synckit::create_group(&db.pool, app, admin, "One", "s", "pk")
209 210 .await
210 211 .unwrap();
211 - let _g2 = synckit::create_group(&db.pool, app, admin, "Two", "s")
212 + let _g2 = synckit::create_group(&db.pool, app, admin, "Two", "s", "pk")
212 213 .await
213 214 .unwrap();
214 215
215 216 let bob = seed_user(&db.pool, "lg_bob").await;
216 - synckit::add_or_update_member(&db.pool, g1.id, bob, "member", "sb", 1)
217 + synckit::add_or_update_member(&db.pool, g1.id, bob, "member", "sb", 1, "pk")
217 218 .await
218 219 .unwrap();
219 220 let carol = seed_user(&db.pool, "lg_carol").await;
@@ -246,11 +247,11 @@ async fn remove_member_revokes_membership() {
246 247 let db = TestDb::new().await;
247 248 let admin = seed_user(&db.pool, "rm_admin").await;
248 249 let app = seed_app(&db.pool, admin, "removem").await;
249 - let group = synckit::create_group(&db.pool, app, admin, "Team", "s")
250 + let group = synckit::create_group(&db.pool, app, admin, "Team", "s", "pk")
250 251 .await
251 252 .unwrap();
252 253 let bob = seed_user(&db.pool, "rm_bob").await;
253 - synckit::add_or_update_member(&db.pool, group.id, bob, "member", "sb", 1)
254 + synckit::add_or_update_member(&db.pool, group.id, bob, "member", "sb", 1, "pk")
254 255 .await
255 256 .unwrap();
256 257
@@ -279,7 +280,7 @@ async fn group_push_pull_roundtrip_and_idempotent() {
279 280 let db = TestDb::new().await;
280 281 let admin = seed_user(&db.pool, "pp_admin").await;
281 282 let app = seed_app(&db.pool, admin, "pushpull").await;
282 - let group = synckit::create_group(&db.pool, app, admin, "Team", "s")
283 + let group = synckit::create_group(&db.pool, app, admin, "Team", "s", "pk")
283 284 .await
284 285 .unwrap();
285 286 let device = seed_device(&db.pool, app, admin, "dev").await;
@@ -315,10 +316,10 @@ async fn group_pull_is_scoped_to_its_group() {
315 316 let db = TestDb::new().await;
316 317 let admin = seed_user(&db.pool, "sc_admin").await;
317 318 let app = seed_app(&db.pool, admin, "scoped").await;
318 - let g1 = synckit::create_group(&db.pool, app, admin, "One", "s")
319 + let g1 = synckit::create_group(&db.pool, app, admin, "One", "s", "pk")
319 320 .await
320 321 .unwrap();
321 - let g2 = synckit::create_group(&db.pool, app, admin, "Two", "s")
322 + let g2 = synckit::create_group(&db.pool, app, admin, "Two", "s", "pk")
322 323 .await
323 324 .unwrap();
324 325 let device = seed_device(&db.pool, app, admin, "dev").await;
@@ -370,7 +371,7 @@ async fn group_entries_never_appear_in_personal_sync() {
370 371 let db = TestDb::new().await;
371 372 let user = seed_user(&db.pool, "iso_user").await;
372 373 let app = seed_app(&db.pool, user, "isolate").await;
373 - let group = synckit::create_group(&db.pool, app, user, "Team", "s")
374 + let group = synckit::create_group(&db.pool, app, user, "Team", "s", "pk")
374 375 .await
375 376 .unwrap();
376 377 let device = seed_device(&db.pool, app, user, "dev").await;
@@ -437,15 +438,15 @@ async fn rotate_group_gck_bumps_version_regrants_and_drops_removed() {
437 438 let db = TestDb::new().await;
438 439 let admin = seed_user(&db.pool, "rot_admin").await;
439 440 let app = seed_app(&db.pool, admin, "grprot").await;
440 - let group = synckit::create_group(&db.pool, app, admin, "Team", "admin_v1")
441 + let group = synckit::create_group(&db.pool, app, admin, "Team", "admin_v1", "pk")
441 442 .await
442 443 .unwrap();
443 444 let bob = seed_user(&db.pool, "rot_bob").await;
444 445 let carol = seed_user(&db.pool, "rot_carol").await;
445 - synckit::add_or_update_member(&db.pool, group.id, bob, "member", "bob_v1", 1)
446 + synckit::add_or_update_member(&db.pool, group.id, bob, "member", "bob_v1", 1, "pk")
446 447 .await
447 448 .unwrap();
448 - synckit::add_or_update_member(&db.pool, group.id, carol, "member", "carol_v1", 1)
449 + synckit::add_or_update_member(&db.pool, group.id, carol, "member", "carol_v1", 1, "pk")
449 450 .await
450 451 .unwrap();
451 452
@@ -478,3 +479,33 @@ async fn rotate_group_gck_bumps_version_regrants_and_drops_removed() {
478 479 .unwrap()
479 480 );
480 481 }
482 +
483 + #[tokio::test]
484 + async fn list_member_pubkeys_returns_admin_and_members() {
485 + let db = TestDb::new().await;
486 + let admin = seed_user(&db.pool, "pk_admin").await;
487 + let app = seed_app(&db.pool, admin, "pubkeys").await;
488 + let group = synckit::create_group(&db.pool, app, admin, "Team", "sealed_admin", "admin_pub")
489 + .await
490 + .unwrap();
491 + let bob = seed_user(&db.pool, "pk_bob").await;
492 + synckit::add_or_update_member(
493 + &db.pool,
494 + group.id,
495 + bob,
496 + "member",
497 + "sealed_bob",
498 + 1,
499 + "bob_pub",
500 + )
501 + .await
502 + .unwrap();
503 +
504 + let mut pubkeys = synckit::list_member_pubkeys(&db.pool, group.id)
505 + .await
506 + .unwrap();
507 + pubkeys.sort_by_key(|(_, k)| k.clone());
508 + assert_eq!(pubkeys.len(), 2);
509 + assert_eq!(pubkeys[0], (admin, "admin_pub".to_string()));
510 + assert_eq!(pubkeys[1], (bob, "bob_pub".to_string()));
511 + }
@@ -19,7 +19,7 @@ async fn create_group(h: &mut TestHarness, name: &str) -> String {
19 19 .client
20 20 .post_json(
21 21 "/api/sync/groups",
22 - &json!({ "name": name, "admin_sealed_gck": "sealed_admin" }).to_string(),
22 + &json!({ "name": name, "admin_sealed_gck": "sealed_admin", "admin_pubkey": "pk_admin" }).to_string(),
23 23 )
24 24 .await;
25 25 assert_eq!(resp.status, 200, "create group: {}", resp.text);
@@ -115,7 +115,7 @@ async fn member_without_own_subscription_writes_on_admin_slot() {
115 115 .client
116 116 .post_json(
117 117 &format!("/api/sync/groups/{group_id}/members"),
118 - &json!({ "member_email": "gb2_bob@example.com", "sealed_gck": "sealed_bob" })
118 + &json!({ "member_email": "gb2_bob@example.com", "sealed_gck": "sealed_bob", "member_pubkey": "pk_bob" })
119 119 .to_string(),
120 120 )
121 121 .await;