max / makenotwork
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(); |