Skip to main content

tsafe_core/
team.rs

1//! Team vault — age-encrypted shared secret store for multi-user environments.
2//!
3//! Team vaults use X25519 age encryption so each team member's public key can
4//! decrypt the shared vault independently.  Membership and key rotation are
5//! tracked in the vault's metadata.
6
7use std::collections::HashMap;
8
9use chrono::Utc;
10
11use crate::age_crypto;
12use crate::crypto::{self, KeyPurpose, KeySchedule, VaultKey};
13use crate::errors::{SafeError, SafeResult};
14use crate::rbac::RbacProfile;
15use crate::vault::{KdfParams, SecretEntry, VaultChallenge, VaultFile, VAULT_CHALLENGE_PLAINTEXT};
16
17const TEAM_SCHEMA: &str = "tsafe/vault/v2";
18const TEAM_KEY_SCHEDULE: KeySchedule = KeySchedule::HkdfSha256V1;
19
20/// Create a new team vault file encrypted to the given age recipient public keys.
21/// Returns the VaultFile and the randomly generated DEK (for the caller to use).
22pub fn create_team_vault(recipients: &[String]) -> SafeResult<(VaultFile, VaultKey)> {
23    create_team_vault_with_access_profile(recipients, RbacProfile::ReadWrite)
24}
25
26/// Create a new team vault file with an explicit access profile.
27pub fn create_team_vault_with_access_profile(
28    recipients: &[String],
29    access_profile: RbacProfile,
30) -> SafeResult<(VaultFile, VaultKey)> {
31    access_profile.ensure_write_allowed()?;
32    if recipients.is_empty() {
33        return Err(SafeError::Crypto {
34            context: "at least one recipient is required".into(),
35        });
36    }
37    let parsed = age_crypto::parse_recipients(recipients)?;
38
39    // Generate a random 256-bit data encryption key (DEK).
40    let dek_bytes = crypto::random_salt(); // 32 bytes
41    let dek = VaultKey::from_bytes(dek_bytes);
42    let cipher = crypto::default_vault_cipher();
43
44    // Wrap DEK for all recipients using age.
45    let wrapped = age_crypto::encrypt_to_recipients(&parsed, dek.as_bytes())?;
46
47    // Create vault challenge encrypted with a purpose-scoped subkey.
48    let (ch_nonce, ch_ct) = crypto::encrypt_with_key_schedule(
49        &dek,
50        TEAM_KEY_SCHEDULE,
51        KeyPurpose::VaultChallenge,
52        cipher,
53        VAULT_CHALLENGE_PLAINTEXT,
54    )?;
55
56    let now = Utc::now();
57    let file = VaultFile {
58        schema: TEAM_SCHEMA.to_string(),
59        kdf: KdfParams {
60            algorithm: "age".to_string(),
61            m_cost: 0,
62            t_cost: 0,
63            p_cost: 0,
64            salt: String::new(),
65        },
66        cipher: cipher.as_str().to_string(),
67        vault_challenge: VaultChallenge {
68            nonce: crypto::encode_b64(&ch_nonce),
69            ciphertext: crypto::encode_b64(&ch_ct),
70        },
71        created_at: now,
72        updated_at: now,
73        secrets: HashMap::new(),
74        age_recipients: recipients.to_vec(),
75        wrapped_dek: Some(crypto::encode_b64(&wrapped)),
76    };
77
78    Ok((file, dek))
79}
80
81/// Open a team vault by unwrapping the DEK with an age identity.
82pub fn unwrap_dek(file: &VaultFile, identities: &[Box<dyn age::Identity>]) -> SafeResult<VaultKey> {
83    let wrapped_b64 = file.wrapped_dek.as_ref().ok_or_else(|| SafeError::Crypto {
84        context: "not a team/age vault — no wrapped_dek".into(),
85    })?;
86    let wrapped = crypto::decode_b64(wrapped_b64)?;
87    let dek_bytes = age_crypto::decrypt_with_identities(identities, &wrapped)?;
88    if dek_bytes.len() != 32 {
89        return Err(SafeError::Crypto {
90            context: format!("DEK has wrong length: expected 32, got {}", dek_bytes.len()),
91        });
92    }
93    let mut arr = [0u8; 32];
94    arr.copy_from_slice(&dek_bytes);
95    Ok(VaultKey::from_bytes(arr))
96}
97
98/// Add a recipient to a team vault. Re-wraps the DEK for all recipients (including the new one).
99pub fn add_member(
100    file: &mut VaultFile,
101    new_recipient: &str,
102    identities: &[Box<dyn age::Identity>],
103) -> SafeResult<()> {
104    add_member_with_access_profile(file, new_recipient, identities, RbacProfile::ReadWrite)
105}
106
107/// Add a recipient to a team vault under an explicit access profile.
108pub fn add_member_with_access_profile(
109    file: &mut VaultFile,
110    new_recipient: &str,
111    identities: &[Box<dyn age::Identity>],
112    access_profile: RbacProfile,
113) -> SafeResult<()> {
114    access_profile.ensure_write_allowed()?;
115    let _dek = unwrap_dek(file, identities)?;
116
117    if file.age_recipients.contains(&new_recipient.to_string()) {
118        return Err(SafeError::Crypto {
119            context: format!("recipient already exists: {new_recipient}"),
120        });
121    }
122    file.age_recipients.push(new_recipient.to_string());
123
124    // Re-wrap DEK for all recipients.
125    let parsed = age_crypto::parse_recipients(&file.age_recipients)?;
126    let wrapped = age_crypto::encrypt_to_recipients(&parsed, _dek.as_bytes())?;
127    file.wrapped_dek = Some(crypto::encode_b64(&wrapped));
128    file.updated_at = Utc::now();
129
130    Ok(())
131}
132
133/// Remove a recipient and re-key the vault (generate new DEK, re-encrypt all secrets).
134/// The removed member could have cached the old DEK, so all secrets must be re-encrypted.
135pub fn remove_member(
136    file: &mut VaultFile,
137    remove_recipient: &str,
138    identities: &[Box<dyn age::Identity>],
139) -> SafeResult<()> {
140    remove_member_with_access_profile(file, remove_recipient, identities, RbacProfile::ReadWrite)
141}
142
143/// Remove a recipient and re-key the vault under an explicit access profile.
144pub fn remove_member_with_access_profile(
145    file: &mut VaultFile,
146    remove_recipient: &str,
147    identities: &[Box<dyn age::Identity>],
148    access_profile: RbacProfile,
149) -> SafeResult<()> {
150    access_profile.ensure_write_allowed()?;
151    let old_dek = unwrap_dek(file, identities)?;
152    let old_cipher = crypto::parse_cipher_kind(&file.cipher)?;
153    let challenge_nonce = crypto::decode_b64(&file.vault_challenge.nonce)?;
154    let challenge_ct = crypto::decode_b64(&file.vault_challenge.ciphertext)?;
155    let old_schedule = crypto::detect_key_schedule(
156        &old_dek,
157        KeyPurpose::VaultChallenge,
158        old_cipher,
159        &challenge_nonce,
160        &challenge_ct,
161        VAULT_CHALLENGE_PLAINTEXT,
162    )?;
163
164    file.age_recipients.retain(|r| r != remove_recipient);
165    if file.age_recipients.is_empty() {
166        return Err(SafeError::Crypto {
167            context: "cannot remove the last recipient".into(),
168        });
169    }
170
171    // Generate new DEK.
172    let new_dek_bytes = crypto::random_salt();
173    let new_dek = VaultKey::from_bytes(new_dek_bytes);
174    let new_cipher = crypto::default_vault_cipher();
175
176    // Re-encrypt all secrets under the new DEK.
177    let mut new_secrets = HashMap::with_capacity(file.secrets.len());
178    for (key, entry) in &file.secrets {
179        // Decrypt with old DEK.
180        let nonce = crypto::decode_b64(&entry.nonce)?;
181        let ct = crypto::decode_b64(&entry.ciphertext)?;
182        let pt = crypto::decrypt_with_key_schedule(
183            &old_dek,
184            old_schedule,
185            KeyPurpose::SecretData,
186            old_cipher,
187            &nonce,
188            &ct,
189        )?;
190
191        // Re-encrypt with the current HKDF-scoped schedule.
192        let (new_nonce, new_ct) = crypto::encrypt_with_key_schedule(
193            &new_dek,
194            TEAM_KEY_SCHEDULE,
195            KeyPurpose::SecretData,
196            new_cipher,
197            &pt,
198        )?;
199
200        // Re-encrypt history entries too.
201        let mut new_history = Vec::new();
202        for h in &entry.history {
203            let hn = crypto::decode_b64(&h.nonce)?;
204            let hct = crypto::decode_b64(&h.ciphertext)?;
205            let hpt = crypto::decrypt_with_key_schedule(
206                &old_dek,
207                old_schedule,
208                KeyPurpose::SecretData,
209                old_cipher,
210                &hn,
211                &hct,
212            )?;
213            let (nhn, nhct) = crypto::encrypt_with_key_schedule(
214                &new_dek,
215                TEAM_KEY_SCHEDULE,
216                KeyPurpose::SecretData,
217                new_cipher,
218                &hpt,
219            )?;
220            new_history.push(crate::vault::HistoryEntry {
221                nonce: crypto::encode_b64(&nhn),
222                ciphertext: crypto::encode_b64(&nhct),
223                updated_at: h.updated_at,
224            });
225        }
226
227        new_secrets.insert(
228            key.clone(),
229            SecretEntry {
230                nonce: crypto::encode_b64(&new_nonce),
231                ciphertext: crypto::encode_b64(&new_ct),
232                created_at: entry.created_at,
233                updated_at: entry.updated_at,
234                tags: entry.tags.clone(),
235                history: new_history,
236            },
237        );
238    }
239    file.secrets = new_secrets;
240
241    // Re-encrypt vault challenge with the HKDF-scoped schedule.
242    let (ch_nonce, ch_ct) = crypto::encrypt_with_key_schedule(
243        &new_dek,
244        TEAM_KEY_SCHEDULE,
245        KeyPurpose::VaultChallenge,
246        new_cipher,
247        VAULT_CHALLENGE_PLAINTEXT,
248    )?;
249    file.vault_challenge = VaultChallenge {
250        nonce: crypto::encode_b64(&ch_nonce),
251        ciphertext: crypto::encode_b64(&ch_ct),
252    };
253    file.cipher = new_cipher.as_str().to_string();
254
255    // Wrap new DEK for remaining recipients.
256    let parsed = age_crypto::parse_recipients(&file.age_recipients)?;
257    let wrapped = age_crypto::encrypt_to_recipients(&parsed, new_dek.as_bytes())?;
258    file.wrapped_dek = Some(crypto::encode_b64(&wrapped));
259    file.updated_at = Utc::now();
260
261    Ok(())
262}
263
264/// List the current team members (recipient public keys).
265pub fn members(file: &VaultFile) -> &[String] {
266    &file.age_recipients
267}
268
269/// Check if a vault is a team vault (has age recipients).
270pub fn is_team_vault(file: &VaultFile) -> bool {
271    !file.age_recipients.is_empty() && file.wrapped_dek.is_some()
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use crate::age_crypto;
278    use crate::crypto::CipherKind;
279    use crate::vault::HistoryEntry;
280
281    fn identities_from(secret: &str) -> Vec<Box<dyn age::Identity>> {
282        age::IdentityFile::from_buffer(secret.as_bytes())
283            .unwrap()
284            .into_identities()
285            .unwrap()
286    }
287
288    #[test]
289    fn create_team_vault_uses_hkdf_scoped_challenge() {
290        let (secret, recipient) = age_crypto::generate_identity();
291        let identities = identities_from(&secret);
292        let (file, _dek) = create_team_vault(&[recipient]).unwrap();
293        let dek = unwrap_dek(&file, &identities).unwrap();
294        let challenge_nonce = crypto::decode_b64(&file.vault_challenge.nonce).unwrap();
295        let challenge_ct = crypto::decode_b64(&file.vault_challenge.ciphertext).unwrap();
296
297        assert!(matches!(
298            crypto::decrypt_for_cipher(
299                crypto::default_vault_cipher(),
300                &dek,
301                &challenge_nonce,
302                &challenge_ct
303            ),
304            Err(SafeError::DecryptionFailed)
305        ));
306        assert_eq!(
307            crypto::decrypt_with_key_schedule(
308                &dek,
309                KeySchedule::HkdfSha256V1,
310                KeyPurpose::VaultChallenge,
311                crypto::default_vault_cipher(),
312                &challenge_nonce,
313                &challenge_ct
314            )
315            .unwrap(),
316            VAULT_CHALLENGE_PLAINTEXT
317        );
318    }
319
320    #[test]
321    fn remove_member_migrates_legacy_team_vault_to_hkdf_schedule() {
322        let (secret1, recipient1) = age_crypto::generate_identity();
323        let (_secret2, recipient2) = age_crypto::generate_identity();
324        let identities = identities_from(&secret1);
325        let recipients = vec![recipient1.clone(), recipient2.clone()];
326        let parsed_recipients = age_crypto::parse_recipients(&recipients).unwrap();
327
328        let dek = VaultKey::from_bytes(crypto::random_salt());
329        let wrapped =
330            age_crypto::encrypt_to_recipients(&parsed_recipients, dek.as_bytes()).unwrap();
331        let now = Utc::now();
332        let (challenge_nonce, challenge_ct) =
333            crypto::encrypt(&dek, VAULT_CHALLENGE_PLAINTEXT).unwrap();
334        let (secret_nonce, secret_ct) = crypto::encrypt(&dek, b"legacy-team-secret").unwrap();
335        let (history_nonce, history_ct) = crypto::encrypt(&dek, b"legacy-history").unwrap();
336        let mut secrets = HashMap::new();
337        secrets.insert(
338            "TEAM_SECRET".into(),
339            SecretEntry {
340                nonce: crypto::encode_b64(&secret_nonce),
341                ciphertext: crypto::encode_b64(&secret_ct),
342                created_at: now,
343                updated_at: now,
344                tags: HashMap::new(),
345                history: vec![HistoryEntry {
346                    nonce: crypto::encode_b64(&history_nonce),
347                    ciphertext: crypto::encode_b64(&history_ct),
348                    updated_at: now,
349                }],
350            },
351        );
352
353        let mut file = VaultFile {
354            schema: TEAM_SCHEMA.to_string(),
355            kdf: KdfParams {
356                algorithm: "age".to_string(),
357                m_cost: 0,
358                t_cost: 0,
359                p_cost: 0,
360                salt: String::new(),
361            },
362            cipher: CipherKind::XChaCha20Poly1305.as_str().to_string(),
363            vault_challenge: VaultChallenge {
364                nonce: crypto::encode_b64(&challenge_nonce),
365                ciphertext: crypto::encode_b64(&challenge_ct),
366            },
367            created_at: now,
368            updated_at: now,
369            secrets,
370            age_recipients: recipients,
371            wrapped_dek: Some(crypto::encode_b64(&wrapped)),
372        };
373
374        remove_member(&mut file, &recipient2, &identities).unwrap();
375
376        let new_dek = unwrap_dek(&file, &identities).unwrap();
377        let new_challenge_nonce = crypto::decode_b64(&file.vault_challenge.nonce).unwrap();
378        let new_challenge_ct = crypto::decode_b64(&file.vault_challenge.ciphertext).unwrap();
379        let new_cipher = crypto::parse_cipher_kind(&file.cipher).unwrap();
380        assert!(matches!(
381            crypto::decrypt_for_cipher(
382                new_cipher,
383                &new_dek,
384                &new_challenge_nonce,
385                &new_challenge_ct
386            ),
387            Err(SafeError::DecryptionFailed)
388        ));
389        assert_eq!(
390            crypto::decrypt_with_key_schedule(
391                &new_dek,
392                KeySchedule::HkdfSha256V1,
393                KeyPurpose::VaultChallenge,
394                new_cipher,
395                &new_challenge_nonce,
396                &new_challenge_ct
397            )
398            .unwrap(),
399            VAULT_CHALLENGE_PLAINTEXT
400        );
401
402        let entry = &file.secrets["TEAM_SECRET"];
403        let nonce = crypto::decode_b64(&entry.nonce).unwrap();
404        let ciphertext = crypto::decode_b64(&entry.ciphertext).unwrap();
405        assert_eq!(
406            crypto::decrypt_with_key_schedule(
407                &new_dek,
408                KeySchedule::HkdfSha256V1,
409                KeyPurpose::SecretData,
410                new_cipher,
411                &nonce,
412                &ciphertext
413            )
414            .unwrap(),
415            b"legacy-team-secret"
416        );
417        let history = &entry.history[0];
418        let history_nonce = crypto::decode_b64(&history.nonce).unwrap();
419        let history_ct = crypto::decode_b64(&history.ciphertext).unwrap();
420        assert_eq!(
421            crypto::decrypt_with_key_schedule(
422                &new_dek,
423                KeySchedule::HkdfSha256V1,
424                KeyPurpose::SecretData,
425                new_cipher,
426                &history_nonce,
427                &history_ct
428            )
429            .unwrap(),
430            b"legacy-history"
431        );
432    }
433
434    #[cfg(feature = "fips")]
435    #[test]
436    fn fips_build_creates_aes256gcm_team_vaults() {
437        let (_secret, recipient) = age_crypto::generate_identity();
438        let (file, _dek) = create_team_vault(&[recipient]).unwrap();
439        assert_eq!(file.cipher, CipherKind::Aes256Gcm.as_str());
440    }
441
442    // ── add_member ────────────────────────────────────────────────────────────
443
444    #[test]
445    fn add_member_allows_new_member_to_unwrap_dek() {
446        let (secret1, recipient1) = age_crypto::generate_identity();
447        let (secret2, recipient2) = age_crypto::generate_identity();
448        let identities1 = identities_from(&secret1);
449        let identities2 = identities_from(&secret2);
450
451        let (mut file, _) = create_team_vault(&[recipient1]).unwrap();
452        add_member(&mut file, &recipient2, &identities1).unwrap();
453
454        // Both members can now unwrap the DEK.
455        assert!(unwrap_dek(&file, &identities1).is_ok());
456        assert!(unwrap_dek(&file, &identities2).is_ok());
457        assert_eq!(file.age_recipients.len(), 2);
458    }
459
460    #[test]
461    fn add_member_rejects_duplicate_recipient() {
462        let (secret, recipient) = age_crypto::generate_identity();
463        let identities = identities_from(&secret);
464        let (mut file, _) = create_team_vault(std::slice::from_ref(&recipient)).unwrap();
465
466        let result = add_member(&mut file, &recipient, &identities);
467        assert!(matches!(result, Err(SafeError::Crypto { .. })));
468    }
469
470    // ── members ───────────────────────────────────────────────────────────────
471
472    #[test]
473    fn members_returns_current_recipient_list() {
474        let (_secret, recipient) = age_crypto::generate_identity();
475        let (file, _) = create_team_vault(std::slice::from_ref(&recipient)).unwrap();
476
477        let m = members(&file);
478        assert_eq!(m.len(), 1);
479        assert_eq!(m[0], recipient);
480    }
481
482    #[test]
483    fn members_reflects_add_member() {
484        let (secret1, recipient1) = age_crypto::generate_identity();
485        let (_secret2, recipient2) = age_crypto::generate_identity();
486        let identities1 = identities_from(&secret1);
487
488        let (mut file, _) = create_team_vault(std::slice::from_ref(&recipient1)).unwrap();
489        add_member(&mut file, &recipient2, &identities1).unwrap();
490
491        let m = members(&file);
492        assert_eq!(m.len(), 2);
493        assert!(m.contains(&recipient1));
494        assert!(m.contains(&recipient2));
495    }
496
497    #[test]
498    fn read_only_profile_rejects_team_mutations() {
499        let (secret1, recipient1) = age_crypto::generate_identity();
500        let (_secret2, recipient2) = age_crypto::generate_identity();
501        let identities1 = identities_from(&secret1);
502        let (mut file, _) = create_team_vault(std::slice::from_ref(&recipient1)).unwrap();
503
504        assert!(matches!(
505            add_member_with_access_profile(
506                &mut file,
507                &recipient2,
508                &identities1,
509                RbacProfile::ReadOnly
510            ),
511            Err(SafeError::InvalidVault { .. })
512        ));
513        assert!(matches!(
514            remove_member_with_access_profile(
515                &mut file,
516                &recipient1,
517                &identities1,
518                RbacProfile::ReadOnly
519            ),
520            Err(SafeError::InvalidVault { .. })
521        ));
522        assert!(matches!(
523            create_team_vault_with_access_profile(&[recipient1], RbacProfile::ReadOnly),
524            Err(SafeError::InvalidVault { .. })
525        ));
526    }
527
528    // ── is_team_vault ─────────────────────────────────────────────────────────
529
530    #[test]
531    fn is_team_vault_returns_true_for_team_vault() {
532        let (_secret, recipient) = age_crypto::generate_identity();
533        let (file, _) = create_team_vault(&[recipient]).unwrap();
534        assert!(is_team_vault(&file));
535    }
536
537    #[test]
538    fn is_team_vault_returns_false_when_no_recipients() {
539        let file = VaultFile {
540            schema: "tsafe/vault/v1".into(),
541            kdf: KdfParams {
542                algorithm: "argon2id".into(),
543                m_cost: 65536,
544                t_cost: 3,
545                p_cost: 4,
546                salt: String::new(),
547            },
548            cipher: "xchacha20poly1305".into(),
549            vault_challenge: VaultChallenge {
550                nonce: String::new(),
551                ciphertext: String::new(),
552            },
553            created_at: Utc::now(),
554            updated_at: Utc::now(),
555            secrets: HashMap::new(),
556            age_recipients: vec![],
557            wrapped_dek: None,
558        };
559        assert!(!is_team_vault(&file));
560    }
561
562    // ── error paths ───────────────────────────────────────────────────────────
563
564    #[test]
565    fn create_team_vault_with_no_recipients_errors() {
566        let result = create_team_vault(&[]);
567        assert!(matches!(result, Err(SafeError::Crypto { .. })));
568    }
569
570    #[test]
571    fn remove_last_member_errors() {
572        let (secret, recipient) = age_crypto::generate_identity();
573        let identities = identities_from(&secret);
574        let (mut file, _) = create_team_vault(std::slice::from_ref(&recipient)).unwrap();
575
576        let result = remove_member(&mut file, &recipient, &identities);
577        assert!(matches!(result, Err(SafeError::Crypto { .. })));
578    }
579}