fabrica

hanna/fabrica

feat(git): parse and fingerprint registered public keys

a7cf523 · hanna committed on 2026-07-25

Add parse_public_key(kind, material) returning the SHA256 (SSH) or hex
(GPG) fingerprint the store keys on plus the canonical text to persist,
so `key add` validates key material up front and shares the ssh-key/pgp
path with signature verification — a key we can register is one we can
verify against. SSH keys also yield their comment as a default label.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
2 files changed · +149 −0UnifiedSplit
crates/git/src/lib.rs +11 −0
@@ -28,6 +28,7 @@
2828 //! name→path authority; [`repo_path`] is the one place that mapping is computed.
2929
3030 mod attributes;
31+mod pubkey;
3132 mod repo;
3233 mod signature;
3334 mod types;
@@ -40,6 +41,7 @@ use std::path::{Path, PathBuf};
4041 use git2::{Repository, RepositoryInitOptions};
4142
4243 pub use crate::attributes::{AttrValue, Attributes, AttributesCache, AttributesSet};
44+pub use crate::pubkey::{ParsedKey, parse_public_key};
4345 pub use crate::repo::Repo;
4446 pub use crate::signature::{SignatureCache, SignatureState, SigningKey};
4547 pub use crate::types::{
@@ -79,6 +81,15 @@ pub enum GitError {
7981 /// A path resolved to something other than a file where a file was required.
8082 #[error("path {0:?} is not a file")]
8183 NotAFile(String),
84+
85+ /// A public key could not be parsed as the declared kind.
86+ #[error("invalid {kind} public key: {reason}")]
87+ BadPublicKey {
88+ /// The declared key kind.
89+ kind: model::KeyKind,
90+ /// A human-readable parse failure reason.
91+ reason: String,
92+ },
8293 }
8394
8495 /// Compute the on-disk path of a repository from its id: `{root}/{id[0..2]}/{id}.git`.
crates/git/src/pubkey.rs +138 −0
@@ -0,0 +1,138 @@
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+
12+use model::KeyKind;
13+use pgp::composed::{Deserializable, SignedPublicKey};
14+use pgp::types::KeyDetails;
15+use ssh_key::{HashAlg, PublicKey};
16+
17+use crate::GitError;
18+
19+/// A parsed, validated public key ready to persist.
20+#[derive(Debug, Clone, PartialEq, Eq)]
21+pub 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.
38+pub 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.
46+fn 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.
63+fn 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)]
78+mod 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\
85+mDMEamQrVhYJKwYBBAHaRw8BAQdAZmnSAPnE0i9ZFD376jPvfybRQnCVgdZznu/Z\n\
86+xu9NMc+0I0ZhYnJpY2EgVGVzdCA8dGVzdEBmYWJyaWNhLmV4YW1wbGU+iJAEExYK\n\
87+ADgWIQRCw0r0w7/IPhyGEPWZoNNqXdKXKAUCamQrVgIbAwULCQgHAgYVCgkICwIE\n\
88+FgIDAQIeAQIXgAAKCRCZoNNqXdKXKHT3APkBBY7MB1be0vsOf0Krr5+mLzStHwjQ\n\
89+ZhFF/DtG/1L+sgD+KcKERhLj5L5b/FHBxYGpw2ZY4aQoCOeGmk+zmflLbwS4OARq\n\
90+ZCtWEgorBgEEAZdVAQUBAQdAeLjNUQ2tEAUDtr+Iu7qa/EOArrAd3QSWKHNWXfX7\n\
91+4lIDAQgHiHgEGBYKACAWIQRCw0r0w7/IPhyGEPWZoNNqXdKXKAUCamQrVgIbDAAK\n\
92+CRCZoNNqXdKXKJIEAP9Z2KdPQ7yVGAC6XjDoYKbbeAu58G4L4hqiq+JK6v18lwD/\n\
93+T4OoXtnXPpwpy5jv0KTtE+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+}