Skip to main content

max / synckit

21.1 KB · 547 lines History Blame Raw
1 //! Group identity keys and Group Content Key (GCK) grants.
2 //!
3 //! This is the one asymmetric layer in SyncKit. Everything else in the crate is
4 //! symmetric (a passphrase-derived `master_key` per user). Groups break the
5 //! "one key, one owner" assumption: several users need a shared symmetric key,
6 //! the **Group Content Key**, that the server never sees. Handing that key to a
7 //! member who shares no prior secret with the group admin is, by definition,
8 //! public-key crypto. See the wiki design note.
9 //! <!-- wiki: synckit-groups-design -->
10 //!
11 //! ## The pieces
12 //!
13 //! - [`IdentityKeypair`], a per-user X25519 keypair. The [`IdentityPublicKey`]
14 //! is the value a member shares with an admin (non-secret, safe to transmit in
15 //! the clear). The private half is wrapped under the user's existing
16 //! `master_key` for storage ([`IdentityKeypair::wrap_secret`]), mirroring the
17 //! master-key lifecycle.
18 //! - The **GCK**, a random 32-byte symmetric key ([`generate_group_key`]),
19 //! playing the role `master_key` plays for personal data. Group changelog
20 //! entries are sealed under it.
21 //! - A **grant**, the GCK sealed to one member's public key
22 //! ([`seal_gck_to_member`] / [`open_gck_grant`]). Adding a member is: seal the
23 //! GCK to their pubkey, upload one grant. Removing a member is a GCK rotation
24 //! that re-seals to the *remaining* members' stored pubkeys with no member
25 //! interaction, the payoff of the asymmetric design.
26 //!
27 //! ## Grant construction (ephemeral-static ECDH, an ECIES/sealed-box shape)
28 //!
29 //! For each grant the sender mints a throwaway (ephemeral) X25519 keypair, does
30 //! ECDH against the member's public key, derives a symmetric key from the shared
31 //! secret via SHA-256 (X25519 output must never be used as a key directly), and
32 //! seals the GCK with the crate's XChaCha20-Poly1305. The wire form is
33 //! `ephemeral_public[32] || nonce[24] || ciphertext || tag[16]`, base64-encoded.
34 //! The ephemeral secret is discarded after one seal, so a later compromise of the
35 //! sender's long-term key cannot recover past grants.
36 //!
37 //! The KDF binds both public keys (`ephemeral` and `recipient`) so a grant cannot
38 //! be re-pointed at a different recipient, and the AEAD's associated data binds
39 //! `(group_id, gck_version)` so a grant cannot be replayed as a different group or
40 //! key generation.
41
42 use base64::{Engine, engine::general_purpose::STANDARD as B64};
43 use rand::Rng;
44 use sha2::{Digest, Sha256};
45 use x25519_dalek::{PublicKey, StaticSecret};
46 use zeroize::Zeroize;
47
48 use crate::crypto::{self, ENCRYPTION_OVERHEAD, ZeroizeOnDrop};
49 use crate::error::{Result, SyncKitError};
50
51 /// Size of an X25519 key (public or secret scalar) in bytes.
52 const X25519_KEY_LEN: usize = 32;
53
54 /// Domain-separation label mixed into the grant KDF, so the derived key is
55 /// specific to this construction and version.
56 const GRANT_KDF_DOMAIN: &[u8] = b"synckit-group-grant-v1";
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
61 /// Mint a fresh Group Content Key (GCK): 32 random bytes.
62 ///
63 /// A thin, intention-revealing alias of [`crypto::generate_master_key`], a GCK
64 /// is structurally the same 256-bit symmetric key as a personal `master_key`,
65 /// just shared across a group instead of owned by one user.
66 pub fn generate_group_key() -> [u8; X25519_KEY_LEN] {
67 crypto::generate_master_key()
68 }
69
70 /// A user's long-lived X25519 identity keypair.
71 ///
72 /// The public key is shared with group admins; the secret opens grants sealed to
73 /// it. The secret zeroizes on drop (via `x25519-dalek`'s `zeroize` feature).
74 pub struct IdentityKeypair {
75 secret: StaticSecret,
76 public: PublicKey,
77 }
78
79 impl std::fmt::Debug for IdentityKeypair {
80 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81 // Never render the secret. The public key is safe to show.
82 f.debug_struct("IdentityKeypair")
83 .field("public", &self.public_key().to_base64())
84 .field("secret", &"[REDACTED]")
85 .finish()
86 }
87 }
88
89 impl IdentityKeypair {
90 /// Generate a new random identity keypair.
91 ///
92 /// Seeds the scalar from the crate's existing `rand` (not `x25519-dalek`'s
93 /// own RNG), so the whole crate keeps a single randomness source.
94 pub fn generate() -> Self {
95 let mut scalar = [0u8; X25519_KEY_LEN];
96 rand::rng().fill_bytes(&mut scalar);
97 let secret = StaticSecret::from(scalar);
98 scalar.zeroize();
99 let public = PublicKey::from(&secret);
100 Self { secret, public }
101 }
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
126 /// This keypair's public key, the value a member shares with an admin.
127 pub fn public_key(&self) -> IdentityPublicKey {
128 IdentityPublicKey(self.public.to_bytes())
129 }
130
131 /// Wrap the identity secret under the user's `master_key` for storage
132 /// (synced ciphertext and/or keychain), returning a base64 blob. The server
133 /// only ever sees this ciphertext.
134 pub fn wrap_secret(&self, master_key: &[u8; X25519_KEY_LEN]) -> Result<String> {
135 let mut scalar = self.secret.to_bytes();
136 let wrapped = crypto::encrypt_data(&scalar, master_key);
137 scalar.zeroize();
138 wrapped
139 }
140
141 /// Recover an identity keypair from a [`wrap_secret`](Self::wrap_secret) blob
142 /// using the user's `master_key`. Wrong key or corrupt blob fails closed.
143 pub fn unwrap_secret(wrapped: &str, master_key: &[u8; X25519_KEY_LEN]) -> Result<Self> {
144 let mut scalar_vec = crypto::decrypt_data(wrapped, master_key)?;
145 if scalar_vec.len() != X25519_KEY_LEN {
146 scalar_vec.zeroize();
147 return Err(SyncKitError::InvalidEnvelope(
148 "identity secret has wrong length".into(),
149 ));
150 }
151 let mut scalar = [0u8; X25519_KEY_LEN];
152 scalar.copy_from_slice(&scalar_vec);
153 scalar_vec.zeroize();
154 let secret = StaticSecret::from(scalar);
155 scalar.zeroize();
156 let public = PublicKey::from(&secret);
157 Ok(Self { secret, public })
158 }
159
160 /// The raw public-key bytes, for KDF binding on the open path.
161 fn public_bytes(&self) -> [u8; X25519_KEY_LEN] {
162 self.public.to_bytes()
163 }
164 }
165
166 /// A user's X25519 public key: the non-secret value shared with a group admin so
167 /// the GCK can be sealed to it.
168 #[derive(Clone, Debug, PartialEq, Eq)]
169 pub struct IdentityPublicKey([u8; X25519_KEY_LEN]);
170
171 impl IdentityPublicKey {
172 /// The raw 32 key bytes.
173 pub fn as_bytes(&self) -> &[u8; X25519_KEY_LEN] {
174 &self.0
175 }
176
177 /// Base64 encoding, for out-of-band sharing (paste into an admin's UI).
178 pub fn to_base64(&self) -> String {
179 B64.encode(self.0)
180 }
181
182 /// Parse a base64-encoded public key. Rejects anything but exactly 32 bytes.
183 pub fn from_base64(encoded: &str) -> Result<Self> {
184 let bytes = B64.decode(encoded)?;
185 Self::from_bytes(&bytes)
186 }
187
188 fn from_bytes(bytes: &[u8]) -> Result<Self> {
189 if bytes.len() != X25519_KEY_LEN {
190 return Err(SyncKitError::InvalidArgument(format!(
191 "identity public key must be {X25519_KEY_LEN} bytes, got {}",
192 bytes.len()
193 )));
194 }
195 let mut key = [0u8; X25519_KEY_LEN];
196 key.copy_from_slice(bytes);
197 Ok(Self(key))
198 }
199 }
200
201 /// Associated data binding a grant to its `(group_id, gck_version)`, so a valid
202 /// grant cannot be replayed against a different group or a different key
203 /// generation. Injective as long as `group_id` contains no `0x1f`, which we
204 /// reject (matching [`crypto`]'s entry-AAD discipline).
205 fn grant_aad(group_id: &str, gck_version: i32) -> Result<Vec<u8>> {
206 if group_id.as_bytes().contains(&0x1f) {
207 return Err(SyncKitError::Crypto(
208 "group_id contains the 0x1f AAD separator".into(),
209 ));
210 }
211 let mut aad = Vec::with_capacity(group_id.len() + 1 + 4);
212 aad.extend_from_slice(group_id.as_bytes());
213 aad.push(0x1f);
214 aad.extend_from_slice(&gck_version.to_le_bytes());
215 Ok(aad)
216 }
217
218 /// Derive the grant's AEAD key from the ECDH shared secret, binding both public
219 /// keys so the key is unique to this (ephemeral, recipient) pair. SHA-256 is the
220 /// KDF: X25519 output must be hashed, never used as a key directly.
221 fn derive_grant_key(
222 shared_secret: &[u8; X25519_KEY_LEN],
223 ephemeral_public: &[u8; X25519_KEY_LEN],
224 recipient_public: &[u8; X25519_KEY_LEN],
225 ) -> ZeroizeOnDrop {
226 let mut hasher = Sha256::new();
227 hasher.update(GRANT_KDF_DOMAIN);
228 hasher.update(ephemeral_public);
229 hasher.update(recipient_public);
230 hasher.update(shared_secret);
231 let digest = hasher.finalize();
232 let mut key = [0u8; X25519_KEY_LEN];
233 key.copy_from_slice(&digest);
234 ZeroizeOnDrop(key)
235 }
236
237 /// Seal a Group Content Key to a member's public key, producing an opaque
238 /// base64 grant the admin uploads to the server.
239 ///
240 /// `group_id` and `gck_version` are bound as associated data (not secret; they
241 /// scope the grant). One fresh ephemeral key is used per call.
242 pub fn seal_gck_to_member(
243 gck: &[u8; X25519_KEY_LEN],
244 member_public: &IdentityPublicKey,
245 group_id: &str,
246 gck_version: i32,
247 ) -> Result<String> {
248 let aad = grant_aad(group_id, gck_version)?;
249
250 let mut ephemeral_scalar = [0u8; X25519_KEY_LEN];
251 rand::rng().fill_bytes(&mut ephemeral_scalar);
252 let ephemeral_secret = StaticSecret::from(ephemeral_scalar);
253 ephemeral_scalar.zeroize();
254 let ephemeral_public = PublicKey::from(&ephemeral_secret);
255
256 let recipient_public = PublicKey::from(*member_public.as_bytes());
257 let shared = ephemeral_secret.diffie_hellman(&recipient_public);
258 let key = derive_grant_key(
259 shared.as_bytes(),
260 ephemeral_public.as_bytes(),
261 member_public.as_bytes(),
262 );
263
264 let sealed = crypto::seal(gck, &key.0, &aad)?;
265
266 let mut grant = Vec::with_capacity(X25519_KEY_LEN + sealed.len());
267 grant.extend_from_slice(ephemeral_public.as_bytes());
268 grant.extend_from_slice(&sealed);
269 Ok(B64.encode(grant))
270 }
271
272 /// Open a grant sealed to this keypair, recovering the Group Content Key.
273 ///
274 /// `group_id` and `gck_version` must match what the grant was sealed under (they
275 /// are authenticated as associated data). A grant sealed to a different member, a
276 /// tampered grant, or a mismatched group/version all fail closed.
277 pub fn open_gck_grant(
278 grant: &str,
279 recipient: &IdentityKeypair,
280 group_id: &str,
281 gck_version: i32,
282 ) -> Result<[u8; X25519_KEY_LEN]> {
283 let aad = grant_aad(group_id, gck_version)?;
284
285 let raw = B64.decode(grant)?;
286 if raw.len() < X25519_KEY_LEN + ENCRYPTION_OVERHEAD {
287 return Err(SyncKitError::Crypto("grant too short".into()));
288 }
289 let (ephemeral_bytes, sealed) = raw.split_at(X25519_KEY_LEN);
290 let mut ephemeral_public = [0u8; X25519_KEY_LEN];
291 ephemeral_public.copy_from_slice(ephemeral_bytes);
292
293 let shared = recipient
294 .secret
295 .diffie_hellman(&PublicKey::from(ephemeral_public));
296 let recipient_public = recipient.public_bytes();
297 let key = derive_grant_key(shared.as_bytes(), &ephemeral_public, &recipient_public);
298
299 let mut plaintext = crypto::open(sealed, &key.0, &aad)?;
300 if plaintext.len() != X25519_KEY_LEN {
301 plaintext.zeroize();
302 return Err(SyncKitError::InvalidEnvelope(
303 "grant plaintext has wrong length".into(),
304 ));
305 }
306 let mut gck = [0u8; X25519_KEY_LEN];
307 gck.copy_from_slice(&plaintext);
308 plaintext.zeroize();
309 Ok(gck)
310 }
311
312 #[cfg(test)]
313 mod tests {
314 use super::*;
315
316 #[test]
317 fn generate_produces_distinct_keypairs() {
318 let a = IdentityKeypair::generate();
319 let b = IdentityKeypair::generate();
320 assert_ne!(
321 a.public_key(),
322 b.public_key(),
323 "two generated identities must differ"
324 );
325 }
326
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]
361 fn public_key_base64_roundtrip() {
362 let kp = IdentityKeypair::generate();
363 let pk = kp.public_key();
364 let restored = IdentityPublicKey::from_base64(&pk.to_base64()).unwrap();
365 assert_eq!(pk, restored);
366 }
367
368 #[test]
369 fn public_key_from_base64_rejects_wrong_length() {
370 let short = B64.encode([0u8; 16]);
371 assert!(matches!(
372 IdentityPublicKey::from_base64(&short),
373 Err(SyncKitError::InvalidArgument(_))
374 ));
375 }
376
377 #[test]
378 fn wrap_unwrap_secret_roundtrip_preserves_identity() {
379 let master_key = crypto::generate_master_key();
380 let kp = IdentityKeypair::generate();
381 let wrapped = kp.wrap_secret(&master_key).unwrap();
382
383 let recovered = IdentityKeypair::unwrap_secret(&wrapped, &master_key).unwrap();
384 // Same public key means the same secret scalar round-tripped.
385 assert_eq!(kp.public_key(), recovered.public_key());
386
387 // And the recovered keypair can open a grant sealed to the original.
388 let gck = generate_group_key();
389 let grant = seal_gck_to_member(&gck, &kp.public_key(), "g1", 1).unwrap();
390 assert_eq!(open_gck_grant(&grant, &recovered, "g1", 1).unwrap(), gck);
391 }
392
393 #[test]
394 fn unwrap_secret_with_wrong_master_key_fails() {
395 let master_key = crypto::generate_master_key();
396 let wrong_key = crypto::generate_master_key();
397 let kp = IdentityKeypair::generate();
398 let wrapped = kp.wrap_secret(&master_key).unwrap();
399 assert!(IdentityKeypair::unwrap_secret(&wrapped, &wrong_key).is_err());
400 }
401
402 #[test]
403 fn grant_roundtrip_recovers_gck() {
404 let member = IdentityKeypair::generate();
405 let gck = generate_group_key();
406 let grant = seal_gck_to_member(&gck, &member.public_key(), "group-1", 1).unwrap();
407 let opened = open_gck_grant(&grant, &member, "group-1", 1).unwrap();
408 assert_eq!(opened, gck);
409 }
410
411 #[test]
412 fn add_member_flow_every_member_recovers_same_gck() {
413 // The admin mints one GCK and seals a grant to each member's pubkey.
414 let gck = generate_group_key();
415 let members: Vec<IdentityKeypair> = (0..3).map(|_| IdentityKeypair::generate()).collect();
416
417 for member in &members {
418 let grant = seal_gck_to_member(&gck, &member.public_key(), "team", 1).unwrap();
419 assert_eq!(open_gck_grant(&grant, member, "team", 1).unwrap(), gck);
420 }
421 }
422
423 #[test]
424 fn each_seal_uses_a_fresh_ephemeral_key() {
425 // Two grants of the same GCK to the same member must differ (fresh
426 // ephemeral + fresh nonce each time), yet both open to the GCK.
427 let member = IdentityKeypair::generate();
428 let gck = generate_group_key();
429 let g1 = seal_gck_to_member(&gck, &member.public_key(), "g", 1).unwrap();
430 let g2 = seal_gck_to_member(&gck, &member.public_key(), "g", 1).unwrap();
431 assert_ne!(g1, g2, "grants must not be deterministic");
432 assert_eq!(open_gck_grant(&g1, &member, "g", 1).unwrap(), gck);
433 assert_eq!(open_gck_grant(&g2, &member, "g", 1).unwrap(), gck);
434 }
435
436 #[test]
437 fn grant_for_another_member_cannot_be_opened() {
438 let alice = IdentityKeypair::generate();
439 let mallory = IdentityKeypair::generate();
440 let gck = generate_group_key();
441 let grant = seal_gck_to_member(&gck, &alice.public_key(), "g", 1).unwrap();
442 // Mallory holds a valid identity but was not the sealing target.
443 assert!(matches!(
444 open_gck_grant(&grant, &mallory, "g", 1),
445 Err(SyncKitError::DecryptionFailed)
446 ));
447 }
448
449 #[test]
450 fn grant_rejects_mismatched_group_or_version() {
451 let member = IdentityKeypair::generate();
452 let gck = generate_group_key();
453 let grant = seal_gck_to_member(&gck, &member.public_key(), "group-a", 1).unwrap();
454 // Right key, wrong AAD context: the tag fails to authenticate.
455 assert!(matches!(
456 open_gck_grant(&grant, &member, "group-b", 1),
457 Err(SyncKitError::DecryptionFailed)
458 ));
459 assert!(matches!(
460 open_gck_grant(&grant, &member, "group-a", 2),
461 Err(SyncKitError::DecryptionFailed)
462 ));
463 }
464
465 #[test]
466 fn tampered_grant_fails_closed() {
467 let member = IdentityKeypair::generate();
468 let gck = generate_group_key();
469 let grant = seal_gck_to_member(&gck, &member.public_key(), "g", 1).unwrap();
470
471 let mut raw = B64.decode(&grant).unwrap();
472 // Flip a byte in the ciphertext region (past the 32-byte ephemeral key
473 // and 24-byte nonce).
474 let idx = X25519_KEY_LEN + 24 + 1;
475 raw[idx] ^= 0xFF;
476 let tampered = B64.encode(&raw);
477
478 assert!(matches!(
479 open_gck_grant(&tampered, &member, "g", 1),
480 Err(SyncKitError::DecryptionFailed)
481 ));
482 }
483
484 #[test]
485 fn tampered_ephemeral_key_fails_closed() {
486 let member = IdentityKeypair::generate();
487 let gck = generate_group_key();
488 let grant = seal_gck_to_member(&gck, &member.public_key(), "g", 1).unwrap();
489
490 let mut raw = B64.decode(&grant).unwrap();
491 raw[0] ^= 0xFF; // corrupt the ephemeral public key → wrong derived key
492 let tampered = B64.encode(&raw);
493
494 assert!(open_gck_grant(&tampered, &member, "g", 1).is_err());
495 }
496
497 #[test]
498 fn grant_too_short_is_rejected() {
499 let member = IdentityKeypair::generate();
500 let tiny = B64.encode([0u8; X25519_KEY_LEN + 8]);
501 assert!(matches!(
502 open_gck_grant(&tiny, &member, "g", 1),
503 Err(SyncKitError::Crypto(_))
504 ));
505 }
506
507 #[test]
508 fn group_id_with_separator_byte_is_rejected() {
509 let member = IdentityKeypair::generate();
510 let gck = generate_group_key();
511 let bad_group = "a\u{1f}b";
512 assert!(seal_gck_to_member(&gck, &member.public_key(), bad_group, 1).is_err());
513 }
514
515 #[test]
516 fn rotation_reseals_to_remaining_members_without_interaction() {
517 // Simulate member removal: a new GCK generation is sealed to the
518 // remaining members' stored pubkeys. The removed member's old grant does
519 // not open the new GCK.
520 let keep = IdentityKeypair::generate();
521 let removed = IdentityKeypair::generate();
522
523 let gck_v1 = generate_group_key();
524 let removed_grant_v1 = seal_gck_to_member(&gck_v1, &removed.public_key(), "g", 1).unwrap();
525 assert_eq!(
526 open_gck_grant(&removed_grant_v1, &removed, "g", 1).unwrap(),
527 gck_v1
528 );
529
530 // Admin rotates: new GCK, re-sealed only to `keep`, version bumped.
531 let gck_v2 = generate_group_key();
532 assert_ne!(gck_v1, gck_v2);
533 let keep_grant_v2 = seal_gck_to_member(&gck_v2, &keep.public_key(), "g", 2).unwrap();
534 assert_eq!(
535 open_gck_grant(&keep_grant_v2, &keep, "g", 2).unwrap(),
536 gck_v2
537 );
538
539 // The removed member has no v2 grant; their v1 grant still only yields the
540 // old key, which the rotated group no longer uses.
541 assert_eq!(
542 open_gck_grant(&removed_grant_v1, &removed, "g", 1).unwrap(),
543 gck_v1
544 );
545 }
546 }
547