1use std::io::Read;
32
33use ssh_key::{HashAlg, LineEnding, PrivateKey, PublicKey, SshSig};
34
35use crate::agent::client::Agent;
36use crate::allowed_signers::AllowedSigners;
37use crate::AnvilError;
38
39#[derive(Debug, Clone)]
43pub struct Verified {
44 pub principal: String,
47 pub fingerprint: String,
49}
50
51pub fn sign<R: Read>(
65 data: &mut R,
66 key: &PrivateKey,
67 namespace: &str,
68 hash: HashAlg,
69) -> Result<String, AnvilError> {
70 let mut buf = Vec::new();
71 data.read_to_end(&mut buf)?;
72 let sig = SshSig::sign(key, namespace, hash, &buf)
73 .map_err(|e| AnvilError::signing(format!("sshsig sign failed: {e}")))?;
74 sig.to_pem(LineEnding::LF)
75 .map_err(|e| AnvilError::signing(format!("sshsig armor failed: {e}")))
76}
77
78pub fn sign_with_agent<R: Read>(
93 data: &mut R,
94 agent: &mut Agent,
95 public_key: &PublicKey,
96 namespace: &str,
97 hash: HashAlg,
98) -> Result<String, AnvilError> {
99 let mut buf = Vec::new();
100 data.read_to_end(&mut buf)?;
101 let signed_blob = SshSig::signed_data(namespace, hash, &buf)
102 .map_err(|e| AnvilError::signing(format!("sshsig signed_data failed: {e}")))?;
103 let signature = agent.sign(public_key, &signed_blob)?;
104 let sig = SshSig::new(public_key.key_data().clone(), namespace, hash, signature)
105 .map_err(|e| AnvilError::signing(format!("sshsig wrap failed: {e}")))?;
106 sig.to_pem(LineEnding::LF)
107 .map_err(|e| AnvilError::signing(format!("sshsig armor failed: {e}")))
108}
109
110pub fn verify<R: Read>(
124 data: &mut R,
125 armored_sig: &str,
126 signer_identity: &str,
127 namespace: &str,
128 allowed: &AllowedSigners,
129) -> Result<Verified, AnvilError> {
130 let sig = SshSig::from_pem(armored_sig)
131 .map_err(|e| AnvilError::signature_invalid(format!("malformed signature: {e}")))?;
132
133 if sig.namespace() != namespace {
134 return Err(AnvilError::signature_invalid(format!(
135 "namespace mismatch: signature is {:?}, expected {namespace:?}",
136 sig.namespace()
137 )));
138 }
139
140 let mut buf = Vec::new();
141 data.read_to_end(&mut buf)?;
142
143 let public_key = PublicKey::from(sig.public_key().clone());
144 public_key
145 .verify(namespace, &buf, &sig)
146 .map_err(|e| AnvilError::signature_invalid(format!("cryptographic check failed: {e}")))?;
147
148 if !allowed.is_authorized(signer_identity, &public_key, namespace) {
149 return Err(AnvilError::signature_invalid(format!(
150 "signer {signer_identity:?} is not authorized for namespace {namespace:?} \
151 with key {}",
152 public_key.fingerprint(HashAlg::Sha256)
153 )));
154 }
155
156 Ok(Verified {
157 principal: signer_identity.to_owned(),
158 fingerprint: public_key.fingerprint(HashAlg::Sha256).to_string(),
159 })
160}
161
162pub fn check_novalidate<R: Read>(
172 data: &mut R,
173 armored_sig: &str,
174 namespace: &str,
175) -> Result<(), AnvilError> {
176 let sig = SshSig::from_pem(armored_sig)
177 .map_err(|e| AnvilError::signature_invalid(format!("malformed signature: {e}")))?;
178
179 if sig.namespace() != namespace {
180 return Err(AnvilError::signature_invalid(format!(
181 "namespace mismatch: signature is {:?}, expected {namespace:?}",
182 sig.namespace()
183 )));
184 }
185
186 let mut buf = Vec::new();
187 data.read_to_end(&mut buf)?;
188
189 let public_key = PublicKey::from(sig.public_key().clone());
190 public_key
191 .verify(namespace, &buf, &sig)
192 .map_err(|e| AnvilError::signature_invalid(format!("cryptographic check failed: {e}")))?;
193
194 Ok(())
195}
196
197pub fn find_principals(
209 armored_sig: &str,
210 allowed: &AllowedSigners,
211 namespace: &str,
212) -> Result<Vec<String>, AnvilError> {
213 let sig = SshSig::from_pem(armored_sig)
214 .map_err(|e| AnvilError::signature_invalid(format!("malformed signature: {e}")))?;
215 let public_key = PublicKey::from(sig.public_key().clone());
216 Ok(allowed
217 .find_principals(&public_key, namespace)
218 .iter()
219 .map(|s| (*s).to_owned())
220 .collect())
221}
222
223pub fn find_principals_any_ns(
236 armored_sig: &str,
237 allowed: &AllowedSigners,
238) -> Result<Vec<String>, AnvilError> {
239 let sig = SshSig::from_pem(armored_sig)
240 .map_err(|e| AnvilError::signature_invalid(format!("malformed signature: {e}")))?;
241 let public_key = PublicKey::from(sig.public_key().clone());
242 Ok(allowed
243 .find_principals_any_ns(&public_key)
244 .iter()
245 .map(|s| (*s).to_owned())
246 .collect())
247}
248
249#[cfg(test)]
252mod tests {
253 use super::*;
254 use std::io::Cursor;
255
256 use crate::keygen::{generate, KeyType};
257
258 fn roundtrip(kind: KeyType, hash: HashAlg) {
259 let key = generate(kind, None, "sign@test").unwrap();
260 let payload = b"the quick brown fox jumps over the lazy dog";
261 let armored = sign(&mut Cursor::new(payload), &key, "git", hash).unwrap();
262 assert!(armored.contains("BEGIN SSH SIGNATURE"));
263
264 check_novalidate(&mut Cursor::new(payload), &armored, "git").unwrap();
266
267 let err = check_novalidate(&mut Cursor::new(payload), &armored, "file").unwrap_err();
269 assert!(err.to_string().contains("namespace"));
270
271 let err = check_novalidate(&mut Cursor::new(b"tampered"), &armored, "git").unwrap_err();
273 assert!(err.to_string().contains("cryptographic"));
274 }
275
276 #[test]
277 fn ed25519_sign_verify_roundtrip() {
278 roundtrip(KeyType::Ed25519, HashAlg::Sha512);
279 }
280
281 #[test]
282 fn ecdsa_p256_sign_verify_roundtrip() {
283 roundtrip(KeyType::EcdsaP256, HashAlg::Sha512);
284 }
285
286 #[test]
291 #[ignore = "RSA SSHSIG path not yet wired up in ssh-key 0.6.7"]
292 fn rsa_sign_verify_roundtrip() {
293 let key = generate(KeyType::Rsa, Some(2048), "rsa-sign@test").unwrap();
294 let payload = b"hello rsa";
295 let armored = sign(&mut Cursor::new(payload), &key, "git", HashAlg::Sha512).unwrap();
296 check_novalidate(&mut Cursor::new(payload), &armored, "git").unwrap();
297 }
298
299 #[test]
300 fn verify_against_allowed_signers_success() {
301 let key = generate(KeyType::Ed25519, None, "alice@test").unwrap();
302 let pubkey_line = key.public_key().to_openssh().unwrap();
303 let allowed_text = format!("alice@example.com {pubkey_line}");
304 let allowed = AllowedSigners::parse(&allowed_text).unwrap();
305
306 let payload = b"signed content";
307 let armored = sign(&mut Cursor::new(payload), &key, "git", HashAlg::Sha512).unwrap();
308
309 let verified = verify(
310 &mut Cursor::new(payload),
311 &armored,
312 "alice@example.com",
313 "git",
314 &allowed,
315 )
316 .unwrap();
317 assert_eq!(verified.principal, "alice@example.com");
318 assert!(verified.fingerprint.starts_with("SHA256:"));
319 }
320
321 #[test]
322 fn verify_against_allowed_signers_rejects_unknown_identity() {
323 let key = generate(KeyType::Ed25519, None, "bob@test").unwrap();
324 let pubkey_line = key.public_key().to_openssh().unwrap();
325 let allowed_text = format!("alice@example.com {pubkey_line}");
326 let allowed = AllowedSigners::parse(&allowed_text).unwrap();
327
328 let payload = b"signed content";
329 let armored = sign(&mut Cursor::new(payload), &key, "git", HashAlg::Sha512).unwrap();
330
331 let err = verify(
332 &mut Cursor::new(payload),
333 &armored,
334 "mallory@example.com",
335 "git",
336 &allowed,
337 )
338 .unwrap_err();
339 assert!(err.to_string().contains("not authorized"));
340 }
341
342 #[test]
343 fn find_principals_returns_matching_entries() {
344 let key = generate(KeyType::Ed25519, None, "carol@test").unwrap();
345 let pubkey_line = key.public_key().to_openssh().unwrap();
346 let allowed_text = format!("carol@example.com,dave@example.com {pubkey_line}");
347 let allowed = AllowedSigners::parse(&allowed_text).unwrap();
348
349 let armored = sign(&mut Cursor::new(b"x"), &key, "git", HashAlg::Sha512).unwrap();
350 let principals = find_principals(&armored, &allowed, "git").unwrap();
351 assert!(principals.iter().any(|p| p == "carol@example.com"));
352 assert!(principals.iter().any(|p| p == "dave@example.com"));
353 }
354
355 #[test]
356 fn find_principals_any_ns_ignores_namespace_restriction() {
357 let key = generate(KeyType::Ed25519, None, "erin@test").unwrap();
361 let pubkey_line = key.public_key().to_openssh().unwrap();
362 let allowed_text =
363 format!("erin@example.com namespaces=\"signing\" {pubkey_line}");
364 let allowed = AllowedSigners::parse(&allowed_text).unwrap();
365
366 let armored = sign(&mut Cursor::new(b"x"), &key, "git", HashAlg::Sha512).unwrap();
367
368 let strict = find_principals(&armored, &allowed, "git").unwrap();
369 assert!(
370 strict.is_empty(),
371 "namespace-aware lookup must skip entries restricted to a different namespace, got {strict:?}"
372 );
373
374 let any = find_principals_any_ns(&armored, &allowed).unwrap();
375 assert!(
376 any.iter().any(|p| p == "erin@example.com"),
377 "any-ns lookup must return the principal regardless of namespace restriction, got {any:?}"
378 );
379 }
380}