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