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