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