1use base64::Engine;
7use sha2::{Digest, Sha256};
8
9pub fn ed25519_pubkey_raw_from_openssh(line: &str) -> Result<[u8; 32], OpenSshParseError> {
15 let trimmed = line.trim();
16 let mut parts = trimmed.split_whitespace();
17 let algo = parts.next().ok_or(OpenSshParseError::Empty)?;
18 if algo != "ssh-ed25519" {
19 return Err(OpenSshParseError::WrongAlgorithm {
20 got: algo.to_string(),
21 });
22 }
23 let blob_b64 = parts.next().ok_or(OpenSshParseError::MissingBase64)?;
24 let blob = base64::engine::general_purpose::STANDARD
25 .decode(blob_b64)
26 .map_err(|_| OpenSshParseError::InvalidBase64)?;
27
28 let mut cursor = 0usize;
30 let algo_bytes = read_ssh_string(&blob, &mut cursor)?;
31 if algo_bytes != b"ssh-ed25519" {
32 return Err(OpenSshParseError::InnerAlgorithmMismatch);
33 }
34 let pubkey = read_ssh_string(&blob, &mut cursor)?;
35 if pubkey.len() != 32 {
36 return Err(OpenSshParseError::WrongPubkeyLength { got: pubkey.len() });
37 }
38 let mut out = [0u8; 32];
39 out.copy_from_slice(pubkey);
40 Ok(out)
41}
42
43pub fn ed25519_pkcs8_der_from_seed(seed: &[u8; 32]) -> Vec<u8> {
47 let mut out = Vec::with_capacity(48);
48 out.extend_from_slice(&[
49 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04,
50 0x20,
51 ]);
52 out.extend_from_slice(seed);
53 out
54}
55
56pub fn ed25519_pkcs8_pem_from_seed(seed: &[u8; 32]) -> String {
59 let der = ed25519_pkcs8_der_from_seed(seed);
60 let b64 = base64::engine::general_purpose::STANDARD.encode(&der);
61 format!("-----BEGIN PRIVATE KEY-----\n{b64}\n-----END PRIVATE KEY-----\n")
62}
63
64pub fn ed25519_pubkey_raw_from_spki_der(spki: &[u8]) -> Result<[u8; 32], OpenSshParseError> {
69 const ED25519_SPKI_PREFIX: [u8; 12] = [
70 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
71 ];
72 if spki.len() != 44 {
73 return Err(OpenSshParseError::WrongPubkeyLength { got: spki.len() });
74 }
75 if spki[..12] != ED25519_SPKI_PREFIX {
76 return Err(OpenSshParseError::WrongAlgorithm {
77 got: format!(
78 "non-ed25519 SPKI (prefix={:x?})",
79 &spki[..12.min(spki.len())]
80 ),
81 });
82 }
83 let mut out = [0u8; 32];
84 out.copy_from_slice(&spki[12..]);
85 Ok(out)
86}
87
88pub fn fingerprint_openssh_pubkey(line: &str) -> Result<String, OpenSshParseError> {
92 let raw = ed25519_pubkey_raw_from_openssh(line)?;
93 let digest = Sha256::digest(raw);
94 Ok(base64::engine::general_purpose::STANDARD.encode(digest))
95}
96
97fn read_ssh_string<'a>(buf: &'a [u8], cursor: &mut usize) -> Result<&'a [u8], OpenSshParseError> {
98 if *cursor + 4 > buf.len() {
99 return Err(OpenSshParseError::Truncated);
100 }
101 let len = u32::from_be_bytes([
102 buf[*cursor],
103 buf[*cursor + 1],
104 buf[*cursor + 2],
105 buf[*cursor + 3],
106 ]) as usize;
107 *cursor += 4;
108 if *cursor + len > buf.len() {
109 return Err(OpenSshParseError::Truncated);
110 }
111 let s = &buf[*cursor..*cursor + len];
112 *cursor += len;
113 Ok(s)
114}
115
116#[derive(Debug)]
117pub enum OpenSshParseError {
118 Empty,
119 WrongAlgorithm { got: String },
120 MissingBase64,
121 InvalidBase64,
122 Truncated,
123 InnerAlgorithmMismatch,
124 WrongPubkeyLength { got: usize },
125}
126
127impl std::fmt::Display for OpenSshParseError {
128 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129 match self {
130 Self::Empty => write!(f, "empty OpenSSH pubkey line"),
131 Self::WrongAlgorithm { got } => {
132 write!(f, "expected ssh-ed25519 algorithm, got {got}")
133 }
134 Self::MissingBase64 => write!(f, "OpenSSH pubkey missing base64 blob"),
135 Self::InvalidBase64 => write!(f, "OpenSSH pubkey base64 invalid"),
136 Self::Truncated => write!(f, "OpenSSH wire blob truncated"),
137 Self::InnerAlgorithmMismatch => {
138 write!(f, "OpenSSH wire blob inner algo != ssh-ed25519")
139 }
140 Self::WrongPubkeyLength { got } => {
141 write!(f, "ed25519 pubkey is not 32 bytes (got {got})")
142 }
143 }
144 }
145}
146
147impl std::error::Error for OpenSshParseError {}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 fn make_openssh_line(raw: &[u8; 32]) -> String {
155 let mut blob = Vec::new();
156 blob.extend_from_slice(&(b"ssh-ed25519".len() as u32).to_be_bytes());
157 blob.extend_from_slice(b"ssh-ed25519");
158 blob.extend_from_slice(&(raw.len() as u32).to_be_bytes());
159 blob.extend_from_slice(raw);
160 let b64 = base64::engine::general_purpose::STANDARD.encode(&blob);
161 format!("ssh-ed25519 {b64} test@host")
162 }
163
164 #[test]
165 fn round_trips_round_pubkey_bytes() {
166 let raw = [0x42u8; 32];
167 let line = make_openssh_line(&raw);
168 let got = ed25519_pubkey_raw_from_openssh(&line).expect("parse");
169 assert_eq!(got, raw);
170 }
171
172 #[test]
173 fn rejects_non_ed25519_algorithm() {
174 let err = ed25519_pubkey_raw_from_openssh("ssh-rsa AAAA test").unwrap_err();
175 matches!(err, OpenSshParseError::WrongAlgorithm { .. });
176 }
177
178 #[test]
179 fn rejects_missing_blob() {
180 let err = ed25519_pubkey_raw_from_openssh("ssh-ed25519").unwrap_err();
181 matches!(err, OpenSshParseError::MissingBase64);
182 }
183
184 #[test]
185 fn rejects_invalid_base64() {
186 let err = ed25519_pubkey_raw_from_openssh("ssh-ed25519 !!!notbase64!!!").unwrap_err();
187 matches!(err, OpenSshParseError::InvalidBase64);
188 }
189
190 #[test]
191 fn pkcs8_envelope_has_expected_layout() {
192 let seed = [0x55u8; 32];
193 let der = ed25519_pkcs8_der_from_seed(&seed);
194 assert_eq!(der.len(), 48);
195 assert_eq!(
196 &der[..16],
197 &[
198 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22,
199 0x04, 0x20,
200 ]
201 );
202 assert_eq!(&der[16..], &seed);
203 }
204
205 #[test]
206 fn pkcs8_pem_armours_correctly() {
207 let seed = [0u8; 32];
208 let pem = ed25519_pkcs8_pem_from_seed(&seed);
209 assert!(pem.starts_with("-----BEGIN PRIVATE KEY-----\n"));
210 assert!(pem.trim_end().ends_with("-----END PRIVATE KEY-----"));
211 }
212
213 #[test]
214 fn spki_der_round_trips_raw_bytes() {
215 let raw = [0x77u8; 32];
216 let mut spki = vec![
217 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
218 ];
219 spki.extend_from_slice(&raw);
220 let got = ed25519_pubkey_raw_from_spki_der(&spki).expect("parse SPKI");
221 assert_eq!(got, raw);
222 }
223
224 #[test]
225 fn spki_der_rejects_wrong_length() {
226 let err = ed25519_pubkey_raw_from_spki_der(&[0u8; 43]).unwrap_err();
227 matches!(err, OpenSshParseError::WrongPubkeyLength { .. });
228 }
229
230 #[test]
231 fn spki_der_rejects_non_ed25519_prefix() {
232 let mut spki = vec![0xffu8; 44];
233 spki[0] = 0x30;
234 spki[1] = 0x2a;
235 let err = ed25519_pubkey_raw_from_spki_der(&spki).unwrap_err();
236 matches!(err, OpenSshParseError::WrongAlgorithm { .. });
237 }
238
239 #[test]
240 fn fingerprint_matches_manual_computation() {
241 let raw = [0x42u8; 32];
242 let line = make_openssh_line(&raw);
243 let got = fingerprint_openssh_pubkey(&line).expect("fingerprint");
244 let expected = {
245 let digest = Sha256::digest(raw);
246 base64::engine::general_purpose::STANDARD.encode(digest)
247 };
248 assert_eq!(got, expected);
249 }
250}