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