fabrica

hanna/fabrica

5024 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Parsing and fingerprinting of registered public keys.
6//!
7//! `fabrica key add` validates key material before storing it and computes the
8//! fingerprint the schema keys on. This shares the same `ssh-key` / `pgp` crates as
9//! signature verification (§3.6), so a key that can be registered is a key we can
10//! actually verify against.
11
12use model::KeyKind;
13use pgp::composed::{Deserializable, SignedPublicKey};
14use pgp::types::KeyDetails;
15use ssh_key::{HashAlg, PublicKey};
16
17use crate::GitError;
18
19/// A parsed, validated public key ready to persist.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct ParsedKey {
22 /// The fingerprint the store keys on: `SHA256:…` for SSH, lowercase hex for GPG.
23 pub fingerprint: String,
24 /// The canonical text to store: a single-line `authorized_keys` entry (SSH) or
25 /// the ASCII-armored block as given (GPG).
26 pub public_key: String,
27 /// A default label derived from the key (the SSH comment, or the GPG user id),
28 /// used when the operator does not supply one.
29 pub label: Option<String>,
30}
31
32/// Parse and fingerprint `material` as a key of the declared `kind`.
33///
34/// # Errors
35///
36/// Returns [`GitError::BadPublicKey`] if the material does not parse as the declared
37/// kind.
38pub fn parse_public_key(kind: KeyKind, material: &str) -> Result<ParsedKey, GitError> {
39 match kind {
40 KeyKind::Ssh => parse_ssh(material),
41 KeyKind::Gpg => parse_gpg(material),
42 }
43}
44
45/// Parse an OpenSSH `authorized_keys` line.
46fn parse_ssh(material: &str) -> Result<ParsedKey, GitError> {
47 let key = PublicKey::from_openssh(material.trim()).map_err(|err| GitError::BadPublicKey {
48 kind: KeyKind::Ssh,
49 reason: err.to_string(),
50 })?;
51 let comment = key.comment().trim();
52 Ok(ParsedKey {
53 fingerprint: key.fingerprint(HashAlg::Sha256).to_string(),
54 public_key: key.to_openssh().map_err(|err| GitError::BadPublicKey {
55 kind: KeyKind::Ssh,
56 reason: err.to_string(),
57 })?,
58 label: (!comment.is_empty()).then(|| comment.to_string()),
59 })
60}
61
62/// Parse an ASCII-armored `OpenPGP` public key block.
63fn parse_gpg(material: &str) -> Result<ParsedKey, GitError> {
64 let (key, _headers) =
65 SignedPublicKey::from_string(material).map_err(|err| GitError::BadPublicKey {
66 kind: KeyKind::Gpg,
67 reason: err.to_string(),
68 })?;
69 Ok(ParsedKey {
70 fingerprint: format!("{:x}", key.fingerprint()),
71 public_key: material.trim().to_string(),
72 // The operator supplies the label for GPG keys.
73 label: None,
74 })
75}
76
77#[cfg(test)]
78mod tests {
79 #![allow(clippy::unwrap_used)]
80
81 use super::*;
82
83 // Reuse the static GPG public key fixture from the signature tests.
84 const GPG_PUB: &str = "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n\
85mDMEamQrVhYJKwYBBAHaRw8BAQdAZmnSAPnE0i9ZFD376jPvfybRQnCVgdZznu/Z\n\
86xu9NMc+0I0ZhYnJpY2EgVGVzdCA8dGVzdEBmYWJyaWNhLmV4YW1wbGU+iJAEExYK\n\
87ADgWIQRCw0r0w7/IPhyGEPWZoNNqXdKXKAUCamQrVgIbAwULCQgHAgYVCgkICwIE\n\
88FgIDAQIeAQIXgAAKCRCZoNNqXdKXKHT3APkBBY7MB1be0vsOf0Krr5+mLzStHwjQ\n\
89ZhFF/DtG/1L+sgD+KcKERhLj5L5b/FHBxYGpw2ZY4aQoCOeGmk+zmflLbwS4OARq\n\
90ZCtWEgorBgEEAZdVAQUBAQdAeLjNUQ2tEAUDtr+Iu7qa/EOArrAd3QSWKHNWXfX7\n\
914lIDAQgHiHgEGBYKACAWIQRCw0r0w7/IPhyGEPWZoNNqXdKXKAUCamQrVgIbDAAK\n\
92CRCZoNNqXdKXKJIEAP9Z2KdPQ7yVGAC6XjDoYKbbeAu58G4L4hqiq+JK6v18lwD/\n\
93T4OoXtnXPpwpy5jv0KTtE+cjQCl1W2qhQTFSqlXeawM=\n\
94=EOde\n\
95-----END PGP PUBLIC KEY BLOCK-----\n";
96
97 #[test]
98 fn parses_ssh_key_and_fingerprint() {
99 use rand_core::OsRng;
100 use ssh_key::{Algorithm, PrivateKey};
101
102 let private = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap();
103 let openssh = private.public_key().to_openssh().unwrap();
104
105 let parsed = parse_public_key(KeyKind::Ssh, &format!("{openssh} ada@host")).unwrap();
106 assert!(parsed.fingerprint.starts_with("SHA256:"));
107 assert_eq!(
108 parsed.fingerprint,
109 private
110 .public_key()
111 .fingerprint(HashAlg::Sha256)
112 .to_string()
113 );
114 assert_eq!(parsed.label.as_deref(), Some("ada@host"));
115 }
116
117 #[test]
118 fn parses_gpg_key_and_fingerprint() {
119 let parsed = parse_public_key(KeyKind::Gpg, GPG_PUB).unwrap();
120 assert_eq!(
121 parsed.fingerprint,
122 "42c34af4c3bfc83e1c8610f599a0d36a5dd29728"
123 );
124 assert!(parsed.label.is_none());
125 }
126
127 #[test]
128 fn rejects_garbage() {
129 assert!(matches!(
130 parse_public_key(KeyKind::Ssh, "not a key"),
131 Err(GitError::BadPublicKey { .. })
132 ));
133 assert!(matches!(
134 parse_public_key(KeyKind::Gpg, "-----BEGIN PGP PUBLIC KEY BLOCK-----\nx\n"),
135 Err(GitError::BadPublicKey { .. })
136 ));
137 }
138}