1use std::io::Read;
32
33use ssh_key::{HashAlg, LineEnding, PrivateKey, PublicKey, SshSig};
34
35use crate::allowed_signers::AllowedSigners;
36use crate::GitwayError;
37
38#[derive(Debug, Clone)]
42pub struct Verified {
43 pub principal: String,
46 pub fingerprint: String,
48}
49
50pub fn sign<R: Read>(
64 data: &mut R,
65 key: &PrivateKey,
66 namespace: &str,
67 hash: HashAlg,
68) -> Result<String, GitwayError> {
69 let mut buf = Vec::new();
70 data.read_to_end(&mut buf)?;
71 let sig = SshSig::sign(key, namespace, hash, &buf)
72 .map_err(|e| GitwayError::signing(format!("sshsig sign failed: {e}")))?;
73 sig.to_pem(LineEnding::LF)
74 .map_err(|e| GitwayError::signing(format!("sshsig armor failed: {e}")))
75}
76
77pub fn verify<R: Read>(
91 data: &mut R,
92 armored_sig: &str,
93 signer_identity: &str,
94 namespace: &str,
95 allowed: &AllowedSigners,
96) -> Result<Verified, GitwayError> {
97 let sig = SshSig::from_pem(armored_sig)
98 .map_err(|e| GitwayError::signature_invalid(format!("malformed signature: {e}")))?;
99
100 if sig.namespace() != namespace {
101 return Err(GitwayError::signature_invalid(format!(
102 "namespace mismatch: signature is {:?}, expected {namespace:?}",
103 sig.namespace()
104 )));
105 }
106
107 let mut buf = Vec::new();
108 data.read_to_end(&mut buf)?;
109
110 let public_key = PublicKey::from(sig.public_key().clone());
111 public_key
112 .verify(namespace, &buf, &sig)
113 .map_err(|e| GitwayError::signature_invalid(format!("cryptographic check failed: {e}")))?;
114
115 if !allowed.is_authorized(signer_identity, &public_key, namespace) {
116 return Err(GitwayError::signature_invalid(format!(
117 "signer {signer_identity:?} is not authorized for namespace {namespace:?} \
118 with key {}",
119 public_key.fingerprint(HashAlg::Sha256)
120 )));
121 }
122
123 Ok(Verified {
124 principal: signer_identity.to_owned(),
125 fingerprint: public_key.fingerprint(HashAlg::Sha256).to_string(),
126 })
127}
128
129pub fn check_novalidate<R: Read>(
139 data: &mut R,
140 armored_sig: &str,
141 namespace: &str,
142) -> Result<(), GitwayError> {
143 let sig = SshSig::from_pem(armored_sig)
144 .map_err(|e| GitwayError::signature_invalid(format!("malformed signature: {e}")))?;
145
146 if sig.namespace() != namespace {
147 return Err(GitwayError::signature_invalid(format!(
148 "namespace mismatch: signature is {:?}, expected {namespace:?}",
149 sig.namespace()
150 )));
151 }
152
153 let mut buf = Vec::new();
154 data.read_to_end(&mut buf)?;
155
156 let public_key = PublicKey::from(sig.public_key().clone());
157 public_key
158 .verify(namespace, &buf, &sig)
159 .map_err(|e| GitwayError::signature_invalid(format!("cryptographic check failed: {e}")))?;
160
161 Ok(())
162}
163
164pub fn find_principals(
176 armored_sig: &str,
177 allowed: &AllowedSigners,
178 namespace: &str,
179) -> Result<Vec<String>, GitwayError> {
180 let sig = SshSig::from_pem(armored_sig)
181 .map_err(|e| GitwayError::signature_invalid(format!("malformed signature: {e}")))?;
182 let public_key = PublicKey::from(sig.public_key().clone());
183 Ok(allowed
184 .find_principals(&public_key, namespace)
185 .iter()
186 .map(|s| (*s).to_owned())
187 .collect())
188}
189
190#[cfg(test)]
193mod tests {
194 use super::*;
195 use std::io::Cursor;
196
197 use crate::keygen::{generate, KeyType};
198
199 fn roundtrip(kind: KeyType, hash: HashAlg) {
200 let key = generate(kind, None, "sign@test").unwrap();
201 let payload = b"the quick brown fox jumps over the lazy dog";
202 let armored = sign(&mut Cursor::new(payload), &key, "git", hash).unwrap();
203 assert!(armored.contains("BEGIN SSH SIGNATURE"));
204
205 check_novalidate(&mut Cursor::new(payload), &armored, "git").unwrap();
207
208 let err = check_novalidate(&mut Cursor::new(payload), &armored, "file").unwrap_err();
210 assert!(err.to_string().contains("namespace"));
211
212 let err = check_novalidate(&mut Cursor::new(b"tampered"), &armored, "git").unwrap_err();
214 assert!(err.to_string().contains("cryptographic"));
215 }
216
217 #[test]
218 fn ed25519_sign_verify_roundtrip() {
219 roundtrip(KeyType::Ed25519, HashAlg::Sha512);
220 }
221
222 #[test]
223 fn ecdsa_p256_sign_verify_roundtrip() {
224 roundtrip(KeyType::EcdsaP256, HashAlg::Sha512);
225 }
226
227 #[test]
232 #[ignore = "RSA SSHSIG path not yet wired up in ssh-key 0.6.7"]
233 fn rsa_sign_verify_roundtrip() {
234 let key = generate(KeyType::Rsa, Some(2048), "rsa-sign@test").unwrap();
235 let payload = b"hello rsa";
236 let armored = sign(&mut Cursor::new(payload), &key, "git", HashAlg::Sha512).unwrap();
237 check_novalidate(&mut Cursor::new(payload), &armored, "git").unwrap();
238 }
239
240 #[test]
241 fn verify_against_allowed_signers_success() {
242 let key = generate(KeyType::Ed25519, None, "alice@test").unwrap();
243 let pubkey_line = key.public_key().to_openssh().unwrap();
244 let allowed_text = format!("alice@example.com {pubkey_line}");
245 let allowed = AllowedSigners::parse(&allowed_text).unwrap();
246
247 let payload = b"signed content";
248 let armored = sign(&mut Cursor::new(payload), &key, "git", HashAlg::Sha512).unwrap();
249
250 let verified = verify(
251 &mut Cursor::new(payload),
252 &armored,
253 "alice@example.com",
254 "git",
255 &allowed,
256 )
257 .unwrap();
258 assert_eq!(verified.principal, "alice@example.com");
259 assert!(verified.fingerprint.starts_with("SHA256:"));
260 }
261
262 #[test]
263 fn verify_against_allowed_signers_rejects_unknown_identity() {
264 let key = generate(KeyType::Ed25519, None, "bob@test").unwrap();
265 let pubkey_line = key.public_key().to_openssh().unwrap();
266 let allowed_text = format!("alice@example.com {pubkey_line}");
267 let allowed = AllowedSigners::parse(&allowed_text).unwrap();
268
269 let payload = b"signed content";
270 let armored = sign(&mut Cursor::new(payload), &key, "git", HashAlg::Sha512).unwrap();
271
272 let err = verify(
273 &mut Cursor::new(payload),
274 &armored,
275 "mallory@example.com",
276 "git",
277 &allowed,
278 )
279 .unwrap_err();
280 assert!(err.to_string().contains("not authorized"));
281 }
282
283 #[test]
284 fn find_principals_returns_matching_entries() {
285 let key = generate(KeyType::Ed25519, None, "carol@test").unwrap();
286 let pubkey_line = key.public_key().to_openssh().unwrap();
287 let allowed_text = format!("carol@example.com,dave@example.com {pubkey_line}");
288 let allowed = AllowedSigners::parse(&allowed_text).unwrap();
289
290 let armored = sign(&mut Cursor::new(b"x"), &key, "git", HashAlg::Sha512).unwrap();
291 let principals = find_principals(&armored, &allowed, "git").unwrap();
292 assert!(principals.iter().any(|p| p == "carol@example.com"));
293 assert!(principals.iter().any(|p| p == "dave@example.com"));
294 }
295}