use std::env;
use std::fs;
use std::io::{Read, Write};
use std::path::{Component, Path, PathBuf};
use std::process::{Command, Stdio};
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use chacha20poly1305::{
ChaCha20Poly1305, Nonce,
aead::{Aead, KeyInit},
};
use hkdf::Hkdf;
use rho_core::{
IdentityBundle, IdentityBundleManifest, LocalEncryptionKey, LocalIdentityManifest, RhoResult,
SignatureContext, SignatureManifest, SignatureRecord, arg_value, ensure_parent, file_digest,
from_yaml, is_rho_encrypted_text as is_rho_encrypted_yaml_text, normalize_actor_id,
normalize_repo_id, path_matches_pattern, require_arg, to_yaml, uuid_like, validate_request_id,
yaml_top_level_kind,
};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret};
const SIGNATURE_NAMESPACE: &str = "rho";
const TRANSPARENT_KIND: &str = "rho_transparent_file";
const TRANSPARENT_ALGORITHM: &str = "chacha20poly1305-hkdf-sha256";
const RECIPIENT_KDF: &str = "x25519-hkdf-sha256";
const RECIPIENT_CONTENT_ENCRYPTION: &str = "chacha20poly1305";
const TRANSPARENT_INFO: &[u8] = b"rho transparent file v1";
const RECIPIENT_INFO: &[u8] = b"rho recipient envelope v1";
fn usage() -> ! {
eprintln!(
"usage:\n rho crypto sign <path> --identity <rho-id> --out <path.rhosig.yaml> [--repo-id <repo-id> (--request-id <req-id>|--message-id <msg-id>) --recipient <rho-id> --purpose <purpose>]\n rho crypto verify <path> --signature <path.rhosig.yaml> --identity <rho-id> [--repo-root <repo>] [--repo-id <repo-id> (--request-id <req-id>|--message-id <msg-id>) --recipient <rho-id> --purpose <purpose>]\n rho crypto encrypt <path> --key-file <path> --out <path.rhoenc>\n rho crypto decrypt <path.rhoenc> --key-file <path> --out <path>\n rho crypto seal <path> --recipient <rho-id> [--recipient <rho-id>...] --out <path.rhoenc> --repo-root <repo> --path <repo-path> [--purpose <purpose>]\n rho crypto open <path.rhoenc> --identity <rho-id> --out <path> --repo-root <repo> --path <repo-path> [--purpose <purpose>]\n rho crypto extract-tar <path.tar> --out-dir <dir>\n rho crypto view <path> [--root <repo-root>]\n rho crypto clean --key-file <path>\n rho crypto smudge --key-file <path>"
);
std::process::exit(2);
}
pub fn run(args: &[String]) -> RhoResult<()> {
let Some(command) = args.first().map(String::as_str) else {
usage();
};
match command {
"sign" => sign(&args[1..]),
"verify" => verify(&args[1..]),
"encrypt" => encrypt(&args[1..]),
"decrypt" => decrypt(&args[1..]),
"seal" => seal(&args[1..]),
"open" => open(&args[1..]),
"extract-tar" => extract_tar(&args[1..]),
"view" => view(&args[1..]),
"clean" => clean(&args[1..]),
"smudge" => smudge(&args[1..]),
"--help" | "-h" => usage(),
_ => usage(),
}
}
fn view(args: &[String]) -> RhoResult<()> {
let Some(input_path) = args.first().map(PathBuf::from) else {
usage();
};
let repo_root = arg_value(args, "--root")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
let repo_path = repo_relative_path(&repo_root, &input_path)?;
if let Some(content) = index_blob(&repo_root, &repo_path)? {
std::io::stdout().write_all(&content)?;
return Ok(());
}
let plaintext = fs::read(repo_root.join(&repo_path))
.map_err(|_| format!("file not found: {}", input_path.display()))?;
if is_rho_encrypted_text(&plaintext) {
std::io::stdout().write_all(&plaintext)?;
return Ok(());
}
if let Some(recipients) = policy_recipients_for_path(&repo_root, &repo_path)? {
let context = recipient_envelope_context_for(&repo_root, &repo_path, "rho.git_filter")?;
let envelope = seal_bytes_for_recipients_with_repo(
&plaintext,
&recipients,
Some(&repo_root),
context,
)?;
std::io::stdout().write_all(to_yaml(&envelope)?.as_bytes())?;
return Ok(());
}
Err(format!("no staged blob and no rho encryption policy matches: {repo_path}").into())
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
struct EncryptedFileManifest {
version: u32,
kind: String,
crypto: EncryptedFileCrypto,
payload: EncryptedFilePayload,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
struct EncryptedFileCrypto {
algorithm: String,
iterations: u32,
key_source: String,
created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
struct EncryptedFilePayload {
nonce_base64: String,
ciphertext_base64: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
struct RecipientEnvelopeManifest {
version: u32,
kind: String,
crypto: RecipientEnvelopeCrypto,
recipients: Vec<RecipientEnvelopeRecipient>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
struct RecipientEnvelopeCrypto {
algorithm: String,
content_encryption: String,
created_at: String,
context: RecipientEnvelopeContext,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
struct RecipientEnvelopeContext {
repo_id: String,
path: String,
purpose: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
struct RecipientEnvelopeRecipient {
identity_id: String,
key_id: String,
algorithm: String,
ephemeral_public_key: String,
nonce_base64: String,
ciphertext_base64: String,
}
fn encrypt(args: &[String]) -> RhoResult<()> {
let Some(input_path) = args.first().map(PathBuf::from) else {
usage();
};
let key_file = PathBuf::from(require_arg(args, "--key-file").unwrap_or_else(|_| usage()));
let out = PathBuf::from(require_arg(args, "--out").unwrap_or_else(|_| usage()));
let plaintext = fs::read(&input_path)
.map_err(|_| format!("file to encrypt not found: {}", input_path.display()))?;
let envelope = encrypt_bytes(&plaintext, &key_file)?;
ensure_parent(&out)?;
fs::write(&out, to_yaml(&envelope)?)?;
println!("{}", out.display());
Ok(())
}
fn seal(args: &[String]) -> RhoResult<()> {
let Some(input_path) = args.first().map(PathBuf::from) else {
usage();
};
let recipients = repeated_arg(args, "--recipient")
.into_iter()
.map(|recipient| normalize_actor_id(&recipient))
.collect::<RhoResult<Vec<_>>>()?;
if recipients.is_empty() {
return Err("missing required argument: --recipient".into());
}
let out = PathBuf::from(require_arg(args, "--out").unwrap_or_else(|_| usage()));
let plaintext = fs::read(&input_path)
.map_err(|_| format!("file to seal not found: {}", input_path.display()))?;
let (repo_root, context) = recipient_envelope_context_arg(args, "rho.bundle")?;
let expanded_recipients = expand_policy_recipients(&repo_root, recipients)?;
let envelope = seal_bytes_for_recipients_with_repo(
&plaintext,
&expanded_recipients,
Some(&repo_root),
context,
)?;
ensure_parent(&out)?;
fs::write(&out, to_yaml(&envelope)?)?;
println!("{}", out.display());
Ok(())
}
fn seal_bytes_for_recipients_with_repo(
plaintext: &[u8],
recipients: &[String],
repo_root: Option<&Path>,
context: RecipientEnvelopeContext,
) -> RhoResult<RecipientEnvelopeManifest> {
let rho_home = rho_home()?;
let mut records = Vec::new();
for recipient_id in unique_recipients(recipients) {
let handle = github_handle_from_identity_id(&recipient_id)?;
let identity = read_identity_bundle_for_recipient(repo_root, &rho_home, &handle)?;
let key = encryption_public_key(&identity)?;
records.push(seal_for_recipient(plaintext, &recipient_id, key, &context)?);
}
Ok(RecipientEnvelopeManifest {
version: 1,
kind: "rho_recipient_envelope".to_string(),
crypto: RecipientEnvelopeCrypto {
algorithm: RECIPIENT_KDF.to_string(),
content_encryption: RECIPIENT_CONTENT_ENCRYPTION.to_string(),
created_at: rho_core::now_rfc3339(),
context,
},
recipients: records,
})
}
fn open(args: &[String]) -> RhoResult<()> {
let Some(input_path) = args.first().map(PathBuf::from) else {
usage();
};
let identity_id = identity_arg_or_active(args)?;
let out = PathBuf::from(require_arg(args, "--out").unwrap_or_else(|_| usage()));
let text = fs::read_to_string(&input_path)
.map_err(|_| format!("recipient envelope not found: {}", input_path.display()))?;
let (_, expected_context) = recipient_envelope_context_arg(args, "rho.bundle")?;
check_recipient_envelope_context(&text, &expected_context)?;
let plaintext = open_text_for_identity(&text, &identity_id)?;
ensure_parent(&out)?;
fs::write(&out, plaintext)?;
println!("{}", out.display());
Ok(())
}
fn extract_tar(args: &[String]) -> RhoResult<()> {
let Some(input_path) = args.first().map(PathBuf::from) else {
usage();
};
let out_dir = PathBuf::from(require_arg(args, "--out-dir").unwrap_or_else(|_| usage()));
let bytes = fs::read(&input_path)
.map_err(|_| format!("tar file not found: {}", input_path.display()))?;
safe_extract_tar_bytes(&bytes, &out_dir)?;
println!("{}", out_dir.display());
Ok(())
}
fn decrypt(args: &[String]) -> RhoResult<()> {
let Some(input_path) = args.first().map(PathBuf::from) else {
usage();
};
let key_file = PathBuf::from(require_arg(args, "--key-file").unwrap_or_else(|_| usage()));
let out = PathBuf::from(require_arg(args, "--out").unwrap_or_else(|_| usage()));
let text = fs::read_to_string(&input_path)
.map_err(|_| format!("encrypted file not found: {}", input_path.display()))?;
let plaintext = decrypt_text(&text, &key_file)?;
ensure_parent(&out)?;
fs::write(&out, plaintext)?;
println!("{}", out.display());
Ok(())
}
fn clean(args: &[String]) -> RhoResult<()> {
let key_file = arg_value(args, "--key-file").map(PathBuf::from);
let repo_root = arg_value(args, "--repo-root").map(PathBuf::from);
let repo_path = arg_value(args, "--path");
let mut input = Vec::new();
std::io::stdin().read_to_end(&mut input)?;
if is_rho_encrypted_text(&input) {
std::io::stdout().write_all(&input)?;
return Ok(());
}
if let (Some(repo_root), Some(repo_path)) = (repo_root, repo_path)
&& let Some(recipients) = policy_recipients_for_path(&repo_root, &repo_path)?
{
let context = recipient_envelope_context_for(&repo_root, &repo_path, "rho.git_filter")?;
if let Some(existing) = reusable_index_envelope(&repo_root, &repo_path, &input, &context)? {
std::io::stdout().write_all(&existing)?;
return Ok(());
}
let envelope =
seal_bytes_for_recipients_with_repo(&input, &recipients, Some(&repo_root), context)?;
std::io::stdout().write_all(to_yaml(&envelope)?.as_bytes())?;
return Ok(());
}
let Some(key_file) = key_file else {
return Err("missing required argument: --key-file or --repo-root/--path".into());
};
let envelope = encrypt_bytes(&input, &key_file)?;
std::io::stdout().write_all(to_yaml(&envelope)?.as_bytes())?;
Ok(())
}
fn smudge(args: &[String]) -> RhoResult<()> {
let key_file = arg_value(args, "--key-file").map(PathBuf::from);
let expected_context = match (
arg_value(args, "--repo-root").map(PathBuf::from),
arg_value(args, "--path"),
) {
(Some(repo_root), Some(repo_path)) => Some(recipient_envelope_context_for(
&repo_root,
&repo_path,
"rho.git_filter",
)?),
_ => None,
};
let mut input = Vec::new();
std::io::stdin().read_to_end(&mut input)?;
if !is_rho_encrypted_text(&input) {
std::io::stdout().write_all(&input)?;
return Ok(());
}
let text = String::from_utf8(input)?;
if yaml_top_level_kind(&text).as_deref() == Some("rho_recipient_envelope") {
let envelope: RecipientEnvelopeManifest = from_yaml(&text)?;
let Some(expected_context) = expected_context.as_ref() else {
return Err("recipient envelope smudge requires --repo-root and --path".into());
};
check_recipient_envelope_context_manifest(&envelope, expected_context)?;
if let Ok(identity_id) = active_identity() {
match open_envelope_for_identity(&envelope, &identity_id) {
Ok(plaintext) => std::io::stdout().write_all(&plaintext)?,
Err(error)
if recipient_envelope_manifest_has_recipient(&envelope, &identity_id) =>
{
return Err(format!(
"recipient envelope decrypt failed for {identity_id}: {error}"
)
.into());
}
Err(_) => std::io::stdout().write_all(text.as_bytes())?,
}
} else {
match open_envelope_with_any_local_identity(&envelope) {
Ok(plaintext) => std::io::stdout().write_all(&plaintext)?,
Err(_) => std::io::stdout().write_all(text.as_bytes())?,
}
}
return Ok(());
}
let Some(key_file) = key_file else {
std::io::stdout().write_all(text.as_bytes())?;
return Ok(());
};
let plaintext = decrypt_text(&text, &key_file)?;
std::io::stdout().write_all(&plaintext)?;
Ok(())
}
fn encrypt_bytes(plaintext: &[u8], key_file: &Path) -> RhoResult<EncryptedFileManifest> {
if !key_file.is_file() {
return Err(format!("rho crypto key file not found: {}", key_file.display()).into());
}
let key_material = fs::read(key_file)?;
let (nonce, ciphertext) =
encrypt_aead(plaintext, &derive_key(&key_material, TRANSPARENT_INFO)?)?;
Ok(EncryptedFileManifest {
version: 1,
kind: TRANSPARENT_KIND.to_string(),
crypto: EncryptedFileCrypto {
algorithm: TRANSPARENT_ALGORITHM.to_string(),
iterations: 0,
key_source: "repo-local-key-file".to_string(),
created_at: rho_core::now_rfc3339(),
},
payload: EncryptedFilePayload {
nonce_base64: BASE64.encode(nonce),
ciphertext_base64: BASE64.encode(ciphertext),
},
})
}
fn decrypt_text(text: &str, key_file: &Path) -> RhoResult<Vec<u8>> {
if !key_file.is_file() {
return Err(format!("rho crypto key file not found: {}", key_file.display()).into());
}
let envelope: EncryptedFileManifest = from_yaml(text)?;
if envelope.kind != TRANSPARENT_KIND {
return Err(format!("unsupported encrypted file kind: {}", envelope.kind).into());
}
if envelope.crypto.algorithm != TRANSPARENT_ALGORITHM {
return Err(format!(
"unsupported encrypted file algorithm: {}",
envelope.crypto.algorithm
)
.into());
}
let key_material = fs::read(key_file)?;
let nonce = decode_fixed::<12>(&envelope.payload.nonce_base64, "nonce")?;
let ciphertext = BASE64.decode(&envelope.payload.ciphertext_base64)?;
decrypt_aead(
&ciphertext,
&derive_key(&key_material, TRANSPARENT_INFO)?,
&nonce,
)
}
fn is_rho_encrypted_text(input: &[u8]) -> bool {
std::str::from_utf8(input)
.map(is_rho_encrypted_yaml_text)
.unwrap_or(false)
}
fn reusable_index_envelope(
repo_root: &Path,
repo_path: &str,
plaintext: &[u8],
expected_context: &RecipientEnvelopeContext,
) -> RhoResult<Option<Vec<u8>>> {
let Some(blob) = index_blob(repo_root, repo_path)? else {
return Ok(None);
};
let Ok(text) = std::str::from_utf8(&blob) else {
return Ok(None);
};
if yaml_top_level_kind(text).as_deref() != Some("rho_recipient_envelope") {
return Ok(None);
};
let envelope: RecipientEnvelopeManifest = from_yaml(text)?;
if envelope.crypto.context != *expected_context {
return Ok(None);
}
let opened = active_identity()
.ok()
.and_then(|identity_id| open_envelope_for_identity(&envelope, &identity_id).ok())
.or_else(|| open_envelope_with_any_local_identity(&envelope).ok());
if opened.as_deref() == Some(plaintext) {
return Ok(Some(blob));
}
Ok(None)
}
fn seal_for_recipient(
plaintext: &[u8],
identity_id: &str,
key: &rho_core::IdentityPublicKey,
context: &RecipientEnvelopeContext,
) -> RhoResult<RecipientEnvelopeRecipient> {
if key.algorithm != "x25519" || key.kind != "x25519-encryption" {
return Err(format!("recipient key is not an X25519 encryption key: {}", key.id).into());
}
let recipient_public =
X25519PublicKey::from(decode_fixed::<32>(&key.public_key, "recipient public key")?);
let ephemeral_private = random_x25519_secret()?;
let ephemeral_public = X25519PublicKey::from(&ephemeral_private);
let shared = ephemeral_private.diffie_hellman(&recipient_public);
let (nonce, ciphertext) = encrypt_aead(
plaintext,
&derive_key(
shared.as_bytes(),
recipient_envelope_info(context).as_bytes(),
)?,
)?;
Ok(RecipientEnvelopeRecipient {
identity_id: identity_id.to_string(),
key_id: key.id.clone(),
algorithm: "x25519".to_string(),
ephemeral_public_key: BASE64.encode(ephemeral_public.as_bytes()),
nonce_base64: BASE64.encode(nonce),
ciphertext_base64: BASE64.encode(ciphertext),
})
}
fn open_for_recipient(
record: &RecipientEnvelopeRecipient,
private_key_path: &str,
context: &RecipientEnvelopeContext,
) -> RhoResult<Vec<u8>> {
if record.algorithm != "x25519" {
return Err(format!("unsupported recipient algorithm: {}", record.algorithm).into());
}
let private_key = PathBuf::from(private_key_path);
if !private_key.is_file() {
return Err(format!(
"private encryption key not found: {}",
private_key.display()
)
.into());
}
let private = StaticSecret::from(decode_fixed::<32>(
fs::read_to_string(&private_key)?.trim(),
"local private encryption key",
)?);
let ephemeral_public = X25519PublicKey::from(decode_fixed::<32>(
&record.ephemeral_public_key,
"ephemeral public key",
)?);
let shared = private.diffie_hellman(&ephemeral_public);
let nonce = decode_fixed::<12>(&record.nonce_base64, "nonce")?;
let ciphertext = BASE64.decode(&record.ciphertext_base64)?;
decrypt_aead(
&ciphertext,
&derive_key(
shared.as_bytes(),
recipient_envelope_info(context).as_bytes(),
)?,
&nonce,
)
}
fn open_envelope_with_any_local_identity(
envelope: &RecipientEnvelopeManifest,
) -> RhoResult<Vec<u8>> {
let rho_home = rho_home()?;
let identities_dir = rho_home.join("identities/github");
if !identities_dir.is_dir() {
return Err("no local identities available".into());
}
let mut last_error = None;
for entry in fs::read_dir(identities_dir)? {
let path = entry?.path();
if path.extension().and_then(|value| value.to_str()) != Some("yaml") {
continue;
}
let text = fs::read_to_string(&path)?;
let local: LocalIdentityManifest = from_yaml(&text)?;
let identity_id = local.local_identity.identity.id.clone();
let Some(record) = envelope
.recipients
.iter()
.find(|recipient| recipient.identity_id == identity_id)
else {
continue;
};
let Some(encryption_key) = local.local_identity.encryption_key else {
continue;
};
match open_with_local_encryption_key(record, &encryption_key, &envelope.crypto.context) {
Ok(plaintext) => return Ok(plaintext),
Err(error) => last_error = Some(error.to_string()),
}
}
Err(last_error
.unwrap_or_else(|| "no local identity can open recipient envelope".to_string())
.into())
}
fn open_text_for_identity(text: &str, identity_id: &str) -> RhoResult<Vec<u8>> {
let envelope: RecipientEnvelopeManifest = from_yaml(text)?;
if envelope.kind != "rho_recipient_envelope" {
return Err(format!("unsupported envelope kind: {}", envelope.kind).into());
}
open_envelope_for_identity(&envelope, identity_id)
}
fn open_envelope_for_identity(
envelope: &RecipientEnvelopeManifest,
identity_id: &str,
) -> RhoResult<Vec<u8>> {
let record = envelope
.recipients
.iter()
.find(|recipient| recipient.identity_id == identity_id)
.ok_or_else(|| format!("envelope has no recipient entry for {identity_id}"))?;
let rho_home = rho_home()?;
let handle = github_handle_from_identity_id(identity_id)?;
let local = read_local_identity(&rho_home, &handle)?;
let encryption_key = local
.local_identity
.encryption_key
.ok_or("local identity has no encryption key")?;
open_with_local_encryption_key(record, &encryption_key, &envelope.crypto.context)
}
fn recipient_envelope_manifest_has_recipient(
envelope: &RecipientEnvelopeManifest,
identity_id: &str,
) -> bool {
envelope
.recipients
.iter()
.any(|recipient| recipient.identity_id == identity_id)
}
fn open_with_local_encryption_key(
record: &RecipientEnvelopeRecipient,
encryption_key: &LocalEncryptionKey,
context: &RecipientEnvelopeContext,
) -> RhoResult<Vec<u8>> {
if encryption_key.algorithm != "x25519" {
return Err(format!(
"unsupported local encryption key algorithm: {}",
encryption_key.algorithm
)
.into());
}
open_for_recipient(record, &encryption_key.private_key_ref.path, context)
}
fn recipient_envelope_context_arg(
args: &[String],
default_purpose: &str,
) -> RhoResult<(PathBuf, RecipientEnvelopeContext)> {
match (
arg_value(args, "--repo-root").map(PathBuf::from),
arg_value(args, "--path"),
) {
(Some(repo_root), Some(repo_path)) => {
let purpose =
arg_value(args, "--purpose").unwrap_or_else(|| default_purpose.to_string());
let context = recipient_envelope_context_for(&repo_root, &repo_path, &purpose)?;
Ok((repo_root, context))
}
_ => Err("recipient envelopes require --repo-root and --path".into()),
}
}
fn recipient_envelope_context_for(
repo_root: &Path,
repo_path: &str,
purpose: &str,
) -> RhoResult<RecipientEnvelopeContext> {
Ok(RecipientEnvelopeContext {
repo_id: read_repo_id(repo_root)?,
path: normalize_repo_path_context(repo_path)?,
purpose: purpose.to_string(),
})
}
fn check_recipient_envelope_context(
text: &str,
expected: &RecipientEnvelopeContext,
) -> RhoResult<()> {
let envelope: RecipientEnvelopeManifest = from_yaml(text)?;
check_recipient_envelope_context_manifest(&envelope, expected)
}
fn check_recipient_envelope_context_manifest(
envelope: &RecipientEnvelopeManifest,
expected: &RecipientEnvelopeContext,
) -> RhoResult<()> {
let actual = &envelope.crypto.context;
if actual != expected {
return Err(format!(
"recipient envelope context mismatch: expected repo_id={} path={} purpose={}, got repo_id={} path={} purpose={}",
expected.repo_id,
expected.path,
expected.purpose,
actual.repo_id,
actual.path,
actual.purpose
)
.into());
}
Ok(())
}
fn recipient_envelope_info(context: &RecipientEnvelopeContext) -> String {
format!(
"{}\nrepo_id: {}\npath: {}\npurpose: {}\n",
String::from_utf8_lossy(RECIPIENT_INFO),
context.repo_id,
context.path,
context.purpose
)
}
fn normalize_repo_path_context(repo_path: &str) -> RhoResult<String> {
let path = Path::new(repo_path);
if path.is_absolute() {
return Err(format!("recipient envelope path must be repo-relative: {repo_path}").into());
}
for component in path.components() {
match component {
Component::Normal(_) | Component::CurDir => {}
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
return Err(format!("unsafe recipient envelope path: {repo_path}").into());
}
}
}
Ok(repo_path.replace('\\', "/"))
}
fn encryption_public_key(identity: &IdentityBundle) -> RhoResult<&rho_core::IdentityPublicKey> {
identity
.public_keys
.iter()
.find(|key| key.kind == "x25519-encryption" && key.algorithm == "x25519")
.ok_or_else(|| format!("identity has no X25519 encryption key: {}", identity.id).into())
}
fn random_bytes<const N: usize>() -> RhoResult<[u8; N]> {
let mut bytes = [0u8; N];
getrandom::getrandom(&mut bytes).map_err(|error| error.to_string())?;
Ok(bytes)
}
fn random_x25519_secret() -> RhoResult<StaticSecret> {
Ok(StaticSecret::from(random_bytes::<32>()?))
}
fn derive_key(input: &[u8], info: &[u8]) -> RhoResult<[u8; 32]> {
let hk = Hkdf::<Sha256>::new(None, input);
let mut output = [0u8; 32];
hk.expand(info, &mut output)
.map_err(|_| "HKDF output length was invalid")?;
Ok(output)
}
fn encrypt_aead(plaintext: &[u8], key: &[u8; 32]) -> RhoResult<([u8; 12], Vec<u8>)> {
let nonce = random_bytes::<12>()?;
let cipher = ChaCha20Poly1305::new(key.into());
let ciphertext = cipher
.encrypt(Nonce::from_slice(&nonce), plaintext)
.map_err(|_| "AEAD encryption failed")?;
Ok((nonce, ciphertext))
}
fn decrypt_aead(ciphertext: &[u8], key: &[u8; 32], nonce: &[u8; 12]) -> RhoResult<Vec<u8>> {
let cipher = ChaCha20Poly1305::new(key.into());
cipher
.decrypt(Nonce::from_slice(nonce), ciphertext)
.map_err(|_| "AEAD decryption failed".into())
}
fn safe_extract_tar_bytes(bytes: &[u8], out_dir: &Path) -> RhoResult<()> {
fs::create_dir_all(out_dir)?;
let mut offset = 0usize;
while offset + 512 <= bytes.len() {
let header = &bytes[offset..offset + 512];
offset += 512;
if header.iter().all(|byte| *byte == 0) {
break;
}
let path = tar_entry_path(header)?;
validate_tar_path(&path)?;
let entry_type = header[156];
let size = tar_octal(&header[124..136])?;
let data_end = offset.checked_add(size).ok_or("tar entry size overflow")?;
if data_end > bytes.len() {
return Err(
format!("tar entry extends past end of archive: {}", path.display()).into(),
);
}
match entry_type {
0 | b'0' => {
let target = out_dir.join(&path);
ensure_parent(&target)?;
fs::write(&target, &bytes[offset..data_end])?;
}
b'5' => {
fs::create_dir_all(out_dir.join(&path))?;
}
b'1' => return Err(format!("tar hardlink rejected: {}", path.display()).into()),
b'2' => return Err(format!("tar symlink rejected: {}", path.display()).into()),
b'3' | b'4' => {
return Err(format!("tar device file rejected: {}", path.display()).into());
}
b'6' => return Err(format!("tar fifo rejected: {}", path.display()).into()),
_ => {
return Err(format!(
"unsupported tar entry type {} for {}",
entry_type,
path.display()
)
.into());
}
}
offset = data_end + padding_len(size);
}
Ok(())
}
fn tar_entry_path(header: &[u8]) -> RhoResult<PathBuf> {
let name = tar_string(&header[0..100]);
let prefix = tar_string(&header[345..500]);
let combined = if prefix.is_empty() {
name
} else {
format!("{prefix}/{name}")
};
if combined.is_empty() {
return Err("tar entry has empty path".into());
}
Ok(PathBuf::from(combined))
}
fn tar_string(bytes: &[u8]) -> String {
let end = bytes
.iter()
.position(|byte| *byte == 0)
.unwrap_or(bytes.len());
String::from_utf8_lossy(&bytes[..end]).trim().to_string()
}
fn tar_octal(bytes: &[u8]) -> RhoResult<usize> {
let text = tar_string(bytes);
let trimmed = text.trim_matches(char::from(0)).trim();
if trimmed.is_empty() {
return Ok(0);
}
usize::from_str_radix(trimmed, 8)
.map_err(|error| format!("invalid tar octal size {trimmed}: {error}").into())
}
fn padding_len(size: usize) -> usize {
let remainder = size % 512;
if remainder == 0 { 0 } else { 512 - remainder }
}
fn validate_tar_path(path: &Path) -> RhoResult<()> {
if path.is_absolute() {
return Err(format!("tar absolute path rejected: {}", path.display()).into());
}
let mut has_component = false;
for component in path.components() {
match component {
Component::Normal(_) => has_component = true,
Component::CurDir => {}
Component::ParentDir => {
return Err(format!("tar parent path rejected: {}", path.display()).into());
}
Component::RootDir | Component::Prefix(_) => {
return Err(format!("tar absolute path rejected: {}", path.display()).into());
}
}
}
if !has_component {
return Err(format!("tar empty path rejected: {}", path.display()).into());
}
Ok(())
}
fn decode_fixed<const N: usize>(value: &str, label: &str) -> RhoResult<[u8; N]> {
let bytes = BASE64.decode(value.trim())?;
bytes
.try_into()
.map_err(|bytes: Vec<u8>| format!("{label} must be {N} bytes, got {}", bytes.len()).into())
}
#[derive(Debug, Deserialize)]
struct PermissionsManifest {
#[serde(default)]
permissions: Vec<PermissionRule>,
}
#[derive(Debug, Deserialize)]
struct RepoManifest {
repo: RepoRecord,
}
#[derive(Debug, Deserialize)]
struct RepoRecord {
id: String,
}
#[derive(Debug, Deserialize)]
struct PermissionRule {
pattern: String,
#[serde(default)]
read: ReadPermission,
}
#[derive(Debug, Default, Deserialize)]
struct ReadPermission {
#[serde(default)]
public: bool,
#[serde(default)]
encrypted_to: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct MembershipManifest {
#[serde(default)]
members: Vec<MemberRecord>,
}
#[derive(Debug, Deserialize)]
struct MemberRecord {
identity: String,
role: String,
}
#[derive(Debug, Deserialize)]
struct RevocationsManifest {
#[serde(default)]
revocations: Vec<RevocationRecord>,
}
#[derive(Debug, Deserialize)]
struct RevocationRecord {
identity: String,
key_id: String,
reason: Option<String>,
revoked_at: Option<String>,
revoked_by: Option<String>,
}
fn policy_recipients_for_path(repo_root: &Path, repo_path: &str) -> RhoResult<Option<Vec<String>>> {
let path = repo_root.join("rho/policy/permissions.yaml");
let text = match fs::read_to_string(&path) {
Ok(text) => text,
Err(_) => return Ok(None),
};
let manifest: PermissionsManifest = from_yaml(&text)?;
manifest
.permissions
.into_iter()
.find(|rule| !rule.read.public && path_matches_pattern(repo_path, &rule.pattern))
.map(|rule| expand_policy_recipients(repo_root, rule.read.encrypted_to))
.transpose()
}
fn read_repo_id(repo_root: &Path) -> RhoResult<String> {
let path = repo_root.join("rho/repo.yaml");
let text = fs::read_to_string(&path)
.map_err(|_| format!("repo manifest not found: {}", path.display()))?;
let manifest: RepoManifest = from_yaml(&text)?;
normalize_repo_id(&manifest.repo.id)
}
fn expand_policy_recipients(
repo_root: &Path,
mut recipients: Vec<String>,
) -> RhoResult<Vec<String>> {
recipients.extend(repo_admin_recipients(repo_root)?);
if let Some(identity) = local_sender_identity()? {
recipients.push(identity);
}
Ok(unique_recipients(&recipients))
}
fn repo_admin_recipients(repo_root: &Path) -> RhoResult<Vec<String>> {
let path = repo_root.join("rho/membership.yaml");
let text = match fs::read_to_string(path) {
Ok(text) => text,
Err(_) => return Ok(Vec::new()),
};
let manifest: MembershipManifest = from_yaml(&text)?;
Ok(manifest
.members
.into_iter()
.filter(|member| matches!(member.role.as_str(), "owner" | "admin"))
.map(|member| member.identity)
.collect())
}
fn local_sender_identity() -> RhoResult<Option<String>> {
if let Ok(identity) = active_identity() {
return Ok(Some(identity));
}
let rho_home = rho_home()?;
let identity_dir = rho_home.join("identities").join("github");
let entries = match fs::read_dir(identity_dir) {
Ok(entries) => entries,
Err(_) => return Ok(None),
};
let mut identities = Vec::new();
for entry in entries {
let path = entry?.path();
if path.extension().and_then(|value| value.to_str()) != Some("yaml") {
continue;
}
let text = fs::read_to_string(path)?;
let manifest: LocalIdentityManifest = from_yaml(&text)?;
identities.push(manifest.local_identity.identity.id);
}
identities.sort();
identities.dedup();
Ok(if identities.len() == 1 {
identities.pop()
} else {
None
})
}
fn unique_recipients(recipients: &[String]) -> Vec<String> {
let mut unique = Vec::new();
for recipient in recipients {
if !unique.iter().any(|seen| seen == recipient) {
unique.push(recipient.clone());
}
}
unique
}
fn repo_relative_path(repo_root: &Path, input_path: &Path) -> RhoResult<String> {
let path = if input_path.is_absolute() {
let root = repo_root
.canonicalize()
.unwrap_or_else(|_| repo_root.to_path_buf());
let canonical = input_path
.canonicalize()
.map_err(|_| format!("file not found: {}", input_path.display()))?;
canonical
.strip_prefix(&root)
.map_err(|_| {
format!(
"path is outside repo root: {} under {}",
input_path.display(),
repo_root.display()
)
})?
.to_path_buf()
} else {
input_path.to_path_buf()
};
let text = path
.to_str()
.ok_or_else(|| -> Box<dyn std::error::Error> { "path is not valid UTF-8".into() })?
.trim_start_matches("./")
.to_string();
if text.is_empty() || text.starts_with("../") || text.contains("/../") {
return Err(format!("unsafe repo path: {}", input_path.display()).into());
}
Ok(text)
}
fn index_blob(repo_root: &Path, repo_path: &str) -> RhoResult<Option<Vec<u8>>> {
let output = Command::new("git")
.arg("-C")
.arg(repo_root)
.args(["show", &format!(":{repo_path}")])
.output()?;
if output.status.success() {
return Ok(Some(output.stdout));
}
Ok(None)
}
fn repeated_arg(args: &[String], flag: &str) -> Vec<String> {
args.windows(2)
.filter(|window| window[0] == flag)
.map(|window| window[1].clone())
.collect()
}
fn sign(args: &[String]) -> RhoResult<()> {
let Some(signed_path) = args.first().map(PathBuf::from) else {
usage();
};
let identity_id = identity_arg_or_active(args)?;
let out = PathBuf::from(require_arg(args, "--out").unwrap_or_else(|_| usage()));
if !signed_path.is_file() {
return Err(format!("file to sign not found: {}", signed_path.display()).into());
}
let rho_home = rho_home()?;
let handle = github_handle_from_identity_id(&identity_id)?;
let local = read_local_identity(&rho_home, &handle)?;
let identity = &local.local_identity.identity;
if identity.id != identity_id {
return Err(format!(
"identity mismatch: requested {identity_id}, found {}",
identity.id
)
.into());
}
let key = identity
.public_keys
.first()
.ok_or("local identity has no public keys")?;
let private_key = PathBuf::from(&local.local_identity.signing_key.private_key_ref.path);
if !private_key.is_file() {
return Err(format!("private signing key not found: {}", private_key.display()).into());
}
let context = signature_context_from_args(args)?;
let signed_sha256 = file_digest(&signed_path)?;
let raw_signature = if let Some(context) = context.as_ref() {
let payload = signature_context_payload(&signed_sha256, context);
ssh_sign_bytes(payload.as_bytes(), &private_key, out.parent())?
} else {
ssh_sign_file(&signed_path, &private_key, out.parent())?
};
ensure_parent(&out)?;
let manifest = SignatureManifest {
version: 1,
signature: SignatureRecord {
signed_path: signed_path.display().to_string(),
signed_sha256,
signer: identity.id.clone(),
key_id: key.id.clone(),
algorithm: key.algorithm.clone(),
namespace: SIGNATURE_NAMESPACE.to_string(),
context,
signature: raw_signature,
created_at: rho_core::now_rfc3339(),
},
};
fs::write(&out, to_yaml(&manifest)?)?;
println!("{}", out.display());
Ok(())
}
fn verify(args: &[String]) -> RhoResult<()> {
let Some(signed_path) = args.first().map(PathBuf::from) else {
usage();
};
let signature_path =
PathBuf::from(require_arg(args, "--signature").unwrap_or_else(|_| usage()));
let identity_id = identity_arg_or_active(args)?;
let repo_root = arg_value(args, "--repo-root").map(PathBuf::from);
let expected_context = signature_context_from_args(args)?;
if !signed_path.is_file() {
return Err(format!("signed file not found: {}", signed_path.display()).into());
}
let text = fs::read_to_string(&signature_path)
.map_err(|_| format!("signature not found: {}", signature_path.display()))?;
let manifest: SignatureManifest = from_yaml(&text)?;
let record = manifest.signature;
if record.signer != identity_id {
return Err(format!(
"signature signer mismatch: expected {identity_id}, got {}",
record.signer
)
.into());
}
if record.namespace != SIGNATURE_NAMESPACE {
return Err(format!("unsupported signature namespace: {}", record.namespace).into());
}
check_revocation(repo_root.as_deref(), &record)?;
let actual_digest = file_digest(&signed_path)?;
if record.signed_sha256 != actual_digest {
return Err(format!(
"signed file digest mismatch: expected {}, got {actual_digest}",
record.signed_sha256
)
.into());
}
if let Some(expected_context) = expected_context.as_ref() {
check_signature_context(record.context.as_ref(), expected_context)?;
}
let rho_home = rho_home()?;
let handle = github_handle_from_identity_id(&identity_id)?;
let identity = read_identity_bundle_for_recipient(repo_root.as_deref(), &rho_home, &handle)?;
let key = identity
.public_keys
.iter()
.find(|key| key.id == record.key_id)
.ok_or_else(|| format!("identity does not contain key {}", record.key_id))?;
if key.algorithm != record.algorithm {
return Err(format!(
"signature algorithm mismatch: key has {}, signature has {}",
key.algorithm, record.algorithm
)
.into());
}
if let Some(context) = record.context.as_ref() {
let payload = signature_context_payload(&record.signed_sha256, context);
ssh_verify_bytes(
payload.as_bytes(),
&record.signature,
&identity.id,
&key.public_key,
)?;
} else {
ssh_verify_file(
&signed_path,
&record.signature,
&identity.id,
&key.public_key,
)?;
}
println!("verified {}", signature_path.display());
Ok(())
}
fn signature_context_from_args(args: &[String]) -> RhoResult<Option<SignatureContext>> {
let repo_id = arg_value(args, "--repo-id")
.map(|value| normalize_repo_id(&value))
.transpose()?;
let request_id = arg_value(args, "--request-id")
.map(|value| {
validate_request_id(&value)?;
Ok::<String, Box<dyn std::error::Error>>(value)
})
.transpose()?;
let message_id = arg_value(args, "--message-id")
.map(|value| normalize_message_id(&value))
.transpose()?;
let recipient_id = arg_value(args, "--recipient")
.map(|value| normalize_actor_id(&value))
.transpose()?;
let purpose = arg_value(args, "--purpose")
.map(|value| normalize_signature_purpose(&value))
.transpose()?;
let any_context = repo_id.is_some()
|| request_id.is_some()
|| message_id.is_some()
|| recipient_id.is_some()
|| purpose.is_some();
if !any_context {
return Ok(None);
}
if request_id.is_some() && message_id.is_some() {
return Err("signature context cannot include both --request-id and --message-id".into());
}
if repo_id.is_none()
|| (request_id.is_none() && message_id.is_none())
|| recipient_id.is_none()
|| purpose.is_none()
{
return Err(
"signature context requires --repo-id, one of --request-id or --message-id, --recipient, and --purpose".into(),
);
}
Ok(Some(SignatureContext {
repo_id,
request_id,
message_id,
recipient_id,
purpose,
}))
}
fn normalize_message_id(value: &str) -> RhoResult<String> {
let value = value.trim();
if value.is_empty() {
return Err("message id must not be empty".into());
}
if value.len() > 128 {
return Err("message id must be 128 characters or fewer".into());
}
if !value
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-'))
{
return Err(format!(
"message id must contain only ASCII letters, digits, '.', '_', or '-': {value}"
)
.into());
}
Ok(value.to_string())
}
fn normalize_signature_purpose(value: &str) -> RhoResult<String> {
let value = value.trim();
if value.is_empty() {
return Err("signature purpose must not be empty".into());
}
if !value
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-' | ':'))
{
return Err(format!(
"signature purpose must contain only ASCII letters, digits, '.', '_', '-', or ':': {value}"
)
.into());
}
Ok(value.to_string())
}
fn check_signature_context(
actual: Option<&SignatureContext>,
expected: &SignatureContext,
) -> RhoResult<()> {
let Some(actual) = actual else {
return Err("signature missing required context".into());
};
check_context_field(
"repo_id",
actual.repo_id.as_ref(),
expected.repo_id.as_ref(),
)?;
check_context_field(
"request_id",
actual.request_id.as_ref(),
expected.request_id.as_ref(),
)?;
check_context_field(
"message_id",
actual.message_id.as_ref(),
expected.message_id.as_ref(),
)?;
check_context_field(
"recipient_id",
actual.recipient_id.as_ref(),
expected.recipient_id.as_ref(),
)?;
check_context_field(
"purpose",
actual.purpose.as_ref(),
expected.purpose.as_ref(),
)?;
Ok(())
}
fn check_context_field(
field: &str,
actual: Option<&String>,
expected: Option<&String>,
) -> RhoResult<()> {
if actual != expected {
return Err(format!(
"signature context mismatch for {field}: expected {}, got {}",
expected.map(String::as_str).unwrap_or("<missing>"),
actual.map(String::as_str).unwrap_or("<missing>")
)
.into());
}
Ok(())
}
fn signature_context_payload(signed_sha256: &str, context: &SignatureContext) -> String {
if context.message_id.is_none() {
return format!(
"rho signature payload v1\nsigned_sha256: {signed_sha256}\nrepo_id: {}\nrequest_id: {}\nrecipient_id: {}\npurpose: {}\n",
context.repo_id.as_deref().unwrap_or(""),
context.request_id.as_deref().unwrap_or(""),
context.recipient_id.as_deref().unwrap_or(""),
context.purpose.as_deref().unwrap_or("")
);
}
format!(
"rho signature payload v2\nsigned_sha256: {signed_sha256}\nrepo_id: {}\nrequest_id: {}\nmessage_id: {}\nrecipient_id: {}\npurpose: {}\n",
context.repo_id.as_deref().unwrap_or(""),
context.request_id.as_deref().unwrap_or(""),
context.message_id.as_deref().unwrap_or(""),
context.recipient_id.as_deref().unwrap_or(""),
context.purpose.as_deref().unwrap_or("")
)
}
fn check_revocation(repo_root: Option<&Path>, record: &SignatureRecord) -> RhoResult<()> {
let Some(repo_root) = repo_root else {
return Ok(());
};
let path = repo_root.join("rho/policy/revocations.yaml");
let text = match fs::read_to_string(&path) {
Ok(text) => text,
Err(_) => return Ok(()),
};
let manifest: RevocationsManifest = from_yaml(&text)?;
if let Some(revocation) = manifest.revocations.into_iter().find(|revocation| {
revocation.identity == record.signer && revocation.key_id == record.key_id
}) {
let mut message = format!(
"signature key revoked: signer {} key {}",
record.signer, record.key_id
);
if let Some(reason) = revocation.reason.as_deref()
&& !reason.trim().is_empty()
{
message.push_str(&format!(" ({})", reason.trim()));
}
if let Some(revoked_at) = revocation.revoked_at.as_deref()
&& !revoked_at.trim().is_empty()
{
message.push_str(&format!(" revoked_at={}", revoked_at.trim()));
}
if let Some(revoked_by) = revocation.revoked_by.as_deref()
&& !revoked_by.trim().is_empty()
{
message.push_str(&format!(" revoked_by={}", revoked_by.trim()));
}
return Err(message.into());
}
Ok(())
}
fn ssh_sign_file(
signed_path: &Path,
private_key: &Path,
temp_dir: Option<&Path>,
) -> RhoResult<String> {
ssh_sign_bytes(&fs::read(signed_path)?, private_key, temp_dir)
}
fn ssh_sign_bytes(
message_bytes: &[u8],
private_key: &Path,
temp_dir: Option<&Path>,
) -> RhoResult<String> {
let work_dir = temp_dir
.map(PathBuf::from)
.unwrap_or_else(env::temp_dir)
.join(format!("rho-sign-{}", uuid_like()));
fs::create_dir_all(&work_dir)?;
let message = work_dir.join("message");
fs::write(&message, message_bytes)?;
let output = Command::new("ssh-keygen")
.args(["-Y", "sign", "-f"])
.arg(private_key)
.args(["-n", SIGNATURE_NAMESPACE])
.arg(&message)
.output()?;
if !output.status.success() {
let _ = fs::remove_dir_all(&work_dir);
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("ssh-keygen signing failed: {}", stderr.trim()).into());
}
let signature_path = message.with_extension("sig");
let signature = fs::read_to_string(&signature_path)?;
fs::remove_dir_all(&work_dir)?;
Ok(signature)
}
fn ssh_verify_file(
signed_path: &Path,
signature: &str,
identity_id: &str,
public_key: &str,
) -> RhoResult<()> {
ssh_verify_bytes(&fs::read(signed_path)?, signature, identity_id, public_key)
}
fn ssh_verify_bytes(
message_bytes: &[u8],
signature: &str,
identity_id: &str,
public_key: &str,
) -> RhoResult<()> {
let work_dir = env::temp_dir().join(format!("rho-verify-{}", uuid_like()));
fs::create_dir_all(&work_dir)?;
let signature_path = work_dir.join("signature.sig");
let allowed_signers_path = work_dir.join("allowed_signers");
fs::write(&signature_path, signature)?;
fs::write(
&allowed_signers_path,
format!("{identity_id} namespaces=\"{SIGNATURE_NAMESPACE}\" {public_key}\n"),
)?;
let mut child = Command::new("ssh-keygen")
.args(["-Y", "verify", "-f"])
.arg(&allowed_signers_path)
.args(["-I", identity_id, "-n", SIGNATURE_NAMESPACE, "-s"])
.arg(&signature_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
{
let stdin = child
.stdin
.as_mut()
.ok_or("failed to open ssh-keygen stdin")?;
stdin.write_all(message_bytes)?;
}
let output = child.wait_with_output()?;
fs::remove_dir_all(&work_dir)?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("ssh signature verification failed: {}", stderr.trim()).into());
}
Ok(())
}
fn rho_home() -> RhoResult<PathBuf> {
if let Ok(value) = env::var("RHO_HOME")
&& !value.is_empty()
{
return Ok(PathBuf::from(value));
}
let home = env::var("HOME").map_err(|_| "HOME is not set and RHO_HOME was not provided")?;
Ok(PathBuf::from(home).join(".rho"))
}
fn active_identity() -> RhoResult<String> {
let identity = env::var("RHO_IDENTITY")
.map(|value| value.trim().to_string())
.ok()
.filter(|value| !value.is_empty())
.ok_or_else(|| -> Box<dyn std::error::Error> { "RHO_IDENTITY is not set".into() })?;
normalize_actor_id(&identity)
}
fn identity_arg_or_active(args: &[String]) -> RhoResult<String> {
match arg_value(args, "--identity") {
Some(identity) => normalize_actor_id(&identity),
None => active_identity().map_err(|_| {
"missing required argument: --identity, and RHO_IDENTITY is not set".into()
}),
}
}
fn read_local_identity(rho_home: &Path, handle: &str) -> RhoResult<LocalIdentityManifest> {
let path = rho_home
.join("identities")
.join("github")
.join(format!("{handle}.yaml"));
let text = fs::read_to_string(&path)
.map_err(|_| format!("local identity not found: {}", path.display()))?;
from_yaml(&text)
}
fn read_identity_bundle(rho_home: &Path, handle: &str) -> RhoResult<IdentityBundle> {
let local_path = rho_home
.join("identities")
.join("github")
.join(format!("{handle}.yaml"));
if local_path.is_file() {
let text = fs::read_to_string(local_path)?;
let manifest: LocalIdentityManifest = from_yaml(&text)?;
return Ok(manifest.local_identity.identity);
}
let peer_path = rho_home
.join("peers")
.join("github")
.join(format!("{handle}.yaml"));
let text = fs::read_to_string(&peer_path)
.map_err(|_| format!("identity not found: {}", peer_path.display()))?;
let manifest: IdentityBundleManifest = from_yaml(&text)?;
Ok(manifest.identity)
}
fn read_identity_bundle_for_recipient(
repo_root: Option<&Path>,
rho_home: &Path,
handle: &str,
) -> RhoResult<IdentityBundle> {
if let Some(repo_root) = repo_root {
let participant_path = repo_root
.join("rho")
.join("participants")
.join(format!("{handle}.yaml"));
if participant_path.is_file() {
let text = fs::read_to_string(participant_path)?;
let manifest: IdentityBundleManifest = from_yaml(&text)?;
return Ok(manifest.identity);
}
}
read_identity_bundle(rho_home, handle)
}
fn github_handle_from_identity_id(identity_id: &str) -> RhoResult<String> {
let Some(handle) = identity_id.strip_prefix("rho://id/github/") else {
return Err(format!("unsupported identity id: {identity_id}").into());
};
if handle.is_empty() || handle.contains('/') {
return Err(format!("unsupported identity id: {identity_id}").into());
}
Ok(handle.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_github_identity_id() {
assert_eq!(
github_handle_from_identity_id("rho://id/github/user1").unwrap(),
"user1"
);
assert!(github_handle_from_identity_id("rho://id/email/user1").is_err());
}
#[test]
fn detects_transparent_envelope_text() {
let text = b"version: 1\nkind: rho_transparent_file\n";
assert!(is_rho_encrypted_text(text));
assert!(is_rho_encrypted_text(
b"version: 1\nkind: rho_recipient_envelope\n"
));
assert!(!is_rho_encrypted_text(b"the private value is 1113.03\n"));
}
#[test]
fn safe_tar_extracts_regular_files() {
let out_dir = env::temp_dir().join(format!("rho-safe-tar-test-{}", uuid_like()));
let tar = test_tar(vec![
test_tar_entry("rho/requests/req.yaml", b"version: 1\n", b'0'),
test_tar_entry("rho/requests", b"", b'5'),
]);
safe_extract_tar_bytes(&tar, &out_dir).unwrap();
assert_eq!(
fs::read_to_string(out_dir.join("rho/requests/req.yaml")).unwrap(),
"version: 1\n"
);
fs::remove_dir_all(out_dir).unwrap();
}
#[test]
fn safe_tar_rejects_hostile_paths_and_links() {
let out_dir = env::temp_dir().join(format!("rho-hostile-tar-test-{}", uuid_like()));
for (path, entry_type, expected) in [
("../escape.txt", b'0', "parent path"),
("/tmp/escape.txt", b'0', "absolute path"),
("link", b'2', "symlink"),
("hardlink", b'1', "hardlink"),
] {
let tar = test_tar(vec![test_tar_entry(path, b"", entry_type)]);
let error = safe_extract_tar_bytes(&tar, &out_dir)
.unwrap_err()
.to_string();
assert!(
error.contains(expected),
"expected {expected:?} in {error:?}"
);
}
let _ = fs::remove_dir_all(out_dir);
}
fn test_tar(entries: Vec<Vec<u8>>) -> Vec<u8> {
let mut tar = Vec::new();
for entry in entries {
tar.extend(entry);
}
tar.extend([0u8; 1024]);
tar
}
fn test_tar_entry(path: &str, data: &[u8], entry_type: u8) -> Vec<u8> {
let mut header = [0u8; 512];
let path_bytes = path.as_bytes();
assert!(path_bytes.len() <= 100);
header[..path_bytes.len()].copy_from_slice(path_bytes);
let size = if entry_type == b'5' { 0 } else { data.len() };
let size_text = format!("{size:011o}\0");
header[124..124 + size_text.len()].copy_from_slice(size_text.as_bytes());
header[156] = entry_type;
header[257..263].copy_from_slice(b"ustar\0");
let mut out = header.to_vec();
if size > 0 {
out.extend(data);
out.extend(vec![0u8; padding_len(size)]);
}
out
}
}