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
| 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 | |
| 11 | use crate::error::{Error, Result}; |
| 12 | use anyhow::Context; |
| 13 | use base64::Engine; |
| 14 | use serde::{Deserialize, Serialize}; |
| 15 | use sha2::{Digest, Sha256}; |
| 16 | use std::path::Path; |
| 17 | |
| 18 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 19 | pub 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)] |
| 41 | pub struct KeyFile { |
| 42 | #[serde(default)] |
| 43 | pub keys: Vec<KeyRecord>, |
| 44 | } |
| 45 | |
| 46 | impl 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. |
| 84 | pub 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. |
| 92 | pub 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. |
| 105 | pub 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. |
| 165 | fn 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)] |
| 179 | mod 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 | } |