zuka
zuka/src/account/key.rs

worklyn / zukapublic

Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.

Get a copy: git clone https://zuka.worklyn.com/worklyn/zuka.git
RSkey.rs9.3 KBDownload
1// SSH public keys.
2//
3// A key is a credential with the same reach as a `repo:write` token, so it carries
4// the same confinement fields. Lookup matches the offered key's raw bytes against
5// stored keys, and the account falls out of that match rather than being asserted by
6// the client. The stored fingerprint is for display only.
7//
8// Only the public half is ever stored. There is nothing here worth stealing, but
9// the file still holds authorization state, so it is written 0600 like the tokens.
10
11use crate::error::{Error, Result};
12use anyhow::Context;
13use base64::Engine;
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256};
16use std::path::Path;
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct KeyRecord {
20 pub id: String,
21 /// Canonical account name.
22 pub account: String,
23 pub title: String,
24 /// `ssh-ed25519 AAAA...` — algorithm and base64 blob, comment stripped.
25 pub public_key: String,
26 /// `SHA256:...`, the form `ssh-keygen -lf` prints.
27 pub fingerprint: String,
28 /// Whether this key may push. A read-only key can clone and fetch only.
29 #[serde(default)]
30 pub read_only: bool,
31 /// Restricts the key to these repositories. Empty means every repo the account
32 /// owns.
33 #[serde(default)]
34 pub repos: Vec<String>,
35 pub created_at: u64,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub last_used_at: Option<u64>,
38}
39
40#[derive(Debug, Clone, Default, Serialize, Deserialize)]
41pub struct KeyFile {
42 #[serde(default)]
43 pub keys: Vec<KeyRecord>,
44}
45
46impl KeyFile {
47 pub fn load(path: &Path) -> Result<Self> {
48 match std::fs::read(path) {
49 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(KeyFile::default()),
50 Err(e) => Err(Error::Internal(
51 anyhow::Error::from(e).context(format!("read {}", path.display())),
52 )),
53 Ok(bytes) => Ok(serde_json::from_slice(&bytes)
54 .with_context(|| format!("parse {}", path.display()))?),
55 }
56 }
57
58 pub fn save(&self, path: &Path) -> Result<()> {
59 if let Some(dir) = path.parent() {
60 std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
61 }
62 let body = serde_json::to_vec_pretty(self).context("serialize key file")?;
63 let temp = path.with_extension("json.tmp");
64 crate::account::token::write_private(&temp, &body)?;
65 std::fs::rename(&temp, path).with_context(|| format!("rename into {}", path.display()))?;
66 Ok(())
67 }
68
69 /// Find the key matching an offered public key blob.
70 ///
71 /// Matching is on the raw key bytes, not on a client-supplied name: the account
72 /// is derived from which stored key matched.
73 pub fn find_by_blob(&self, algorithm: &str, blob: &[u8]) -> Option<&KeyRecord> {
74 let offered = encode_public_key(algorithm, blob);
75 self.keys.iter().find(|k| k.public_key == offered)
76 }
77
78 pub fn find_by_id(&self, id: &str) -> Option<&KeyRecord> {
79 self.keys.iter().find(|k| k.id == id)
80 }
81}
82
83/// Render an algorithm plus key blob in `authorized_keys` form, without a comment.
84pub fn encode_public_key(algorithm: &str, blob: &[u8]) -> String {
85 format!(
86 "{algorithm} {}",
87 base64::engine::general_purpose::STANDARD.encode(blob)
88 )
89}
90
91/// OpenSSH fingerprint: `SHA256:` plus unpadded base64 of the digest of the blob.
92pub fn fingerprint(blob: &[u8]) -> String {
93 let digest = Sha256::digest(blob);
94 format!(
95 "SHA256:{}",
96 base64::engine::general_purpose::STANDARD_NO_PAD.encode(digest)
97 )
98}
99
100/// Parse an `authorized_keys` line into its algorithm and decoded blob.
101///
102/// Rejects anything that is not a supported public key: an `authorized_keys` file
103/// accepts option prefixes such as `command=` and `environment=`, and accepting one
104/// here would let a stored key change what the SSH server does.
105pub fn parse_authorized_key(line: &str) -> Result<(String, Vec<u8>)> {
106 const SUPPORTED: &[&str] = &[
107 "ssh-ed25519",
108 "ecdsa-sha2-nistp256",
109 "ecdsa-sha2-nistp384",
110 "ecdsa-sha2-nistp521",
111 "ssh-rsa",
112 ];
113
114 let line = line.trim();
115 if line.is_empty() {
116 return Err(Error::invalid("invalid-key", "key must not be empty"));
117 }
118 if line.contains('\n') || line.contains('\r') {
119 return Err(Error::invalid("invalid-key", "key must be a single line"));
120 }
121
122 let mut parts = line.split_whitespace();
123 let algorithm = parts
124 .next()
125 .ok_or_else(|| Error::invalid("invalid-key", "key is missing its algorithm"))?;
126
127 if !SUPPORTED.contains(&algorithm) {
128 return Err(Error::invalid(
129 "invalid-key",
130 format!("unsupported key type {algorithm:?}; supported: {SUPPORTED:?}"),
131 ));
132 }
133
134 let encoded = parts
135 .next()
136 .ok_or_else(|| Error::invalid("invalid-key", "key is missing its body"))?;
137
138 let blob = base64::engine::general_purpose::STANDARD
139 .decode(encoded)
140 .map_err(|_| Error::invalid("invalid-key", "key body is not valid base64"))?;
141
142 // The blob is a length-prefixed SSH string whose first field repeats the
143 // algorithm. A mismatch means the line was assembled by hand and the two halves
144 // disagree about what the key is.
145 let declared = read_ssh_string(&blob)
146 .ok_or_else(|| Error::invalid("invalid-key", "key body is not an SSH public key"))?;
147 if declared != algorithm.as_bytes() {
148 return Err(Error::invalid(
149 "invalid-key",
150 "key body does not match its declared algorithm",
151 ));
152 }
153
154 if algorithm == "ssh-rsa" && blob.len() < 128 {
155 return Err(Error::invalid(
156 "invalid-key",
157 "RSA keys shorter than 1024 bits are refused",
158 ));
159 }
160
161 Ok((algorithm.to_string(), blob))
162}
163
164/// Read the first length-prefixed string from an SSH wire blob.
165fn read_ssh_string(blob: &[u8]) -> Option<&[u8]> {
166 if blob.len() < 4 {
167 return None;
168 }
169 let length = u32::from_be_bytes([blob[0], blob[1], blob[2], blob[3]]) as usize;
170 // Bound the claimed length against the buffer before slicing, or a crafted key
171 // panics the process.
172 if length > 64 || blob.len() < 4 + length {
173 return None;
174 }
175 Some(&blob[4..4 + length])
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 /// A real ed25519 public key, generated by ssh-keygen.
183 const ED25519: &str =
184 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJd8kRTIB0hLbrLZ0hBqUFwjBjHwzMbrUsGVLXCBYqEd";
185
186 #[test]
187 fn parses_a_real_ed25519_key() {
188 let (algorithm, blob) = parse_authorized_key(ED25519).unwrap();
189 assert_eq!(algorithm, "ssh-ed25519");
190 assert_eq!(blob.len(), 51);
191 }
192
193 #[test]
194 fn a_trailing_comment_is_ignored() {
195 let with_comment = format!("{ED25519} alice@laptop");
196 let (_, blob) = parse_authorized_key(&with_comment).unwrap();
197 let (_, plain) = parse_authorized_key(ED25519).unwrap();
198 assert_eq!(blob, plain);
199 }
200
201 #[test]
202 fn rejects_authorized_keys_option_prefixes() {
203 // `command=` in an authorized_keys line changes what the server executes.
204 let line = format!("command=\"/bin/sh\" {ED25519}");
205 assert!(parse_authorized_key(&line).is_err());
206 }
207
208 #[test]
209 fn rejects_a_key_whose_body_contradicts_its_algorithm() {
210 let (_, blob) = parse_authorized_key(ED25519).unwrap();
211 let lying = format!(
212 "ssh-rsa {}",
213 base64::engine::general_purpose::STANDARD.encode(&blob)
214 );
215 assert!(parse_authorized_key(&lying).is_err());
216 }
217
218 #[test]
219 fn rejects_malformed_input() {
220 for line in [
221 "",
222 "ssh-ed25519",
223 "ssh-ed25519 !!!not-base64",
224 "ssh-dss AAAAB3NzaC1kc3M=",
225 "not-a-key-type AAAA",
226 ] {
227 assert!(
228 parse_authorized_key(line).is_err(),
229 "{line:?} must be rejected"
230 );
231 }
232 }
233
234 #[test]
235 fn rejects_a_multiline_key() {
236 let line = format!("{ED25519}\nssh-ed25519 AAAA");
237 assert!(parse_authorized_key(&line).is_err());
238 }
239
240 #[test]
241 fn a_truncated_blob_does_not_panic() {
242 for blob in [vec![], vec![0, 0, 0], vec![0, 0, 0, 200, 1, 2]] {
243 assert!(read_ssh_string(&blob).is_none() || blob.len() > 4);
244 }
245 }
246
247 #[test]
248 fn fingerprints_match_the_openssh_format() {
249 let (_, blob) = parse_authorized_key(ED25519).unwrap();
250 let printed = fingerprint(&blob);
251 assert!(printed.starts_with("SHA256:"));
252 assert!(!printed.ends_with('='), "OpenSSH prints unpadded base64");
253 }
254
255 #[test]
256 fn a_key_is_found_by_its_blob_not_by_a_claimed_name() {
257 let (algorithm, blob) = parse_authorized_key(ED25519).unwrap();
258 let file = KeyFile {
259 keys: vec![KeyRecord {
260 id: "k1".into(),
261 account: "alice".into(),
262 title: "laptop".into(),
263 public_key: encode_public_key(&algorithm, &blob),
264 fingerprint: fingerprint(&blob),
265 read_only: false,
266 repos: vec![],
267 created_at: 0,
268 last_used_at: None,
269 }],
270 };
271
272 assert_eq!(
273 file.find_by_blob(&algorithm, &blob).unwrap().account,
274 "alice"
275 );
276 assert!(file.find_by_blob(&algorithm, b"different").is_none());
277 }
278}