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