use std::env;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{SystemTime, UNIX_EPOCH};
use age::secrecy::ExposeSecret;
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use rho_core::{
IdentityBundle, IdentityBundleManifest, IdentityProof, IdentityPublicKey, LocalEncryptionKey,
LocalGitIdentity, LocalIdentity, LocalIdentityManifest, LocalSigningKey, PrivateKeyRef,
RhoResult, SignatureRecord, TrustManifest, TrustRecord, arg_value, bytes_digest, ensure_parent,
file_digest, from_yaml, has_flag, normalize_actor_id, providers, require_arg, to_yaml,
uuid_like,
};
use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret};
fn usage() -> ! {
eprintln!(
"usage:\n rho id init <github/handle|handle> [--display-name <name>] [--yes]\n rho id init --github <handle> (--ssh-key <path.pub>|--generate-ssh-key) [--display-name <name>]\n rho id rotate-key <rho-id|github/handle|handle> [--yes]\n rho id export --identity <rho-id> --out <path>\n rho id import <identity.yaml>\n rho id verify-github --identity <rho-id> [--provider-file <path>]\n rho id trust <rho-id>\n rho id list\n rho id show <rho-id>\n rho id status <rho-id>"
);
std::process::exit(2);
}
pub fn run(args: &[String]) -> RhoResult<()> {
let Some(command) = args.first().map(String::as_str) else {
usage();
};
match command {
"init" => init(&args[1..]),
"rotate-key" => rotate_key(&args[1..]),
"export" => export(&args[1..]),
"import" => import(&args[1..]),
"verify-github" => verify_github(&args[1..]),
"trust" => trust(&args[1..]),
"list" => list(),
"show" => show(&args[1..]),
"status" => status(&args[1..]),
"--help" | "-h" => usage(),
_ => usage(),
}
}
fn init(args: &[String]) -> RhoResult<()> {
let shorthand = !args.is_empty() && !args[0].starts_with('-');
let handle = if shorthand {
let identity_id = normalize_actor_id(&args[0])?;
github_handle_from_identity_id(&identity_id)?
} else {
require_arg(args, "--github").unwrap_or_else(|_| usage())
};
validate_github_handle(&handle)?;
let mut display_name = arg_value(args, "--display-name").unwrap_or_else(|| handle.clone());
let generate = has_flag(args, "--generate-ssh-key");
let ssh_key = arg_value(args, "--ssh-key");
if shorthand && (generate || ssh_key.is_some()) {
return Err(
"shorthand init uses default key management; omit --ssh-key and --generate-ssh-key"
.into(),
);
}
if !shorthand && generate == ssh_key.is_some() {
return Err("choose exactly one of --ssh-key or --generate-ssh-key".into());
}
let rho_home = rho_home()?;
let path = local_identity_path(&rho_home, &handle);
if path.is_file() {
let mut local: LocalIdentityManifest = from_yaml(&fs::read_to_string(&path)?)?;
if shorthand && !has_flag(args, "--yes") {
maybe_bind_github_commit_identity(&mut local)?;
fs::write(&path, to_yaml(&local)?)?;
}
print_existing_identity(&rho_home, &path, &local);
return Ok(());
}
if shorthand && !has_flag(args, "--yes") {
println!("Rho identity init");
println!(" identity: {}", github_identity_id(&handle));
println!(" rho home: {}", rho_home.display());
println!(
" signing key: {}",
default_ssh_private_key(&rho_home, &handle).display()
);
println!(
" encryption key: {}",
default_age_private_key(&rho_home, &handle).display()
);
let mut answer = String::new();
println!("Display name [{display_name}]: ");
std::io::stdin().read_line(&mut answer)?;
let display_name_answer = answer.trim();
if !display_name_answer.is_empty() {
display_name = display_name_answer.to_string();
}
answer.clear();
println!("Create this identity? [Y/n] ");
std::io::stdin().read_line(&mut answer)?;
let answer = answer.trim().to_ascii_lowercase();
if answer == "n" || answer == "no" {
return Err("rho id init cancelled".into());
}
}
let (public_key_path, private_key_path, signing_key_source) = if shorthand {
let source = if default_ssh_key_pair_exists(&rho_home, &handle) {
"reused default"
} else {
"generated default"
};
let (public, private) = ensure_default_ssh_key(&rho_home, &handle)?;
(public, private, source)
} else if generate {
let (public, private) = generate_ssh_key(&rho_home, &handle)?;
(public, private, "generated")
} else {
let (public, private) = selected_ssh_key(ssh_key.as_deref().unwrap())?;
(public, private, "provided")
};
let public_key = fs::read_to_string(&public_key_path)
.map_err(|_| format!("ssh public key not found: {}", public_key_path.display()))?;
let algorithm = public_key_algorithm(&public_key)?;
let fingerprint = key_fingerprint(&public_key_path)?;
let (age_public_key_path, age_private_key_path, encryption_key_source) = if shorthand {
let source = if default_age_key_pair_exists(&rho_home, &handle) {
"reused default"
} else {
"generated default"
};
let (public, private) = ensure_default_age_key(&rho_home, &handle)?;
(public, private, source)
} else {
let (public, private) = generate_age_key(&rho_home, &handle)?;
(public, private, "generated")
};
let age_public_key = fs::read_to_string(&age_public_key_path)?;
let age_fingerprint = file_digest(&age_public_key_path)?;
let (x25519_public_key_path, _x25519_private_key_path) = if shorthand {
ensure_default_x25519_key(&rho_home, &handle)?
} else {
generate_x25519_key(&rho_home, &handle)?
};
let x25519_public_key = fs::read_to_string(&x25519_public_key_path)?;
let x25519_fingerprint = file_digest(&x25519_public_key_path)?;
let created_at = rho_core::now_rfc3339();
let identity_id = github_identity_id(&handle);
let key_id = format!("{identity_id}/{algorithm}-1").replace("rho://id/", "rho://key/");
let age_key_id = format!("{identity_id}/age-x25519-1").replace("rho://id/", "rho://key/");
let x25519_key_id = format!("{identity_id}/x25519-legacy-1").replace("rho://id/", "rho://key/");
let claim = proof_claim(&handle, &algorithm, &fingerprint);
let provider_url = github_provider_url(&handle);
let proof_url = proof_url(&provider_url, &claim);
let identity = IdentityBundle {
id: identity_id.clone(),
kind: "github".to_string(),
handle: handle.clone(),
display_name,
public_keys: vec![
IdentityPublicKey {
id: key_id,
kind: "ssh-signing".to_string(),
algorithm,
public_key: public_key.trim().to_string(),
fingerprint: fingerprint.clone(),
created_at: created_at.clone(),
},
IdentityPublicKey {
id: age_key_id,
kind: "age-encryption".to_string(),
algorithm: "age-x25519".to_string(),
public_key: age_public_key.trim().to_string(),
fingerprint: age_fingerprint,
created_at: created_at.clone(),
},
IdentityPublicKey {
id: x25519_key_id,
kind: "x25519-encryption".to_string(),
algorithm: "x25519".to_string(),
public_key: x25519_public_key.trim().to_string(),
fingerprint: x25519_fingerprint,
created_at: created_at.clone(),
},
],
proofs: vec![IdentityProof {
kind: "github-profile-fragment-claim".to_string(),
provider_url: Some(provider_url),
claim: Some(claim.clone()),
proof_url: proof_url.clone(),
verified_at: None,
}],
created_at,
};
let mut local = LocalIdentityManifest {
version: 1,
local_identity: LocalIdentity {
identity,
signing_key: LocalSigningKey {
kind: "ssh-signing".to_string(),
algorithm: "ssh-ed25519".to_string(),
public_key_path: public_key_path.display().to_string(),
private_key_ref: PrivateKeyRef {
backend: "ssh-file".to_string(),
path: private_key_path.display().to_string(),
},
},
encryption_key: Some(LocalEncryptionKey {
kind: "age-encryption".to_string(),
algorithm: "age-x25519".to_string(),
public_key_path: age_public_key_path.display().to_string(),
private_key_ref: PrivateKeyRef {
backend: "rho-file".to_string(),
path: age_private_key_path.display().to_string(),
},
}),
git: None,
},
};
if shorthand && !has_flag(args, "--yes") {
maybe_bind_github_commit_identity(&mut local)?;
}
ensure_parent(&path)?;
fs::write(&path, to_yaml(&local)?)?;
println!("created identity");
println!("identity: {identity_id}");
println!("rho home: {}", rho_home.display());
println!("local manifest: {}", path.display());
println!("public key: {}", public_key_path.display());
println!("signing key source: {signing_key_source}");
println!("encryption public key: {}", age_public_key_path.display());
println!(
"legacy x25519 public key: {}",
x25519_public_key_path.display()
);
println!("encryption key source: {encryption_key_source}");
println!("github profile proof URL:");
println!("{proof_url}");
println!();
println!("GitHub verification:");
println!("1. Copy this full proof URL into your public GitHub profile:");
println!("{proof_url}");
println!(" If GitHub only shows the link target partially, make sure this claim text");
println!(" is visible somewhere on the fetched profile page:");
println!("{claim}");
println!("2. Verify it with:");
println!("rho id verify-github --identity {identity_id}");
Ok(())
}
fn print_existing_identity(rho_home: &Path, path: &Path, local: &LocalIdentityManifest) {
let identity = &local.local_identity.identity;
println!("identity already exists");
println!("identity: {}", identity.id);
println!("rho home: {}", rho_home.display());
println!("local manifest: {}", path.display());
println!(
"public key: {}",
local.local_identity.signing_key.public_key_path
);
if let Some(encryption_key) = &local.local_identity.encryption_key {
println!("encryption public key: {}", encryption_key.public_key_path);
}
if let Some(git) = &local.local_identity.git {
println!("github commit login: {}", git.github_login);
println!(
"git commit author: {} <{}>",
git.commit_name, git.commit_email
);
}
if let Ok(proof) = github_profile_proof(identity) {
if !proof.proof_url.is_empty() {
println!("github profile proof URL:");
println!("{}", proof.proof_url);
println!();
println!("GitHub verification:");
println!("1. Copy this full proof URL into your public GitHub profile:");
println!("{}", proof.proof_url);
}
if let Some(claim) = &proof.claim {
println!(
" If GitHub only shows the link target partially, make sure this claim text"
);
println!(" is visible somewhere on the fetched profile page:");
println!("{claim}");
}
println!("2. Verify it with:");
println!("rho id verify-github --identity {}", identity.id);
}
}
fn rotate_key(args: &[String]) -> RhoResult<()> {
let Some(identity_arg) = args.first() else {
usage();
};
let identity_id = normalize_actor_id(identity_arg)?;
let handle = github_handle_from_identity_id(&identity_id)?;
let rho_home = rho_home()?;
let path = local_identity_path(&rho_home, &handle);
let mut local = read_local_identity(&rho_home, &handle)?;
if local.local_identity.identity.id != identity_id {
return Err(format!(
"identity mismatch: requested {identity_id}, found {}",
local.local_identity.identity.id
)
.into());
}
if !has_flag(args, "--yes") {
println!("Rotate Rho signing key");
println!(" identity: {identity_id}");
println!(" local manifest: {}", path.display());
println!(
" current public key: {}",
local.local_identity.signing_key.public_key_path
);
println!("This creates a new GitHub profile proof claim. Continue? [y/N] ");
let mut answer = String::new();
std::io::stdin().read_line(&mut answer)?;
let answer = answer.trim().to_ascii_lowercase();
if answer != "y" && answer != "yes" {
return Err("rho id rotate-key cancelled".into());
}
}
let next_index = next_signing_key_index(&local.local_identity.identity);
let (public_key_path, private_key_path) =
generate_rotated_ssh_key(&rho_home, &handle, next_index)?;
let public_key = fs::read_to_string(&public_key_path)
.map_err(|_| format!("ssh public key not found: {}", public_key_path.display()))?;
let algorithm = public_key_algorithm(&public_key)?;
let fingerprint = key_fingerprint(&public_key_path)?;
let created_at = rho_core::now_rfc3339();
let key_id =
format!("{identity_id}/{algorithm}-{next_index}").replace("rho://id/", "rho://key/");
let new_key = IdentityPublicKey {
id: key_id.clone(),
kind: "ssh-signing".to_string(),
algorithm: algorithm.clone(),
public_key: public_key.trim().to_string(),
fingerprint: fingerprint.clone(),
created_at,
};
local
.local_identity
.identity
.public_keys
.retain(|key| key.id != key_id);
local.local_identity.identity.public_keys.insert(0, new_key);
local.local_identity.signing_key = LocalSigningKey {
kind: "ssh-signing".to_string(),
algorithm: algorithm.clone(),
public_key_path: public_key_path.display().to_string(),
private_key_ref: PrivateKeyRef {
backend: "ssh-file".to_string(),
path: private_key_path.display().to_string(),
},
};
let provider_url = github_provider_url(&handle);
let claim = proof_claim(&handle, &algorithm, &fingerprint);
let proof_url = proof_url(&provider_url, &claim);
if let Ok(proof) = github_profile_proof_mut(&mut local.local_identity.identity) {
proof.provider_url = Some(provider_url);
proof.claim = Some(claim.clone());
proof.proof_url = proof_url.clone();
proof.verified_at = None;
} else {
local.local_identity.identity.proofs.push(IdentityProof {
kind: "github-profile-fragment-claim".to_string(),
provider_url: Some(provider_url),
claim: Some(claim.clone()),
proof_url: proof_url.clone(),
verified_at: None,
});
}
fs::write(&path, to_yaml(&local)?)?;
println!("rotated signing key");
println!("identity: {identity_id}");
println!("local manifest: {}", path.display());
println!("public key: {}", public_key_path.display());
println!("private key: {}", private_key_path.display());
println!("key_id: {key_id}");
println!("github proof: unverified");
println!("github profile proof URL:");
println!("{proof_url}");
println!();
println!("GitHub verification:");
println!("1. Replace the old Rho claim in your public GitHub profile with:");
println!("{proof_url}");
println!(" If GitHub only shows the link target partially, make sure this claim text");
println!(" is visible somewhere on the fetched profile page:");
println!("{claim}");
println!("2. Verify it with:");
println!("rho id verify-github --identity {identity_id}");
Ok(())
}
fn maybe_bind_github_commit_identity(local: &mut LocalIdentityManifest) -> RhoResult<()> {
println!();
println!("GitHub CLI account:");
match Command::new("gh").args(["auth", "status"]).status() {
Ok(status) if status.success() => {}
Ok(_) => {
println!("gh auth status failed; skipping GitHub commit binding");
return Ok(());
}
Err(_) => {
println!("gh is not available; skipping GitHub commit binding");
return Ok(());
}
}
let Some((login, id)) = gh_user() else {
println!("could not read active gh account; skipping GitHub commit binding");
return Ok(());
};
let default_email = format!("{id}+{login}@users.noreply.github.com");
println!("Active gh account: {login}");
println!("Default commit author: {login} <{default_email}>");
println!("Use this gh account for rho commits with this identity? [Y/n] ");
let mut answer = String::new();
std::io::stdin().read_line(&mut answer)?;
let bind_answer = answer.trim().to_ascii_lowercase();
if bind_answer == "n" || bind_answer == "no" {
return Ok(());
}
let mut name = login.clone();
answer.clear();
println!("Commit name [{name}]: ");
std::io::stdin().read_line(&mut answer)?;
let value = answer.trim();
if !value.is_empty() {
name = value.to_string();
}
let mut email = default_email;
answer.clear();
println!("Commit email [{email}]: ");
std::io::stdin().read_line(&mut answer)?;
let value = answer.trim();
if !value.is_empty() {
email = value.to_string();
}
local.local_identity.git = Some(LocalGitIdentity {
github_login: login,
commit_name: name,
commit_email: email,
});
Ok(())
}
fn gh_user() -> Option<(String, String)> {
let login = gh_api_user_field(".login")?;
let id = gh_api_user_field(".id")?;
Some((login, id))
}
fn gh_api_user_field(field: &str) -> Option<String> {
let output = Command::new("gh")
.args(["api", "user", "--jq", field])
.output()
.ok()?;
if !output.status.success() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
.filter(|value| !value.is_empty())
}
fn export(args: &[String]) -> RhoResult<()> {
let identity_id =
normalize_actor_id(&require_arg(args, "--identity").unwrap_or_else(|_| usage()))?;
let out = PathBuf::from(require_arg(args, "--out").unwrap_or_else(|_| usage()));
let handle = github_handle_from_identity_id(&identity_id)?;
let local = read_local_identity(&rho_home()?, &handle)?;
ensure_parent(&out)?;
let self_signature = sign_identity_self_signature(&local)?;
fs::write(
&out,
to_yaml(&IdentityBundleManifest {
version: 1,
identity: local.local_identity.identity,
self_signature: Some(self_signature),
})?,
)?;
println!("{}", out.display());
Ok(())
}
pub(crate) fn sign_identity_self_signature(
local: &LocalIdentityManifest,
) -> RhoResult<SignatureRecord> {
let identity = &local.local_identity.identity;
let key = identity
.public_keys
.iter()
.find(|key| key.kind == "ssh-signing")
.ok_or("local identity has no signing public key")?;
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 payload = identity_self_signature_payload(identity)?;
Ok(SignatureRecord {
signed_path: "rho://identity/self".to_string(),
signed_sha256: bytes_digest(&payload),
signer: identity.id.clone(),
key_id: key.id.clone(),
algorithm: key.algorithm.clone(),
namespace: "rho".to_string(),
context: None,
signature: ssh_sign_bytes(&payload, &private_key)?,
created_at: rho_core::now_rfc3339(),
})
}
pub(crate) fn verify_identity_bundle_self_signature(
bundle: &IdentityBundleManifest,
) -> RhoResult<()> {
let Some(record) = bundle.self_signature.as_ref() else {
return Ok(());
};
let payload = identity_self_signature_payload(&bundle.identity)?;
if record.signed_path != "rho://identity/self" {
return Err(format!(
"identity self-signature signed_path mismatch: {}",
record.signed_path
)
.into());
}
if record.signer != bundle.identity.id {
return Err(format!(
"identity self-signature signer mismatch: expected {}, got {}",
bundle.identity.id, record.signer
)
.into());
}
if record.namespace != "rho" {
return Err(format!(
"identity self-signature namespace mismatch: {}",
record.namespace
)
.into());
}
let actual_digest = bytes_digest(&payload);
if record.signed_sha256 != actual_digest {
return Err(format!(
"identity self-signature digest mismatch: expected {}, got {actual_digest}",
record.signed_sha256
)
.into());
}
let key = bundle
.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 || key.kind != "ssh-signing" {
return Err(format!(
"identity self-signature key mismatch: {} {}",
key.kind, key.algorithm
)
.into());
}
ssh_verify_bytes(
&payload,
&record.signature,
&bundle.identity.id,
&key.public_key,
)
}
fn identity_self_signature_payload(identity: &IdentityBundle) -> RhoResult<Vec<u8>> {
Ok(format!("rho identity self-signature v1\n{}", to_yaml(identity)?).into_bytes())
}
fn ssh_sign_bytes(message_bytes: &[u8], private_key: &Path) -> RhoResult<String> {
let work_dir = env::temp_dir().join(format!("rho-id-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", "rho"])
.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_bytes(
message_bytes: &[u8],
signature: &str,
identity_id: &str,
public_key: &str,
) -> RhoResult<()> {
let work_dir = env::temp_dir().join(format!("rho-id-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=\"rho\" {public_key}\n"),
)?;
let mut child = Command::new("ssh-keygen")
.args(["-Y", "verify", "-f"])
.arg(&allowed_signers_path)
.args(["-I", identity_id, "-n", "rho", "-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 import(args: &[String]) -> RhoResult<()> {
let Some(path) = args.first() else {
usage();
};
let text = fs::read_to_string(path)?;
let bundle: IdentityBundleManifest = from_yaml(&text)?;
verify_identity_bundle_self_signature(&bundle)?;
if bundle.identity.kind != "github" {
return Err(format!("unsupported identity kind: {}", bundle.identity.kind).into());
}
validate_github_handle(&bundle.identity.handle)?;
let target = peer_identity_path(&rho_home()?, &bundle.identity.handle);
ensure_parent(&target)?;
fs::write(&target, to_yaml(&bundle)?)?;
println!("{}", target.display());
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GithubProofStatus {
Verified,
Missing,
Invalid,
}
fn classify_github_failure(message: &str) -> GithubProofStatus {
let lowered = message.to_lowercase();
if lowered.contains("invalid claim") || lowered.contains("does not match this key") {
GithubProofStatus::Invalid
} else {
GithubProofStatus::Missing
}
}
pub fn verify_github_for_handle(
rho_home: &Path,
handle: &str,
provider_file: Option<&Path>,
) -> RhoResult<GithubProofStatus> {
let local_path = local_identity_path(rho_home, handle);
let peer_path = peer_identity_path(rho_home, handle);
if local_path.is_file() {
let text = fs::read_to_string(&local_path)?;
let mut manifest: LocalIdentityManifest = from_yaml(&text)?;
let page = provider_page(provider_file, &manifest.local_identity.identity)?;
return match verify_github_identity(&mut manifest.local_identity.identity, &page) {
Ok(()) => {
fs::write(&local_path, to_yaml(&manifest)?)?;
Ok(GithubProofStatus::Verified)
}
Err(error) => {
clear_github_verification(&mut manifest.local_identity.identity, &local_path);
Ok(classify_github_failure(&error.to_string()))
}
};
}
if peer_path.is_file() {
let text = fs::read_to_string(&peer_path)?;
let mut manifest: IdentityBundleManifest = from_yaml(&text)?;
let page = provider_page(provider_file, &manifest.identity)?;
return match verify_github_identity(&mut manifest.identity, &page) {
Ok(()) => {
fs::write(&peer_path, to_yaml(&manifest)?)?;
Ok(GithubProofStatus::Verified)
}
Err(error) => {
clear_github_verification(&mut manifest.identity, &peer_path);
Ok(classify_github_failure(&error.to_string()))
}
};
}
Err(format!("identity not found: github/{handle}").into())
}
fn verify_github(args: &[String]) -> RhoResult<()> {
let identity_id =
normalize_actor_id(&require_arg(args, "--identity").unwrap_or_else(|_| usage()))?;
let handle = github_handle_from_identity_id(&identity_id)?;
let rho_home = rho_home()?;
let provider_file = arg_value(args, "--provider-file").map(PathBuf::from);
match verify_github_for_handle(&rho_home, &handle, provider_file.as_deref())? {
GithubProofStatus::Verified => {
println!("verified rho://id/github/{handle}");
Ok(())
}
GithubProofStatus::Missing => {
Err(format!("provider page does not contain a claim for github/{handle}").into())
}
GithubProofStatus::Invalid => Err(format!(
"invalid claim: published proof for github/{handle} does not match this key"
)
.into()),
}
}
fn trust(args: &[String]) -> RhoResult<()> {
let Some(identity_id) = args.first() else {
usage();
};
let identity_id = normalize_actor_id(identity_id)?;
let handle = github_handle_from_identity_id(&identity_id)?;
let rho_home = rho_home()?;
let peer = peer_identity_path(&rho_home, &handle);
if !peer.is_file() && !local_identity_path(&rho_home, &handle).is_file() {
return Err(format!("identity has not been imported or initialized: {identity_id}").into());
}
let target = trust_path(&rho_home, &handle);
ensure_parent(&target)?;
fs::write(
&target,
to_yaml(&TrustManifest {
version: 1,
trust: TrustRecord {
identity_id,
decision: "trusted".to_string(),
trusted_at: rho_core::now_rfc3339(),
source: "local-user".to_string(),
},
})?,
)?;
println!("{}", target.display());
Ok(())
}
fn list() -> RhoResult<()> {
let rho_home = rho_home()?;
println!("local identities:");
print_identity_dir(&rho_home.join("identities/github"))?;
println!("peer identities:");
print_identity_dir(&rho_home.join("peers/github"))?;
Ok(())
}
fn show(args: &[String]) -> RhoResult<()> {
let Some(identity_id) = args.first() else {
usage();
};
let identity_id = normalize_actor_id(identity_id)?;
let handle = github_handle_from_identity_id(&identity_id)?;
let rho_home = rho_home()?;
let local_path = local_identity_path(&rho_home, &handle);
if local_path.is_file() {
print!("{}", fs::read_to_string(local_path)?);
return Ok(());
}
let peer_path = peer_identity_path(&rho_home, &handle);
if peer_path.is_file() {
print!("{}", fs::read_to_string(peer_path)?);
return Ok(());
}
Err(format!("identity not found: {identity_id}").into())
}
fn status(args: &[String]) -> RhoResult<()> {
let Some(identity_id) = args.first() else {
usage();
};
let identity_id = normalize_actor_id(identity_id)?;
let handle = github_handle_from_identity_id(&identity_id)?;
let rho_home = rho_home()?;
let local_path = local_identity_path(&rho_home, &handle);
if local_path.is_file() {
let local: LocalIdentityManifest = from_yaml(&fs::read_to_string(&local_path)?)?;
print_identity_status(
&rho_home,
&local_path,
&local.local_identity.identity,
true,
local.local_identity.git.as_ref().map(|git| {
(
git.github_login.as_str(),
git.commit_name.as_str(),
git.commit_email.as_str(),
)
}),
);
return Ok(());
}
let peer_path = peer_identity_path(&rho_home, &handle);
if peer_path.is_file() {
let peer: IdentityBundleManifest = from_yaml(&fs::read_to_string(&peer_path)?)?;
print_identity_status(&rho_home, &peer_path, &peer.identity, false, None);
return Ok(());
}
Err(format!("identity not found: {identity_id}").into())
}
fn print_identity_status(
rho_home: &Path,
path: &Path,
identity: &IdentityBundle,
has_private_key: bool,
git: Option<(&str, &str, &str)>,
) {
println!("identity: {}", identity.id);
println!("kind: {}", identity.kind);
println!("handle: {}", identity.handle);
println!("rho home: {}", rho_home.display());
println!("manifest: {}", path.display());
println!(
"private key: {}",
if has_private_key {
"present"
} else {
"not-present"
}
);
if let Some((github_login, commit_name, commit_email)) = git {
println!("github commit login: {github_login}");
println!("git commit author: {commit_name} <{commit_email}>");
}
match github_profile_proof(identity) {
Ok(proof) => {
if let Some(verified_at) = &proof.verified_at {
println!("github proof: verified");
println!("verified_at: {verified_at}");
} else {
println!("github proof: unverified");
}
if let Some(provider_url) = &proof.provider_url {
println!("provider_url: {provider_url}");
}
if let Some(claim) = &proof.claim {
println!("claim: {claim}");
}
if !proof.proof_url.is_empty() {
println!("proof_url: {}", proof.proof_url);
}
}
Err(error) => {
println!("github proof: missing ({error})");
}
}
}
fn print_identity_dir(dir: &Path) -> RhoResult<()> {
if !dir.is_dir() {
return Ok(());
}
let mut paths: Vec<_> = fs::read_dir(dir)?
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.extension().and_then(|value| value.to_str()) == Some("yaml"))
.collect();
paths.sort();
for path in paths {
let text = fs::read_to_string(&path)?;
if let Ok(local) = from_yaml::<LocalIdentityManifest>(&text) {
let verified = local
.local_identity
.identity
.proofs
.iter()
.any(|proof| proof.verified_at.is_some());
println!(
" {} private-key=yes verified={} {}",
local.local_identity.identity.id,
if verified { "yes" } else { "no" },
path.display()
);
continue;
}
if let Ok(peer) = from_yaml::<IdentityBundleManifest>(&text) {
let verified = peer
.identity
.proofs
.iter()
.any(|proof| proof.verified_at.is_some());
println!(
" {} private-key=no verified={} {}",
peer.identity.id,
if verified { "yes" } else { "no" },
path.display()
);
continue;
}
println!(" unreadable identity file: {}", path.display());
}
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 local_identity_path(rho_home: &Path, handle: &str) -> PathBuf {
rho_home
.join("identities")
.join("github")
.join(format!("{handle}.yaml"))
}
fn peer_identity_path(rho_home: &Path, handle: &str) -> PathBuf {
rho_home
.join("peers")
.join("github")
.join(format!("{handle}.yaml"))
}
fn trust_path(rho_home: &Path, handle: &str) -> PathBuf {
rho_home
.join("trust")
.join("github")
.join(format!("{handle}.yaml"))
}
fn read_local_identity(rho_home: &Path, handle: &str) -> RhoResult<LocalIdentityManifest> {
let path = local_identity_path(rho_home, handle);
let text = fs::read_to_string(&path)
.map_err(|_| format!("local identity not found: {}", path.display()))?;
from_yaml(&text)
}
fn default_ssh_key_pair_exists(rho_home: &Path, handle: &str) -> bool {
let private = default_ssh_private_key(rho_home, handle);
private.is_file() && private.with_extension("pub").is_file()
}
fn generate_ssh_key(rho_home: &Path, handle: &str) -> RhoResult<(PathBuf, PathBuf)> {
let private = default_ssh_private_key(rho_home, handle);
generate_ssh_key_at(&private, &github_identity_id(handle))
}
fn generate_ssh_key_at(private: &Path, comment: &str) -> RhoResult<(PathBuf, PathBuf)> {
let public = private.with_extension("pub");
if private.exists() || public.exists() {
return Err(format!("ssh key already exists: {}", private.display()).into());
}
ensure_parent(private)?;
let status = Command::new("ssh-keygen")
.args(["-t", "ed25519", "-N", "", "-C"])
.arg(comment)
.arg("-f")
.arg(private)
.status()?;
if !status.success() {
return Err("ssh-keygen failed".into());
}
Ok((public, private.to_path_buf()))
}
fn ensure_default_ssh_key(rho_home: &Path, handle: &str) -> RhoResult<(PathBuf, PathBuf)> {
let private = default_ssh_private_key(rho_home, handle);
let public = private.with_extension("pub");
if private.is_file() && public.is_file() {
return Ok((public, private));
}
if private.exists() || public.exists() {
return Err(format!(
"incomplete ssh key pair; expected {} and {}",
private.display(),
public.display()
)
.into());
}
generate_ssh_key(rho_home, handle)
}
fn generate_rotated_ssh_key(
rho_home: &Path,
handle: &str,
index: usize,
) -> RhoResult<(PathBuf, PathBuf)> {
let private = rotated_ssh_private_key(rho_home, handle, index);
generate_ssh_key_at(
&private,
&format!("{} signing key {index}", github_identity_id(handle)),
)
}
fn next_signing_key_index(identity: &IdentityBundle) -> usize {
identity
.public_keys
.iter()
.filter(|key| key.kind == "ssh-signing")
.filter_map(|key| {
let suffix = key.id.rsplit('/').next()?;
let (_, index) = suffix.rsplit_once('-')?;
index.parse::<usize>().ok()
})
.max()
.unwrap_or(0)
+ 1
}
fn default_age_key_pair_exists(rho_home: &Path, handle: &str) -> bool {
default_age_private_key(rho_home, handle).is_file()
&& default_age_public_key(rho_home, handle).is_file()
}
fn generate_age_key(rho_home: &Path, handle: &str) -> RhoResult<(PathBuf, PathBuf)> {
let private = default_age_private_key(rho_home, handle);
let public = default_age_public_key(rho_home, handle);
if private.exists() || public.exists() {
return Err(format!("age encryption key already exists: {}", private.display()).into());
}
ensure_parent(&private)?;
let identity = age::x25519::Identity::generate();
fs::write(
&private,
format!("{}\n", identity.to_string().expose_secret()),
)?;
fs::write(&public, format!("{}\n", identity.to_public()))?;
Ok((public, private))
}
fn generate_x25519_key(rho_home: &Path, handle: &str) -> RhoResult<(PathBuf, PathBuf)> {
let private = default_x25519_private_key(rho_home, handle);
let public = default_x25519_public_key(rho_home, handle);
if private.exists() || public.exists() {
return Err(format!("encryption key already exists: {}", private.display()).into());
}
ensure_parent(&private)?;
let secret = StaticSecret::from(random_bytes::<32>()?);
let public_key = X25519PublicKey::from(&secret);
fs::write(&private, format!("{}\n", BASE64.encode(secret.to_bytes())))?;
fs::write(
&public,
format!("{}\n", BASE64.encode(public_key.as_bytes())),
)?;
Ok((public, private))
}
fn ensure_default_x25519_key(rho_home: &Path, handle: &str) -> RhoResult<(PathBuf, PathBuf)> {
let private = default_x25519_private_key(rho_home, handle);
let public = default_x25519_public_key(rho_home, handle);
if private.is_file() && public.is_file() {
return Ok((public, private));
}
if private.exists() || public.exists() {
return Err(format!(
"incomplete encryption key pair; expected {} and {}",
private.display(),
public.display()
)
.into());
}
generate_x25519_key(rho_home, handle)
}
fn ensure_default_age_key(rho_home: &Path, handle: &str) -> RhoResult<(PathBuf, PathBuf)> {
let private = default_age_private_key(rho_home, handle);
let public = default_age_public_key(rho_home, handle);
if private.is_file() && public.is_file() {
return Ok((public, private));
}
if private.exists() || public.exists() {
return Err(format!(
"incomplete age encryption key pair; expected {} and {}",
private.display(),
public.display()
)
.into());
}
generate_age_key(rho_home, handle)
}
fn default_ssh_private_key(rho_home: &Path, handle: &str) -> PathBuf {
rho_home
.join("keys")
.join("github")
.join(handle)
.join("signing_ed25519")
}
fn rotated_ssh_private_key(rho_home: &Path, handle: &str, index: usize) -> PathBuf {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0);
rho_home
.join("keys")
.join("github")
.join(handle)
.join(format!("signing_ed25519-{index}-{timestamp}"))
}
fn default_x25519_private_key(rho_home: &Path, handle: &str) -> PathBuf {
rho_home
.join("keys")
.join("github")
.join(handle)
.join("encryption_x25519.key")
}
fn default_x25519_public_key(rho_home: &Path, handle: &str) -> PathBuf {
rho_home
.join("keys")
.join("github")
.join(handle)
.join("encryption_x25519.pub")
}
fn default_age_private_key(rho_home: &Path, handle: &str) -> PathBuf {
rho_home
.join("keys")
.join("github")
.join(handle)
.join("encryption_age.key")
}
fn default_age_public_key(rho_home: &Path, handle: &str) -> PathBuf {
rho_home
.join("keys")
.join("github")
.join(handle)
.join("encryption_age.pub")
}
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 selected_ssh_key(value: &str) -> RhoResult<(PathBuf, PathBuf)> {
let public = expand_home(value);
if !public.is_file() {
return Err(format!("ssh public key not found: {}", public.display()).into());
}
let private = if public.extension().and_then(|value| value.to_str()) == Some("pub") {
public.with_extension("")
} else {
return Err("--ssh-key must point to a .pub file".into());
};
Ok((public, private))
}
fn expand_home(value: &str) -> PathBuf {
if let Some(rest) = value.strip_prefix("~/")
&& let Ok(home) = env::var("HOME")
{
return PathBuf::from(home).join(rest);
}
PathBuf::from(value)
}
fn public_key_algorithm(public_key: &str) -> RhoResult<String> {
let algorithm = public_key
.split_whitespace()
.next()
.ok_or("ssh public key is empty")?;
match algorithm {
"ssh-ed25519" => Ok(algorithm.to_string()),
_ => Err(format!("unsupported ssh key algorithm for v1: {algorithm}").into()),
}
}
fn key_fingerprint(public_key_path: &Path) -> RhoResult<String> {
let output = Command::new("ssh-keygen")
.args(["-l", "-f"])
.arg(public_key_path)
.output();
if let Ok(output) = output
&& output.status.success()
{
let text = String::from_utf8_lossy(&output.stdout);
if let Some(value) = text.split_whitespace().nth(1) {
return Ok(value.to_string());
}
}
Ok(format!("sha256-{}", &file_digest(public_key_path)?[..32]))
}
fn provider_page(provider_file: Option<&Path>, identity: &IdentityBundle) -> RhoResult<String> {
if let Some(path) = provider_file {
return Ok(fs::read_to_string(path)?);
}
let proof = github_profile_proof(identity)?;
let provider_url = proof
.provider_url
.as_deref()
.ok_or("github proof is missing provider_url")?;
let fetch_url = provider_url.split('#').next().unwrap_or(provider_url);
let response = ureq::get(fetch_url)
.set("User-Agent", "rho-cli")
.set("Accept", "text/html")
.call()
.map_err(|error| format!("failed to fetch {fetch_url}: {error}"))?;
let body = response
.into_string()
.map_err(|error| format!("failed to read {fetch_url}: {error}"))?;
Ok(body)
}
fn clear_github_verification(identity: &mut IdentityBundle, path: &Path) {
if let Ok(proof) = github_profile_proof_mut(identity) {
if proof.verified_at.is_none() {
return;
}
proof.verified_at = None;
} else {
return;
}
if let Ok(text) = fs::read_to_string(path) {
if let Ok(mut manifest) = from_yaml::<LocalIdentityManifest>(&text) {
if let Ok(proof) = github_profile_proof_mut(&mut manifest.local_identity.identity) {
proof.verified_at = None;
}
if let Ok(serialized) = to_yaml(&manifest) {
let _ = fs::write(path, serialized);
}
} else if let Ok(mut manifest) = from_yaml::<IdentityBundleManifest>(&text) {
if let Ok(proof) = github_profile_proof_mut(&mut manifest.identity) {
proof.verified_at = None;
}
if let Ok(serialized) = to_yaml(&manifest) {
let _ = fs::write(path, serialized);
}
}
}
}
fn is_claim_token_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'-' || b == b'_'
}
fn page_contains_claim_token(page: &str, claim: &str) -> bool {
if claim.is_empty() {
return false;
}
let bytes = page.as_bytes();
let clen = claim.len();
let mut from = 0;
while let Some(rel) = page[from..].find(claim) {
let idx = from + rel;
let before_ok = idx == 0 || !is_claim_token_byte(bytes[idx - 1]);
let after = idx + clen;
let after_ok = after >= bytes.len() || !is_claim_token_byte(bytes[after]);
if before_ok && after_ok {
return true;
}
from = idx + 1;
}
false
}
fn verify_github_identity(identity: &mut IdentityBundle, provider_page: &str) -> RhoResult<()> {
if identity.kind != "github" {
return Err(format!("unsupported identity kind: {}", identity.kind).into());
}
let identity_handle = github_handle_from_identity_id(&identity.id)?;
if identity.handle != identity_handle {
return Err(format!(
"identity handle mismatch: id has {identity_handle}, bundle has {}",
identity.handle
)
.into());
}
let proof = github_profile_proof(identity)?;
let provider_url = proof
.provider_url
.as_deref()
.ok_or("github proof is missing provider_url")?;
let claim = proof
.claim
.as_deref()
.ok_or("github proof is missing claim")?;
let provider_handle = github_handle_from_provider_url(provider_url)?;
if provider_handle != identity_handle {
return Err(format!(
"provider handle mismatch: provider has {provider_handle}, identity has {identity_handle}"
)
.into());
}
let parsed = parse_proof_claim(claim)?;
if parsed.handle != identity_handle {
return Err(format!(
"claim handle mismatch: claim has {}, identity has {identity_handle}",
parsed.handle
)
.into());
}
let expected_url = proof_url(provider_url, claim);
if proof.proof_url != expected_url {
return Err(format!(
"proof_url mismatch: expected {expected_url}, got {}",
proof.proof_url
)
.into());
}
let key = identity
.public_keys
.iter()
.find(|key| key.algorithm == parsed.algorithm)
.ok_or_else(|| format!("no public key with algorithm {}", parsed.algorithm))?;
if claim_fingerprint(&key.fingerprint) != parsed.fingerprint {
return Err(format!(
"fingerprint mismatch: claim has {}, key has {}",
parsed.fingerprint, key.fingerprint
)
.into());
}
if !page_contains_claim_token(provider_page, claim) {
let identity_prefix = format!("rho.claim/id/github/{identity_handle}/");
if provider_page.contains(&identity_prefix) {
return Err(format!(
"invalid claim: a rho proof for {identity_handle} is published but does not match this key"
)
.into());
}
return Err(format!("provider page does not contain claim: {claim}").into());
}
let verified_at = rho_core::now_rfc3339();
let proof = github_profile_proof_mut(identity)?;
proof.verified_at = Some(verified_at);
Ok(())
}
fn github_profile_proof(identity: &IdentityBundle) -> RhoResult<&IdentityProof> {
identity
.proofs
.iter()
.find(|proof| proof.kind == "github-profile-fragment-claim")
.ok_or("identity has no github-profile-fragment-claim proof".into())
}
fn github_profile_proof_mut(identity: &mut IdentityBundle) -> RhoResult<&mut IdentityProof> {
identity
.proofs
.iter_mut()
.find(|proof| proof.kind == "github-profile-fragment-claim")
.ok_or("identity has no github-profile-fragment-claim proof".into())
}
fn github_identity_id(handle: &str) -> String {
providers::github::identity_id(handle).expect("validated github handle")
}
fn github_handle_from_identity_id(identity_id: &str) -> RhoResult<String> {
providers::github::handle_from_identity_id(identity_id)
}
fn github_provider_url(handle: &str) -> String {
providers::github::provider_url(handle).expect("validated github handle")
}
fn github_handle_from_provider_url(provider_url: &str) -> RhoResult<String> {
providers::github::handle_from_provider_url(provider_url)
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ParsedProofClaim {
handle: String,
algorithm: String,
fingerprint: String,
}
fn parse_proof_claim(claim: &str) -> RhoResult<ParsedProofClaim> {
let parts: Vec<&str> = claim.split('/').collect();
if parts.len() != 7
|| parts[0] != "rho.claim"
|| parts[1] != "id"
|| parts[2] != "github"
|| parts[4] != "key"
{
return Err(format!("unsupported github proof claim: {claim}").into());
}
validate_github_handle(parts[3])?;
Ok(ParsedProofClaim {
handle: parts[3].to_string(),
algorithm: parts[5].to_string(),
fingerprint: parts[6].to_string(),
})
}
fn proof_claim(handle: &str, algorithm: &str, fingerprint: &str) -> String {
format!(
"rho.claim/id/github/{handle}/key/{algorithm}/{}",
claim_fingerprint(fingerprint)
)
}
fn claim_fingerprint(fingerprint: &str) -> String {
fingerprint
.replace("SHA256:", "sha256-")
.replace(['/', '+', '='], "-")
}
fn proof_url(provider_url: &str, claim: &str) -> String {
format!("{provider_url}#{claim}")
}
fn validate_github_handle(handle: &str) -> RhoResult<()> {
providers::github::validate_handle(handle)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_github_identity_id() {
assert_eq!(
github_handle_from_identity_id("rho://id/github/madhavajay").unwrap(),
"madhavajay"
);
}
#[test]
fn rejects_invalid_github_handles() {
assert!(validate_github_handle("-bad").is_err());
assert!(validate_github_handle("bad-").is_err());
assert!(validate_github_handle("bad_name").is_err());
assert!(validate_github_handle("bad--name").is_err());
}
#[test]
fn proof_claim_is_url_safe() {
assert_eq!(
proof_claim("madhavajay", "ssh-ed25519", "SHA256:abc/def+ghi="),
"rho.claim/id/github/madhavajay/key/ssh-ed25519/sha256-abc-def-ghi-"
);
}
#[test]
fn proof_url_uses_provider_fragment() {
assert_eq!(
proof_url(
"https://github.com/madhavajay",
"rho.claim/id/github/madhavajay/key/ssh-ed25519/sha256-abc"
),
"https://github.com/madhavajay#rho.claim/id/github/madhavajay/key/ssh-ed25519/sha256-abc"
);
}
#[test]
fn parses_proof_claim() {
let parsed =
parse_proof_claim("rho.claim/id/github/madhavajay/key/ssh-ed25519/sha256-abc").unwrap();
assert_eq!(parsed.handle, "madhavajay");
assert_eq!(parsed.algorithm, "ssh-ed25519");
assert_eq!(parsed.fingerprint, "sha256-abc");
}
#[test]
fn parses_github_provider_url() {
assert_eq!(
github_handle_from_provider_url("https://github.com/madhavajay").unwrap(),
"madhavajay"
);
assert_eq!(
github_handle_from_provider_url("https://github.com/madhavajay/").unwrap(),
"madhavajay"
);
assert!(github_handle_from_provider_url("https://github.com/madhavajay/repo").is_err());
}
}