use std::io::Read;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use chrono::{Duration, Utc};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tsafe_core::{age_crypto, team};
use tsafe_mobile_core::envelopes::{
parse_enrollment_response_envelope, parse_enrollment_start_envelope,
validate_enrollment_response_matches_start, EnrollmentResponseEnvelope,
EnrollmentStartEnvelope, MobileRepoRef, ENROLLMENT_START_SCHEMA,
};
use crate::cli::{MobileAction, MobileEnrollAction};
pub(crate) fn cmd_mobile(profile: &str, action: MobileAction) -> Result<()> {
match action {
MobileAction::Enroll { action } => cmd_mobile_enroll(profile, action),
}
}
fn cmd_mobile_enroll(profile: &str, action: MobileEnrollAction) -> Result<()> {
match action {
MobileEnrollAction::Start {
repo,
branch,
team_keys_path,
vault_path,
ttl_minutes,
display_name,
json,
qr,
} => enroll_start(
profile,
&repo,
&branch,
&team_keys_path,
vault_path.as_deref(),
ttl_minutes,
display_name,
json,
qr,
),
MobileEnrollAction::Accept {
start,
response,
team_keys,
identity,
json,
} => enroll_accept(
&start,
&response,
team_keys.as_deref(),
identity.as_deref(),
json,
),
}
}
#[allow(clippy::too_many_arguments)]
fn enroll_start(
profile: &str,
repo: &str,
branch: &str,
team_keys_path: &str,
vault_path: Option<&str>,
ttl_minutes: i64,
display_name: Option<String>,
emit_json: bool,
emit_qr: bool,
) -> Result<()> {
if !emit_json && !emit_qr {
anyhow::bail!("pass --json or --qr so the enrollment payload is explicit");
}
if ttl_minutes <= 0 {
anyhow::bail!("--ttl-minutes must be greater than zero");
}
let repo = parse_repo_ref(repo)?;
let created_at = Utc::now();
let expires_at = created_at + Duration::minutes(ttl_minutes);
let nonce = random_b64url(32);
let session_id = format!("mobenr_{}", uuid::Uuid::new_v4());
let (_desktop_private_identity, desktop_public_key) = age_crypto::generate_identity();
let vault_path = vault_path
.map(str::to_string)
.unwrap_or_else(|| format!(".tsafe/vaults/{profile}.vault"));
let envelope = EnrollmentStartEnvelope {
schema: ENROLLMENT_START_SCHEMA.to_string(),
version: "1.0".to_string(),
session_id,
created_at,
expires_at,
desktop_public_key,
nonce,
repo,
branch: Some(branch.to_string()),
profile: profile.to_string(),
team_keys_path: team_keys_path.to_string(),
vault_path,
vault_identity: None,
requested_capabilities: vec![
"mobile_age_recipient".to_string(),
"team_keys_membership_read".to_string(),
"vault_ciphertext_pull".to_string(),
"encrypted_qr_share".to_string(),
],
display_name,
};
let bytes = serde_json::to_vec(&envelope)?;
parse_enrollment_start_envelope(&bytes, created_at)
.context("generated enrollment start envelope did not validate")?;
if emit_qr {
let qr_payload = json!({
"schema": "tsafe/mobile/enrollment-qr-frame/v1",
"version": "1.0",
"encoding": "json",
"frame_count": 1,
"frame_index": 0,
"payload": envelope,
});
println!("{}", serde_json::to_string_pretty(&qr_payload)?);
} else {
println!("{}", serde_json::to_string_pretty(&envelope)?);
}
Ok(())
}
fn enroll_accept(
start_path: &Path,
response_path: &str,
team_keys_override: Option<&Path>,
identity_path: Option<&Path>,
emit_json: bool,
) -> Result<()> {
let now = Utc::now();
let start_bytes = std::fs::read(start_path)
.with_context(|| format!("read enrollment start envelope {}", start_path.display()))?;
let response_bytes = if response_path == "-" {
let mut bytes = Vec::new();
std::io::stdin()
.read_to_end(&mut bytes)
.context("read enrollment response from stdin")?;
bytes
} else {
std::fs::read(response_path)
.with_context(|| format!("read enrollment response envelope {response_path}"))?
};
let start = parse_enrollment_start_envelope(&start_bytes, now)
.context("invalid enrollment start envelope")?;
let response = parse_enrollment_response_envelope(&response_bytes, now)
.context("invalid enrollment response envelope")?;
validate_enrollment_response_matches_start(&start, &response)
.context("enrollment response does not match start envelope")?;
let team_keys_path = team_keys_override
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(&start.team_keys_path));
let record = record_mobile_recipient(&team_keys_path, &start, &response)?;
let team_rewrap = rewrap_team_vault_for_mobile(&start, &response, identity_path)?;
let receipt = json!({
"schema": "tsafe/mobile/enrollment-accept-receipt/v1",
"status": "accepted",
"profile": start.profile,
"session_id": start.session_id,
"nonce": start.nonce,
"repo": start.repo,
"team_keys_path": team_keys_path,
"mobile_recipient": response.mobile_recipient,
"device_label": response.device_label,
"already_recorded": record.already_recorded,
"team_rewrap": team_rewrap,
});
if emit_json {
println!("{}", serde_json::to_string_pretty(&receipt)?);
} else if record.already_recorded {
println!(
"Mobile recipient already recorded: {}",
response.mobile_recipient
);
} else {
println!("Recorded mobile recipient: {}", response.mobile_recipient);
}
if !emit_json {
match team_rewrap.status.as_str() {
"rewrapped" => println!(
"Rewrapped team vault for mobile recipient: {}",
response.mobile_recipient
),
"already_rewrapped" => println!(
"Team vault already includes mobile recipient: {}",
response.mobile_recipient
),
"handoff_required" => println!(
"Team rewrap handoff required: rerun accept with --identity to update {}",
team_rewrap.vault_path
),
"blocked" => {
if let Some(reason) = &team_rewrap.reason {
println!("Team rewrap blocked: {reason}");
}
}
_ => {}
}
}
Ok(())
}
fn parse_repo_ref(repo: &str) -> Result<MobileRepoRef> {
let (owner, name) = repo
.split_once('/')
.ok_or_else(|| anyhow::anyhow!("--repo must be OWNER/NAME"))?;
if owner.trim().is_empty() || name.trim().is_empty() || name.contains('/') {
anyhow::bail!("--repo must be OWNER/NAME");
}
Ok(MobileRepoRef {
owner: owner.to_string(),
name: name.to_string(),
})
}
fn random_b64url(bytes_len: usize) -> String {
let mut bytes = vec![0_u8; bytes_len];
rand::thread_rng().fill_bytes(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes)
}
#[derive(Debug, Default, Deserialize, Serialize)]
struct TeamKeysFile {
#[serde(default)]
members: Vec<TeamKeyEntry>,
#[serde(flatten)]
extra: serde_json::Map<String, Value>,
}
#[derive(Debug, Deserialize, Serialize)]
struct TeamKeyEntry {
name: String,
email: String,
public_key: String,
#[serde(skip_serializing_if = "Option::is_none")]
source: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
enrolled_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
enrollment_session_id: Option<String>,
#[serde(flatten)]
extra: serde_json::Map<String, Value>,
}
struct RecordResult {
already_recorded: bool,
}
#[derive(Debug, Serialize)]
struct TeamRewrapResult {
status: String,
vault_path: String,
#[serde(skip_serializing_if = "Option::is_none")]
already_rewrapped: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
recipient_count: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
next_action: Option<String>,
}
fn record_mobile_recipient(
path: &Path,
start: &EnrollmentStartEnvelope,
response: &EnrollmentResponseEnvelope,
) -> Result<RecordResult> {
let mut file = if path.exists() {
let bytes = std::fs::read(path)
.with_context(|| format!("read team keys file {}", path.display()))?;
serde_json::from_slice::<TeamKeysFile>(&bytes)
.with_context(|| format!("parse team keys file {}", path.display()))?
} else {
TeamKeysFile::default()
};
let already_recorded = file
.members
.iter()
.any(|member| member.public_key == response.mobile_recipient);
if !already_recorded {
file.members.push(TeamKeyEntry {
name: response
.device_label
.clone()
.unwrap_or_else(|| "Mobile device".to_string()),
email: "mobile-enrollment@local.invalid".to_string(),
public_key: response.mobile_recipient.clone(),
source: Some("tsafe mobile enroll accept".to_string()),
enrolled_at: Some(Utc::now().to_rfc3339()),
enrollment_session_id: Some(start.session_id.clone()),
extra: serde_json::Map::new(),
});
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, serde_json::to_string_pretty(&file)?)
.with_context(|| format!("write temporary team keys file {}", tmp.display()))?;
std::fs::rename(&tmp, path)
.with_context(|| format!("replace team keys file {}", path.display()))?;
}
Ok(RecordResult { already_recorded })
}
fn rewrap_team_vault_for_mobile(
start: &EnrollmentStartEnvelope,
response: &EnrollmentResponseEnvelope,
identity_path: Option<&Path>,
) -> Result<TeamRewrapResult> {
let vault_path = PathBuf::from(&start.vault_path);
let vault_path_display = vault_path.display().to_string();
let Some(identity_path) = identity_path else {
return Ok(TeamRewrapResult {
status: "handoff_required".to_string(),
vault_path: vault_path_display.clone(),
already_rewrapped: None,
recipient_count: None,
reason: Some(
"source-local team vault rewrap requires an operator age identity".to_string(),
),
next_action: Some(format!(
"rerun accept with --identity <age-identity> to rewrap {vault_path_display}"
)),
});
};
if !vault_path.exists() {
return Ok(TeamRewrapResult {
status: "blocked".to_string(),
vault_path: vault_path_display,
already_rewrapped: None,
recipient_count: None,
reason: Some("vault ciphertext path from enrollment start envelope was not found".to_string()),
next_action: Some(
"make the source-local team vault ciphertext available, then rerun accept with --identity"
.to_string(),
),
});
}
let vault_json = std::fs::read_to_string(&vault_path)
.with_context(|| format!("read team vault {}", vault_path.display()))?;
let mut file: tsafe_core::vault::VaultFile = serde_json::from_str(&vault_json)
.with_context(|| format!("parse team vault {}", vault_path.display()))?;
if !team::is_team_vault(&file) {
return Ok(TeamRewrapResult {
status: "blocked".to_string(),
vault_path: vault_path_display,
already_rewrapped: None,
recipient_count: None,
reason: Some("vault ciphertext is not an age team vault".to_string()),
next_action: Some(
"initialize or select a team vault before accepting mobile enrollment".to_string(),
),
});
}
if file.age_recipients.contains(&response.mobile_recipient) {
return Ok(TeamRewrapResult {
status: "already_rewrapped".to_string(),
vault_path: vault_path_display,
already_rewrapped: Some(true),
recipient_count: Some(file.age_recipients.len()),
reason: None,
next_action: None,
});
}
let identities = age_crypto::load_identities(identity_path)
.with_context(|| format!("load age identity {}", identity_path.display()))?;
team::add_member(&mut file, &response.mobile_recipient, &identities)
.context("rewrap team vault for mobile recipient")?;
write_team_vault(&vault_path, &file)?;
Ok(TeamRewrapResult {
status: "rewrapped".to_string(),
vault_path: vault_path_display,
already_rewrapped: Some(false),
recipient_count: Some(file.age_recipients.len()),
reason: None,
next_action: None,
})
}
fn write_team_vault(path: &Path, file: &tsafe_core::vault::VaultFile) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = path.with_extension("vault.tmp");
std::fs::write(&tmp, serde_json::to_string_pretty(file)?)
.with_context(|| format!("write temporary team vault {}", tmp.display()))?;
std::fs::rename(&tmp, path)
.with_context(|| format!("replace team vault {}", path.display()))?;
Ok(())
}