1use 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
20pub fn create_team_vault(recipients: &[String]) -> SafeResult<(VaultFile, VaultKey)> {
23 create_team_vault_with_access_profile(recipients, RbacProfile::ReadWrite)
24}
25
26pub 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 let dek_bytes = crypto::random_salt(); let dek = VaultKey::from_bytes(dek_bytes);
42 let cipher = crypto::default_vault_cipher();
43
44 let wrapped = age_crypto::encrypt_to_recipients(&parsed, dek.as_bytes())?;
46
47 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
81pub 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
98pub 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
107pub 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 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
133pub 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
143pub 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 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 let mut new_secrets = HashMap::with_capacity(file.secrets.len());
178 for (key, entry) in &file.secrets {
179 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 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 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 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 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
264pub fn members(file: &VaultFile) -> &[String] {
266 &file.age_recipients
267}
268
269pub 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 #[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 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 #[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 #[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 #[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}