1use std::env;
2use std::fs;
3use std::io::Write;
4use std::path::{Path, PathBuf};
5use std::process::{Command, Stdio};
6use std::thread;
7use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
8
9use age::secrecy::ExposeSecret;
10use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
11use k256::{SecretKey, elliptic_curve::sec1::ToEncodedPoint};
12use rho_core::{
13 IdentityBundle, IdentityBundleManifest, IdentityProof, IdentityPublicKey, LocalControllerKey,
14 LocalEncryptionKey, LocalGitIdentity, LocalIdentity, LocalIdentityManifest, LocalSigningKey,
15 PrivateKeyRef, RhoResult, SignatureRecord, TrustManifest, TrustRecord, arg_value, bytes_digest,
16 ensure_parent, file_digest, from_yaml, has_flag, normalize_actor_id, providers, require_arg,
17 to_yaml, uuid_like,
18};
19use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret};
20
21const GITHUB_OAUTH_CLIENT_ID: &str = "Ov23liMtgjJdOf53iqgV";
22
23#[derive(Debug, serde::Deserialize)]
24struct GitHubDeviceCodeResponse {
25 device_code: String,
26 user_code: String,
27 verification_uri: String,
28 expires_in: u64,
29 interval: Option<u64>,
30}
31
32#[derive(Debug, serde::Deserialize)]
33struct GitHubAccessTokenResponse {
34 access_token: Option<String>,
35 error: Option<String>,
36 error_description: Option<String>,
37}
38
39#[derive(Debug, serde::Deserialize)]
40struct GitHubUserResponse {
41 login: String,
42}
43
44fn usage() -> ! {
45 eprintln!(
46 "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>"
47 );
48 std::process::exit(2);
49}
50
51pub fn run(args: &[String]) -> RhoResult<()> {
52 let Some(command) = args.first().map(String::as_str) else {
53 usage();
54 };
55 match command {
56 "init" => init(&args[1..]),
57 "rotate-key" => rotate_key(&args[1..]),
58 "publish-relay" => publish_relay(&args[1..]),
59 "export" => export(&args[1..]),
60 "import" => import(&args[1..]),
61 "verify-github" => verify_github(&args[1..]),
62 "trust" => trust(&args[1..]),
63 "list" => list(),
64 "show" => show(&args[1..]),
65 "status" => status(&args[1..]),
66 "--help" | "-h" => usage(),
67 _ => usage(),
68 }
69}
70
71fn init(args: &[String]) -> RhoResult<()> {
72 let shorthand = !args.is_empty() && !args[0].starts_with('-');
73 let login_with_github =
74 has_flag(args, "--github-login") || has_flag(args, "--login-with-github");
75 if login_with_github && (shorthand || arg_value(args, "--github").is_some()) {
76 return Err("--github-login cannot be combined with a GitHub handle".into());
77 }
78 let handle = if shorthand {
79 let identity_id = normalize_actor_id(&args[0])?;
80 github_handle_from_identity_id(&identity_id)?
81 } else if login_with_github {
82 github_device_login()?
83 } else {
84 require_arg(args, "--github").unwrap_or_else(|_| usage())
85 };
86 validate_github_handle(&handle)?;
87 let mut display_name = arg_value(args, "--display-name").unwrap_or_else(|| handle.clone());
88 let generate = has_flag(args, "--generate-ssh-key");
89 let ssh_key = arg_value(args, "--ssh-key");
90 if generate && ssh_key.is_some() {
91 return Err("choose only one of --ssh-key or --generate-ssh-key".into());
92 }
93 let default_key_management = !generate && ssh_key.is_none();
94
95 let rho_home = rho_home()?;
96 let path = local_identity_path(&rho_home, &handle);
97 if path.is_file() {
98 let mut local: LocalIdentityManifest = from_yaml(&fs::read_to_string(&path)?)?;
99 let mut changed = ensure_nostr_controller(&rho_home, &handle, &mut local)?;
100 if has_flag(args, "--bind-git") {
101 maybe_bind_github_commit_identity(&mut local)?;
102 changed = true;
103 }
104 if changed {
105 fs::write(&path, to_yaml(&local)?)?;
106 }
107 print_existing_identity(&rho_home, &path, &local);
108 return Ok(());
109 }
110 if default_key_management && !has_flag(args, "--yes") {
111 println!("Rho identity init");
112 println!(" identity: {}", github_identity_id(&handle));
113 println!(" rho home: {}", rho_home.display());
114 println!(
115 " signing key: {}",
116 default_ssh_private_key(&rho_home, &handle).display()
117 );
118 println!(
119 " encryption key: {}",
120 default_age_private_key(&rho_home, &handle).display()
121 );
122 println!(
123 " nostr controller key: {}",
124 default_nostr_private_key(&rho_home, &handle).display()
125 );
126 let mut answer = String::new();
127 println!("Display name [{display_name}]: ");
128 std::io::stdin().read_line(&mut answer)?;
129 let display_name_answer = answer.trim();
130 if !display_name_answer.is_empty() {
131 display_name = display_name_answer.to_string();
132 }
133 answer.clear();
134 println!("Create this identity? [Y/n] ");
135 std::io::stdin().read_line(&mut answer)?;
136 let answer = answer.trim().to_ascii_lowercase();
137 if answer == "n" || answer == "no" {
138 return Err("rho id init cancelled".into());
139 }
140 }
141
142 let (public_key_path, private_key_path, signing_key_source) = if default_key_management {
143 let source = if default_ssh_key_pair_exists(&rho_home, &handle) {
144 "reused default"
145 } else {
146 "generated default"
147 };
148 let (public, private) = ensure_default_ssh_key(&rho_home, &handle)?;
149 (public, private, source)
150 } else if generate {
151 let (public, private) = generate_ssh_key(&rho_home, &handle)?;
152 (public, private, "generated")
153 } else {
154 let (public, private) = selected_ssh_key(ssh_key.as_deref().unwrap())?;
155 (public, private, "provided")
156 };
157 let public_key = fs::read_to_string(&public_key_path)
158 .map_err(|_| format!("ssh public key not found: {}", public_key_path.display()))?;
159 let algorithm = public_key_algorithm(&public_key)?;
160 let fingerprint = key_fingerprint(&public_key_path)?;
161 let (age_public_key_path, age_private_key_path, encryption_key_source) =
162 if default_key_management {
163 let source = if default_age_key_pair_exists(&rho_home, &handle) {
164 "reused default"
165 } else {
166 "generated default"
167 };
168 let (public, private) = ensure_default_age_key(&rho_home, &handle)?;
169 (public, private, source)
170 } else {
171 let (public, private) = generate_age_key(&rho_home, &handle)?;
172 (public, private, "generated")
173 };
174 let age_public_key = fs::read_to_string(&age_public_key_path)?;
175 let age_fingerprint = file_digest(&age_public_key_path)?;
176 let (nostr_public_key_path, nostr_private_key_path) =
177 ensure_default_nostr_key(&rho_home, &handle)?;
178 let nostr_public_key = fs::read_to_string(&nostr_public_key_path)?;
179 let nostr_fingerprint = file_digest(&nostr_public_key_path)?;
180 let (x25519_public_key_path, _x25519_private_key_path) = if default_key_management {
181 ensure_default_x25519_key(&rho_home, &handle)?
182 } else {
183 generate_x25519_key(&rho_home, &handle)?
184 };
185 let x25519_public_key = fs::read_to_string(&x25519_public_key_path)?;
186 let x25519_fingerprint = file_digest(&x25519_public_key_path)?;
187 let created_at = rho_core::now_rfc3339();
188 let identity_id = github_identity_id(&handle);
189 let key_id = format!("{identity_id}/{algorithm}-1").replace("rho://id/", "rho://key/");
190 let age_key_id = format!("{identity_id}/age-x25519-1").replace("rho://id/", "rho://key/");
191 let nostr_key_id =
192 format!("{identity_id}/nostr-controller-1").replace("rho://id/", "rho://key/");
193 let x25519_key_id = format!("{identity_id}/x25519-legacy-1").replace("rho://id/", "rho://key/");
194 let claim = nostr_proof_claim(&handle, nostr_public_key.trim());
195 let provider_url = github_provider_url(&handle);
196 let proof_url = proof_url(&provider_url, &claim);
197 let identity = IdentityBundle {
198 id: identity_id.clone(),
199 kind: "github".to_string(),
200 handle: handle.clone(),
201 display_name,
202 public_keys: vec![
203 IdentityPublicKey {
204 id: key_id,
205 kind: "ssh-signing".to_string(),
206 algorithm,
207 public_key: public_key.trim().to_string(),
208 fingerprint: fingerprint.clone(),
209 created_at: created_at.clone(),
210 },
211 IdentityPublicKey {
212 id: age_key_id,
213 kind: "age-encryption".to_string(),
214 algorithm: "age-x25519".to_string(),
215 public_key: age_public_key.trim().to_string(),
216 fingerprint: age_fingerprint,
217 created_at: created_at.clone(),
218 },
219 IdentityPublicKey {
220 id: nostr_key_id,
221 kind: "nostr-controller".to_string(),
222 algorithm: "secp256k1-bip340".to_string(),
223 public_key: nostr_public_key.trim().to_string(),
224 fingerprint: nostr_fingerprint,
225 created_at: created_at.clone(),
226 },
227 IdentityPublicKey {
228 id: x25519_key_id,
229 kind: "x25519-encryption".to_string(),
230 algorithm: "x25519".to_string(),
231 public_key: x25519_public_key.trim().to_string(),
232 fingerprint: x25519_fingerprint,
233 created_at: created_at.clone(),
234 },
235 ],
236 proofs: vec![IdentityProof {
237 kind: "github-profile-fragment-claim".to_string(),
238 provider_url: Some(provider_url),
239 claim: Some(claim.clone()),
240 proof_url: proof_url.clone(),
241 verified_at: None,
242 }],
243 created_at,
244 };
245 let mut local = LocalIdentityManifest {
246 version: 1,
247 local_identity: LocalIdentity {
248 identity,
249 controller_key: Some(LocalControllerKey {
250 kind: "nostr-controller".to_string(),
251 algorithm: "secp256k1-bip340".to_string(),
252 public_key_path: nostr_public_key_path.display().to_string(),
253 private_key_ref: PrivateKeyRef {
254 backend: "rho-file".to_string(),
255 path: nostr_private_key_path.display().to_string(),
256 },
257 }),
258 signing_key: LocalSigningKey {
259 kind: "ssh-signing".to_string(),
260 algorithm: "ssh-ed25519".to_string(),
261 public_key_path: public_key_path.display().to_string(),
262 private_key_ref: PrivateKeyRef {
263 backend: "ssh-file".to_string(),
264 path: private_key_path.display().to_string(),
265 },
266 },
267 encryption_key: Some(LocalEncryptionKey {
268 kind: "age-encryption".to_string(),
269 algorithm: "age-x25519".to_string(),
270 public_key_path: age_public_key_path.display().to_string(),
271 private_key_ref: PrivateKeyRef {
272 backend: "rho-file".to_string(),
273 path: age_private_key_path.display().to_string(),
274 },
275 }),
276 git: None,
277 },
278 };
279 if has_flag(args, "--bind-git") {
280 maybe_bind_github_commit_identity(&mut local)?;
281 }
282 ensure_parent(&path)?;
283 fs::write(&path, to_yaml(&local)?)?;
284
285 println!("created identity");
286 println!("identity: {identity_id}");
287 println!("rho home: {}", rho_home.display());
288 println!("local manifest: {}", path.display());
289 println!("public key: {}", public_key_path.display());
290 println!("signing key source: {signing_key_source}");
291 println!("encryption public key: {}", age_public_key_path.display());
292 println!(
293 "nostr controller public key: {}",
294 nostr_public_key_path.display()
295 );
296 println!(
297 "legacy x25519 public key: {}",
298 x25519_public_key_path.display()
299 );
300 println!("encryption key source: {encryption_key_source}");
301 println!("github profile proof URL:");
302 println!("{proof_url}");
303 println!();
304 println!("GitHub verification:");
305 println!("1. Copy this full proof URL into your public GitHub profile:");
306 println!("{proof_url}");
307 println!(" If GitHub only shows the link target partially, make sure this claim text");
308 println!(" is visible somewhere on the fetched profile page:");
309 println!("{claim}");
310 println!("2. Verify it with:");
311 println!("rho id verify-github --identity {identity_id}");
312 println!("3. Publish the signed identity record to your local relay:");
313 println!("rho id publish-relay --identity {identity_id}");
314 Ok(())
315}
316
317fn print_existing_identity(rho_home: &Path, path: &Path, local: &LocalIdentityManifest) {
318 let identity = &local.local_identity.identity;
319 println!("identity already exists");
320 println!("identity: {}", identity.id);
321 println!("rho home: {}", rho_home.display());
322 println!("local manifest: {}", path.display());
323 println!(
324 "public key: {}",
325 local.local_identity.signing_key.public_key_path
326 );
327 if let Some(encryption_key) = &local.local_identity.encryption_key {
328 println!("encryption public key: {}", encryption_key.public_key_path);
329 }
330 if let Some(controller_key) = &local.local_identity.controller_key {
331 println!(
332 "nostr controller public key: {}",
333 controller_key.public_key_path
334 );
335 } else if let Some(controller) = nostr_controller_key(identity) {
336 println!("nostr controller: {}", controller.public_key);
337 }
338 if let Some(git) = &local.local_identity.git {
339 println!("github commit login: {}", git.github_login);
340 println!(
341 "git commit author: {} <{}>",
342 git.commit_name, git.commit_email
343 );
344 }
345 if let Ok(proof) = github_profile_proof(identity) {
346 if !proof.proof_url.is_empty() {
347 println!("github profile proof URL:");
348 println!("{}", proof.proof_url);
349 println!();
350 println!("GitHub verification:");
351 println!("1. Copy this full proof URL into your public GitHub profile:");
352 println!("{}", proof.proof_url);
353 }
354 if let Some(claim) = &proof.claim {
355 println!(
356 " If GitHub only shows the link target partially, make sure this claim text"
357 );
358 println!(" is visible somewhere on the fetched profile page:");
359 println!("{claim}");
360 }
361 println!("2. Verify it with:");
362 println!("rho id verify-github --identity {}", identity.id);
363 println!("3. Publish the signed identity record to your local relay:");
364 println!("rho id publish-relay --identity {}", identity.id);
365 }
366}
367
368fn publish_relay(args: &[String]) -> RhoResult<()> {
369 let identity_id =
370 normalize_actor_id(&require_arg(args, "--identity").unwrap_or_else(|_| usage()))?;
371 let handle = github_handle_from_identity_id(&identity_id)?;
372 let relay_url = arg_value(args, "--relay")
373 .or_else(|| env::var("RHO_RELAY_URL").ok())
374 .unwrap_or_else(|| "ws://127.0.0.1:8787".to_string());
375 let rho_home = rho_home()?;
376 let local = read_local_identity(&rho_home, &handle)?;
377 let identity = &local.local_identity.identity;
378 if identity.id != identity_id {
379 return Err(format!(
380 "identity mismatch: requested {identity_id}, found {}",
381 identity.id
382 )
383 .into());
384 }
385 let private_key_path = local
386 .local_identity
387 .controller_key
388 .as_ref()
389 .filter(|key| key.kind == "nostr-controller" && key.algorithm == "secp256k1-bip340")
390 .map(|key| PathBuf::from(&key.private_key_ref.path))
391 .unwrap_or_else(|| default_nostr_private_key(&rho_home, &handle));
392 let private_key = fs::read_to_string(&private_key_path).map_err(|_| {
393 format!(
394 "nostr controller private key not found: {}",
395 private_key_path.display()
396 )
397 })?;
398 let nostr_identity = rho_core::nostr::NostrIdentity::from_secret_hex(private_key.trim())?;
399 let event = rho_core::nostr::build_identity_event(
400 &nostr_identity,
401 &identity.id,
402 &identity.display_name,
403 &relay_url,
404 )?;
405 let event_json = serde_json::to_string_pretty(&event)? + "\n";
406 if has_flag(args, "--print") {
407 print!("{event_json}");
408 }
409 if has_flag(args, "--dry-run") {
410 return Ok(());
411 }
412
413 let script = relay_smoke_script()
414 .ok_or("could not find relay/scripts/local-smoke.mjs from the current directory")?;
415 let event_path = temp_event_path()?;
416 fs::write(&event_path, event_json)?;
417 let status = Command::new("node")
418 .arg(script)
419 .arg("--publish-event")
420 .arg(&event_path)
421 .arg("--relay")
422 .arg(&relay_url)
423 .stdin(Stdio::null())
424 .stdout(Stdio::inherit())
425 .stderr(Stdio::inherit())
426 .status();
427 let _ = fs::remove_file(&event_path);
428 let status = status?;
429 if !status.success() {
430 return Err(format!(
431 "relay publish failed with exit code {}",
432 status.code().unwrap_or(1)
433 )
434 .into());
435 }
436
437 println!("published identity record");
438 println!("identity: {identity_id}");
439 println!("relay: {relay_url}");
440 println!("event: {}", event.id);
441 Ok(())
442}
443
444fn rotate_key(args: &[String]) -> RhoResult<()> {
445 let Some(identity_arg) = args.first() else {
446 usage();
447 };
448 let identity_id = normalize_actor_id(identity_arg)?;
449 let handle = github_handle_from_identity_id(&identity_id)?;
450 let rho_home = rho_home()?;
451 let path = local_identity_path(&rho_home, &handle);
452 let mut local = read_local_identity(&rho_home, &handle)?;
453 if local.local_identity.identity.id != identity_id {
454 return Err(format!(
455 "identity mismatch: requested {identity_id}, found {}",
456 local.local_identity.identity.id
457 )
458 .into());
459 }
460 if !has_flag(args, "--yes") {
461 println!("Rotate Rho signing key");
462 println!(" identity: {identity_id}");
463 println!(" local manifest: {}", path.display());
464 println!(
465 " current public key: {}",
466 local.local_identity.signing_key.public_key_path
467 );
468 println!(
469 "This rotates the local SSH signing key. The GitHub profile proof remains tied to the Nostr controller key. Continue? [y/N] "
470 );
471 let mut answer = String::new();
472 std::io::stdin().read_line(&mut answer)?;
473 let answer = answer.trim().to_ascii_lowercase();
474 if answer != "y" && answer != "yes" {
475 return Err("rho id rotate-key cancelled".into());
476 }
477 }
478
479 let next_index = next_signing_key_index(&local.local_identity.identity);
480 let (public_key_path, private_key_path) =
481 generate_rotated_ssh_key(&rho_home, &handle, next_index)?;
482 let public_key = fs::read_to_string(&public_key_path)
483 .map_err(|_| format!("ssh public key not found: {}", public_key_path.display()))?;
484 let algorithm = public_key_algorithm(&public_key)?;
485 let fingerprint = key_fingerprint(&public_key_path)?;
486 let created_at = rho_core::now_rfc3339();
487 let key_id =
488 format!("{identity_id}/{algorithm}-{next_index}").replace("rho://id/", "rho://key/");
489 let new_key = IdentityPublicKey {
490 id: key_id.clone(),
491 kind: "ssh-signing".to_string(),
492 algorithm: algorithm.clone(),
493 public_key: public_key.trim().to_string(),
494 fingerprint: fingerprint.clone(),
495 created_at,
496 };
497 local
498 .local_identity
499 .identity
500 .public_keys
501 .retain(|key| key.id != key_id);
502 local.local_identity.identity.public_keys.insert(0, new_key);
503 local.local_identity.signing_key = LocalSigningKey {
504 kind: "ssh-signing".to_string(),
505 algorithm: algorithm.clone(),
506 public_key_path: public_key_path.display().to_string(),
507 private_key_ref: PrivateKeyRef {
508 backend: "ssh-file".to_string(),
509 path: private_key_path.display().to_string(),
510 },
511 };
512
513 fs::write(&path, to_yaml(&local)?)?;
514 println!("rotated signing key");
515 println!("identity: {identity_id}");
516 println!("local manifest: {}", path.display());
517 println!("public key: {}", public_key_path.display());
518 println!("private key: {}", private_key_path.display());
519 println!("key_id: {key_id}");
520 println!("github proof: unchanged");
521 Ok(())
522}
523
524fn maybe_bind_github_commit_identity(local: &mut LocalIdentityManifest) -> RhoResult<()> {
525 println!();
526 println!("GitHub CLI account:");
527 match Command::new("gh").args(["auth", "status"]).status() {
528 Ok(status) if status.success() => {}
529 Ok(_) => {
530 println!("gh auth status failed; skipping GitHub commit binding");
531 return Ok(());
532 }
533 Err(_) => {
534 println!("gh is not available; skipping GitHub commit binding");
535 return Ok(());
536 }
537 }
538 let Some((login, id)) = gh_user() else {
539 println!("could not read active gh account; skipping GitHub commit binding");
540 return Ok(());
541 };
542 let default_email = format!("{id}+{login}@users.noreply.github.com");
543 println!("Active gh account: {login}");
544 println!("Default commit author: {login} <{default_email}>");
545 println!("Use this gh account for rho commits with this identity? [Y/n] ");
546 let mut answer = String::new();
547 std::io::stdin().read_line(&mut answer)?;
548 let bind_answer = answer.trim().to_ascii_lowercase();
549 if bind_answer == "n" || bind_answer == "no" {
550 return Ok(());
551 }
552 let mut name = login.clone();
553 answer.clear();
554 println!("Commit name [{name}]: ");
555 std::io::stdin().read_line(&mut answer)?;
556 let value = answer.trim();
557 if !value.is_empty() {
558 name = value.to_string();
559 }
560 let mut email = default_email;
561 answer.clear();
562 println!("Commit email [{email}]: ");
563 std::io::stdin().read_line(&mut answer)?;
564 let value = answer.trim();
565 if !value.is_empty() {
566 email = value.to_string();
567 }
568 local.local_identity.git = Some(LocalGitIdentity {
569 github_login: login,
570 commit_name: name,
571 commit_email: email,
572 });
573 Ok(())
574}
575
576fn github_device_login() -> RhoResult<String> {
577 let response = ureq::post("https://github.com/login/device/code")
578 .set("Accept", "application/json")
579 .send_form(&[
580 ("client_id", GITHUB_OAUTH_CLIENT_ID),
581 ("scope", "read:user"),
582 ])
583 .map_err(|error| format!("failed to start GitHub login: {error}"))?;
584 let body = response
585 .into_string()
586 .map_err(|error| format!("failed to read GitHub login response: {error}"))?;
587 let flow: GitHubDeviceCodeResponse = serde_json::from_str(&body)
588 .map_err(|error| format!("failed to parse GitHub login response: {error}"))?;
589
590 println!("GitHub login");
591 println!(" open: {}", flow.verification_uri);
592 println!(" code: {}", flow.user_code);
593 println!("Waiting for GitHub approval...");
594
595 let started = Instant::now();
596 let mut interval = flow.interval.unwrap_or(5).max(1);
597 while started.elapsed().as_secs() < flow.expires_in {
598 thread::sleep(Duration::from_secs(interval));
599 let response = ureq::post("https://github.com/login/oauth/access_token")
600 .set("Accept", "application/json")
601 .send_form(&[
602 ("client_id", GITHUB_OAUTH_CLIENT_ID),
603 ("device_code", flow.device_code.as_str()),
604 ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
605 ])
606 .map_err(|error| format!("failed to poll GitHub login: {error}"))?;
607 let body = response
608 .into_string()
609 .map_err(|error| format!("failed to read GitHub token response: {error}"))?;
610 let token: GitHubAccessTokenResponse = serde_json::from_str(&body)
611 .map_err(|error| format!("failed to parse GitHub token response: {error}"))?;
612 if let Some(error) = token.error {
613 match error.as_str() {
614 "authorization_pending" => continue,
615 "slow_down" => {
616 interval += 5;
617 continue;
618 }
619 "expired_token" => return Err("GitHub login code expired".into()),
620 "access_denied" => return Err("GitHub login was denied".into()),
621 _ => {
622 return Err(token
623 .error_description
624 .unwrap_or_else(|| format!("GitHub login failed: {error}"))
625 .into());
626 }
627 }
628 }
629 let access_token = token
630 .access_token
631 .ok_or("GitHub did not return an access token")?;
632 let response = ureq::get("https://api.github.com/user")
633 .set("Accept", "application/vnd.github+json")
634 .set("Authorization", &format!("Bearer {access_token}"))
635 .set("X-GitHub-Api-Version", "2022-11-28")
636 .call()
637 .map_err(|error| format!("failed to read GitHub user: {error}"))?;
638 let body = response
639 .into_string()
640 .map_err(|error| format!("failed to read GitHub user response: {error}"))?;
641 let user: GitHubUserResponse = serde_json::from_str(&body)
642 .map_err(|error| format!("failed to parse GitHub user response: {error}"))?;
643 let login = user.login.trim().to_string();
644 if login.is_empty() {
645 return Err("GitHub returned an empty login".into());
646 }
647 println!("GitHub account: {login}");
648 return Ok(login);
649 }
650
651 Err("GitHub login timed out".into())
652}
653
654fn gh_user() -> Option<(String, String)> {
655 let login = gh_api_user_field(".login")?;
656 let id = gh_api_user_field(".id")?;
657 Some((login, id))
658}
659
660fn gh_api_user_field(field: &str) -> Option<String> {
661 let output = Command::new("gh")
662 .args(["api", "user", "--jq", field])
663 .output()
664 .ok()?;
665 if !output.status.success() {
666 return None;
667 }
668 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
669 .filter(|value| !value.is_empty())
670}
671
672fn export(args: &[String]) -> RhoResult<()> {
673 let identity_id =
674 normalize_actor_id(&require_arg(args, "--identity").unwrap_or_else(|_| usage()))?;
675 let out = PathBuf::from(require_arg(args, "--out").unwrap_or_else(|_| usage()));
676 let handle = github_handle_from_identity_id(&identity_id)?;
677 let local = read_local_identity(&rho_home()?, &handle)?;
678 ensure_parent(&out)?;
679 let self_signature = sign_identity_self_signature(&local)?;
680 fs::write(
681 &out,
682 to_yaml(&IdentityBundleManifest {
683 version: 1,
684 identity: local.local_identity.identity,
685 self_signature: Some(self_signature),
686 })?,
687 )?;
688 println!("{}", out.display());
689 Ok(())
690}
691
692pub(crate) fn sign_identity_self_signature(
693 local: &LocalIdentityManifest,
694) -> RhoResult<SignatureRecord> {
695 let identity = &local.local_identity.identity;
696 let key = identity
697 .public_keys
698 .iter()
699 .find(|key| key.kind == "ssh-signing")
700 .ok_or("local identity has no signing public key")?;
701 let private_key = PathBuf::from(&local.local_identity.signing_key.private_key_ref.path);
702 if !private_key.is_file() {
703 return Err(format!("private signing key not found: {}", private_key.display()).into());
704 }
705 let payload = identity_self_signature_payload(identity)?;
706 Ok(SignatureRecord {
707 signed_path: "rho://identity/self".to_string(),
708 signed_sha256: bytes_digest(&payload),
709 signer: identity.id.clone(),
710 key_id: key.id.clone(),
711 algorithm: key.algorithm.clone(),
712 namespace: "rho".to_string(),
713 context: None,
714 signature: ssh_sign_bytes(&payload, &private_key)?,
715 created_at: rho_core::now_rfc3339(),
716 })
717}
718
719pub(crate) fn verify_identity_bundle_self_signature(
720 bundle: &IdentityBundleManifest,
721) -> RhoResult<()> {
722 let Some(record) = bundle.self_signature.as_ref() else {
723 return Ok(());
724 };
725 let payload = identity_self_signature_payload(&bundle.identity)?;
726 if record.signed_path != "rho://identity/self" {
727 return Err(format!(
728 "identity self-signature signed_path mismatch: {}",
729 record.signed_path
730 )
731 .into());
732 }
733 if record.signer != bundle.identity.id {
734 return Err(format!(
735 "identity self-signature signer mismatch: expected {}, got {}",
736 bundle.identity.id, record.signer
737 )
738 .into());
739 }
740 if record.namespace != "rho" {
741 return Err(format!(
742 "identity self-signature namespace mismatch: {}",
743 record.namespace
744 )
745 .into());
746 }
747 let actual_digest = bytes_digest(&payload);
748 if record.signed_sha256 != actual_digest {
749 return Err(format!(
750 "identity self-signature digest mismatch: expected {}, got {actual_digest}",
751 record.signed_sha256
752 )
753 .into());
754 }
755 let key = bundle
756 .identity
757 .public_keys
758 .iter()
759 .find(|key| key.id == record.key_id)
760 .ok_or_else(|| format!("identity does not contain key {}", record.key_id))?;
761 if key.algorithm != record.algorithm || key.kind != "ssh-signing" {
762 return Err(format!(
763 "identity self-signature key mismatch: {} {}",
764 key.kind, key.algorithm
765 )
766 .into());
767 }
768 ssh_verify_bytes(
769 &payload,
770 &record.signature,
771 &bundle.identity.id,
772 &key.public_key,
773 )
774}
775
776fn identity_self_signature_payload(identity: &IdentityBundle) -> RhoResult<Vec<u8>> {
777 Ok(format!("rho identity self-signature v1\n{}", to_yaml(identity)?).into_bytes())
778}
779
780fn ssh_sign_bytes(message_bytes: &[u8], private_key: &Path) -> RhoResult<String> {
781 let work_dir = env::temp_dir().join(format!("rho-id-sign-{}", uuid_like()));
782 fs::create_dir_all(&work_dir)?;
783 let message = work_dir.join("message");
784 fs::write(&message, message_bytes)?;
785 let output = Command::new("ssh-keygen")
786 .args(["-Y", "sign", "-f"])
787 .arg(private_key)
788 .args(["-n", "rho"])
789 .arg(&message)
790 .output()?;
791 if !output.status.success() {
792 let _ = fs::remove_dir_all(&work_dir);
793 let stderr = String::from_utf8_lossy(&output.stderr);
794 return Err(format!("ssh-keygen signing failed: {}", stderr.trim()).into());
795 }
796 let signature_path = message.with_extension("sig");
797 let signature = fs::read_to_string(&signature_path)?;
798 fs::remove_dir_all(&work_dir)?;
799 Ok(signature)
800}
801
802fn ssh_verify_bytes(
803 message_bytes: &[u8],
804 signature: &str,
805 identity_id: &str,
806 public_key: &str,
807) -> RhoResult<()> {
808 let work_dir = env::temp_dir().join(format!("rho-id-verify-{}", uuid_like()));
809 fs::create_dir_all(&work_dir)?;
810 let signature_path = work_dir.join("signature.sig");
811 let allowed_signers_path = work_dir.join("allowed_signers");
812 fs::write(&signature_path, signature)?;
813 fs::write(
814 &allowed_signers_path,
815 format!("{identity_id} namespaces=\"rho\" {public_key}\n"),
816 )?;
817 let mut child = Command::new("ssh-keygen")
818 .args(["-Y", "verify", "-f"])
819 .arg(&allowed_signers_path)
820 .args(["-I", identity_id, "-n", "rho", "-s"])
821 .arg(&signature_path)
822 .stdin(Stdio::piped())
823 .stdout(Stdio::piped())
824 .stderr(Stdio::piped())
825 .spawn()?;
826 {
827 let stdin = child
828 .stdin
829 .as_mut()
830 .ok_or("failed to open ssh-keygen stdin")?;
831 stdin.write_all(message_bytes)?;
832 }
833 let output = child.wait_with_output()?;
834 fs::remove_dir_all(&work_dir)?;
835 if !output.status.success() {
836 let stderr = String::from_utf8_lossy(&output.stderr);
837 return Err(format!("ssh signature verification failed: {}", stderr.trim()).into());
838 }
839 Ok(())
840}
841
842fn import(args: &[String]) -> RhoResult<()> {
843 let Some(path) = args.first() else {
844 usage();
845 };
846 let text = fs::read_to_string(path)?;
847 let bundle: IdentityBundleManifest = from_yaml(&text)?;
848 verify_identity_bundle_self_signature(&bundle)?;
849 if bundle.identity.kind != "github" {
850 return Err(format!("unsupported identity kind: {}", bundle.identity.kind).into());
851 }
852 validate_github_handle(&bundle.identity.handle)?;
853 let target = peer_identity_path(&rho_home()?, &bundle.identity.handle);
854 ensure_parent(&target)?;
855 fs::write(&target, to_yaml(&bundle)?)?;
856 println!("{}", target.display());
857 Ok(())
858}
859
860#[derive(Debug, Clone, Copy, PartialEq, Eq)]
862pub enum GithubProofStatus {
863 Verified,
864 Missing,
865 Invalid,
866}
867
868fn classify_github_failure(message: &str) -> GithubProofStatus {
869 let lowered = message.to_lowercase();
870 if lowered.contains("invalid claim") || lowered.contains("does not match this key") {
871 GithubProofStatus::Invalid
872 } else {
873 GithubProofStatus::Missing
874 }
875}
876
877pub fn verify_github_for_handle(
881 rho_home: &Path,
882 handle: &str,
883 provider_file: Option<&Path>,
884) -> RhoResult<GithubProofStatus> {
885 let local_path = local_identity_path(rho_home, handle);
886 let peer_path = peer_identity_path(rho_home, handle);
887
888 if local_path.is_file() {
889 let text = fs::read_to_string(&local_path)?;
890 let mut manifest: LocalIdentityManifest = from_yaml(&text)?;
891 let page = provider_page(provider_file, &manifest.local_identity.identity)?;
892 return match verify_github_identity(&mut manifest.local_identity.identity, &page) {
893 Ok(()) => {
894 fs::write(&local_path, to_yaml(&manifest)?)?;
895 Ok(GithubProofStatus::Verified)
896 }
897 Err(error) => {
898 clear_github_verification(&mut manifest.local_identity.identity, &local_path);
899 Ok(classify_github_failure(&error.to_string()))
900 }
901 };
902 }
903
904 if peer_path.is_file() {
905 let text = fs::read_to_string(&peer_path)?;
906 let mut manifest: IdentityBundleManifest = from_yaml(&text)?;
907 let page = provider_page(provider_file, &manifest.identity)?;
908 return match verify_github_identity(&mut manifest.identity, &page) {
909 Ok(()) => {
910 fs::write(&peer_path, to_yaml(&manifest)?)?;
911 Ok(GithubProofStatus::Verified)
912 }
913 Err(error) => {
914 clear_github_verification(&mut manifest.identity, &peer_path);
915 Ok(classify_github_failure(&error.to_string()))
916 }
917 };
918 }
919
920 Err(format!("identity not found: github/{handle}").into())
921}
922
923fn verify_github(args: &[String]) -> RhoResult<()> {
924 let identity_id =
925 normalize_actor_id(&require_arg(args, "--identity").unwrap_or_else(|_| usage()))?;
926 let handle = github_handle_from_identity_id(&identity_id)?;
927 let rho_home = rho_home()?;
928 let provider_file = arg_value(args, "--provider-file").map(PathBuf::from);
929 match verify_github_for_handle(&rho_home, &handle, provider_file.as_deref())? {
930 GithubProofStatus::Verified => {
931 println!("verified rho://id/github/{handle}");
932 Ok(())
933 }
934 GithubProofStatus::Missing => {
935 Err(format!("provider page does not contain a claim for github/{handle}").into())
936 }
937 GithubProofStatus::Invalid => Err(format!(
938 "invalid claim: published proof for github/{handle} does not match this key"
939 )
940 .into()),
941 }
942}
943
944fn trust(args: &[String]) -> RhoResult<()> {
945 let Some(identity_id) = args.first() else {
946 usage();
947 };
948 let identity_id = normalize_actor_id(identity_id)?;
949 let handle = github_handle_from_identity_id(&identity_id)?;
950 let rho_home = rho_home()?;
951 let peer = peer_identity_path(&rho_home, &handle);
952 if !peer.is_file() && !local_identity_path(&rho_home, &handle).is_file() {
953 return Err(format!("identity has not been imported or initialized: {identity_id}").into());
954 }
955 let target = trust_path(&rho_home, &handle);
956 ensure_parent(&target)?;
957 fs::write(
958 &target,
959 to_yaml(&TrustManifest {
960 version: 1,
961 trust: TrustRecord {
962 identity_id,
963 decision: "trusted".to_string(),
964 trusted_at: rho_core::now_rfc3339(),
965 source: "local-user".to_string(),
966 },
967 })?,
968 )?;
969 println!("{}", target.display());
970 Ok(())
971}
972
973fn list() -> RhoResult<()> {
974 let rho_home = rho_home()?;
975 println!("local identities:");
976 print_identity_dir(&rho_home.join("identities/github"))?;
977 println!("peer identities:");
978 print_identity_dir(&rho_home.join("peers/github"))?;
979 Ok(())
980}
981
982fn show(args: &[String]) -> RhoResult<()> {
983 let Some(identity_id) = args.first() else {
984 usage();
985 };
986 let identity_id = normalize_actor_id(identity_id)?;
987 let handle = github_handle_from_identity_id(&identity_id)?;
988 let rho_home = rho_home()?;
989 let local_path = local_identity_path(&rho_home, &handle);
990 if local_path.is_file() {
991 print!("{}", fs::read_to_string(local_path)?);
992 return Ok(());
993 }
994 let peer_path = peer_identity_path(&rho_home, &handle);
995 if peer_path.is_file() {
996 print!("{}", fs::read_to_string(peer_path)?);
997 return Ok(());
998 }
999 Err(format!("identity not found: {identity_id}").into())
1000}
1001
1002fn status(args: &[String]) -> RhoResult<()> {
1003 let Some(identity_id) = args.first() else {
1004 usage();
1005 };
1006 let identity_id = normalize_actor_id(identity_id)?;
1007 let handle = github_handle_from_identity_id(&identity_id)?;
1008 let rho_home = rho_home()?;
1009 let local_path = local_identity_path(&rho_home, &handle);
1010 if local_path.is_file() {
1011 let local: LocalIdentityManifest = from_yaml(&fs::read_to_string(&local_path)?)?;
1012 print_identity_status(
1013 &rho_home,
1014 &local_path,
1015 &local.local_identity.identity,
1016 true,
1017 local.local_identity.git.as_ref().map(|git| {
1018 (
1019 git.github_login.as_str(),
1020 git.commit_name.as_str(),
1021 git.commit_email.as_str(),
1022 )
1023 }),
1024 );
1025 return Ok(());
1026 }
1027
1028 let peer_path = peer_identity_path(&rho_home, &handle);
1029 if peer_path.is_file() {
1030 let peer: IdentityBundleManifest = from_yaml(&fs::read_to_string(&peer_path)?)?;
1031 print_identity_status(&rho_home, &peer_path, &peer.identity, false, None);
1032 return Ok(());
1033 }
1034
1035 Err(format!("identity not found: {identity_id}").into())
1036}
1037
1038fn print_identity_status(
1039 rho_home: &Path,
1040 path: &Path,
1041 identity: &IdentityBundle,
1042 has_private_key: bool,
1043 git: Option<(&str, &str, &str)>,
1044) {
1045 println!("identity: {}", identity.id);
1046 println!("kind: {}", identity.kind);
1047 println!("handle: {}", identity.handle);
1048 println!("rho home: {}", rho_home.display());
1049 println!("manifest: {}", path.display());
1050 println!(
1051 "private key: {}",
1052 if has_private_key {
1053 "present"
1054 } else {
1055 "not-present"
1056 }
1057 );
1058 if let Some((github_login, commit_name, commit_email)) = git {
1059 println!("github commit login: {github_login}");
1060 println!("git commit author: {commit_name} <{commit_email}>");
1061 }
1062 match github_profile_proof(identity) {
1063 Ok(proof) => {
1064 if let Some(verified_at) = &proof.verified_at {
1065 println!("github proof: verified");
1066 println!("verified_at: {verified_at}");
1067 } else {
1068 println!("github proof: unverified");
1069 }
1070 if let Some(provider_url) = &proof.provider_url {
1071 println!("provider_url: {provider_url}");
1072 }
1073 if let Some(claim) = &proof.claim {
1074 println!("claim: {claim}");
1075 }
1076 if !proof.proof_url.is_empty() {
1077 println!("proof_url: {}", proof.proof_url);
1078 }
1079 }
1080 Err(error) => {
1081 println!("github proof: missing ({error})");
1082 }
1083 }
1084}
1085
1086fn print_identity_dir(dir: &Path) -> RhoResult<()> {
1087 if !dir.is_dir() {
1088 return Ok(());
1089 }
1090 let mut paths: Vec<_> = fs::read_dir(dir)?
1091 .filter_map(Result::ok)
1092 .map(|entry| entry.path())
1093 .filter(|path| path.extension().and_then(|value| value.to_str()) == Some("yaml"))
1094 .collect();
1095 paths.sort();
1096 for path in paths {
1097 let text = fs::read_to_string(&path)?;
1098 if let Ok(local) = from_yaml::<LocalIdentityManifest>(&text) {
1099 let verified = local
1100 .local_identity
1101 .identity
1102 .proofs
1103 .iter()
1104 .any(|proof| proof.verified_at.is_some());
1105 println!(
1106 " {} private-key=yes verified={} {}",
1107 local.local_identity.identity.id,
1108 if verified { "yes" } else { "no" },
1109 path.display()
1110 );
1111 continue;
1112 }
1113 if let Ok(peer) = from_yaml::<IdentityBundleManifest>(&text) {
1114 let verified = peer
1115 .identity
1116 .proofs
1117 .iter()
1118 .any(|proof| proof.verified_at.is_some());
1119 println!(
1120 " {} private-key=no verified={} {}",
1121 peer.identity.id,
1122 if verified { "yes" } else { "no" },
1123 path.display()
1124 );
1125 continue;
1126 }
1127 println!(" unreadable identity file: {}", path.display());
1128 }
1129 Ok(())
1130}
1131
1132fn rho_home() -> RhoResult<PathBuf> {
1133 if let Ok(value) = env::var("RHO_HOME")
1134 && !value.is_empty()
1135 {
1136 return Ok(PathBuf::from(value));
1137 }
1138 let home = env::var("HOME").map_err(|_| "HOME is not set and RHO_HOME was not provided")?;
1139 Ok(PathBuf::from(home).join("rho"))
1140}
1141
1142fn relay_smoke_script() -> Option<PathBuf> {
1143 let mut starts = Vec::new();
1144 if let Ok(current_dir) = env::current_dir() {
1145 starts.push(current_dir);
1146 }
1147 if let Ok(current_exe) = env::current_exe()
1148 && let Some(parent) = current_exe.parent()
1149 {
1150 starts.push(parent.to_path_buf());
1151 }
1152
1153 for start in starts {
1154 for dir in start.ancestors() {
1155 let script = dir.join("relay").join("scripts").join("local-smoke.mjs");
1156 if script.is_file() {
1157 return Some(script);
1158 }
1159 }
1160 }
1161 None
1162}
1163
1164fn temp_event_path() -> RhoResult<PathBuf> {
1165 let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis();
1166 Ok(env::temp_dir().join(format!(
1167 "rho-nostr-event-{}-{timestamp}.json",
1168 std::process::id()
1169 )))
1170}
1171
1172fn local_identity_path(rho_home: &Path, handle: &str) -> PathBuf {
1173 rho_home
1174 .join("identities")
1175 .join("github")
1176 .join(format!("{handle}.yaml"))
1177}
1178
1179fn peer_identity_path(rho_home: &Path, handle: &str) -> PathBuf {
1180 rho_home
1181 .join("peers")
1182 .join("github")
1183 .join(format!("{handle}.yaml"))
1184}
1185
1186fn trust_path(rho_home: &Path, handle: &str) -> PathBuf {
1187 rho_home
1188 .join("trust")
1189 .join("github")
1190 .join(format!("{handle}.yaml"))
1191}
1192
1193fn read_local_identity(rho_home: &Path, handle: &str) -> RhoResult<LocalIdentityManifest> {
1194 let path = local_identity_path(rho_home, handle);
1195 let text = fs::read_to_string(&path)
1196 .map_err(|_| format!("local identity not found: {}", path.display()))?;
1197 from_yaml(&text)
1198}
1199
1200fn default_ssh_key_pair_exists(rho_home: &Path, handle: &str) -> bool {
1201 let private = default_ssh_private_key(rho_home, handle);
1202 private.is_file() && private.with_extension("pub").is_file()
1203}
1204
1205fn generate_ssh_key(rho_home: &Path, handle: &str) -> RhoResult<(PathBuf, PathBuf)> {
1206 let private = default_ssh_private_key(rho_home, handle);
1207 generate_ssh_key_at(&private, &github_identity_id(handle))
1208}
1209
1210fn generate_ssh_key_at(private: &Path, comment: &str) -> RhoResult<(PathBuf, PathBuf)> {
1211 let public = private.with_extension("pub");
1212 if private.exists() || public.exists() {
1213 return Err(format!("ssh key already exists: {}", private.display()).into());
1214 }
1215 ensure_parent(private)?;
1216 let status = Command::new("ssh-keygen")
1217 .args(["-t", "ed25519", "-N", "", "-C"])
1218 .arg(comment)
1219 .arg("-f")
1220 .arg(private)
1221 .status()?;
1222 if !status.success() {
1223 return Err("ssh-keygen failed".into());
1224 }
1225 Ok((public, private.to_path_buf()))
1226}
1227
1228fn ensure_default_ssh_key(rho_home: &Path, handle: &str) -> RhoResult<(PathBuf, PathBuf)> {
1229 let private = default_ssh_private_key(rho_home, handle);
1230 let public = private.with_extension("pub");
1231 if private.is_file() && public.is_file() {
1232 return Ok((public, private));
1233 }
1234 if private.exists() || public.exists() {
1235 return Err(format!(
1236 "incomplete ssh key pair; expected {} and {}",
1237 private.display(),
1238 public.display()
1239 )
1240 .into());
1241 }
1242 generate_ssh_key(rho_home, handle)
1243}
1244
1245fn generate_rotated_ssh_key(
1246 rho_home: &Path,
1247 handle: &str,
1248 index: usize,
1249) -> RhoResult<(PathBuf, PathBuf)> {
1250 let private = rotated_ssh_private_key(rho_home, handle, index);
1251 generate_ssh_key_at(
1252 &private,
1253 &format!("{} signing key {index}", github_identity_id(handle)),
1254 )
1255}
1256
1257fn next_signing_key_index(identity: &IdentityBundle) -> usize {
1258 identity
1259 .public_keys
1260 .iter()
1261 .filter(|key| key.kind == "ssh-signing")
1262 .filter_map(|key| {
1263 let suffix = key.id.rsplit('/').next()?;
1264 let (_, index) = suffix.rsplit_once('-')?;
1265 index.parse::<usize>().ok()
1266 })
1267 .max()
1268 .unwrap_or(0)
1269 + 1
1270}
1271
1272fn default_age_key_pair_exists(rho_home: &Path, handle: &str) -> bool {
1273 default_age_private_key(rho_home, handle).is_file()
1274 && default_age_public_key(rho_home, handle).is_file()
1275}
1276
1277fn generate_age_key(rho_home: &Path, handle: &str) -> RhoResult<(PathBuf, PathBuf)> {
1278 let private = default_age_private_key(rho_home, handle);
1279 let public = default_age_public_key(rho_home, handle);
1280 if private.exists() || public.exists() {
1281 return Err(format!("age encryption key already exists: {}", private.display()).into());
1282 }
1283 ensure_parent(&private)?;
1284 let identity = age::x25519::Identity::generate();
1285 fs::write(
1286 &private,
1287 format!("{}\n", identity.to_string().expose_secret()),
1288 )?;
1289 fs::write(&public, format!("{}\n", identity.to_public()))?;
1290 Ok((public, private))
1291}
1292
1293fn generate_x25519_key(rho_home: &Path, handle: &str) -> RhoResult<(PathBuf, PathBuf)> {
1294 let private = default_x25519_private_key(rho_home, handle);
1295 let public = default_x25519_public_key(rho_home, handle);
1296 if private.exists() || public.exists() {
1297 return Err(format!("encryption key already exists: {}", private.display()).into());
1298 }
1299 ensure_parent(&private)?;
1300 let secret = StaticSecret::from(random_bytes::<32>()?);
1301 let public_key = X25519PublicKey::from(&secret);
1302 fs::write(&private, format!("{}\n", BASE64.encode(secret.to_bytes())))?;
1303 fs::write(
1304 &public,
1305 format!("{}\n", BASE64.encode(public_key.as_bytes())),
1306 )?;
1307 Ok((public, private))
1308}
1309
1310fn ensure_default_x25519_key(rho_home: &Path, handle: &str) -> RhoResult<(PathBuf, PathBuf)> {
1311 let private = default_x25519_private_key(rho_home, handle);
1312 let public = default_x25519_public_key(rho_home, handle);
1313 if private.is_file() && public.is_file() {
1314 return Ok((public, private));
1315 }
1316 if private.exists() || public.exists() {
1317 return Err(format!(
1318 "incomplete encryption key pair; expected {} and {}",
1319 private.display(),
1320 public.display()
1321 )
1322 .into());
1323 }
1324 generate_x25519_key(rho_home, handle)
1325}
1326
1327fn ensure_default_age_key(rho_home: &Path, handle: &str) -> RhoResult<(PathBuf, PathBuf)> {
1328 let private = default_age_private_key(rho_home, handle);
1329 let public = default_age_public_key(rho_home, handle);
1330 if private.is_file() && public.is_file() {
1331 return Ok((public, private));
1332 }
1333 if private.exists() || public.exists() {
1334 return Err(format!(
1335 "incomplete age encryption key pair; expected {} and {}",
1336 private.display(),
1337 public.display()
1338 )
1339 .into());
1340 }
1341 generate_age_key(rho_home, handle)
1342}
1343
1344fn generate_nostr_key(rho_home: &Path, handle: &str) -> RhoResult<(PathBuf, PathBuf)> {
1345 let private = default_nostr_private_key(rho_home, handle);
1346 let public = default_nostr_public_key(rho_home, handle);
1347 if private.exists() || public.exists() {
1348 return Err(format!("nostr controller key already exists: {}", private.display()).into());
1349 }
1350 ensure_parent(&private)?;
1351 let secret = random_nostr_secret()?;
1352 let public_key = nostr_public_key_hex(&secret)?;
1353 fs::write(&private, format!("{}\n", hex_encode(secret.to_bytes())))?;
1354 fs::write(&public, format!("{public_key}\n"))?;
1355 Ok((public, private))
1356}
1357
1358fn ensure_default_nostr_key(rho_home: &Path, handle: &str) -> RhoResult<(PathBuf, PathBuf)> {
1359 let private = default_nostr_private_key(rho_home, handle);
1360 let public = default_nostr_public_key(rho_home, handle);
1361 if private.is_file() && public.is_file() {
1362 return Ok((public, private));
1363 }
1364 if private.exists() || public.exists() {
1365 return Err(format!(
1366 "incomplete nostr controller key pair; expected {} and {}",
1367 private.display(),
1368 public.display()
1369 )
1370 .into());
1371 }
1372 generate_nostr_key(rho_home, handle)
1373}
1374
1375fn ensure_nostr_controller(
1376 rho_home: &Path,
1377 handle: &str,
1378 local: &mut LocalIdentityManifest,
1379) -> RhoResult<bool> {
1380 let (public_key_path, private_key_path) = ensure_default_nostr_key(rho_home, handle)?;
1381 let public_key = fs::read_to_string(&public_key_path)?.trim().to_string();
1382 if !is_nostr_public_key_hex(&public_key) {
1383 return Err(format!(
1384 "invalid nostr controller public key in {}",
1385 public_key_path.display()
1386 )
1387 .into());
1388 }
1389
1390 let created_at = rho_core::now_rfc3339();
1391 let identity_id = local.local_identity.identity.id.clone();
1392 let key_id = format!("{identity_id}/nostr-controller-1").replace("rho://id/", "rho://key/");
1393 let mut changed = false;
1394
1395 match local
1396 .local_identity
1397 .identity
1398 .public_keys
1399 .iter_mut()
1400 .find(|key| key.kind == "nostr-controller")
1401 {
1402 Some(key) => {
1403 if key.public_key != public_key || key.algorithm != "secp256k1-bip340" {
1404 key.algorithm = "secp256k1-bip340".to_string();
1405 key.public_key = public_key.clone();
1406 key.fingerprint = file_digest(&public_key_path)?;
1407 changed = true;
1408 }
1409 }
1410 None => {
1411 local
1412 .local_identity
1413 .identity
1414 .public_keys
1415 .push(IdentityPublicKey {
1416 id: key_id,
1417 kind: "nostr-controller".to_string(),
1418 algorithm: "secp256k1-bip340".to_string(),
1419 public_key: public_key.clone(),
1420 fingerprint: file_digest(&public_key_path)?,
1421 created_at,
1422 });
1423 changed = true;
1424 }
1425 }
1426
1427 let expected_controller = LocalControllerKey {
1428 kind: "nostr-controller".to_string(),
1429 algorithm: "secp256k1-bip340".to_string(),
1430 public_key_path: public_key_path.display().to_string(),
1431 private_key_ref: PrivateKeyRef {
1432 backend: "rho-file".to_string(),
1433 path: private_key_path.display().to_string(),
1434 },
1435 };
1436 if local.local_identity.controller_key.as_ref() != Some(&expected_controller) {
1437 local.local_identity.controller_key = Some(expected_controller);
1438 changed = true;
1439 }
1440
1441 let provider_url = github_provider_url(handle);
1442 let claim = nostr_proof_claim(handle, &public_key);
1443 let proof_url = proof_url(&provider_url, &claim);
1444 match github_profile_proof_mut(&mut local.local_identity.identity) {
1445 Ok(proof) => {
1446 if proof.provider_url.as_deref() != Some(provider_url.as_str())
1447 || proof.claim.as_deref() != Some(claim.as_str())
1448 || proof.proof_url != proof_url
1449 {
1450 proof.provider_url = Some(provider_url);
1451 proof.claim = Some(claim);
1452 proof.proof_url = proof_url;
1453 proof.verified_at = None;
1454 changed = true;
1455 }
1456 }
1457 Err(_) => {
1458 local.local_identity.identity.proofs.push(IdentityProof {
1459 kind: "github-profile-fragment-claim".to_string(),
1460 provider_url: Some(provider_url),
1461 claim: Some(claim),
1462 proof_url,
1463 verified_at: None,
1464 });
1465 changed = true;
1466 }
1467 }
1468
1469 Ok(changed)
1470}
1471
1472fn default_ssh_private_key(rho_home: &Path, handle: &str) -> PathBuf {
1473 rho_home
1474 .join("keys")
1475 .join("github")
1476 .join(handle)
1477 .join("signing_ed25519")
1478}
1479
1480fn rotated_ssh_private_key(rho_home: &Path, handle: &str, index: usize) -> PathBuf {
1481 let timestamp = SystemTime::now()
1482 .duration_since(UNIX_EPOCH)
1483 .map(|duration| duration.as_secs())
1484 .unwrap_or(0);
1485 rho_home
1486 .join("keys")
1487 .join("github")
1488 .join(handle)
1489 .join(format!("signing_ed25519-{index}-{timestamp}"))
1490}
1491
1492fn default_x25519_private_key(rho_home: &Path, handle: &str) -> PathBuf {
1493 rho_home
1494 .join("keys")
1495 .join("github")
1496 .join(handle)
1497 .join("encryption_x25519.key")
1498}
1499
1500fn default_x25519_public_key(rho_home: &Path, handle: &str) -> PathBuf {
1501 rho_home
1502 .join("keys")
1503 .join("github")
1504 .join(handle)
1505 .join("encryption_x25519.pub")
1506}
1507
1508fn default_age_private_key(rho_home: &Path, handle: &str) -> PathBuf {
1509 rho_home
1510 .join("keys")
1511 .join("github")
1512 .join(handle)
1513 .join("encryption_age.key")
1514}
1515
1516fn default_age_public_key(rho_home: &Path, handle: &str) -> PathBuf {
1517 rho_home
1518 .join("keys")
1519 .join("github")
1520 .join(handle)
1521 .join("encryption_age.pub")
1522}
1523
1524fn default_nostr_private_key(rho_home: &Path, handle: &str) -> PathBuf {
1525 rho_home
1526 .join("keys")
1527 .join("github")
1528 .join(handle)
1529 .join("nostr_controller.key")
1530}
1531
1532fn default_nostr_public_key(rho_home: &Path, handle: &str) -> PathBuf {
1533 rho_home
1534 .join("keys")
1535 .join("github")
1536 .join(handle)
1537 .join("nostr_controller.pub")
1538}
1539
1540fn random_nostr_secret() -> RhoResult<SecretKey> {
1541 loop {
1542 let bytes = random_bytes::<32>()?;
1543 if let Ok(secret) = SecretKey::from_slice(&bytes) {
1544 return Ok(secret);
1545 }
1546 }
1547}
1548
1549fn nostr_public_key_hex(secret: &SecretKey) -> RhoResult<String> {
1550 let public = secret.public_key();
1551 let encoded = public.to_encoded_point(false);
1552 let x = encoded
1553 .x()
1554 .ok_or("secp256k1 public key is missing x coordinate")?;
1555 Ok(hex_encode(x))
1556}
1557
1558fn hex_encode(bytes: impl AsRef<[u8]>) -> String {
1559 bytes
1560 .as_ref()
1561 .iter()
1562 .map(|byte| format!("{byte:02x}"))
1563 .collect()
1564}
1565
1566fn random_bytes<const N: usize>() -> RhoResult<[u8; N]> {
1567 let mut bytes = [0u8; N];
1568 getrandom::getrandom(&mut bytes).map_err(|error| error.to_string())?;
1569 Ok(bytes)
1570}
1571
1572fn selected_ssh_key(value: &str) -> RhoResult<(PathBuf, PathBuf)> {
1573 let public = expand_home(value);
1574 if !public.is_file() {
1575 return Err(format!("ssh public key not found: {}", public.display()).into());
1576 }
1577 let private = if public.extension().and_then(|value| value.to_str()) == Some("pub") {
1578 public.with_extension("")
1579 } else {
1580 return Err("--ssh-key must point to a .pub file".into());
1581 };
1582 Ok((public, private))
1583}
1584
1585fn expand_home(value: &str) -> PathBuf {
1586 if let Some(rest) = value.strip_prefix("~/")
1587 && let Ok(home) = env::var("HOME")
1588 {
1589 return PathBuf::from(home).join(rest);
1590 }
1591 PathBuf::from(value)
1592}
1593
1594fn public_key_algorithm(public_key: &str) -> RhoResult<String> {
1595 let algorithm = public_key
1596 .split_whitespace()
1597 .next()
1598 .ok_or("ssh public key is empty")?;
1599 match algorithm {
1600 "ssh-ed25519" => Ok(algorithm.to_string()),
1601 _ => Err(format!("unsupported ssh key algorithm for v1: {algorithm}").into()),
1602 }
1603}
1604
1605fn key_fingerprint(public_key_path: &Path) -> RhoResult<String> {
1606 let output = Command::new("ssh-keygen")
1607 .args(["-l", "-f"])
1608 .arg(public_key_path)
1609 .output();
1610 if let Ok(output) = output
1611 && output.status.success()
1612 {
1613 let text = String::from_utf8_lossy(&output.stdout);
1614 if let Some(value) = text.split_whitespace().nth(1) {
1615 return Ok(value.to_string());
1616 }
1617 }
1618 Ok(format!("sha256-{}", &file_digest(public_key_path)?[..32]))
1619}
1620
1621fn provider_page(provider_file: Option<&Path>, identity: &IdentityBundle) -> RhoResult<String> {
1622 if let Some(path) = provider_file {
1623 return Ok(fs::read_to_string(path)?);
1624 }
1625 let proof = github_profile_proof(identity)?;
1626 let provider_url = proof
1627 .provider_url
1628 .as_deref()
1629 .ok_or("github proof is missing provider_url")?;
1630 let fetch_url = provider_url.split('#').next().unwrap_or(provider_url);
1633 let response = ureq::get(fetch_url)
1634 .set("User-Agent", "rho-cli")
1635 .set("Accept", "text/html")
1636 .call()
1637 .map_err(|error| format!("failed to fetch {fetch_url}: {error}"))?;
1638 let body = response
1639 .into_string()
1640 .map_err(|error| format!("failed to read {fetch_url}: {error}"))?;
1641 Ok(body)
1642}
1643
1644fn clear_github_verification(identity: &mut IdentityBundle, path: &Path) {
1647 if let Ok(proof) = github_profile_proof_mut(identity) {
1648 if proof.verified_at.is_none() {
1649 return;
1650 }
1651 proof.verified_at = None;
1652 } else {
1653 return;
1654 }
1655 if let Ok(text) = fs::read_to_string(path) {
1656 if let Ok(mut manifest) = from_yaml::<LocalIdentityManifest>(&text) {
1657 if let Ok(proof) = github_profile_proof_mut(&mut manifest.local_identity.identity) {
1658 proof.verified_at = None;
1659 }
1660 if let Ok(serialized) = to_yaml(&manifest) {
1661 let _ = fs::write(path, serialized);
1662 }
1663 } else if let Ok(mut manifest) = from_yaml::<IdentityBundleManifest>(&text) {
1664 if let Ok(proof) = github_profile_proof_mut(&mut manifest.identity) {
1665 proof.verified_at = None;
1666 }
1667 if let Ok(serialized) = to_yaml(&manifest) {
1668 let _ = fs::write(path, serialized);
1669 }
1670 }
1671 }
1672}
1673
1674fn is_claim_token_byte(b: u8) -> bool {
1675 b.is_ascii_alphanumeric() || b == b'-' || b == b'_'
1676}
1677
1678fn page_contains_claim_token(page: &str, claim: &str) -> bool {
1682 if claim.is_empty() {
1683 return false;
1684 }
1685 let bytes = page.as_bytes();
1686 let clen = claim.len();
1687 let mut from = 0;
1688 while let Some(rel) = page[from..].find(claim) {
1689 let idx = from + rel;
1690 let before_ok = idx == 0 || !is_claim_token_byte(bytes[idx - 1]);
1691 let after = idx + clen;
1692 let after_ok = after >= bytes.len() || !is_claim_token_byte(bytes[after]);
1693 if before_ok && after_ok {
1694 return true;
1695 }
1696 from = idx + 1;
1697 }
1698 false
1699}
1700
1701fn verify_github_identity(identity: &mut IdentityBundle, provider_page: &str) -> RhoResult<()> {
1702 if identity.kind != "github" {
1703 return Err(format!("unsupported identity kind: {}", identity.kind).into());
1704 }
1705 let identity_handle = github_handle_from_identity_id(&identity.id)?;
1706 if identity.handle != identity_handle {
1707 return Err(format!(
1708 "identity handle mismatch: id has {identity_handle}, bundle has {}",
1709 identity.handle
1710 )
1711 .into());
1712 }
1713
1714 let proof = github_profile_proof(identity)?;
1715 let provider_url = proof
1716 .provider_url
1717 .as_deref()
1718 .ok_or("github proof is missing provider_url")?;
1719 let claim = proof
1720 .claim
1721 .as_deref()
1722 .ok_or("github proof is missing claim")?;
1723 let provider_handle = github_handle_from_provider_url(provider_url)?;
1724 if provider_handle != identity_handle {
1725 return Err(format!(
1726 "provider handle mismatch: provider has {provider_handle}, identity has {identity_handle}"
1727 )
1728 .into());
1729 }
1730 let parsed = parse_proof_claim(claim)?;
1731 let claim_handle = parsed.handle();
1732 if claim_handle != identity_handle {
1733 return Err(format!(
1734 "claim handle mismatch: claim has {claim_handle}, identity has {identity_handle}"
1735 )
1736 .into());
1737 }
1738 let expected_url = proof_url(provider_url, claim);
1739 if proof.proof_url != expected_url {
1740 return Err(format!(
1741 "proof_url mismatch: expected {expected_url}, got {}",
1742 proof.proof_url
1743 )
1744 .into());
1745 }
1746 verify_proof_claim_matches_identity(identity, &parsed)?;
1747 if !page_contains_claim_token(provider_page, claim) {
1748 let identity_prefix = format!("rho.claim/id/github/{identity_handle}/");
1749 if provider_page.contains(&identity_prefix) {
1750 return Err(format!(
1751 "invalid claim: a rho proof for {identity_handle} is published but does not match this key"
1752 )
1753 .into());
1754 }
1755 return Err(format!("provider page does not contain claim: {claim}").into());
1756 }
1757
1758 let verified_at = rho_core::now_rfc3339();
1759 let proof = github_profile_proof_mut(identity)?;
1760 proof.verified_at = Some(verified_at);
1761 Ok(())
1762}
1763
1764fn verify_proof_claim_matches_identity(
1765 identity: &IdentityBundle,
1766 parsed: &ParsedProofClaim,
1767) -> RhoResult<()> {
1768 match parsed {
1769 ParsedProofClaim::NostrController { public_key, .. } => {
1770 let key = nostr_controller_key(identity)
1771 .ok_or("identity has no nostr controller public key")?;
1772 if key.public_key != *public_key {
1773 return Err(format!(
1774 "nostr controller mismatch: claim has {}, identity has {}",
1775 public_key, key.public_key
1776 )
1777 .into());
1778 }
1779 }
1780 }
1781 Ok(())
1782}
1783
1784fn nostr_controller_key(identity: &IdentityBundle) -> Option<&IdentityPublicKey> {
1785 identity
1786 .public_keys
1787 .iter()
1788 .find(|key| key.kind == "nostr-controller" && key.algorithm == "secp256k1-bip340")
1789}
1790
1791fn github_profile_proof(identity: &IdentityBundle) -> RhoResult<&IdentityProof> {
1792 identity
1793 .proofs
1794 .iter()
1795 .find(|proof| proof.kind == "github-profile-fragment-claim")
1796 .ok_or("identity has no github-profile-fragment-claim proof".into())
1797}
1798
1799fn github_profile_proof_mut(identity: &mut IdentityBundle) -> RhoResult<&mut IdentityProof> {
1800 identity
1801 .proofs
1802 .iter_mut()
1803 .find(|proof| proof.kind == "github-profile-fragment-claim")
1804 .ok_or("identity has no github-profile-fragment-claim proof".into())
1805}
1806
1807fn github_identity_id(handle: &str) -> String {
1808 providers::github::identity_id(handle).expect("validated github handle")
1809}
1810
1811fn github_handle_from_identity_id(identity_id: &str) -> RhoResult<String> {
1812 providers::github::handle_from_identity_id(identity_id)
1813}
1814
1815fn github_provider_url(handle: &str) -> String {
1816 providers::github::provider_url(handle).expect("validated github handle")
1817}
1818
1819fn github_handle_from_provider_url(provider_url: &str) -> RhoResult<String> {
1820 providers::github::handle_from_provider_url(provider_url)
1821}
1822
1823#[derive(Debug, Clone, PartialEq, Eq)]
1824enum ParsedProofClaim {
1825 NostrController { handle: String, public_key: String },
1826}
1827
1828impl ParsedProofClaim {
1829 fn handle(&self) -> &str {
1830 match self {
1831 Self::NostrController { handle, .. } => handle,
1832 }
1833 }
1834}
1835
1836fn parse_proof_claim(claim: &str) -> RhoResult<ParsedProofClaim> {
1837 let parts: Vec<&str> = claim.split('/').collect();
1838 if parts.len() != 7
1839 || parts[0] != "rho.claim"
1840 || parts[1] != "id"
1841 || parts[2] != "github"
1842 || parts[4] != "controller"
1843 || parts[5] != "nostr"
1844 {
1845 return Err(format!("unsupported github proof claim: {claim}").into());
1846 }
1847 validate_github_handle(parts[3])?;
1848 let public_key = parts[6].to_string();
1849 if !is_nostr_public_key_hex(&public_key) {
1850 return Err(format!("invalid nostr controller public key in claim: {claim}").into());
1851 }
1852 Ok(ParsedProofClaim::NostrController {
1853 handle: parts[3].to_string(),
1854 public_key,
1855 })
1856}
1857
1858fn nostr_proof_claim(handle: &str, public_key: &str) -> String {
1859 format!("rho.claim/id/github/{handle}/controller/nostr/{public_key}")
1860}
1861
1862fn is_nostr_public_key_hex(value: &str) -> bool {
1863 value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
1864}
1865
1866fn proof_url(provider_url: &str, claim: &str) -> String {
1867 format!("{provider_url}#{claim}")
1868}
1869
1870fn validate_github_handle(handle: &str) -> RhoResult<()> {
1871 providers::github::validate_handle(handle)
1872}
1873
1874#[cfg(test)]
1875mod tests {
1876 use super::*;
1877
1878 #[test]
1879 fn parses_github_identity_id() {
1880 assert_eq!(
1881 github_handle_from_identity_id("rho://id/github/madhavajay").unwrap(),
1882 "madhavajay"
1883 );
1884 }
1885
1886 #[test]
1887 fn rejects_invalid_github_handles() {
1888 assert!(validate_github_handle("-bad").is_err());
1889 assert!(validate_github_handle("bad-").is_err());
1890 assert!(validate_github_handle("bad_name").is_err());
1891 assert!(validate_github_handle("bad--name").is_err());
1892 }
1893
1894 #[test]
1895 fn nostr_proof_claim_uses_controller_key() {
1896 assert_eq!(
1897 nostr_proof_claim(
1898 "madhavajay",
1899 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
1900 ),
1901 "rho.claim/id/github/madhavajay/controller/nostr/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
1902 );
1903 }
1904
1905 #[test]
1906 fn proof_url_uses_provider_fragment() {
1907 assert_eq!(
1908 proof_url(
1909 "https://github.com/madhavajay",
1910 "rho.claim/id/github/madhavajay/controller/nostr/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
1911 ),
1912 "https://github.com/madhavajay#rho.claim/id/github/madhavajay/controller/nostr/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
1913 );
1914 }
1915
1916 #[test]
1917 fn parses_proof_claim() {
1918 let parsed = parse_proof_claim(
1919 "rho.claim/id/github/madhavajay/controller/nostr/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1920 )
1921 .unwrap();
1922 assert_eq!(parsed.handle(), "madhavajay");
1923 assert_eq!(
1924 parsed,
1925 ParsedProofClaim::NostrController {
1926 handle: "madhavajay".to_string(),
1927 public_key: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
1928 .to_string(),
1929 }
1930 );
1931 }
1932
1933 #[test]
1934 fn rejects_legacy_ssh_key_proof_claim() {
1935 assert!(
1936 parse_proof_claim("rho.claim/id/github/madhavajay/key/ssh-ed25519/sha256-abc").is_err()
1937 );
1938 }
1939
1940 #[test]
1941 fn parses_github_provider_url() {
1942 assert_eq!(
1943 github_handle_from_provider_url("https://github.com/madhavajay").unwrap(),
1944 "madhavajay"
1945 );
1946 assert_eq!(
1947 github_handle_from_provider_url("https://github.com/madhavajay/").unwrap(),
1948 "madhavajay"
1949 );
1950 assert!(github_handle_from_provider_url("https://github.com/madhavajay/repo").is_err());
1951 }
1952}