1use std::env;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5use std::time::{SystemTime, UNIX_EPOCH};
6
7use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
8use rho_core::{
9 IdentityBundle, IdentityBundleManifest, IdentityProof, IdentityPublicKey, LocalEncryptionKey,
10 LocalGitIdentity, LocalIdentity, LocalIdentityManifest, LocalSigningKey, PrivateKeyRef,
11 RhoResult, TrustManifest, TrustRecord, arg_value, ensure_parent, file_digest, from_yaml,
12 has_flag, normalize_actor_id, require_arg, to_yaml,
13};
14use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret};
15
16fn usage() -> ! {
17 eprintln!(
18 "usage:\n rho id init <github/handle|handle> [--display-name <name>] [--yes]\n rho id init --github <handle> (--ssh-key <path.pub>|--generate-ssh-key) [--display-name <name>]\n rho id rotate-key <rho-id|github/handle|handle> [--yes]\n rho id export --identity <rho-id> --out <path>\n rho id import <identity.yaml>\n rho id verify-github --identity <rho-id> [--provider-file <path>]\n rho id trust <rho-id>\n rho id list\n rho id show <rho-id>\n rho id status <rho-id>"
19 );
20 std::process::exit(2);
21}
22
23pub fn run(args: &[String]) -> RhoResult<()> {
24 let Some(command) = args.first().map(String::as_str) else {
25 usage();
26 };
27 match command {
28 "init" => init(&args[1..]),
29 "rotate-key" => rotate_key(&args[1..]),
30 "export" => export(&args[1..]),
31 "import" => import(&args[1..]),
32 "verify-github" => verify_github(&args[1..]),
33 "trust" => trust(&args[1..]),
34 "list" => list(),
35 "show" => show(&args[1..]),
36 "status" => status(&args[1..]),
37 "--help" | "-h" => usage(),
38 _ => usage(),
39 }
40}
41
42fn init(args: &[String]) -> RhoResult<()> {
43 let shorthand = !args.is_empty() && !args[0].starts_with('-');
44 let handle = if shorthand {
45 let identity_id = normalize_actor_id(&args[0])?;
46 github_handle_from_identity_id(&identity_id)?
47 } else {
48 require_arg(args, "--github").unwrap_or_else(|_| usage())
49 };
50 validate_github_handle(&handle)?;
51 let mut display_name = arg_value(args, "--display-name").unwrap_or_else(|| handle.clone());
52 let generate = has_flag(args, "--generate-ssh-key");
53 let ssh_key = arg_value(args, "--ssh-key");
54 if shorthand && (generate || ssh_key.is_some()) {
55 return Err(
56 "shorthand init uses default key management; omit --ssh-key and --generate-ssh-key"
57 .into(),
58 );
59 }
60 if !shorthand && generate == ssh_key.is_some() {
61 return Err("choose exactly one of --ssh-key or --generate-ssh-key".into());
62 }
63
64 let rho_home = rho_home()?;
65 let path = local_identity_path(&rho_home, &handle);
66 if path.is_file() {
67 let mut local: LocalIdentityManifest = from_yaml(&fs::read_to_string(&path)?)?;
68 if shorthand && !has_flag(args, "--yes") {
69 maybe_bind_github_commit_identity(&mut local)?;
70 fs::write(&path, to_yaml(&local)?)?;
71 }
72 print_existing_identity(&rho_home, &path, &local);
73 return Ok(());
74 }
75 if shorthand && !has_flag(args, "--yes") {
76 println!("Rho identity init");
77 println!(" identity: {}", github_identity_id(&handle));
78 println!(" rho home: {}", rho_home.display());
79 println!(
80 " signing key: {}",
81 default_ssh_private_key(&rho_home, &handle).display()
82 );
83 println!(
84 " encryption key: {}",
85 default_x25519_private_key(&rho_home, &handle).display()
86 );
87 let mut answer = String::new();
88 println!("Display name [{display_name}]: ");
89 std::io::stdin().read_line(&mut answer)?;
90 let display_name_answer = answer.trim();
91 if !display_name_answer.is_empty() {
92 display_name = display_name_answer.to_string();
93 }
94 answer.clear();
95 println!("Create this identity? [Y/n] ");
96 std::io::stdin().read_line(&mut answer)?;
97 let answer = answer.trim().to_ascii_lowercase();
98 if answer == "n" || answer == "no" {
99 return Err("rho id init cancelled".into());
100 }
101 }
102
103 let (public_key_path, private_key_path, signing_key_source) = if shorthand {
104 let source = if default_ssh_key_pair_exists(&rho_home, &handle) {
105 "reused default"
106 } else {
107 "generated default"
108 };
109 let (public, private) = ensure_default_ssh_key(&rho_home, &handle)?;
110 (public, private, source)
111 } else if generate {
112 let (public, private) = generate_ssh_key(&rho_home, &handle)?;
113 (public, private, "generated")
114 } else {
115 let (public, private) = selected_ssh_key(ssh_key.as_deref().unwrap())?;
116 (public, private, "provided")
117 };
118 let public_key = fs::read_to_string(&public_key_path)
119 .map_err(|_| format!("ssh public key not found: {}", public_key_path.display()))?;
120 let algorithm = public_key_algorithm(&public_key)?;
121 let fingerprint = key_fingerprint(&public_key_path)?;
122 let (encryption_public_key_path, encryption_private_key_path, encryption_key_source) =
123 if shorthand {
124 let source = if default_x25519_key_pair_exists(&rho_home, &handle) {
125 "reused default"
126 } else {
127 "generated default"
128 };
129 let (public, private) = ensure_default_x25519_key(&rho_home, &handle)?;
130 (public, private, source)
131 } else {
132 let (public, private) = generate_x25519_key(&rho_home, &handle)?;
133 (public, private, "generated")
134 };
135 let encryption_public_key = fs::read_to_string(&encryption_public_key_path)?;
136 let encryption_fingerprint = file_digest(&encryption_public_key_path)?;
137 let created_at = rho_core::now_rfc3339();
138 let identity_id = github_identity_id(&handle);
139 let key_id = format!("{identity_id}/{algorithm}-1").replace("rho://id/", "rho://key/");
140 let encryption_key_id = format!("{identity_id}/x25519-1").replace("rho://id/", "rho://key/");
141 let claim = proof_claim(&handle, &algorithm, &fingerprint);
142 let provider_url = github_provider_url(&handle);
143 let proof_url = proof_url(&provider_url, &claim);
144 let identity = IdentityBundle {
145 id: identity_id.clone(),
146 kind: "github".to_string(),
147 handle: handle.clone(),
148 display_name,
149 public_keys: vec![
150 IdentityPublicKey {
151 id: key_id,
152 kind: "ssh-signing".to_string(),
153 algorithm,
154 public_key: public_key.trim().to_string(),
155 fingerprint: fingerprint.clone(),
156 created_at: created_at.clone(),
157 },
158 IdentityPublicKey {
159 id: encryption_key_id,
160 kind: "x25519-encryption".to_string(),
161 algorithm: "x25519".to_string(),
162 public_key: encryption_public_key.trim().to_string(),
163 fingerprint: encryption_fingerprint,
164 created_at: created_at.clone(),
165 },
166 ],
167 proofs: vec![IdentityProof {
168 kind: "github-profile-fragment-claim".to_string(),
169 provider_url: Some(provider_url),
170 claim: Some(claim.clone()),
171 proof_url: proof_url.clone(),
172 verified_at: None,
173 }],
174 created_at,
175 };
176 let mut local = LocalIdentityManifest {
177 version: 1,
178 local_identity: LocalIdentity {
179 identity,
180 signing_key: LocalSigningKey {
181 kind: "ssh-signing".to_string(),
182 algorithm: "ssh-ed25519".to_string(),
183 public_key_path: public_key_path.display().to_string(),
184 private_key_ref: PrivateKeyRef {
185 backend: "ssh-file".to_string(),
186 path: private_key_path.display().to_string(),
187 },
188 },
189 encryption_key: Some(LocalEncryptionKey {
190 kind: "x25519-encryption".to_string(),
191 algorithm: "x25519".to_string(),
192 public_key_path: encryption_public_key_path.display().to_string(),
193 private_key_ref: PrivateKeyRef {
194 backend: "rho-file".to_string(),
195 path: encryption_private_key_path.display().to_string(),
196 },
197 }),
198 git: None,
199 },
200 };
201 if shorthand && !has_flag(args, "--yes") {
202 maybe_bind_github_commit_identity(&mut local)?;
203 }
204 ensure_parent(&path)?;
205 fs::write(&path, to_yaml(&local)?)?;
206
207 println!("created identity");
208 println!("identity: {identity_id}");
209 println!("rho home: {}", rho_home.display());
210 println!("local manifest: {}", path.display());
211 println!("public key: {}", public_key_path.display());
212 println!("signing key source: {signing_key_source}");
213 println!(
214 "encryption public key: {}",
215 encryption_public_key_path.display()
216 );
217 println!("encryption key source: {encryption_key_source}");
218 println!("github profile proof URL:");
219 println!("{proof_url}");
220 println!();
221 println!("GitHub verification:");
222 println!("1. Copy this full proof URL into your public GitHub profile:");
223 println!("{proof_url}");
224 println!(" If GitHub only shows the link target partially, make sure this claim text");
225 println!(" is visible somewhere on the fetched profile page:");
226 println!("{claim}");
227 println!("2. Verify it with:");
228 println!("rho id verify-github --identity {identity_id}");
229 Ok(())
230}
231
232fn print_existing_identity(rho_home: &Path, path: &Path, local: &LocalIdentityManifest) {
233 let identity = &local.local_identity.identity;
234 println!("identity already exists");
235 println!("identity: {}", identity.id);
236 println!("rho home: {}", rho_home.display());
237 println!("local manifest: {}", path.display());
238 println!(
239 "public key: {}",
240 local.local_identity.signing_key.public_key_path
241 );
242 if let Some(encryption_key) = &local.local_identity.encryption_key {
243 println!("encryption public key: {}", encryption_key.public_key_path);
244 }
245 if let Some(git) = &local.local_identity.git {
246 println!("github commit login: {}", git.github_login);
247 println!(
248 "git commit author: {} <{}>",
249 git.commit_name, git.commit_email
250 );
251 }
252 if let Ok(proof) = github_profile_proof(identity) {
253 if !proof.proof_url.is_empty() {
254 println!("github profile proof URL:");
255 println!("{}", proof.proof_url);
256 println!();
257 println!("GitHub verification:");
258 println!("1. Copy this full proof URL into your public GitHub profile:");
259 println!("{}", proof.proof_url);
260 }
261 if let Some(claim) = &proof.claim {
262 println!(
263 " If GitHub only shows the link target partially, make sure this claim text"
264 );
265 println!(" is visible somewhere on the fetched profile page:");
266 println!("{claim}");
267 }
268 println!("2. Verify it with:");
269 println!("rho id verify-github --identity {}", identity.id);
270 }
271}
272
273fn rotate_key(args: &[String]) -> RhoResult<()> {
274 let Some(identity_arg) = args.first() else {
275 usage();
276 };
277 let identity_id = normalize_actor_id(identity_arg)?;
278 let handle = github_handle_from_identity_id(&identity_id)?;
279 let rho_home = rho_home()?;
280 let path = local_identity_path(&rho_home, &handle);
281 let mut local = read_local_identity(&rho_home, &handle)?;
282 if local.local_identity.identity.id != identity_id {
283 return Err(format!(
284 "identity mismatch: requested {identity_id}, found {}",
285 local.local_identity.identity.id
286 )
287 .into());
288 }
289 if !has_flag(args, "--yes") {
290 println!("Rotate Rho signing key");
291 println!(" identity: {identity_id}");
292 println!(" local manifest: {}", path.display());
293 println!(
294 " current public key: {}",
295 local.local_identity.signing_key.public_key_path
296 );
297 println!("This creates a new GitHub profile proof claim. Continue? [y/N] ");
298 let mut answer = String::new();
299 std::io::stdin().read_line(&mut answer)?;
300 let answer = answer.trim().to_ascii_lowercase();
301 if answer != "y" && answer != "yes" {
302 return Err("rho id rotate-key cancelled".into());
303 }
304 }
305
306 let next_index = next_signing_key_index(&local.local_identity.identity);
307 let (public_key_path, private_key_path) =
308 generate_rotated_ssh_key(&rho_home, &handle, next_index)?;
309 let public_key = fs::read_to_string(&public_key_path)
310 .map_err(|_| format!("ssh public key not found: {}", public_key_path.display()))?;
311 let algorithm = public_key_algorithm(&public_key)?;
312 let fingerprint = key_fingerprint(&public_key_path)?;
313 let created_at = rho_core::now_rfc3339();
314 let key_id =
315 format!("{identity_id}/{algorithm}-{next_index}").replace("rho://id/", "rho://key/");
316 let new_key = IdentityPublicKey {
317 id: key_id.clone(),
318 kind: "ssh-signing".to_string(),
319 algorithm: algorithm.clone(),
320 public_key: public_key.trim().to_string(),
321 fingerprint: fingerprint.clone(),
322 created_at,
323 };
324 local
325 .local_identity
326 .identity
327 .public_keys
328 .retain(|key| key.id != key_id);
329 local.local_identity.identity.public_keys.insert(0, new_key);
330 local.local_identity.signing_key = LocalSigningKey {
331 kind: "ssh-signing".to_string(),
332 algorithm: algorithm.clone(),
333 public_key_path: public_key_path.display().to_string(),
334 private_key_ref: PrivateKeyRef {
335 backend: "ssh-file".to_string(),
336 path: private_key_path.display().to_string(),
337 },
338 };
339
340 let provider_url = github_provider_url(&handle);
341 let claim = proof_claim(&handle, &algorithm, &fingerprint);
342 let proof_url = proof_url(&provider_url, &claim);
343 if let Ok(proof) = github_profile_proof_mut(&mut local.local_identity.identity) {
344 proof.provider_url = Some(provider_url);
345 proof.claim = Some(claim.clone());
346 proof.proof_url = proof_url.clone();
347 proof.verified_at = None;
348 } else {
349 local.local_identity.identity.proofs.push(IdentityProof {
350 kind: "github-profile-fragment-claim".to_string(),
351 provider_url: Some(provider_url),
352 claim: Some(claim.clone()),
353 proof_url: proof_url.clone(),
354 verified_at: None,
355 });
356 }
357
358 fs::write(&path, to_yaml(&local)?)?;
359 println!("rotated signing key");
360 println!("identity: {identity_id}");
361 println!("local manifest: {}", path.display());
362 println!("public key: {}", public_key_path.display());
363 println!("private key: {}", private_key_path.display());
364 println!("key_id: {key_id}");
365 println!("github proof: unverified");
366 println!("github profile proof URL:");
367 println!("{proof_url}");
368 println!();
369 println!("GitHub verification:");
370 println!("1. Replace the old Rho claim in your public GitHub profile with:");
371 println!("{proof_url}");
372 println!(" If GitHub only shows the link target partially, make sure this claim text");
373 println!(" is visible somewhere on the fetched profile page:");
374 println!("{claim}");
375 println!("2. Verify it with:");
376 println!("rho id verify-github --identity {identity_id}");
377 Ok(())
378}
379
380fn maybe_bind_github_commit_identity(local: &mut LocalIdentityManifest) -> RhoResult<()> {
381 println!();
382 println!("GitHub CLI account:");
383 match Command::new("gh").args(["auth", "status"]).status() {
384 Ok(status) if status.success() => {}
385 Ok(_) => {
386 println!("gh auth status failed; skipping GitHub commit binding");
387 return Ok(());
388 }
389 Err(_) => {
390 println!("gh is not available; skipping GitHub commit binding");
391 return Ok(());
392 }
393 }
394 let Some((login, id)) = gh_user() else {
395 println!("could not read active gh account; skipping GitHub commit binding");
396 return Ok(());
397 };
398 let default_email = format!("{id}+{login}@users.noreply.github.com");
399 println!("Active gh account: {login}");
400 println!("Default commit author: {login} <{default_email}>");
401 println!("Use this gh account for rho commits with this identity? [Y/n] ");
402 let mut answer = String::new();
403 std::io::stdin().read_line(&mut answer)?;
404 let bind_answer = answer.trim().to_ascii_lowercase();
405 if bind_answer == "n" || bind_answer == "no" {
406 return Ok(());
407 }
408 let mut name = login.clone();
409 answer.clear();
410 println!("Commit name [{name}]: ");
411 std::io::stdin().read_line(&mut answer)?;
412 let value = answer.trim();
413 if !value.is_empty() {
414 name = value.to_string();
415 }
416 let mut email = default_email;
417 answer.clear();
418 println!("Commit email [{email}]: ");
419 std::io::stdin().read_line(&mut answer)?;
420 let value = answer.trim();
421 if !value.is_empty() {
422 email = value.to_string();
423 }
424 local.local_identity.git = Some(LocalGitIdentity {
425 github_login: login,
426 commit_name: name,
427 commit_email: email,
428 });
429 Ok(())
430}
431
432fn gh_user() -> Option<(String, String)> {
433 let login = gh_api_user_field(".login")?;
434 let id = gh_api_user_field(".id")?;
435 Some((login, id))
436}
437
438fn gh_api_user_field(field: &str) -> Option<String> {
439 let output = Command::new("gh")
440 .args(["api", "user", "--jq", field])
441 .output()
442 .ok()?;
443 if !output.status.success() {
444 return None;
445 }
446 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
447 .filter(|value| !value.is_empty())
448}
449
450fn export(args: &[String]) -> RhoResult<()> {
451 let identity_id =
452 normalize_actor_id(&require_arg(args, "--identity").unwrap_or_else(|_| usage()))?;
453 let out = PathBuf::from(require_arg(args, "--out").unwrap_or_else(|_| usage()));
454 let handle = github_handle_from_identity_id(&identity_id)?;
455 let local = read_local_identity(&rho_home()?, &handle)?;
456 ensure_parent(&out)?;
457 fs::write(
458 &out,
459 to_yaml(&IdentityBundleManifest {
460 version: 1,
461 identity: local.local_identity.identity,
462 })?,
463 )?;
464 println!("{}", out.display());
465 Ok(())
466}
467
468fn import(args: &[String]) -> RhoResult<()> {
469 let Some(path) = args.first() else {
470 usage();
471 };
472 let text = fs::read_to_string(path)?;
473 let bundle: IdentityBundleManifest = from_yaml(&text)?;
474 if bundle.identity.kind != "github" {
475 return Err(format!("unsupported identity kind: {}", bundle.identity.kind).into());
476 }
477 validate_github_handle(&bundle.identity.handle)?;
478 let target = peer_identity_path(&rho_home()?, &bundle.identity.handle);
479 ensure_parent(&target)?;
480 fs::write(&target, to_yaml(&bundle)?)?;
481 println!("{}", target.display());
482 Ok(())
483}
484
485#[derive(Debug, Clone, Copy, PartialEq, Eq)]
487pub enum GithubProofStatus {
488 Verified,
489 Missing,
490 Invalid,
491}
492
493fn classify_github_failure(message: &str) -> GithubProofStatus {
494 let lowered = message.to_lowercase();
495 if lowered.contains("invalid claim") || lowered.contains("does not match this key") {
496 GithubProofStatus::Invalid
497 } else {
498 GithubProofStatus::Missing
499 }
500}
501
502pub fn verify_github_for_handle(
506 rho_home: &Path,
507 handle: &str,
508 provider_file: Option<&Path>,
509) -> RhoResult<GithubProofStatus> {
510 let local_path = local_identity_path(rho_home, handle);
511 let peer_path = peer_identity_path(rho_home, handle);
512
513 if local_path.is_file() {
514 let text = fs::read_to_string(&local_path)?;
515 let mut manifest: LocalIdentityManifest = from_yaml(&text)?;
516 let page = provider_page(provider_file, &manifest.local_identity.identity)?;
517 return match verify_github_identity(&mut manifest.local_identity.identity, &page) {
518 Ok(()) => {
519 fs::write(&local_path, to_yaml(&manifest)?)?;
520 Ok(GithubProofStatus::Verified)
521 }
522 Err(error) => {
523 clear_github_verification(&mut manifest.local_identity.identity, &local_path);
524 Ok(classify_github_failure(&error.to_string()))
525 }
526 };
527 }
528
529 if peer_path.is_file() {
530 let text = fs::read_to_string(&peer_path)?;
531 let mut manifest: IdentityBundleManifest = from_yaml(&text)?;
532 let page = provider_page(provider_file, &manifest.identity)?;
533 return match verify_github_identity(&mut manifest.identity, &page) {
534 Ok(()) => {
535 fs::write(&peer_path, to_yaml(&manifest)?)?;
536 Ok(GithubProofStatus::Verified)
537 }
538 Err(error) => {
539 clear_github_verification(&mut manifest.identity, &peer_path);
540 Ok(classify_github_failure(&error.to_string()))
541 }
542 };
543 }
544
545 Err(format!("identity not found: github/{handle}").into())
546}
547
548fn verify_github(args: &[String]) -> RhoResult<()> {
549 let identity_id =
550 normalize_actor_id(&require_arg(args, "--identity").unwrap_or_else(|_| usage()))?;
551 let handle = github_handle_from_identity_id(&identity_id)?;
552 let rho_home = rho_home()?;
553 let provider_file = arg_value(args, "--provider-file").map(PathBuf::from);
554 match verify_github_for_handle(&rho_home, &handle, provider_file.as_deref())? {
555 GithubProofStatus::Verified => {
556 println!("verified rho://id/github/{handle}");
557 Ok(())
558 }
559 GithubProofStatus::Missing => {
560 Err(format!("provider page does not contain a claim for github/{handle}").into())
561 }
562 GithubProofStatus::Invalid => Err(format!(
563 "invalid claim: published proof for github/{handle} does not match this key"
564 )
565 .into()),
566 }
567}
568
569fn trust(args: &[String]) -> RhoResult<()> {
570 let Some(identity_id) = args.first() else {
571 usage();
572 };
573 let identity_id = normalize_actor_id(identity_id)?;
574 let handle = github_handle_from_identity_id(&identity_id)?;
575 let rho_home = rho_home()?;
576 let peer = peer_identity_path(&rho_home, &handle);
577 if !peer.is_file() && !local_identity_path(&rho_home, &handle).is_file() {
578 return Err(format!("identity has not been imported or initialized: {identity_id}").into());
579 }
580 let target = trust_path(&rho_home, &handle);
581 ensure_parent(&target)?;
582 fs::write(
583 &target,
584 to_yaml(&TrustManifest {
585 version: 1,
586 trust: TrustRecord {
587 identity_id,
588 decision: "trusted".to_string(),
589 trusted_at: rho_core::now_rfc3339(),
590 source: "local-user".to_string(),
591 },
592 })?,
593 )?;
594 println!("{}", target.display());
595 Ok(())
596}
597
598fn list() -> RhoResult<()> {
599 let rho_home = rho_home()?;
600 println!("local identities:");
601 print_identity_dir(&rho_home.join("identities/github"))?;
602 println!("peer identities:");
603 print_identity_dir(&rho_home.join("peers/github"))?;
604 Ok(())
605}
606
607fn show(args: &[String]) -> RhoResult<()> {
608 let Some(identity_id) = args.first() else {
609 usage();
610 };
611 let identity_id = normalize_actor_id(identity_id)?;
612 let handle = github_handle_from_identity_id(&identity_id)?;
613 let rho_home = rho_home()?;
614 let local_path = local_identity_path(&rho_home, &handle);
615 if local_path.is_file() {
616 print!("{}", fs::read_to_string(local_path)?);
617 return Ok(());
618 }
619 let peer_path = peer_identity_path(&rho_home, &handle);
620 if peer_path.is_file() {
621 print!("{}", fs::read_to_string(peer_path)?);
622 return Ok(());
623 }
624 Err(format!("identity not found: {identity_id}").into())
625}
626
627fn status(args: &[String]) -> RhoResult<()> {
628 let Some(identity_id) = args.first() else {
629 usage();
630 };
631 let identity_id = normalize_actor_id(identity_id)?;
632 let handle = github_handle_from_identity_id(&identity_id)?;
633 let rho_home = rho_home()?;
634 let local_path = local_identity_path(&rho_home, &handle);
635 if local_path.is_file() {
636 let local: LocalIdentityManifest = from_yaml(&fs::read_to_string(&local_path)?)?;
637 print_identity_status(
638 &rho_home,
639 &local_path,
640 &local.local_identity.identity,
641 true,
642 local.local_identity.git.as_ref().map(|git| {
643 (
644 git.github_login.as_str(),
645 git.commit_name.as_str(),
646 git.commit_email.as_str(),
647 )
648 }),
649 );
650 return Ok(());
651 }
652
653 let peer_path = peer_identity_path(&rho_home, &handle);
654 if peer_path.is_file() {
655 let peer: IdentityBundleManifest = from_yaml(&fs::read_to_string(&peer_path)?)?;
656 print_identity_status(&rho_home, &peer_path, &peer.identity, false, None);
657 return Ok(());
658 }
659
660 Err(format!("identity not found: {identity_id}").into())
661}
662
663fn print_identity_status(
664 rho_home: &Path,
665 path: &Path,
666 identity: &IdentityBundle,
667 has_private_key: bool,
668 git: Option<(&str, &str, &str)>,
669) {
670 println!("identity: {}", identity.id);
671 println!("kind: {}", identity.kind);
672 println!("handle: {}", identity.handle);
673 println!("rho home: {}", rho_home.display());
674 println!("manifest: {}", path.display());
675 println!(
676 "private key: {}",
677 if has_private_key {
678 "present"
679 } else {
680 "not-present"
681 }
682 );
683 if let Some((github_login, commit_name, commit_email)) = git {
684 println!("github commit login: {github_login}");
685 println!("git commit author: {commit_name} <{commit_email}>");
686 }
687 match github_profile_proof(identity) {
688 Ok(proof) => {
689 if let Some(verified_at) = &proof.verified_at {
690 println!("github proof: verified");
691 println!("verified_at: {verified_at}");
692 } else {
693 println!("github proof: unverified");
694 }
695 if let Some(provider_url) = &proof.provider_url {
696 println!("provider_url: {provider_url}");
697 }
698 if let Some(claim) = &proof.claim {
699 println!("claim: {claim}");
700 }
701 if !proof.proof_url.is_empty() {
702 println!("proof_url: {}", proof.proof_url);
703 }
704 }
705 Err(error) => {
706 println!("github proof: missing ({error})");
707 }
708 }
709}
710
711fn print_identity_dir(dir: &Path) -> RhoResult<()> {
712 if !dir.is_dir() {
713 return Ok(());
714 }
715 let mut paths: Vec<_> = fs::read_dir(dir)?
716 .filter_map(Result::ok)
717 .map(|entry| entry.path())
718 .filter(|path| path.extension().and_then(|value| value.to_str()) == Some("yaml"))
719 .collect();
720 paths.sort();
721 for path in paths {
722 let text = fs::read_to_string(&path)?;
723 if let Ok(local) = from_yaml::<LocalIdentityManifest>(&text) {
724 let verified = local
725 .local_identity
726 .identity
727 .proofs
728 .iter()
729 .any(|proof| proof.verified_at.is_some());
730 println!(
731 " {} private-key=yes verified={} {}",
732 local.local_identity.identity.id,
733 if verified { "yes" } else { "no" },
734 path.display()
735 );
736 continue;
737 }
738 if let Ok(peer) = from_yaml::<IdentityBundleManifest>(&text) {
739 let verified = peer
740 .identity
741 .proofs
742 .iter()
743 .any(|proof| proof.verified_at.is_some());
744 println!(
745 " {} private-key=no verified={} {}",
746 peer.identity.id,
747 if verified { "yes" } else { "no" },
748 path.display()
749 );
750 continue;
751 }
752 println!(" unreadable identity file: {}", path.display());
753 }
754 Ok(())
755}
756
757fn rho_home() -> RhoResult<PathBuf> {
758 if let Ok(value) = env::var("RHO_HOME")
759 && !value.is_empty()
760 {
761 return Ok(PathBuf::from(value));
762 }
763 let home = env::var("HOME").map_err(|_| "HOME is not set and RHO_HOME was not provided")?;
764 Ok(PathBuf::from(home).join(".rho"))
765}
766
767fn local_identity_path(rho_home: &Path, handle: &str) -> PathBuf {
768 rho_home
769 .join("identities")
770 .join("github")
771 .join(format!("{handle}.yaml"))
772}
773
774fn peer_identity_path(rho_home: &Path, handle: &str) -> PathBuf {
775 rho_home
776 .join("peers")
777 .join("github")
778 .join(format!("{handle}.yaml"))
779}
780
781fn trust_path(rho_home: &Path, handle: &str) -> PathBuf {
782 rho_home
783 .join("trust")
784 .join("github")
785 .join(format!("{handle}.yaml"))
786}
787
788fn read_local_identity(rho_home: &Path, handle: &str) -> RhoResult<LocalIdentityManifest> {
789 let path = local_identity_path(rho_home, handle);
790 let text = fs::read_to_string(&path)
791 .map_err(|_| format!("local identity not found: {}", path.display()))?;
792 from_yaml(&text)
793}
794
795fn default_ssh_key_pair_exists(rho_home: &Path, handle: &str) -> bool {
796 let private = default_ssh_private_key(rho_home, handle);
797 private.is_file() && private.with_extension("pub").is_file()
798}
799
800fn generate_ssh_key(rho_home: &Path, handle: &str) -> RhoResult<(PathBuf, PathBuf)> {
801 let private = default_ssh_private_key(rho_home, handle);
802 generate_ssh_key_at(&private, &github_identity_id(handle))
803}
804
805fn generate_ssh_key_at(private: &Path, comment: &str) -> RhoResult<(PathBuf, PathBuf)> {
806 let public = private.with_extension("pub");
807 if private.exists() || public.exists() {
808 return Err(format!("ssh key already exists: {}", private.display()).into());
809 }
810 ensure_parent(private)?;
811 let status = Command::new("ssh-keygen")
812 .args(["-t", "ed25519", "-N", "", "-C"])
813 .arg(comment)
814 .arg("-f")
815 .arg(private)
816 .status()?;
817 if !status.success() {
818 return Err("ssh-keygen failed".into());
819 }
820 Ok((public, private.to_path_buf()))
821}
822
823fn ensure_default_ssh_key(rho_home: &Path, handle: &str) -> RhoResult<(PathBuf, PathBuf)> {
824 let private = default_ssh_private_key(rho_home, handle);
825 let public = private.with_extension("pub");
826 if private.is_file() && public.is_file() {
827 return Ok((public, private));
828 }
829 if private.exists() || public.exists() {
830 return Err(format!(
831 "incomplete ssh key pair; expected {} and {}",
832 private.display(),
833 public.display()
834 )
835 .into());
836 }
837 generate_ssh_key(rho_home, handle)
838}
839
840fn generate_rotated_ssh_key(
841 rho_home: &Path,
842 handle: &str,
843 index: usize,
844) -> RhoResult<(PathBuf, PathBuf)> {
845 let private = rotated_ssh_private_key(rho_home, handle, index);
846 generate_ssh_key_at(
847 &private,
848 &format!("{} signing key {index}", github_identity_id(handle)),
849 )
850}
851
852fn next_signing_key_index(identity: &IdentityBundle) -> usize {
853 identity
854 .public_keys
855 .iter()
856 .filter(|key| key.kind == "ssh-signing")
857 .filter_map(|key| {
858 let suffix = key.id.rsplit('/').next()?;
859 let (_, index) = suffix.rsplit_once('-')?;
860 index.parse::<usize>().ok()
861 })
862 .max()
863 .unwrap_or(0)
864 + 1
865}
866
867fn default_x25519_key_pair_exists(rho_home: &Path, handle: &str) -> bool {
868 default_x25519_private_key(rho_home, handle).is_file()
869 && default_x25519_public_key(rho_home, handle).is_file()
870}
871
872fn generate_x25519_key(rho_home: &Path, handle: &str) -> RhoResult<(PathBuf, PathBuf)> {
873 let private = default_x25519_private_key(rho_home, handle);
874 let public = default_x25519_public_key(rho_home, handle);
875 if private.exists() || public.exists() {
876 return Err(format!("encryption key already exists: {}", private.display()).into());
877 }
878 ensure_parent(&private)?;
879 let secret = StaticSecret::from(random_bytes::<32>()?);
880 let public_key = X25519PublicKey::from(&secret);
881 fs::write(&private, format!("{}\n", BASE64.encode(secret.to_bytes())))?;
882 fs::write(
883 &public,
884 format!("{}\n", BASE64.encode(public_key.as_bytes())),
885 )?;
886 Ok((public, private))
887}
888
889fn ensure_default_x25519_key(rho_home: &Path, handle: &str) -> RhoResult<(PathBuf, PathBuf)> {
890 let private = default_x25519_private_key(rho_home, handle);
891 let public = default_x25519_public_key(rho_home, handle);
892 if private.is_file() && public.is_file() {
893 return Ok((public, private));
894 }
895 if private.exists() || public.exists() {
896 return Err(format!(
897 "incomplete encryption key pair; expected {} and {}",
898 private.display(),
899 public.display()
900 )
901 .into());
902 }
903 generate_x25519_key(rho_home, handle)
904}
905
906fn default_ssh_private_key(rho_home: &Path, handle: &str) -> PathBuf {
907 rho_home
908 .join("keys")
909 .join("github")
910 .join(handle)
911 .join("signing_ed25519")
912}
913
914fn rotated_ssh_private_key(rho_home: &Path, handle: &str, index: usize) -> PathBuf {
915 let timestamp = SystemTime::now()
916 .duration_since(UNIX_EPOCH)
917 .map(|duration| duration.as_secs())
918 .unwrap_or(0);
919 rho_home
920 .join("keys")
921 .join("github")
922 .join(handle)
923 .join(format!("signing_ed25519-{index}-{timestamp}"))
924}
925
926fn default_x25519_private_key(rho_home: &Path, handle: &str) -> PathBuf {
927 rho_home
928 .join("keys")
929 .join("github")
930 .join(handle)
931 .join("encryption_x25519.key")
932}
933
934fn default_x25519_public_key(rho_home: &Path, handle: &str) -> PathBuf {
935 rho_home
936 .join("keys")
937 .join("github")
938 .join(handle)
939 .join("encryption_x25519.pub")
940}
941
942fn random_bytes<const N: usize>() -> RhoResult<[u8; N]> {
943 let mut bytes = [0u8; N];
944 getrandom::getrandom(&mut bytes).map_err(|error| error.to_string())?;
945 Ok(bytes)
946}
947
948fn selected_ssh_key(value: &str) -> RhoResult<(PathBuf, PathBuf)> {
949 let public = expand_home(value);
950 if !public.is_file() {
951 return Err(format!("ssh public key not found: {}", public.display()).into());
952 }
953 let private = if public.extension().and_then(|value| value.to_str()) == Some("pub") {
954 public.with_extension("")
955 } else {
956 return Err("--ssh-key must point to a .pub file".into());
957 };
958 Ok((public, private))
959}
960
961fn expand_home(value: &str) -> PathBuf {
962 if let Some(rest) = value.strip_prefix("~/")
963 && let Ok(home) = env::var("HOME")
964 {
965 return PathBuf::from(home).join(rest);
966 }
967 PathBuf::from(value)
968}
969
970fn public_key_algorithm(public_key: &str) -> RhoResult<String> {
971 let algorithm = public_key
972 .split_whitespace()
973 .next()
974 .ok_or("ssh public key is empty")?;
975 match algorithm {
976 "ssh-ed25519" => Ok(algorithm.to_string()),
977 _ => Err(format!("unsupported ssh key algorithm for v1: {algorithm}").into()),
978 }
979}
980
981fn key_fingerprint(public_key_path: &Path) -> RhoResult<String> {
982 let output = Command::new("ssh-keygen")
983 .args(["-l", "-f"])
984 .arg(public_key_path)
985 .output();
986 if let Ok(output) = output
987 && output.status.success()
988 {
989 let text = String::from_utf8_lossy(&output.stdout);
990 if let Some(value) = text.split_whitespace().nth(1) {
991 return Ok(value.to_string());
992 }
993 }
994 Ok(format!("sha256-{}", &file_digest(public_key_path)?[..32]))
995}
996
997fn provider_page(provider_file: Option<&Path>, identity: &IdentityBundle) -> RhoResult<String> {
998 if let Some(path) = provider_file {
999 return Ok(fs::read_to_string(path)?);
1000 }
1001 let proof = github_profile_proof(identity)?;
1002 let provider_url = proof
1003 .provider_url
1004 .as_deref()
1005 .ok_or("github proof is missing provider_url")?;
1006 let fetch_url = provider_url.split('#').next().unwrap_or(provider_url);
1009 let response = ureq::get(fetch_url)
1010 .set("User-Agent", "rho-cli")
1011 .set("Accept", "text/html")
1012 .call()
1013 .map_err(|error| format!("failed to fetch {fetch_url}: {error}"))?;
1014 let body = response
1015 .into_string()
1016 .map_err(|error| format!("failed to read {fetch_url}: {error}"))?;
1017 Ok(body)
1018}
1019
1020fn clear_github_verification(identity: &mut IdentityBundle, path: &Path) {
1023 if let Ok(proof) = github_profile_proof_mut(identity) {
1024 if proof.verified_at.is_none() {
1025 return;
1026 }
1027 proof.verified_at = None;
1028 } else {
1029 return;
1030 }
1031 if let Ok(text) = fs::read_to_string(path) {
1032 if let Ok(mut manifest) = from_yaml::<LocalIdentityManifest>(&text) {
1033 if let Ok(proof) = github_profile_proof_mut(&mut manifest.local_identity.identity) {
1034 proof.verified_at = None;
1035 }
1036 if let Ok(serialized) = to_yaml(&manifest) {
1037 let _ = fs::write(path, serialized);
1038 }
1039 } else if let Ok(mut manifest) = from_yaml::<IdentityBundleManifest>(&text) {
1040 if let Ok(proof) = github_profile_proof_mut(&mut manifest.identity) {
1041 proof.verified_at = None;
1042 }
1043 if let Ok(serialized) = to_yaml(&manifest) {
1044 let _ = fs::write(path, serialized);
1045 }
1046 }
1047 }
1048}
1049
1050fn is_claim_token_byte(b: u8) -> bool {
1051 b.is_ascii_alphanumeric() || b == b'-' || b == b'_'
1052}
1053
1054fn page_contains_claim_token(page: &str, claim: &str) -> bool {
1058 if claim.is_empty() {
1059 return false;
1060 }
1061 let bytes = page.as_bytes();
1062 let clen = claim.len();
1063 let mut from = 0;
1064 while let Some(rel) = page[from..].find(claim) {
1065 let idx = from + rel;
1066 let before_ok = idx == 0 || !is_claim_token_byte(bytes[idx - 1]);
1067 let after = idx + clen;
1068 let after_ok = after >= bytes.len() || !is_claim_token_byte(bytes[after]);
1069 if before_ok && after_ok {
1070 return true;
1071 }
1072 from = idx + 1;
1073 }
1074 false
1075}
1076
1077fn verify_github_identity(identity: &mut IdentityBundle, provider_page: &str) -> RhoResult<()> {
1078 if identity.kind != "github" {
1079 return Err(format!("unsupported identity kind: {}", identity.kind).into());
1080 }
1081 let identity_handle = github_handle_from_identity_id(&identity.id)?;
1082 if identity.handle != identity_handle {
1083 return Err(format!(
1084 "identity handle mismatch: id has {identity_handle}, bundle has {}",
1085 identity.handle
1086 )
1087 .into());
1088 }
1089
1090 let proof = github_profile_proof(identity)?;
1091 let provider_url = proof
1092 .provider_url
1093 .as_deref()
1094 .ok_or("github proof is missing provider_url")?;
1095 let claim = proof
1096 .claim
1097 .as_deref()
1098 .ok_or("github proof is missing claim")?;
1099 let provider_handle = github_handle_from_provider_url(provider_url)?;
1100 if provider_handle != identity_handle {
1101 return Err(format!(
1102 "provider handle mismatch: provider has {provider_handle}, identity has {identity_handle}"
1103 )
1104 .into());
1105 }
1106 let parsed = parse_proof_claim(claim)?;
1107 if parsed.handle != identity_handle {
1108 return Err(format!(
1109 "claim handle mismatch: claim has {}, identity has {identity_handle}",
1110 parsed.handle
1111 )
1112 .into());
1113 }
1114 let expected_url = proof_url(provider_url, claim);
1115 if proof.proof_url != expected_url {
1116 return Err(format!(
1117 "proof_url mismatch: expected {expected_url}, got {}",
1118 proof.proof_url
1119 )
1120 .into());
1121 }
1122 let key = identity
1123 .public_keys
1124 .iter()
1125 .find(|key| key.algorithm == parsed.algorithm)
1126 .ok_or_else(|| format!("no public key with algorithm {}", parsed.algorithm))?;
1127 if claim_fingerprint(&key.fingerprint) != parsed.fingerprint {
1128 return Err(format!(
1129 "fingerprint mismatch: claim has {}, key has {}",
1130 parsed.fingerprint, key.fingerprint
1131 )
1132 .into());
1133 }
1134 if !page_contains_claim_token(provider_page, claim) {
1135 let identity_prefix = format!("rho.claim/id/github/{identity_handle}/");
1136 if provider_page.contains(&identity_prefix) {
1137 return Err(format!(
1138 "invalid claim: a rho proof for {identity_handle} is published but does not match this key"
1139 )
1140 .into());
1141 }
1142 return Err(format!("provider page does not contain claim: {claim}").into());
1143 }
1144
1145 let verified_at = rho_core::now_rfc3339();
1146 let proof = github_profile_proof_mut(identity)?;
1147 proof.verified_at = Some(verified_at);
1148 Ok(())
1149}
1150
1151fn github_profile_proof(identity: &IdentityBundle) -> RhoResult<&IdentityProof> {
1152 identity
1153 .proofs
1154 .iter()
1155 .find(|proof| proof.kind == "github-profile-fragment-claim")
1156 .ok_or("identity has no github-profile-fragment-claim proof".into())
1157}
1158
1159fn github_profile_proof_mut(identity: &mut IdentityBundle) -> RhoResult<&mut IdentityProof> {
1160 identity
1161 .proofs
1162 .iter_mut()
1163 .find(|proof| proof.kind == "github-profile-fragment-claim")
1164 .ok_or("identity has no github-profile-fragment-claim proof".into())
1165}
1166
1167fn github_identity_id(handle: &str) -> String {
1168 format!("rho://id/github/{handle}")
1169}
1170
1171fn github_handle_from_identity_id(identity_id: &str) -> RhoResult<String> {
1172 let Some(handle) = identity_id.strip_prefix("rho://id/github/") else {
1173 return Err(format!("unsupported identity id: {identity_id}").into());
1174 };
1175 validate_github_handle(handle)?;
1176 Ok(handle.to_string())
1177}
1178
1179fn github_provider_url(handle: &str) -> String {
1180 format!("https://github.com/{handle}")
1181}
1182
1183fn github_handle_from_provider_url(provider_url: &str) -> RhoResult<String> {
1184 let Some(handle) = provider_url.strip_prefix("https://github.com/") else {
1185 return Err(format!("unsupported github provider_url: {provider_url}").into());
1186 };
1187 let handle = handle.trim_end_matches('/');
1188 if handle.contains('/') || handle.contains('#') || handle.contains('?') {
1189 return Err(format!("unsupported github provider_url: {provider_url}").into());
1190 }
1191 validate_github_handle(handle)?;
1192 Ok(handle.to_string())
1193}
1194
1195#[derive(Debug, Clone, PartialEq, Eq)]
1196struct ParsedProofClaim {
1197 handle: String,
1198 algorithm: String,
1199 fingerprint: String,
1200}
1201
1202fn parse_proof_claim(claim: &str) -> RhoResult<ParsedProofClaim> {
1203 let parts: Vec<&str> = claim.split('/').collect();
1204 if parts.len() != 7
1205 || parts[0] != "rho.claim"
1206 || parts[1] != "id"
1207 || parts[2] != "github"
1208 || parts[4] != "key"
1209 {
1210 return Err(format!("unsupported github proof claim: {claim}").into());
1211 }
1212 validate_github_handle(parts[3])?;
1213 Ok(ParsedProofClaim {
1214 handle: parts[3].to_string(),
1215 algorithm: parts[5].to_string(),
1216 fingerprint: parts[6].to_string(),
1217 })
1218}
1219
1220fn proof_claim(handle: &str, algorithm: &str, fingerprint: &str) -> String {
1221 format!(
1222 "rho.claim/id/github/{handle}/key/{algorithm}/{}",
1223 claim_fingerprint(fingerprint)
1224 )
1225}
1226
1227fn claim_fingerprint(fingerprint: &str) -> String {
1228 fingerprint
1229 .replace("SHA256:", "sha256-")
1230 .replace(['/', '+', '='], "-")
1231}
1232
1233fn proof_url(provider_url: &str, claim: &str) -> String {
1234 format!("{provider_url}#{claim}")
1235}
1236
1237fn validate_github_handle(handle: &str) -> RhoResult<()> {
1238 if handle.is_empty() || handle.len() > 39 {
1239 return Err("github handle must be 1-39 characters".into());
1240 }
1241 if handle.starts_with('-') || handle.ends_with('-') {
1242 return Err(format!("invalid github handle: {handle}").into());
1243 }
1244 let valid = handle
1245 .chars()
1246 .all(|ch| ch.is_ascii_alphanumeric() || ch == '-');
1247 if !valid || handle.contains("--") {
1248 return Err(format!("invalid github handle: {handle}").into());
1249 }
1250 Ok(())
1251}
1252
1253#[cfg(test)]
1254mod tests {
1255 use super::*;
1256
1257 #[test]
1258 fn parses_github_identity_id() {
1259 assert_eq!(
1260 github_handle_from_identity_id("rho://id/github/madhavajay").unwrap(),
1261 "madhavajay"
1262 );
1263 }
1264
1265 #[test]
1266 fn rejects_invalid_github_handles() {
1267 assert!(validate_github_handle("-bad").is_err());
1268 assert!(validate_github_handle("bad-").is_err());
1269 assert!(validate_github_handle("bad_name").is_err());
1270 assert!(validate_github_handle("bad--name").is_err());
1271 }
1272
1273 #[test]
1274 fn proof_claim_is_url_safe() {
1275 assert_eq!(
1276 proof_claim("madhavajay", "ssh-ed25519", "SHA256:abc/def+ghi="),
1277 "rho.claim/id/github/madhavajay/key/ssh-ed25519/sha256-abc-def-ghi-"
1278 );
1279 }
1280
1281 #[test]
1282 fn proof_url_uses_provider_fragment() {
1283 assert_eq!(
1284 proof_url(
1285 "https://github.com/madhavajay",
1286 "rho.claim/id/github/madhavajay/key/ssh-ed25519/sha256-abc"
1287 ),
1288 "https://github.com/madhavajay#rho.claim/id/github/madhavajay/key/ssh-ed25519/sha256-abc"
1289 );
1290 }
1291
1292 #[test]
1293 fn parses_proof_claim() {
1294 let parsed =
1295 parse_proof_claim("rho.claim/id/github/madhavajay/key/ssh-ed25519/sha256-abc").unwrap();
1296 assert_eq!(parsed.handle, "madhavajay");
1297 assert_eq!(parsed.algorithm, "ssh-ed25519");
1298 assert_eq!(parsed.fingerprint, "sha256-abc");
1299 }
1300
1301 #[test]
1302 fn parses_github_provider_url() {
1303 assert_eq!(
1304 github_handle_from_provider_url("https://github.com/madhavajay").unwrap(),
1305 "madhavajay"
1306 );
1307 assert_eq!(
1308 github_handle_from_provider_url("https://github.com/madhavajay/").unwrap(),
1309 "madhavajay"
1310 );
1311 assert!(github_handle_from_provider_url("https://github.com/madhavajay/repo").is_err());
1312 }
1313}