Fetch User Keys - simple tool for fetching SSH keys from various sources
1// SPDX-FileCopyrightText: 2024 Łukasz Niemier <#@hauleth.dev>
2// SPDX-FileCopyrightText: 2025 Łukasz Niemier <#@hauleth.dev>
3//
4// SPDX-License-Identifier: EUPL-1.2
5
6use serde::Deserialize;
7use simple_eyre::eyre::Result;
8use ssh_key::PublicKey;
9use std::process::Command;
10
11mod atproto;
12mod helpers;
13
14pub use atproto::ATProto;
15
16pub trait Fetch: std::fmt::Debug {
17 fn fetch(&self) -> Result<Vec<PublicKey>>;
18}
19
20#[derive(Debug, Deserialize)]
21#[serde(rename_all = "lowercase")]
22#[non_exhaustive]
23pub enum Source {
24 Raw(Raw),
25 Hosts(Hosts),
26 Http(Http),
27 Github(String),
28 Sourcehut(String),
29 Gitlab(String),
30 Codeberg(String),
31 #[serde(deserialize_with = "helpers::string_or_struct")]
32 Tangled(ATProto),
33}
34
35impl Fetch for Source {
36 fn fetch(&self) -> Result<Vec<PublicKey>> {
37 match *self {
38 Source::Raw(ref raw) => raw.fetch(),
39 Source::Hosts(ref raw) => raw.fetch(),
40 Source::Http(ref raw) => raw.fetch(),
41 Source::Github(ref user) => Http {
42 url: format!("https://github.com/{user}.keys"),
43 }
44 .fetch(),
45 Source::Sourcehut(ref user) => Http {
46 url: format!("https://meta.sr.ht/{}.keys", normalize_sourcehut(user)),
47 }
48 .fetch(),
49 Source::Gitlab(ref user) => Http {
50 url: format!("https://gitlab.com/{user}.keys"),
51 }
52 .fetch(),
53 Source::Codeberg(ref user) => Http {
54 url: format!("https://codeberg.org/{user}.keys"),
55 }
56 .fetch(),
57 Source::Tangled(ref atproto) => atproto.fetch(),
58 }
59 }
60}
61
62fn normalize_sourcehut(s: &str) -> std::borrow::Cow<'_, str> {
63 if s.starts_with("~") {
64 s.into()
65 } else {
66 format!("~{s}").into()
67 }
68}
69
70#[derive(Debug, Deserialize)]
71pub struct Raw(Box<[PublicKey]>);
72
73impl Fetch for Raw {
74 fn fetch(&self) -> Result<Vec<PublicKey>> {
75 Ok(self.0.clone().into())
76 }
77}
78
79#[derive(Debug, Deserialize)]
80pub struct Hosts(pub Box<[String]>);
81
82impl Fetch for Hosts {
83 fn fetch(&self) -> Result<Vec<PublicKey>> {
84 // TODO: Check if we can do it in-process instead of shelling out to `ssh-keyscan`
85 let result = Command::new("ssh-keyscan").args(&self.0).output().unwrap();
86
87 std::str::from_utf8(&result.stdout)?
88 .trim()
89 .split('\n')
90 .map(str::trim)
91 // Remove comments
92 .filter(|line| !line.starts_with("#"))
93 // Ignore first column as it contain hostname which is not
94 // needed there
95 .map(|line| line.split_once(' ').unwrap().1)
96 .map(|k| PublicKey::from_openssh(k).map_err(Into::into))
97 .collect()
98 }
99}
100
101#[derive(Debug, Deserialize)]
102pub struct Http {
103 pub url: String,
104}
105
106impl Fetch for Http {
107 fn fetch(&self) -> Result<Vec<PublicKey>> {
108 use ureq::tls::{RootCerts, TlsConfig, TlsProvider};
109
110 let config = ureq::Agent::config_builder()
111 .tls_config(
112 TlsConfig::builder()
113 .root_certs(RootCerts::PlatformVerifier)
114 .provider(TlsProvider::NativeTls)
115 .build(),
116 )
117 .build();
118
119 let agent = config.new_agent();
120
121 agent.get(&self.url)
122 .call()?
123 .body_mut()
124 .read_to_string()?
125 .trim()
126 .split('\n')
127 .map(|s| PublicKey::from_openssh(s).map_err(Into::into))
128 .collect()
129 }
130}