Skip to main content

rho_core/commands/
id.rs

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