Skip to main content

rho_core/commands/
id.rs

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/// Outcome of checking a GitHub identity proof against the live profile page.
486#[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
502/// Verify a GitHub identity proof for `handle`, persisting the result (sets or
503/// clears `verified_at`). This is the single source of truth shared by the CLI
504/// and the desktop app so neither has to shell out to the other.
505pub 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    // HTTP requests never carry the URL fragment; strip it so the GET targets the
1007    // real profile page (e.g. drop the trailing "#rho.claim/...").
1008    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
1020// When verification fails (e.g. the proof was removed from the GitHub profile),
1021// drop any stale verified_at so the stored status reflects reality on next read.
1022fn 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
1054// Match the claim as a complete token, not a loose substring: the characters
1055// immediately around it must not be claim-token characters. Otherwise a
1056// published value like `<claim>1111` would pass because it *contains* the claim.
1057fn 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}