//! Group identity keys and Group Content Key (GCK) grants. //! //! This is the one asymmetric layer in SyncKit. Everything else in the crate is //! symmetric (a passphrase-derived `master_key` per user). Groups break the //! "one key, one owner" assumption: several users need a shared symmetric key, //! the **Group Content Key**, that the server never sees. Handing that key to a //! member who shares no prior secret with the group admin is, by definition, //! public-key crypto. See the wiki design note. //! //! //! ## The pieces //! //! - [`IdentityKeypair`], a per-user X25519 keypair. The [`IdentityPublicKey`] //! is the value a member shares with an admin (non-secret, safe to transmit in //! the clear). The private half is wrapped under the user's existing //! `master_key` for storage ([`IdentityKeypair::wrap_secret`]), mirroring the //! master-key lifecycle. //! - The **GCK**, a random 32-byte symmetric key ([`generate_group_key`]), //! playing the role `master_key` plays for personal data. Group changelog //! entries are sealed under it. //! - A **grant**, the GCK sealed to one member's public key //! ([`seal_gck_to_member`] / [`open_gck_grant`]). Adding a member is: seal the //! GCK to their pubkey, upload one grant. Removing a member is a GCK rotation //! that re-seals to the *remaining* members' stored pubkeys with no member //! interaction, the payoff of the asymmetric design. //! //! ## Grant construction (ephemeral-static ECDH, an ECIES/sealed-box shape) //! //! For each grant the sender mints a throwaway (ephemeral) X25519 keypair, does //! ECDH against the member's public key, derives a symmetric key from the shared //! secret via SHA-256 (X25519 output must never be used as a key directly), and //! seals the GCK with the crate's XChaCha20-Poly1305. The wire form is //! `ephemeral_public[32] || nonce[24] || ciphertext || tag[16]`, base64-encoded. //! The ephemeral secret is discarded after one seal, so a later compromise of the //! sender's long-term key cannot recover past grants. //! //! The KDF binds both public keys (`ephemeral` and `recipient`) so a grant cannot //! be re-pointed at a different recipient, and the AEAD's associated data binds //! `(group_id, gck_version)` so a grant cannot be replayed as a different group or //! key generation. use base64::{Engine, engine::general_purpose::STANDARD as B64}; use rand::Rng; use sha2::{Digest, Sha256}; use x25519_dalek::{PublicKey, StaticSecret}; use zeroize::Zeroize; use crate::crypto::{self, ENCRYPTION_OVERHEAD, ZeroizeOnDrop}; use crate::error::{Result, SyncKitError}; /// Size of an X25519 key (public or secret scalar) in bytes. const X25519_KEY_LEN: usize = 32; /// Domain-separation label mixed into the grant KDF, so the derived key is /// specific to this construction and version. const GRANT_KDF_DOMAIN: &[u8] = b"synckit-group-grant-v1"; /// Domain-separation label for deriving the identity keypair from a master key. const IDENTITY_KDF_DOMAIN: &[u8] = b"synckit-identity-v1"; /// Mint a fresh Group Content Key (GCK): 32 random bytes. /// /// A thin, intention-revealing alias of [`crypto::generate_master_key`], a GCK /// is structurally the same 256-bit symmetric key as a personal `master_key`, /// just shared across a group instead of owned by one user. pub fn generate_group_key() -> [u8; X25519_KEY_LEN] { crypto::generate_master_key() } /// A user's long-lived X25519 identity keypair. /// /// The public key is shared with group admins; the secret opens grants sealed to /// it. The secret zeroizes on drop (via `x25519-dalek`'s `zeroize` feature). pub struct IdentityKeypair { secret: StaticSecret, public: PublicKey, } impl std::fmt::Debug for IdentityKeypair { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { // Never render the secret. The public key is safe to show. f.debug_struct("IdentityKeypair") .field("public", &self.public_key().to_base64()) .field("secret", &"[REDACTED]") .finish() } } impl IdentityKeypair { /// Generate a new random identity keypair. /// /// Seeds the scalar from the crate's existing `rand` (not `x25519-dalek`'s /// own RNG), so the whole crate keeps a single randomness source. pub fn generate() -> Self { let mut scalar = [0u8; X25519_KEY_LEN]; rand::rng().fill_bytes(&mut scalar); let secret = StaticSecret::from(scalar); scalar.zeroize(); let public = PublicKey::from(&secret); Self { secret, public } } /// Derive a user's identity keypair *deterministically* from their /// `master_key`, so every device that holds the master key derives the same /// keypair with no storage, syncing, or server round-trip. The X25519 secret /// scalar is `SHA-256(domain || master_key)`, a single hash is a sound KDF /// for one output from a uniformly random 256-bit key. /// /// Consequence: a personal key rotation changes the master key and therefore /// this identity, so a member's group grants must be re-issued (re-sealed to /// the new public key) after a rotation. See the wiki design note /// `synckit-multiscope-design`. pub fn from_master_key(master_key: &[u8; X25519_KEY_LEN]) -> Self { let mut hasher = Sha256::new(); hasher.update(IDENTITY_KDF_DOMAIN); hasher.update(master_key); let digest = hasher.finalize(); let mut scalar = [0u8; X25519_KEY_LEN]; scalar.copy_from_slice(&digest); let secret = StaticSecret::from(scalar); scalar.zeroize(); let public = PublicKey::from(&secret); Self { secret, public } } /// This keypair's public key, the value a member shares with an admin. pub fn public_key(&self) -> IdentityPublicKey { IdentityPublicKey(self.public.to_bytes()) } /// Wrap the identity secret under the user's `master_key` for storage /// (synced ciphertext and/or keychain), returning a base64 blob. The server /// only ever sees this ciphertext. pub fn wrap_secret(&self, master_key: &[u8; X25519_KEY_LEN]) -> Result { let mut scalar = self.secret.to_bytes(); let wrapped = crypto::encrypt_data(&scalar, master_key); scalar.zeroize(); wrapped } /// Recover an identity keypair from a [`wrap_secret`](Self::wrap_secret) blob /// using the user's `master_key`. Wrong key or corrupt blob fails closed. pub fn unwrap_secret(wrapped: &str, master_key: &[u8; X25519_KEY_LEN]) -> Result { let mut scalar_vec = crypto::decrypt_data(wrapped, master_key)?; if scalar_vec.len() != X25519_KEY_LEN { scalar_vec.zeroize(); return Err(SyncKitError::InvalidEnvelope( "identity secret has wrong length".into(), )); } let mut scalar = [0u8; X25519_KEY_LEN]; scalar.copy_from_slice(&scalar_vec); scalar_vec.zeroize(); let secret = StaticSecret::from(scalar); scalar.zeroize(); let public = PublicKey::from(&secret); Ok(Self { secret, public }) } /// The raw public-key bytes, for KDF binding on the open path. fn public_bytes(&self) -> [u8; X25519_KEY_LEN] { self.public.to_bytes() } } /// A user's X25519 public key: the non-secret value shared with a group admin so /// the GCK can be sealed to it. #[derive(Clone, Debug, PartialEq, Eq)] pub struct IdentityPublicKey([u8; X25519_KEY_LEN]); impl IdentityPublicKey { /// The raw 32 key bytes. pub fn as_bytes(&self) -> &[u8; X25519_KEY_LEN] { &self.0 } /// Base64 encoding, for out-of-band sharing (paste into an admin's UI). pub fn to_base64(&self) -> String { B64.encode(self.0) } /// Parse a base64-encoded public key. Rejects anything but exactly 32 bytes. pub fn from_base64(encoded: &str) -> Result { let bytes = B64.decode(encoded)?; Self::from_bytes(&bytes) } fn from_bytes(bytes: &[u8]) -> Result { if bytes.len() != X25519_KEY_LEN { return Err(SyncKitError::InvalidArgument(format!( "identity public key must be {X25519_KEY_LEN} bytes, got {}", bytes.len() ))); } let mut key = [0u8; X25519_KEY_LEN]; key.copy_from_slice(bytes); Ok(Self(key)) } } /// Associated data binding a grant to its `(group_id, gck_version)`, so a valid /// grant cannot be replayed against a different group or a different key /// generation. Injective as long as `group_id` contains no `0x1f`, which we /// reject (matching [`crypto`]'s entry-AAD discipline). fn grant_aad(group_id: &str, gck_version: i32) -> Result> { if group_id.as_bytes().contains(&0x1f) { return Err(SyncKitError::Crypto( "group_id contains the 0x1f AAD separator".into(), )); } let mut aad = Vec::with_capacity(group_id.len() + 1 + 4); aad.extend_from_slice(group_id.as_bytes()); aad.push(0x1f); aad.extend_from_slice(&gck_version.to_le_bytes()); Ok(aad) } /// Derive the grant's AEAD key from the ECDH shared secret, binding both public /// keys so the key is unique to this (ephemeral, recipient) pair. SHA-256 is the /// KDF: X25519 output must be hashed, never used as a key directly. fn derive_grant_key( shared_secret: &[u8; X25519_KEY_LEN], ephemeral_public: &[u8; X25519_KEY_LEN], recipient_public: &[u8; X25519_KEY_LEN], ) -> ZeroizeOnDrop { let mut hasher = Sha256::new(); hasher.update(GRANT_KDF_DOMAIN); hasher.update(ephemeral_public); hasher.update(recipient_public); hasher.update(shared_secret); let digest = hasher.finalize(); let mut key = [0u8; X25519_KEY_LEN]; key.copy_from_slice(&digest); ZeroizeOnDrop(key) } /// Seal a Group Content Key to a member's public key, producing an opaque /// base64 grant the admin uploads to the server. /// /// `group_id` and `gck_version` are bound as associated data (not secret; they /// scope the grant). One fresh ephemeral key is used per call. pub fn seal_gck_to_member( gck: &[u8; X25519_KEY_LEN], member_public: &IdentityPublicKey, group_id: &str, gck_version: i32, ) -> Result { let aad = grant_aad(group_id, gck_version)?; let mut ephemeral_scalar = [0u8; X25519_KEY_LEN]; rand::rng().fill_bytes(&mut ephemeral_scalar); let ephemeral_secret = StaticSecret::from(ephemeral_scalar); ephemeral_scalar.zeroize(); let ephemeral_public = PublicKey::from(&ephemeral_secret); let recipient_public = PublicKey::from(*member_public.as_bytes()); let shared = ephemeral_secret.diffie_hellman(&recipient_public); let key = derive_grant_key( shared.as_bytes(), ephemeral_public.as_bytes(), member_public.as_bytes(), ); let sealed = crypto::seal(gck, &key.0, &aad)?; let mut grant = Vec::with_capacity(X25519_KEY_LEN + sealed.len()); grant.extend_from_slice(ephemeral_public.as_bytes()); grant.extend_from_slice(&sealed); Ok(B64.encode(grant)) } /// Open a grant sealed to this keypair, recovering the Group Content Key. /// /// `group_id` and `gck_version` must match what the grant was sealed under (they /// are authenticated as associated data). A grant sealed to a different member, a /// tampered grant, or a mismatched group/version all fail closed. pub fn open_gck_grant( grant: &str, recipient: &IdentityKeypair, group_id: &str, gck_version: i32, ) -> Result<[u8; X25519_KEY_LEN]> { let aad = grant_aad(group_id, gck_version)?; let raw = B64.decode(grant)?; if raw.len() < X25519_KEY_LEN + ENCRYPTION_OVERHEAD { return Err(SyncKitError::Crypto("grant too short".into())); } let (ephemeral_bytes, sealed) = raw.split_at(X25519_KEY_LEN); let mut ephemeral_public = [0u8; X25519_KEY_LEN]; ephemeral_public.copy_from_slice(ephemeral_bytes); let shared = recipient .secret .diffie_hellman(&PublicKey::from(ephemeral_public)); let recipient_public = recipient.public_bytes(); let key = derive_grant_key(shared.as_bytes(), &ephemeral_public, &recipient_public); let mut plaintext = crypto::open(sealed, &key.0, &aad)?; if plaintext.len() != X25519_KEY_LEN { plaintext.zeroize(); return Err(SyncKitError::InvalidEnvelope( "grant plaintext has wrong length".into(), )); } let mut gck = [0u8; X25519_KEY_LEN]; gck.copy_from_slice(&plaintext); plaintext.zeroize(); Ok(gck) } #[cfg(test)] mod tests { use super::*; #[test] fn generate_produces_distinct_keypairs() { let a = IdentityKeypair::generate(); let b = IdentityKeypair::generate(); assert_ne!( a.public_key(), b.public_key(), "two generated identities must differ" ); } #[test] fn from_master_key_is_deterministic_and_key_specific() { let mk = crypto::generate_master_key(); // Same master key -> same identity on every device. assert_eq!( IdentityKeypair::from_master_key(&mk).public_key(), IdentityKeypair::from_master_key(&mk).public_key(), ); // A different master key -> a different identity. let other = crypto::generate_master_key(); assert_ne!( IdentityKeypair::from_master_key(&mk).public_key(), IdentityKeypair::from_master_key(&other).public_key(), ); } #[test] fn derived_identity_opens_a_grant_sealed_to_it() { // The end-to-end shape: an admin seals the GCK to a member's derived // public key; the member's device re-derives the same keypair from the // master key and opens the grant. let mk = crypto::generate_master_key(); let member = IdentityKeypair::from_master_key(&mk); let gck = generate_group_key(); let grant = seal_gck_to_member(&gck, &member.public_key(), "team", 1).unwrap(); let member_again = IdentityKeypair::from_master_key(&mk); assert_eq!( open_gck_grant(&grant, &member_again, "team", 1).unwrap(), gck ); } #[test] fn public_key_base64_roundtrip() { let kp = IdentityKeypair::generate(); let pk = kp.public_key(); let restored = IdentityPublicKey::from_base64(&pk.to_base64()).unwrap(); assert_eq!(pk, restored); } #[test] fn public_key_from_base64_rejects_wrong_length() { let short = B64.encode([0u8; 16]); assert!(matches!( IdentityPublicKey::from_base64(&short), Err(SyncKitError::InvalidArgument(_)) )); } #[test] fn wrap_unwrap_secret_roundtrip_preserves_identity() { let master_key = crypto::generate_master_key(); let kp = IdentityKeypair::generate(); let wrapped = kp.wrap_secret(&master_key).unwrap(); let recovered = IdentityKeypair::unwrap_secret(&wrapped, &master_key).unwrap(); // Same public key means the same secret scalar round-tripped. assert_eq!(kp.public_key(), recovered.public_key()); // And the recovered keypair can open a grant sealed to the original. let gck = generate_group_key(); let grant = seal_gck_to_member(&gck, &kp.public_key(), "g1", 1).unwrap(); assert_eq!(open_gck_grant(&grant, &recovered, "g1", 1).unwrap(), gck); } #[test] fn unwrap_secret_with_wrong_master_key_fails() { let master_key = crypto::generate_master_key(); let wrong_key = crypto::generate_master_key(); let kp = IdentityKeypair::generate(); let wrapped = kp.wrap_secret(&master_key).unwrap(); assert!(IdentityKeypair::unwrap_secret(&wrapped, &wrong_key).is_err()); } #[test] fn grant_roundtrip_recovers_gck() { let member = IdentityKeypair::generate(); let gck = generate_group_key(); let grant = seal_gck_to_member(&gck, &member.public_key(), "group-1", 1).unwrap(); let opened = open_gck_grant(&grant, &member, "group-1", 1).unwrap(); assert_eq!(opened, gck); } #[test] fn add_member_flow_every_member_recovers_same_gck() { // The admin mints one GCK and seals a grant to each member's pubkey. let gck = generate_group_key(); let members: Vec = (0..3).map(|_| IdentityKeypair::generate()).collect(); for member in &members { let grant = seal_gck_to_member(&gck, &member.public_key(), "team", 1).unwrap(); assert_eq!(open_gck_grant(&grant, member, "team", 1).unwrap(), gck); } } #[test] fn each_seal_uses_a_fresh_ephemeral_key() { // Two grants of the same GCK to the same member must differ (fresh // ephemeral + fresh nonce each time), yet both open to the GCK. let member = IdentityKeypair::generate(); let gck = generate_group_key(); let g1 = seal_gck_to_member(&gck, &member.public_key(), "g", 1).unwrap(); let g2 = seal_gck_to_member(&gck, &member.public_key(), "g", 1).unwrap(); assert_ne!(g1, g2, "grants must not be deterministic"); assert_eq!(open_gck_grant(&g1, &member, "g", 1).unwrap(), gck); assert_eq!(open_gck_grant(&g2, &member, "g", 1).unwrap(), gck); } #[test] fn grant_for_another_member_cannot_be_opened() { let alice = IdentityKeypair::generate(); let mallory = IdentityKeypair::generate(); let gck = generate_group_key(); let grant = seal_gck_to_member(&gck, &alice.public_key(), "g", 1).unwrap(); // Mallory holds a valid identity but was not the sealing target. assert!(matches!( open_gck_grant(&grant, &mallory, "g", 1), Err(SyncKitError::DecryptionFailed) )); } #[test] fn grant_rejects_mismatched_group_or_version() { let member = IdentityKeypair::generate(); let gck = generate_group_key(); let grant = seal_gck_to_member(&gck, &member.public_key(), "group-a", 1).unwrap(); // Right key, wrong AAD context: the tag fails to authenticate. assert!(matches!( open_gck_grant(&grant, &member, "group-b", 1), Err(SyncKitError::DecryptionFailed) )); assert!(matches!( open_gck_grant(&grant, &member, "group-a", 2), Err(SyncKitError::DecryptionFailed) )); } #[test] fn tampered_grant_fails_closed() { let member = IdentityKeypair::generate(); let gck = generate_group_key(); let grant = seal_gck_to_member(&gck, &member.public_key(), "g", 1).unwrap(); let mut raw = B64.decode(&grant).unwrap(); // Flip a byte in the ciphertext region (past the 32-byte ephemeral key // and 24-byte nonce). let idx = X25519_KEY_LEN + 24 + 1; raw[idx] ^= 0xFF; let tampered = B64.encode(&raw); assert!(matches!( open_gck_grant(&tampered, &member, "g", 1), Err(SyncKitError::DecryptionFailed) )); } #[test] fn tampered_ephemeral_key_fails_closed() { let member = IdentityKeypair::generate(); let gck = generate_group_key(); let grant = seal_gck_to_member(&gck, &member.public_key(), "g", 1).unwrap(); let mut raw = B64.decode(&grant).unwrap(); raw[0] ^= 0xFF; // corrupt the ephemeral public key → wrong derived key let tampered = B64.encode(&raw); assert!(open_gck_grant(&tampered, &member, "g", 1).is_err()); } #[test] fn grant_too_short_is_rejected() { let member = IdentityKeypair::generate(); let tiny = B64.encode([0u8; X25519_KEY_LEN + 8]); assert!(matches!( open_gck_grant(&tiny, &member, "g", 1), Err(SyncKitError::Crypto(_)) )); } #[test] fn group_id_with_separator_byte_is_rejected() { let member = IdentityKeypair::generate(); let gck = generate_group_key(); let bad_group = "a\u{1f}b"; assert!(seal_gck_to_member(&gck, &member.public_key(), bad_group, 1).is_err()); } #[test] fn rotation_reseals_to_remaining_members_without_interaction() { // Simulate member removal: a new GCK generation is sealed to the // remaining members' stored pubkeys. The removed member's old grant does // not open the new GCK. let keep = IdentityKeypair::generate(); let removed = IdentityKeypair::generate(); let gck_v1 = generate_group_key(); let removed_grant_v1 = seal_gck_to_member(&gck_v1, &removed.public_key(), "g", 1).unwrap(); assert_eq!( open_gck_grant(&removed_grant_v1, &removed, "g", 1).unwrap(), gck_v1 ); // Admin rotates: new GCK, re-sealed only to `keep`, version bumped. let gck_v2 = generate_group_key(); assert_ne!(gck_v1, gck_v2); let keep_grant_v2 = seal_gck_to_member(&gck_v2, &keep.public_key(), "g", 2).unwrap(); assert_eq!( open_gck_grant(&keep_grant_v2, &keep, "g", 2).unwrap(), gck_v2 ); // The removed member has no v2 grant; their v1 grant still only yields the // old key, which the rotated group no longer uses. assert_eq!( open_gck_grant(&removed_grant_v1, &removed, "g", 1).unwrap(), gck_v1 ); } }