Skip to main content

max / makenotwork

synckit: identity derivation + GCK resolver/cache (Groups phase 3, slice 3) The store never sees the master key or identity secret: it asks the client for a resolved GCK, and the client derives + opens everything internally. - identity: IdentityKeypair::from_master_key derives the X25519 identity keypair deterministically from the master_key (SHA-256(domain || master_key)), so every device that holds the master key derives the SAME identity -- no storage, no syncing, no server round-trip. Resolves the multi-device requirement (a grant is sealed to the user's pubkey, and every device must open it). Documented consequence: a personal key rotation changes the identity, so group grants must be re-issued after a rotation. (Decision 2026-07-23.) - client: a per-group GCK cache (group_id -> (gck_version, gck)) and group_content_key(group_id, version) -- fast-path cache hit, else fetch the sealed grant, open it with the derived identity, cache by the grant's version. invalidate_gck drops a stale entry on a decrypt failure. - tests: derivation is deterministic + key-specific; a grant sealed to the derived pubkey opens via a re-derived identity; open_group_grant recovers the GCK and fails closed on a version (AAD) mismatch. group_content_key/invalidate_gck are wired into sync_now in slice 4 (marked allow(dead_code) until then). 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:40 UTC
Commit: be98109b660867ffdbea135642474b84071a83d1
Parent: 031a465
3 files changed, +152 insertions, -1 deletion
@@ -12,6 +12,7 @@ use tracing::instrument;
12 12 use uuid::Uuid;
13 13
14 14 use crate::{
15 + crypto,
15 16 error::Result,
16 17 ids::{DeviceId, GroupId},
17 18 types::*,
@@ -120,12 +121,69 @@ impl SyncKitClient {
120 121 .collect::<Result<Vec<_>>>()?;
121 122 Ok((changes, pull_resp.cursor, pull_resp.has_more))
122 123 }
124 +
125 + /// Resolve a group's decrypted Group Content Key for `gck_version`, fetching
126 + /// and opening the sealed grant on a cache miss.
127 + ///
128 + /// Fast path: a cached GCK at the requested version is returned without a
129 + /// network call. On a miss (or a version mismatch — the group's key rotated),
130 + /// the sealed grant is fetched, opened with the identity keypair derived from
131 + /// this client's master key, and cached under the grant's actual version. The
132 + /// master key and identity secret never leave the client — the `SyncStore`
133 + /// only ever receives the resolved GCK.
134 + // Consumed by `sync_now`'s scope iteration in slice 4.
135 + #[allow(dead_code)]
136 + pub(crate) async fn group_content_key(
137 + &self,
138 + group_id: GroupId,
139 + gck_version: i32,
140 + ) -> Result<crypto::ZeroizeOnDrop> {
141 + if let Some((v, gck)) = self.gck_cache.read().get(&group_id)
142 + && *v == gck_version
143 + {
144 + return Ok(crypto::ZeroizeOnDrop(**gck));
145 + }
146 +
147 + let grant = self.group_grant(group_id).await?;
148 + let master = self.require_master_key()?;
149 + let gck = Self::open_group_grant(&grant, &master, group_id)?;
150 + let out = crypto::ZeroizeOnDrop(*gck);
151 + self.gck_cache
152 + .write()
153 + .insert(group_id, (grant.gck_version, gck));
154 + Ok(out)
155 + }
156 +
157 + /// Open a sealed grant with the identity keypair derived from `master_key`.
158 + /// Pure (no I/O), so the derive-and-open path is unit-testable.
159 + fn open_group_grant(
160 + grant: &GroupGrant,
161 + master_key: &[u8; 32],
162 + group_id: GroupId,
163 + ) -> Result<crypto::ZeroizeOnDrop> {
164 + let identity = crate::identity::IdentityKeypair::from_master_key(master_key);
165 + let gck = crate::identity::open_gck_grant(
166 + &grant.sealed_gck,
167 + &identity,
168 + &group_id.to_string(),
169 + grant.gck_version,
170 + )?;
171 + Ok(crypto::ZeroizeOnDrop(gck))
172 + }
173 +
174 + /// Drop a group's cached GCK, forcing the next
175 + /// [`group_content_key`](Self::group_content_key) to re-fetch its grant. Call
176 + /// on a decrypt failure (a stale key after a rotation this client missed).
177 + // Consumed by `sync_now`'s scope iteration in slice 4.
178 + #[allow(dead_code)]
179 + pub(crate) fn invalidate_gck(&self, group_id: GroupId) {
180 + self.gck_cache.write().remove(&group_id);
181 + }
123 182 }
124 183
125 184 #[cfg(test)]
126 185 mod tests {
127 186 use super::*;
128 - use crate::crypto;
129 187 use crate::error::SyncKitError;
130 188 use crate::ids::DeviceId;
131 189 use chrono::Utc;
@@ -218,4 +276,31 @@ mod tests {
218 276 .unwrap_err();
219 277 assert!(matches!(err, SyncKitError::DecryptionFailed));
220 278 }
279 +
280 + #[test]
281 + fn open_group_grant_recovers_gck_via_derived_identity() {
282 + // The admin seals the GCK to the member's derived public key; the member's
283 + // client re-derives the identity from its master key and opens the grant.
284 + let group = GroupId::new(uuid::Uuid::new_v4());
285 + let master_key = crypto::generate_master_key();
286 + let member = crate::identity::IdentityKeypair::from_master_key(&master_key);
287 + let gck = crate::identity::generate_group_key();
288 + let sealed =
289 + crate::identity::seal_gck_to_member(&gck, &member.public_key(), &group.to_string(), 4)
290 + .unwrap();
291 + let grant = GroupGrant {
292 + sealed_gck: sealed,
293 + gck_version: 4,
294 + };
295 +
296 + let opened = SyncKitClient::open_group_grant(&grant, &master_key, group).unwrap();
297 + assert_eq!(*opened, gck);
298 +
299 + // A wrong-version grant (AAD mismatch) fails closed.
300 + let bad = GroupGrant {
301 + sealed_gck: grant.sealed_gck.clone(),
302 + gck_version: 5,
303 + };
304 + assert!(SyncKitClient::open_group_grant(&bad, &master_key, group).is_err());
305 + }
221 306 }
@@ -307,6 +307,11 @@ pub struct SyncKitClient {
307 307 master_key_id: RwLock<i32>,
308 308 /// Pending rotation key, if a rotation is in progress.
309 309 pending_key: RwLock<Option<PendingKeyState>>,
310 + /// Per-group decrypted Group Content Key cache: `group_id -> (gck_version,
311 + /// gck)`. Populated lazily by [`group_content_key`](Self::group_content_key)
312 + /// from the sealed grant; a version mismatch (rotation) or a decrypt failure
313 + /// forces a re-fetch. Holds one entry per group — the current generation.
314 + gck_cache: RwLock<std::collections::HashMap<GroupId, (i32, crypto::ZeroizeOnDrop)>>,
310 315 }
311 316
312 317 impl SyncKitClient {
@@ -349,6 +354,7 @@ impl SyncKitClient {
349 354 master_key: RwLock::new(None),
350 355 master_key_id: RwLock::new(1),
351 356 pending_key: RwLock::new(None),
357 + gck_cache: RwLock::new(std::collections::HashMap::new()),
352 358 }
353 359 }
354 360
@@ -369,6 +375,7 @@ impl SyncKitClient {
369 375 master_key: RwLock::new(None),
370 376 master_key_id: RwLock::new(1),
371 377 pending_key: RwLock::new(None),
378 + gck_cache: RwLock::new(std::collections::HashMap::new()),
372 379 }
373 380 }
374 381
@@ -55,6 +55,9 @@ const X25519_KEY_LEN: usize = 32;
55 55 /// specific to this construction and version.
56 56 const GRANT_KDF_DOMAIN: &[u8] = b"synckit-group-grant-v1";
57 57
58 + /// Domain-separation label for deriving the identity keypair from a master key.
59 + const IDENTITY_KDF_DOMAIN: &[u8] = b"synckit-identity-v1";
60 +
58 61 /// Mint a fresh Group Content Key (GCK): 32 random bytes.
59 62 ///
60 63 /// A thin, intention-revealing alias of [`crypto::generate_master_key`] — a GCK
@@ -97,6 +100,29 @@ impl IdentityKeypair {
97 100 Self { secret, public }
98 101 }
99 102
103 + /// Derive a user's identity keypair *deterministically* from their
104 + /// `master_key`, so every device that holds the master key derives the same
105 + /// keypair with no storage, syncing, or server round-trip. The X25519 secret
106 + /// scalar is `SHA-256(domain || master_key)` — a single hash is a sound KDF
107 + /// for one output from a uniformly random 256-bit key.
108 + ///
109 + /// Consequence: a personal key rotation changes the master key and therefore
110 + /// this identity, so a member's group grants must be re-issued (re-sealed to
111 + /// the new public key) after a rotation. See the wiki design note
112 + /// `synckit-multiscope-design`.
113 + pub fn from_master_key(master_key: &[u8; X25519_KEY_LEN]) -> Self {
114 + let mut hasher = Sha256::new();
115 + hasher.update(IDENTITY_KDF_DOMAIN);
116 + hasher.update(master_key);
117 + let digest = hasher.finalize();
118 + let mut scalar = [0u8; X25519_KEY_LEN];
119 + scalar.copy_from_slice(&digest);
120 + let secret = StaticSecret::from(scalar);
121 + scalar.zeroize();
122 + let public = PublicKey::from(&secret);
123 + Self { secret, public }
124 + }
125 +
100 126 /// This keypair's public key — the value a member shares with an admin.
101 127 pub fn public_key(&self) -> IdentityPublicKey {
102 128 IdentityPublicKey(self.public.to_bytes())
@@ -299,6 +325,39 @@ mod tests {
299 325 }
300 326
301 327 #[test]
328 + fn from_master_key_is_deterministic_and_key_specific() {
329 + let mk = crypto::generate_master_key();
330 + // Same master key -> same identity on every device.
331 + assert_eq!(
332 + IdentityKeypair::from_master_key(&mk).public_key(),
333 + IdentityKeypair::from_master_key(&mk).public_key(),
334 + );
335 + // A different master key -> a different identity.
336 + let other = crypto::generate_master_key();
337 + assert_ne!(
338 + IdentityKeypair::from_master_key(&mk).public_key(),
339 + IdentityKeypair::from_master_key(&other).public_key(),
340 + );
341 + }
342 +
343 + #[test]
344 + fn derived_identity_opens_a_grant_sealed_to_it() {
345 + // The end-to-end shape: an admin seals the GCK to a member's derived
346 + // public key; the member's device re-derives the same keypair from the
347 + // master key and opens the grant.
348 + let mk = crypto::generate_master_key();
349 + let member = IdentityKeypair::from_master_key(&mk);
350 + let gck = generate_group_key();
351 + let grant = seal_gck_to_member(&gck, &member.public_key(), "team", 1).unwrap();
352 +
353 + let member_again = IdentityKeypair::from_master_key(&mk);
354 + assert_eq!(
355 + open_gck_grant(&grant, &member_again, "team", 1).unwrap(),
356 + gck
357 + );
358 + }
359 +
360 + #[test]
302 361 fn public_key_base64_roundtrip() {
303 362 let kp = IdentityKeypair::generate();
304 363 let pk = kp.public_key();