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 @@
28//! name→path authority; [`repo_path`] is the one place that mapping is computed.28//! name→path authority; [`repo_path`] is the one place that mapping is computed.
2929
30mod attributes;30mod attributes;
31mod pubkey;
31mod repo;32mod repo;
32mod signature;33mod signature;
33mod types;34mod types;
@@ -40,6 +41,7 @@ use std::path::{Path, PathBuf};
40use git2::{Repository, RepositoryInitOptions};41use git2::{Repository, RepositoryInitOptions};
4142
42pub use crate::attributes::{AttrValue, Attributes, AttributesCache, AttributesSet};43pub use crate::attributes::{AttrValue, Attributes, AttributesCache, AttributesSet};
44pub use crate::pubkey::{ParsedKey, parse_public_key};
43pub use crate::repo::Repo;45pub use crate::repo::Repo;
44pub use crate::signature::{SignatureCache, SignatureState, SigningKey};46pub use crate::signature::{SignatureCache, SignatureState, SigningKey};
45pub use crate::types::{47pub use crate::types::{
@@ -79,6 +81,15 @@ pub enum GitError {
79 /// A path resolved to something other than a file where a file was required.81 /// A path resolved to something other than a file where a file was required.
80 #[error("path {0:?} is not a file")]82 #[error("path {0:?} is not a file")]
81 NotAFile(String),83 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 },
82}93}
8394
84/// Compute the on-disk path of a repository from its id: `{root}/{id[0..2]}/{id}.git`.95/// 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
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}