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#[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
796pub 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 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
1533fn 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
1567fn 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}