use std::env;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use age::secrecy::ExposeSecret;
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use k256::{SecretKey, elliptic_curve::sec1::ToEncodedPoint};
use rho_core::{
IdentityBundle, IdentityBundleManifest, IdentityProof, IdentityPublicKey, LocalControllerKey,
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};
const GITHUB_OAUTH_CLIENT_ID: &str = "Ov23liMtgjJdOf53iqgV";
#[derive(Debug, serde::Deserialize)]
struct GitHubDeviceCodeResponse {
device_code: String,
user_code: String,
verification_uri: String,
expires_in: u64,
interval: Option<u64>,
}
#[derive(Debug, serde::Deserialize)]
struct GitHubAccessTokenResponse {
access_token: Option<String>,
error: Option<String>,
error_description: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
struct GitHubUserResponse {
login: String,
}
fn usage() -> ! {
eprintln!(
"usage:\n rho id init <github/handle|handle> [--display-name <name>] [--yes] [--bind-git]\n rho id init --github <handle> [--display-name <name>] [--yes] [--bind-git]\n rho id init --github-login [--display-name <name>] [--yes] [--bind-git]\n rho id init --github <handle> (--ssh-key <path.pub>|--generate-ssh-key) [--display-name <name>] [--bind-git]\n rho id rotate-key <rho-id|github/handle|handle> [--yes]\n rho id publish-relay --identity <rho-id> [--relay <ws-url>] [--print] [--dry-run]\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..]),
"publish-relay" => publish_relay(&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 login_with_github =
has_flag(args, "--github-login") || has_flag(args, "--login-with-github");
if login_with_github && (shorthand || arg_value(args, "--github").is_some()) {
return Err("--github-login cannot be combined with a GitHub handle".into());
}
let handle = if shorthand {
let identity_id = normalize_actor_id(&args[0])?;
github_handle_from_identity_id(&identity_id)?
} else if login_with_github {
github_device_login()?
} 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 generate && ssh_key.is_some() {
return Err("choose only one of --ssh-key or --generate-ssh-key".into());
}
let default_key_management = !generate && ssh_key.is_none();
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)?)?;
let mut changed = ensure_nostr_controller(&rho_home, &handle, &mut local)?;
if has_flag(args, "--bind-git") {
maybe_bind_github_commit_identity(&mut local)?;
changed = true;
}
if changed {
fs::write(&path, to_yaml(&local)?)?;
}
print_existing_identity(&rho_home, &path, &local);
return Ok(());
}
if default_key_management && !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()
);
println!(
" nostr controller key: {}",
default_nostr_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 default_key_management {
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 default_key_management {
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 (nostr_public_key_path, nostr_private_key_path) =
ensure_default_nostr_key(&rho_home, &handle)?;
let nostr_public_key = fs::read_to_string(&nostr_public_key_path)?;
let nostr_fingerprint = file_digest(&nostr_public_key_path)?;
let (x25519_public_key_path, _x25519_private_key_path) = if default_key_management {
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 nostr_key_id =
format!("{identity_id}/nostr-controller-1").replace("rho://id/", "rho://key/");
let x25519_key_id = format!("{identity_id}/x25519-legacy-1").replace("rho://id/", "rho://key/");
let claim = nostr_proof_claim(&handle, nostr_public_key.trim());
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: nostr_key_id,
kind: "nostr-controller".to_string(),
algorithm: "secp256k1-bip340".to_string(),
public_key: nostr_public_key.trim().to_string(),
fingerprint: nostr_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,
controller_key: Some(LocalControllerKey {
kind: "nostr-controller".to_string(),
algorithm: "secp256k1-bip340".to_string(),
public_key_path: nostr_public_key_path.display().to_string(),
private_key_ref: PrivateKeyRef {
backend: "rho-file".to_string(),
path: nostr_private_key_path.display().to_string(),
},
}),
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 has_flag(args, "--bind-git") {
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!(
"nostr controller public key: {}",
nostr_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}");
println!("3. Publish the signed identity record to your local relay:");
println!("rho id publish-relay --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(controller_key) = &local.local_identity.controller_key {
println!(
"nostr controller public key: {}",
controller_key.public_key_path
);
} else if let Some(controller) = nostr_controller_key(identity) {
println!("nostr controller: {}", controller.public_key);
}
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);
println!("3. Publish the signed identity record to your local relay:");
println!("rho id publish-relay --identity {}", identity.id);
}
}
fn publish_relay(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 relay_url = arg_value(args, "--relay")
.or_else(|| env::var("RHO_RELAY_URL").ok())
.unwrap_or_else(|| "ws://127.0.0.1:8787".to_string());
let rho_home = rho_home()?;
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 private_key_path = local
.local_identity
.controller_key
.as_ref()
.filter(|key| key.kind == "nostr-controller" && key.algorithm == "secp256k1-bip340")
.map(|key| PathBuf::from(&key.private_key_ref.path))
.unwrap_or_else(|| default_nostr_private_key(&rho_home, &handle));
let private_key = fs::read_to_string(&private_key_path).map_err(|_| {
format!(
"nostr controller private key not found: {}",
private_key_path.display()
)
})?;
let nostr_identity = rho_core::nostr::NostrIdentity::from_secret_hex(private_key.trim())?;
let event = rho_core::nostr::build_identity_event(
&nostr_identity,
&identity.id,
&identity.display_name,
&relay_url,
)?;
let event_json = serde_json::to_string_pretty(&event)? + "\n";
if has_flag(args, "--print") {
print!("{event_json}");
}
if has_flag(args, "--dry-run") {
return Ok(());
}
let script = relay_smoke_script()
.ok_or("could not find relay/scripts/local-smoke.mjs from the current directory")?;
let event_path = temp_event_path()?;
fs::write(&event_path, event_json)?;
let status = Command::new("node")
.arg(script)
.arg("--publish-event")
.arg(&event_path)
.arg("--relay")
.arg(&relay_url)
.stdin(Stdio::null())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status();
let _ = fs::remove_file(&event_path);
let status = status?;
if !status.success() {
return Err(format!(
"relay publish failed with exit code {}",
status.code().unwrap_or(1)
)
.into());
}
println!("published identity record");
println!("identity: {identity_id}");
println!("relay: {relay_url}");
println!("event: {}", event.id);
Ok(())
}
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 rotates the local SSH signing key. The GitHub profile proof remains tied to the Nostr controller key. 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(),
},
};
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: unchanged");
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 github_device_login() -> RhoResult<String> {
let response = ureq::post("https://github.com/login/device/code")
.set("Accept", "application/json")
.send_form(&[
("client_id", GITHUB_OAUTH_CLIENT_ID),
("scope", "read:user"),
])
.map_err(|error| format!("failed to start GitHub login: {error}"))?;
let body = response
.into_string()
.map_err(|error| format!("failed to read GitHub login response: {error}"))?;
let flow: GitHubDeviceCodeResponse = serde_json::from_str(&body)
.map_err(|error| format!("failed to parse GitHub login response: {error}"))?;
println!("GitHub login");
println!(" open: {}", flow.verification_uri);
println!(" code: {}", flow.user_code);
println!("Waiting for GitHub approval...");
let started = Instant::now();
let mut interval = flow.interval.unwrap_or(5).max(1);
while started.elapsed().as_secs() < flow.expires_in {
thread::sleep(Duration::from_secs(interval));
let response = ureq::post("https://github.com/login/oauth/access_token")
.set("Accept", "application/json")
.send_form(&[
("client_id", GITHUB_OAUTH_CLIENT_ID),
("device_code", flow.device_code.as_str()),
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
])
.map_err(|error| format!("failed to poll GitHub login: {error}"))?;
let body = response
.into_string()
.map_err(|error| format!("failed to read GitHub token response: {error}"))?;
let token: GitHubAccessTokenResponse = serde_json::from_str(&body)
.map_err(|error| format!("failed to parse GitHub token response: {error}"))?;
if let Some(error) = token.error {
match error.as_str() {
"authorization_pending" => continue,
"slow_down" => {
interval += 5;
continue;
}
"expired_token" => return Err("GitHub login code expired".into()),
"access_denied" => return Err("GitHub login was denied".into()),
_ => {
return Err(token
.error_description
.unwrap_or_else(|| format!("GitHub login failed: {error}"))
.into());
}
}
}
let access_token = token
.access_token
.ok_or("GitHub did not return an access token")?;
let response = ureq::get("https://api.github.com/user")
.set("Accept", "application/vnd.github+json")
.set("Authorization", &format!("Bearer {access_token}"))
.set("X-GitHub-Api-Version", "2022-11-28")
.call()
.map_err(|error| format!("failed to read GitHub user: {error}"))?;
let body = response
.into_string()
.map_err(|error| format!("failed to read GitHub user response: {error}"))?;
let user: GitHubUserResponse = serde_json::from_str(&body)
.map_err(|error| format!("failed to parse GitHub user response: {error}"))?;
let login = user.login.trim().to_string();
if login.is_empty() {
return Err("GitHub returned an empty login".into());
}
println!("GitHub account: {login}");
return Ok(login);
}
Err("GitHub login timed out".into())
}
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(())
}
/// Outcome of checking a GitHub identity proof against the live profile page.
#[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
}
}
/// Verify a GitHub identity proof for `handle`, persisting the result (sets or
/// clears `verified_at`). This is the single source of truth shared by the CLI
/// and the desktop app so neither has to shell out to the other.
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 relay_smoke_script() -> Option<PathBuf> {
let mut starts = Vec::new();
if let Ok(current_dir) = env::current_dir() {
starts.push(current_dir);
}
if let Ok(current_exe) = env::current_exe()
&& let Some(parent) = current_exe.parent()
{
starts.push(parent.to_path_buf());
}
for start in starts {
for dir in start.ancestors() {
let script = dir.join("relay").join("scripts").join("local-smoke.mjs");
if script.is_file() {
return Some(script);
}
}
}
None
}
fn temp_event_path() -> RhoResult<PathBuf> {
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis();
Ok(env::temp_dir().join(format!(
"rho-nostr-event-{}-{timestamp}.json",
std::process::id()
)))
}
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 generate_nostr_key(rho_home: &Path, handle: &str) -> RhoResult<(PathBuf, PathBuf)> {
let private = default_nostr_private_key(rho_home, handle);
let public = default_nostr_public_key(rho_home, handle);
if private.exists() || public.exists() {
return Err(format!("nostr controller key already exists: {}", private.display()).into());
}
ensure_parent(&private)?;
let secret = random_nostr_secret()?;
let public_key = nostr_public_key_hex(&secret)?;
fs::write(&private, format!("{}\n", hex_encode(secret.to_bytes())))?;
fs::write(&public, format!("{public_key}\n"))?;
Ok((public, private))
}
fn ensure_default_nostr_key(rho_home: &Path, handle: &str) -> RhoResult<(PathBuf, PathBuf)> {
let private = default_nostr_private_key(rho_home, handle);
let public = default_nostr_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 nostr controller key pair; expected {} and {}",
private.display(),
public.display()
)
.into());
}
generate_nostr_key(rho_home, handle)
}
fn ensure_nostr_controller(
rho_home: &Path,
handle: &str,
local: &mut LocalIdentityManifest,
) -> RhoResult<bool> {
let (public_key_path, private_key_path) = ensure_default_nostr_key(rho_home, handle)?;
let public_key = fs::read_to_string(&public_key_path)?.trim().to_string();
if !is_nostr_public_key_hex(&public_key) {
return Err(format!(
"invalid nostr controller public key in {}",
public_key_path.display()
)
.into());
}
let created_at = rho_core::now_rfc3339();
let identity_id = local.local_identity.identity.id.clone();
let key_id = format!("{identity_id}/nostr-controller-1").replace("rho://id/", "rho://key/");
let mut changed = false;
match local
.local_identity
.identity
.public_keys
.iter_mut()
.find(|key| key.kind == "nostr-controller")
{
Some(key) => {
if key.public_key != public_key || key.algorithm != "secp256k1-bip340" {
key.algorithm = "secp256k1-bip340".to_string();
key.public_key = public_key.clone();
key.fingerprint = file_digest(&public_key_path)?;
changed = true;
}
}
None => {
local
.local_identity
.identity
.public_keys
.push(IdentityPublicKey {
id: key_id,
kind: "nostr-controller".to_string(),
algorithm: "secp256k1-bip340".to_string(),
public_key: public_key.clone(),
fingerprint: file_digest(&public_key_path)?,
created_at,
});
changed = true;
}
}
let expected_controller = LocalControllerKey {
kind: "nostr-controller".to_string(),
algorithm: "secp256k1-bip340".to_string(),
public_key_path: public_key_path.display().to_string(),
private_key_ref: PrivateKeyRef {
backend: "rho-file".to_string(),
path: private_key_path.display().to_string(),
},
};
if local.local_identity.controller_key.as_ref() != Some(&expected_controller) {
local.local_identity.controller_key = Some(expected_controller);
changed = true;
}
let provider_url = github_provider_url(handle);
let claim = nostr_proof_claim(handle, &public_key);
let proof_url = proof_url(&provider_url, &claim);
match github_profile_proof_mut(&mut local.local_identity.identity) {
Ok(proof) => {
if proof.provider_url.as_deref() != Some(provider_url.as_str())
|| proof.claim.as_deref() != Some(claim.as_str())
|| proof.proof_url != proof_url
{
proof.provider_url = Some(provider_url);
proof.claim = Some(claim);
proof.proof_url = proof_url;
proof.verified_at = None;
changed = true;
}
}
Err(_) => {
local.local_identity.identity.proofs.push(IdentityProof {
kind: "github-profile-fragment-claim".to_string(),
provider_url: Some(provider_url),
claim: Some(claim),
proof_url,
verified_at: None,
});
changed = true;
}
}
Ok(changed)
}
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 default_nostr_private_key(rho_home: &Path, handle: &str) -> PathBuf {
rho_home
.join("keys")
.join("github")
.join(handle)
.join("nostr_controller.key")
}
fn default_nostr_public_key(rho_home: &Path, handle: &str) -> PathBuf {
rho_home
.join("keys")
.join("github")
.join(handle)
.join("nostr_controller.pub")
}
fn random_nostr_secret() -> RhoResult<SecretKey> {
loop {
let bytes = random_bytes::<32>()?;
if let Ok(secret) = SecretKey::from_slice(&bytes) {
return Ok(secret);
}
}
}
fn nostr_public_key_hex(secret: &SecretKey) -> RhoResult<String> {
let public = secret.public_key();
let encoded = public.to_encoded_point(false);
let x = encoded
.x()
.ok_or("secp256k1 public key is missing x coordinate")?;
Ok(hex_encode(x))
}
fn hex_encode(bytes: impl AsRef<[u8]>) -> String {
bytes
.as_ref()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
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")?;
// HTTP requests never carry the URL fragment; strip it so the GET targets the
// real profile page (e.g. drop the trailing "#rho.claim/...").
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)
}
// When verification fails (e.g. the proof was removed from the GitHub profile),
// drop any stale verified_at so the stored status reflects reality on next read.
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'_'
}
// Match the claim as a complete token, not a loose substring: the characters
// immediately around it must not be claim-token characters. Otherwise a
// published value like `<claim>1111` would pass because it *contains* the claim.
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)?;
let claim_handle = parsed.handle();
if claim_handle != identity_handle {
return Err(format!(
"claim handle mismatch: claim has {claim_handle}, identity has {identity_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());
}
verify_proof_claim_matches_identity(identity, &parsed)?;
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 verify_proof_claim_matches_identity(
identity: &IdentityBundle,
parsed: &ParsedProofClaim,
) -> RhoResult<()> {
match parsed {
ParsedProofClaim::NostrController { public_key, .. } => {
let key = nostr_controller_key(identity)
.ok_or("identity has no nostr controller public key")?;
if key.public_key != *public_key {
return Err(format!(
"nostr controller mismatch: claim has {}, identity has {}",
public_key, key.public_key
)
.into());
}
}
}
Ok(())
}
fn nostr_controller_key(identity: &IdentityBundle) -> Option<&IdentityPublicKey> {
identity
.public_keys
.iter()
.find(|key| key.kind == "nostr-controller" && key.algorithm == "secp256k1-bip340")
}
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)]
enum ParsedProofClaim {
NostrController { handle: String, public_key: String },
}
impl ParsedProofClaim {
fn handle(&self) -> &str {
match self {
Self::NostrController { handle, .. } => handle,
}
}
}
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] != "controller"
|| parts[5] != "nostr"
{
return Err(format!("unsupported github proof claim: {claim}").into());
}
validate_github_handle(parts[3])?;
let public_key = parts[6].to_string();
if !is_nostr_public_key_hex(&public_key) {
return Err(format!("invalid nostr controller public key in claim: {claim}").into());
}
Ok(ParsedProofClaim::NostrController {
handle: parts[3].to_string(),
public_key,
})
}
fn nostr_proof_claim(handle: &str, public_key: &str) -> String {
format!("rho.claim/id/github/{handle}/controller/nostr/{public_key}")
}
fn is_nostr_public_key_hex(value: &str) -> bool {
value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
}
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 nostr_proof_claim_uses_controller_key() {
assert_eq!(
nostr_proof_claim(
"madhavajay",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
),
"rho.claim/id/github/madhavajay/controller/nostr/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
);
}
#[test]
fn proof_url_uses_provider_fragment() {
assert_eq!(
proof_url(
"https://github.com/madhavajay",
"rho.claim/id/github/madhavajay/controller/nostr/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
),
"https://github.com/madhavajay#rho.claim/id/github/madhavajay/controller/nostr/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
);
}
#[test]
fn parses_proof_claim() {
let parsed = parse_proof_claim(
"rho.claim/id/github/madhavajay/controller/nostr/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
)
.unwrap();
assert_eq!(parsed.handle(), "madhavajay");
assert_eq!(
parsed,
ParsedProofClaim::NostrController {
handle: "madhavajay".to_string(),
public_key: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
.to_string(),
}
);
}
#[test]
fn rejects_legacy_ssh_key_proof_claim() {
assert!(
parse_proof_claim("rho.claim/id/github/madhavajay/key/ssh-ed25519/sha256-abc").is_err()
);
}
#[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());
}
}