use super::{GitRepo, GitError, GitResult};
use crate::crypto::{CryptoEngine, DerivedKey, EncryptedSecret, PlaintextSecret};
use git2::Signature;
use std::collections::HashMap;
use std::path::PathBuf;
use tokio::fs;
use serde::{Deserialize, Serialize};
use ring::rand::SystemRandom;
use base64ct::{Base64, Encoding};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeyShareConfig {
pub team_ref: String,
pub require_signatures: bool,
pub max_members: usize,
pub rotation_interval: u64,
pub backup_locations: Vec<String>,
}
impl Default for KeyShareConfig {
fn default() -> Self {
Self {
team_ref: "refs/cargocrypt/team".to_string(),
require_signatures: true,
max_members: 20,
rotation_interval: 90, backup_locations: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TeamMember {
pub id: String,
pub public_key: String,
pub signing_key: String,
pub role: TeamRole,
pub added_at: u64,
pub added_by: String,
pub active: bool,
}
impl TeamMember {
pub fn new(id: String, public_key: String, signing_key: String, role: TeamRole, added_by: String) -> Self {
Self {
id,
public_key,
signing_key,
role,
added_at: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
added_by,
active: true,
}
}
pub fn can_perform(&self, operation: &TeamOperation) -> bool {
if !self.active {
return false;
}
match self.role {
TeamRole::Owner => true,
TeamRole::Admin => matches!(operation,
TeamOperation::AddMember |
TeamOperation::RemoveMember |
TeamOperation::RotateKeys |
TeamOperation::ViewKeys |
TeamOperation::EncryptFile |
TeamOperation::DecryptFile
),
TeamRole::Member => matches!(operation,
TeamOperation::ViewKeys |
TeamOperation::EncryptFile |
TeamOperation::DecryptFile
),
TeamRole::ReadOnly => matches!(operation,
TeamOperation::ViewKeys |
TeamOperation::DecryptFile
),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum TeamRole {
Owner,
Admin,
Member,
ReadOnly,
}
#[derive(Debug, Clone)]
pub enum TeamOperation {
AddMember,
RemoveMember,
RotateKeys,
ViewKeys,
EncryptFile,
DecryptFile,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SharedKey {
pub id: String,
pub encrypted_for_members: HashMap<String, String>,
pub metadata: KeyMetadata,
pub signature: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeyMetadata {
pub created_at: u64,
pub created_by: String,
pub purpose: String,
pub algorithm: String,
pub expires_at: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEntry {
pub timestamp: u64,
pub operation: String,
pub actor: String,
pub details: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TeamBackup {
pub timestamp: u64,
pub keys: Vec<SharedKey>,
pub members: Vec<TeamMember>,
pub config: KeyShareConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OnboardingResult {
pub member: TeamMember,
pub onboarding_package: OnboardingPackage,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OnboardingPackage {
pub member_id: String,
pub access_token: String,
pub team_config: KeyShareConfig,
pub available_keys: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OffboardingResult {
pub member: TeamMember,
pub summary: OffboardingSummary,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OffboardingSummary {
pub member_id: String,
pub removed_at: u64,
pub keys_revoked: usize,
pub role: TeamRole,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct AccessTokenData {
member_id: String,
role: TeamRole,
issued_at: u64,
expires_at: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct TokenRevocation {
member_id: String,
revoked_at: u64,
}
#[derive(Debug, Clone)]
pub struct PermissionCheck {
pub allowed: bool,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TeamStats {
pub total_members: usize,
pub active_members: usize,
pub total_keys: usize,
pub expired_keys: usize,
pub audit_entries: usize,
}
pub struct TeamKeySharing {
repo: GitRepo,
crypto: CryptoEngine,
config: KeyShareConfig,
team_dir: PathBuf,
}
impl TeamKeySharing {
pub fn new(repo: &GitRepo, crypto: &CryptoEngine) -> GitResult<Self> {
let config = KeyShareConfig::default();
let team_dir = repo.workdir().join(".cargocrypt").join("team");
Ok(Self {
repo: repo.clone(),
crypto: crypto.clone(),
config,
team_dir,
})
}
pub fn with_config(repo: &GitRepo, crypto: &CryptoEngine, config: KeyShareConfig) -> GitResult<Self> {
let team_dir = repo.workdir().join(".cargocrypt").join("team");
Ok(Self {
repo: repo.clone(),
crypto: crypto.clone(),
config,
team_dir,
})
}
pub async fn initialize(&self) -> GitResult<()> {
fs::create_dir_all(&self.team_dir).await
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to create team directory: {}", e)))?;
fs::create_dir_all(self.team_dir.join("members")).await
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to create members directory: {}", e)))?;
fs::create_dir_all(self.team_dir.join("keys")).await
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to create keys directory: {}", e)))?;
let team_config_path = self.team_dir.join("config.toml");
let config_content = toml::to_string(&self.config)
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to serialize team config: {}", e)))?;
fs::write(&team_config_path, config_content).await
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to write team config: {}", e)))?;
self.init_team_ref().await?;
Ok(())
}
pub async fn add_member(&self, member: TeamMember) -> GitResult<()> {
if self.get_members().await?.len() >= self.config.max_members {
return Err(GitError::TeamSharingFailed("Maximum team size reached".to_string()));
}
if self.member_exists(&member.id).await? {
return Err(GitError::TeamSharingFailed(format!("Member {} already exists", member.id)));
}
let member_path = self.team_dir.join("members").join(format!("{}.json", member.id));
let member_json = serde_json::to_string_pretty(&member)
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to serialize member: {}", e)))?;
fs::write(&member_path, member_json).await
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to write member file: {}", e)))?;
self.reencrypt_keys_for_new_member(&member).await?;
self.commit_team_changes(&format!("Add team member: {}", member.id)).await?;
Ok(())
}
pub async fn remove_member(&self, member_id: &str) -> GitResult<()> {
let member_path = self.team_dir.join("members").join(format!("{}.json", member_id));
if !member_path.exists() {
return Err(GitError::TeamSharingFailed(format!("Member {} not found", member_id)));
}
fs::remove_file(&member_path).await
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to remove member file: {}", e)))?;
self.reencrypt_keys_without_member(member_id).await?;
self.commit_team_changes(&format!("Remove team member: {}", member_id)).await?;
Ok(())
}
pub async fn get_members(&self) -> GitResult<Vec<TeamMember>> {
let mut members = Vec::new();
let members_dir = self.team_dir.join("members");
if !members_dir.exists() {
return Ok(members);
}
let mut entries = fs::read_dir(&members_dir).await
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to read members directory: {}", e)))?;
while let Some(entry) = entries.next_entry().await
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to read directory entry: {}", e)))? {
if entry.path().extension().and_then(|ext| ext.to_str()) == Some("json") {
let member_content = fs::read_to_string(entry.path()).await
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to read member file: {}", e)))?;
let member: TeamMember = serde_json::from_str(&member_content)
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to parse member file: {}", e)))?;
members.push(member);
}
}
Ok(members)
}
pub async fn member_exists(&self, member_id: &str) -> GitResult<bool> {
let member_path = self.team_dir.join("members").join(format!("{}.json", member_id));
Ok(member_path.exists())
}
pub async fn generate_shared_key(&self, purpose: &str, created_by: &str) -> GitResult<SharedKey> {
let members = self.get_members().await?;
if members.is_empty() {
return Err(GitError::TeamSharingFailed("No team members found".to_string()));
}
let key_material = self.crypto.generate_key()
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to generate key: {}", e)))?;
let mut encrypted_for_members = HashMap::new();
for member in &members {
if member.active {
let encrypted_key = self.encrypt_key_for_member(&key_material, member).await?;
encrypted_for_members.insert(member.id.clone(), encrypted_key);
}
}
let metadata = KeyMetadata {
created_at: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
created_by: created_by.to_string(),
purpose: purpose.to_string(),
algorithm: "ChaCha20-Poly1305".to_string(),
expires_at: Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() + (self.config.rotation_interval * 24 * 60 * 60)
),
};
let signature = self.create_key_signature(&key_material, &metadata, created_by).await?;
let shared_key = SharedKey {
id: self.generate_key_id(),
encrypted_for_members,
metadata,
signature,
};
self.store_shared_key(&shared_key).await?;
self.log_team_operation("key_generation", created_by, &format!("Generated key {} for purpose: {}", shared_key.id, purpose)).await?;
Ok(shared_key)
}
pub async fn get_shared_key(&self, key_id: &str, member_id: &str) -> GitResult<DerivedKey> {
let shared_key = self.load_shared_key(key_id).await?;
let encrypted_key = shared_key.encrypted_for_members.get(member_id)
.ok_or_else(|| GitError::TeamSharingFailed(format!("Member {} does not have access to key {}", member_id, key_id)))?;
let member = self.get_member(member_id).await?;
let decrypted_key = self.decrypt_key_for_member(encrypted_key, &member).await?;
Ok(decrypted_key)
}
pub async fn rotate_keys(&self) -> GitResult<()> {
let shared_keys = self.list_shared_keys().await?;
for key in shared_keys {
let _new_key = self.generate_shared_key(&key.metadata.purpose, "system").await?;
self.archive_shared_key(&key.id).await?;
}
self.commit_team_changes("Rotate team keys").await?;
Ok(())
}
pub async fn rotate_key(&self, key_id: &str, rotated_by: &str) -> GitResult<SharedKey> {
let old_key = self.load_shared_key(key_id).await?;
let new_key = self.generate_shared_key(&old_key.metadata.purpose, rotated_by).await?;
self.archive_shared_key(key_id).await?;
self.log_team_operation(
"single_key_rotation",
rotated_by,
&format!("Rotated key {} -> {} (purpose: {})", old_key.id, new_key.id, old_key.metadata.purpose)
).await?;
self.commit_team_changes(&format!("Rotate key: {}", key_id)).await?;
Ok(new_key)
}
async fn get_member(&self, member_id: &str) -> GitResult<TeamMember> {
let member_path = self.team_dir.join("members").join(format!("{}.json", member_id));
if !member_path.exists() {
return Err(GitError::TeamSharingFailed(format!("Member {} not found", member_id)));
}
let member_content = fs::read_to_string(&member_path).await
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to read member file: {}", e)))?;
let member: TeamMember = serde_json::from_str(&member_content)
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to parse member file: {}", e)))?;
Ok(member)
}
async fn store_shared_key(&self, shared_key: &SharedKey) -> GitResult<()> {
let key_path = self.team_dir.join("keys").join(format!("{}.json", shared_key.id));
let key_json = serde_json::to_string_pretty(shared_key)
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to serialize shared key: {}", e)))?;
fs::write(&key_path, key_json).await
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to write shared key: {}", e)))?;
Ok(())
}
async fn load_shared_key(&self, key_id: &str) -> GitResult<SharedKey> {
let key_path = self.team_dir.join("keys").join(format!("{}.json", key_id));
if !key_path.exists() {
return Err(GitError::TeamSharingFailed(format!("Shared key {} not found", key_id)));
}
let key_content = fs::read_to_string(&key_path).await
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to read shared key: {}", e)))?;
let shared_key: SharedKey = serde_json::from_str(&key_content)
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to parse shared key: {}", e)))?;
Ok(shared_key)
}
async fn list_shared_keys(&self) -> GitResult<Vec<SharedKey>> {
let mut keys = Vec::new();
let keys_dir = self.team_dir.join("keys");
if !keys_dir.exists() {
return Ok(keys);
}
let mut entries = fs::read_dir(&keys_dir).await
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to read keys directory: {}", e)))?;
while let Some(entry) = entries.next_entry().await
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to read directory entry: {}", e)))? {
if entry.path().extension().and_then(|ext| ext.to_str()) == Some("json") {
let key_content = fs::read_to_string(entry.path()).await
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to read key file: {}", e)))?;
let shared_key: SharedKey = serde_json::from_str(&key_content)
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to parse key file: {}", e)))?;
keys.push(shared_key);
}
}
Ok(keys)
}
fn generate_key_id(&self) -> String {
let rng = SystemRandom::new();
let mut random_bytes = [0u8; 16];
ring::rand::SecureRandom::fill(&rng, &mut random_bytes).unwrap();
hex::encode(random_bytes)
}
async fn encrypt_key_for_member(&self, key: &DerivedKey, _member: &TeamMember) -> GitResult<String> {
let key_hex = key.to_hex();
let plaintext = PlaintextSecret::from_string(key_hex);
let encrypted = self.crypto.encrypt_data(plaintext.as_bytes(), "team_key_password").await
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to encrypt key: {}", e)))?;
let serialized = bincode::serialize(&encrypted)
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to serialize encrypted key: {}", e)))?;
Ok(Base64::encode_string(&serialized))
}
async fn decrypt_key_for_member(&self, encrypted_key: &str, _member: &TeamMember) -> GitResult<DerivedKey> {
let serialized = Base64::decode_vec(encrypted_key)
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to decode encrypted key: {}", e)))?;
let encrypted: EncryptedSecret = bincode::deserialize(&serialized)
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to deserialize encrypted key: {}", e)))?;
let decrypted = self.crypto.decrypt_data(&encrypted, "team_key_password")
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to decrypt key: {}", e)))?;
let key_hex = String::from_utf8(decrypted)
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to convert decrypted data: {}", e)))?;
DerivedKey::from_hex(&key_hex)
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to create derived key: {}", e)))
}
async fn reencrypt_keys_for_new_member(&self, new_member: &TeamMember) -> GitResult<()> {
let shared_keys = self.list_shared_keys().await?;
for mut shared_key in shared_keys {
if let Some((admin_id, encrypted_key)) = shared_key.encrypted_for_members.iter().next() {
let admin_member = self.get_member(admin_id).await?;
match self.decrypt_key_for_member(encrypted_key, &admin_member).await {
Ok(key_material) => {
let encrypted_for_new_member = self.encrypt_key_for_member(&key_material, new_member).await?;
shared_key.encrypted_for_members.insert(new_member.id.clone(), encrypted_for_new_member);
self.store_shared_key(&shared_key).await?;
self.log_team_operation(
"key_reencryption",
"system",
&format!("Re-encrypted key {} for new member {}", shared_key.id, new_member.id)
).await?;
}
Err(_) => {
continue;
}
}
}
}
Ok(())
}
async fn reencrypt_keys_without_member(&self, removed_member_id: &str) -> GitResult<()> {
let shared_keys = self.list_shared_keys().await?;
for mut shared_key in shared_keys {
shared_key.encrypted_for_members.remove(removed_member_id);
self.store_shared_key(&shared_key).await?;
}
Ok(())
}
async fn archive_shared_key(&self, key_id: &str) -> GitResult<()> {
let key_path = self.team_dir.join("keys").join(format!("{}.json", key_id));
let archived_path = self.team_dir.join("keys").join("archived").join(format!("{}.json", key_id));
fs::create_dir_all(archived_path.parent().unwrap()).await
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to create archived directory: {}", e)))?;
fs::rename(&key_path, &archived_path).await
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to archive key: {}", e)))?;
Ok(())
}
async fn init_team_ref(&self) -> GitResult<()> {
let git_repo = self.repo.inner();
let signature = self.get_signature()?;
let tree_builder = git_repo.treebuilder(None)?;
let tree_oid = tree_builder.write()?;
let tree = git_repo.find_tree(tree_oid)?;
git_repo.commit(
Some(&self.config.team_ref),
&signature,
&signature,
"Initialize CargoCrypt team key sharing",
&tree,
&[],
)?;
Ok(())
}
async fn commit_team_changes(&self, message: &str) -> GitResult<()> {
self.repo.stage_file(&self.team_dir).await?;
self.repo.commit(&format!("CargoCrypt: {}", message)).await?;
Ok(())
}
fn get_signature(&self) -> GitResult<Signature> {
self.repo.inner().signature()
.or_else(|_| Signature::now("CargoCrypt Team", "team@cargocrypt.local"))
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to create signature: {}", e)))
}
async fn create_key_signature(&self, key: &DerivedKey, metadata: &KeyMetadata, signer_id: &str) -> GitResult<String> {
let mut data_to_sign = Vec::new();
data_to_sign.extend_from_slice(key.key().as_slice());
data_to_sign.extend_from_slice(&metadata.created_at.to_le_bytes());
data_to_sign.extend_from_slice(metadata.purpose.as_bytes());
data_to_sign.extend_from_slice(signer_id.as_bytes());
use ring::hmac;
let signing_key = hmac::Key::new(hmac::HMAC_SHA256, b"CargoCrypt-Team-Key-Signature");
let signature = hmac::sign(&signing_key, &data_to_sign);
Ok(hex::encode(signature.as_ref()))
}
async fn verify_key_signature(&self, key: &DerivedKey, metadata: &KeyMetadata, signature: &str, signer_id: &str) -> GitResult<bool> {
let expected_signature = self.create_key_signature(key, metadata, signer_id).await?;
Ok(expected_signature == signature)
}
async fn log_team_operation(&self, operation: &str, actor: &str, details: &str) -> GitResult<()> {
let audit_log_path = self.team_dir.join("audit.log");
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let log_entry = format!(
"{} | {} | {} | {}\n",
timestamp,
operation,
actor,
details
);
if audit_log_path.exists() {
let mut existing_content = fs::read_to_string(&audit_log_path).await
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to read audit log: {}", e)))?;
existing_content.push_str(&log_entry);
fs::write(&audit_log_path, existing_content).await
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to update audit log: {}", e)))?;
} else {
fs::write(&audit_log_path, log_entry).await
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to create audit log: {}", e)))?;
}
Ok(())
}
pub async fn get_audit_trail(&self, limit: Option<usize>) -> GitResult<Vec<AuditEntry>> {
let audit_log_path = self.team_dir.join("audit.log");
if !audit_log_path.exists() {
return Ok(Vec::new());
}
let content = fs::read_to_string(&audit_log_path).await
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to read audit log: {}", e)))?;
let mut entries: Vec<AuditEntry> = content
.lines()
.filter_map(|line| {
let parts: Vec<&str> = line.split(" | ").collect();
if parts.len() == 4 {
Some(AuditEntry {
timestamp: parts[0].parse().unwrap_or(0),
operation: parts[1].to_string(),
actor: parts[2].to_string(),
details: parts[3].to_string(),
})
} else {
None
}
})
.collect();
entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
if let Some(limit) = limit {
entries.truncate(limit);
}
Ok(entries)
}
pub async fn deactivate_member(&self, member_id: &str, deactivated_by: &str) -> GitResult<()> {
let member_path = self.team_dir.join("members").join(format!("{}.json", member_id));
if !member_path.exists() {
return Err(GitError::TeamSharingFailed(format!("Member {} not found", member_id)));
}
let mut member = self.get_member(member_id).await?;
member.active = false;
let member_json = serde_json::to_string_pretty(&member)
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to serialize member: {}", e)))?;
fs::write(&member_path, member_json).await
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to write member file: {}", e)))?;
self.log_team_operation(
"member_deactivation",
deactivated_by,
&format!("Deactivated member: {}", member_id)
).await?;
self.commit_team_changes(&format!("Deactivate team member: {}", member_id)).await?;
Ok(())
}
pub async fn backup_team_keys(&self, backup_path: &std::path::Path) -> GitResult<()> {
let shared_keys = self.list_shared_keys().await?;
let members = self.get_members().await?;
let backup_data = TeamBackup {
timestamp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
keys: shared_keys,
members,
config: self.config.clone(),
};
let backup_json = serde_json::to_string_pretty(&backup_data)
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to serialize backup: {}", e)))?;
fs::write(backup_path, backup_json).await
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to write backup: {}", e)))?;
self.log_team_operation(
"team_backup",
"system",
&format!("Created team backup at: {}", backup_path.display())
).await?;
Ok(())
}
pub async fn restore_from_backup(&self, backup_path: &std::path::Path) -> GitResult<()> {
let backup_content = fs::read_to_string(backup_path).await
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to read backup: {}", e)))?;
let backup_data: TeamBackup = serde_json::from_str(&backup_content)
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to parse backup: {}", e)))?;
for member in backup_data.members {
let member_path = self.team_dir.join("members").join(format!("{}.json", member.id));
let member_json = serde_json::to_string_pretty(&member)
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to serialize member: {}", e)))?;
fs::write(&member_path, member_json).await
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to restore member: {}", e)))?;
}
for key in backup_data.keys {
self.store_shared_key(&key).await?;
}
self.log_team_operation(
"team_restore",
"system",
&format!("Restored team from backup: {}", backup_path.display())
).await?;
self.commit_team_changes("Restore team from backup").await?;
Ok(())
}
pub async fn onboard_member(
&self,
member_id: String,
public_key: String,
signing_key: String,
role: TeamRole,
invited_by: &str,
) -> GitResult<OnboardingResult> {
if self.member_exists(&member_id).await? {
return Err(GitError::TeamSharingFailed(
format!("Member {} already exists", member_id)
));
}
let member = TeamMember::new(
member_id.clone(),
public_key,
signing_key,
role,
invited_by.to_string(),
);
let access_token = self.generate_access_token(&member).await?;
self.add_member(member.clone()).await?;
let onboarding_package = OnboardingPackage {
member_id: member_id.clone(),
access_token,
team_config: self.config.clone(),
available_keys: self.list_available_keys_for_member(&member_id).await?,
};
self.log_team_operation(
"member_onboarding",
invited_by,
&format!("Onboarded new member: {} with role: {:?}", member_id, member.role)
).await?;
Ok(OnboardingResult {
member,
onboarding_package,
})
}
pub async fn offboard_member(&self, member_id: &str, removed_by: &str) -> GitResult<OffboardingResult> {
let member = self.get_member(member_id).await?;
self.deactivate_member(member_id, removed_by).await?;
let shared_keys = self.list_shared_keys().await?;
let mut keys_updated = 0;
for mut shared_key in shared_keys {
if shared_key.encrypted_for_members.remove(member_id).is_some() {
self.store_shared_key(&shared_key).await?;
keys_updated += 1;
}
}
self.revoke_member_tokens(member_id).await?;
let summary = OffboardingSummary {
member_id: member_id.to_string(),
removed_at: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
keys_revoked: keys_updated,
role: member.role.clone(),
};
self.log_team_operation(
"member_offboarding",
removed_by,
&format!(
"Offboarded member: {} (role: {:?}, keys revoked: {})",
member_id, member.role, keys_updated
)
).await?;
self.remove_member(member_id).await?;
Ok(OffboardingResult {
member,
summary,
})
}
async fn generate_access_token(&self, member: &TeamMember) -> GitResult<String> {
let token_data = AccessTokenData {
member_id: member.id.clone(),
role: member.role.clone(),
issued_at: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
expires_at: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() + (7 * 24 * 60 * 60), };
let token_json = serde_json::to_string(&token_data)
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to serialize token: {}", e)))?;
let plaintext = PlaintextSecret::from_string(token_json);
let encrypted_token = EncryptedSecret::encrypt_with_password(
plaintext,
"team_access_token_secret", None,
).map_err(|e| GitError::TeamSharingFailed(format!("Failed to encrypt token: {}", e)))?;
let token_bytes = encrypted_token.to_bytes()
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to serialize encrypted token: {}", e)))?;
Ok(Base64::encode_string(&token_bytes))
}
async fn list_available_keys_for_member(&self, member_id: &str) -> GitResult<Vec<String>> {
let member = self.get_member(member_id).await?;
let shared_keys = self.list_shared_keys().await?;
let available_keys: Vec<String> = shared_keys
.into_iter()
.filter_map(|key| {
if key.encrypted_for_members.contains_key(member_id) && member.active {
Some(key.id)
} else {
None
}
})
.collect();
Ok(available_keys)
}
async fn revoke_member_tokens(&self, member_id: &str) -> GitResult<()> {
let revocation_path = self.team_dir.join("revoked_tokens.json");
let revocation_entry = TokenRevocation {
member_id: member_id.to_string(),
revoked_at: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
};
let mut revocations = if revocation_path.exists() {
let content = fs::read_to_string(&revocation_path).await
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to read revocations: {}", e)))?;
serde_json::from_str::<Vec<TokenRevocation>>(&content)
.unwrap_or_default()
} else {
Vec::new()
};
revocations.push(revocation_entry);
let revocations_json = serde_json::to_string_pretty(&revocations)
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to serialize revocations: {}", e)))?;
fs::write(&revocation_path, revocations_json).await
.map_err(|e| GitError::TeamSharingFailed(format!("Failed to write revocations: {}", e)))?;
Ok(())
}
pub async fn check_permission(&self, member_id: &str, operation: &TeamOperation) -> GitResult<PermissionCheck> {
let member = self.get_member(member_id).await?;
let allowed = member.can_perform(operation);
let reason = if allowed {
format!("Member {} with role {:?} can perform {:?}", member_id, member.role, operation)
} else {
format!("Member {} with role {:?} cannot perform {:?}", member_id, member.role, operation)
};
Ok(PermissionCheck { allowed, reason })
}
pub async fn get_team_stats(&self) -> GitResult<TeamStats> {
let members = self.get_members().await?;
let keys = self.list_shared_keys().await?;
let audit_entries = self.get_audit_trail(None).await?;
let active_members = members.iter().filter(|m| m.active).count();
let current_time = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let expired_keys = keys.iter()
.filter(|k| {
if let Some(expires_at) = k.metadata.expires_at {
current_time >= expires_at
} else {
false
}
})
.count();
Ok(TeamStats {
total_members: members.len(),
active_members,
total_keys: keys.len(),
expired_keys,
audit_entries: audit_entries.len(),
})
}
pub async fn cleanup_expired_keys(&self, cleanup_by: &str) -> GitResult<usize> {
let keys = self.list_shared_keys().await?;
let current_time = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let mut cleaned_count = 0;
for key in keys {
if let Some(expires_at) = key.metadata.expires_at {
if current_time >= expires_at {
self.archive_shared_key(&key.id).await?;
cleaned_count += 1;
self.log_team_operation(
"key_cleanup",
cleanup_by,
&format!("Cleaned up expired key: {} (purpose: {})", key.id, key.metadata.purpose)
).await?;
}
}
}
if cleaned_count > 0 {
self.commit_team_changes(&format!("Clean up {} expired keys", cleaned_count)).await?;
}
Ok(cleaned_count)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[tokio::test]
async fn test_team_key_sharing_creation() {
let temp_dir = TempDir::new().unwrap();
let repo = GitRepo::init(temp_dir.path()).unwrap();
let crypto = CryptoEngine::new();
let team_sharing = TeamKeySharing::new(&repo, &crypto).unwrap();
team_sharing.initialize().await.unwrap();
assert!(team_sharing.team_dir.exists());
assert!(team_sharing.team_dir.join("config.toml").exists());
}
#[tokio::test]
async fn test_add_team_member() {
let temp_dir = TempDir::new().unwrap();
let repo = GitRepo::init(temp_dir.path()).unwrap();
let crypto = CryptoEngine::new();
let team_sharing = TeamKeySharing::new(&repo, &crypto).unwrap();
team_sharing.initialize().await.unwrap();
let member = TeamMember::new(
"alice@example.com".to_string(),
"public_key_alice".to_string(),
"signing_key_alice".to_string(),
TeamRole::Admin,
"system".to_string(),
);
team_sharing.add_member(member).await.unwrap();
let members = team_sharing.get_members().await.unwrap();
assert_eq!(members.len(), 1);
assert_eq!(members[0].id, "alice@example.com");
}
#[tokio::test]
async fn test_member_permissions() {
let owner = TeamMember::new(
"owner@example.com".to_string(),
"pk".to_string(),
"sk".to_string(),
TeamRole::Owner,
"system".to_string(),
);
let readonly = TeamMember::new(
"readonly@example.com".to_string(),
"pk".to_string(),
"sk".to_string(),
TeamRole::ReadOnly,
"system".to_string(),
);
assert!(owner.can_perform(&TeamOperation::AddMember));
assert!(owner.can_perform(&TeamOperation::RotateKeys));
assert!(!readonly.can_perform(&TeamOperation::AddMember));
assert!(readonly.can_perform(&TeamOperation::DecryptFile));
}
#[tokio::test]
async fn test_shared_key_generation() {
let temp_dir = TempDir::new().unwrap();
let repo = GitRepo::init(temp_dir.path()).unwrap();
let crypto = CryptoEngine::new();
let team_sharing = TeamKeySharing::new(&repo, &crypto).unwrap();
team_sharing.initialize().await.unwrap();
let member = TeamMember::new(
"alice@example.com".to_string(),
"public_key_alice".to_string(),
"signing_key_alice".to_string(),
TeamRole::Admin,
"system".to_string(),
);
team_sharing.add_member(member).await.unwrap();
let shared_key = team_sharing.generate_shared_key("test", "alice@example.com").await.unwrap();
assert!(!shared_key.id.is_empty());
assert!(shared_key.encrypted_for_members.contains_key("alice@example.com"));
}
}