Skip to main content

uselesskey_pgp/
keypair.rs

1use std::fmt;
2use std::sync::Arc;
3
4use pgp::composed::{EncryptionCaps, KeyType, SecretKeyParamsBuilder, SignedPublicKey};
5use pgp::ser::Serialize;
6use pgp::types::KeyDetails;
7use rand_chacha::ChaCha20Rng;
8use rand_core::SeedableRng;
9use uselesskey_core::negative::{
10    CorruptPem, corrupt_der_deterministic, corrupt_pem, corrupt_pem_deterministic, truncate_der,
11};
12use uselesskey_core::sink::TempArtifact;
13use uselesskey_core::{Error, Factory};
14
15use crate::PgpSpec;
16
17/// Cache domain for OpenPGP keypair fixtures.
18///
19/// Keep this stable: changing it changes deterministic outputs.
20pub const DOMAIN_PGP_KEYPAIR: &str = "uselesskey:pgp:keypair";
21
22#[derive(Clone)]
23pub struct PgpKeyPair {
24    factory: Factory,
25    label: String,
26    spec: PgpSpec,
27    inner: Arc<Inner>,
28}
29
30struct Inner {
31    user_id: String,
32    fingerprint: String,
33    private_binary: Arc<[u8]>,
34    private_armor: String,
35    public_binary: Arc<[u8]>,
36    public_armor: String,
37}
38
39impl fmt::Debug for PgpKeyPair {
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        f.debug_struct("PgpKeyPair")
42            .field("label", &self.label)
43            .field("spec", &self.spec)
44            .field("fingerprint", &self.inner.fingerprint)
45            .finish_non_exhaustive()
46    }
47}
48
49/// Extension trait to hang OpenPGP helpers off the core [`Factory`].
50pub trait PgpFactoryExt {
51    fn pgp(&self, label: impl AsRef<str>, spec: PgpSpec) -> PgpKeyPair;
52}
53
54impl PgpFactoryExt for Factory {
55    fn pgp(&self, label: impl AsRef<str>, spec: PgpSpec) -> PgpKeyPair {
56        PgpKeyPair::new(self.clone(), label.as_ref(), spec)
57    }
58}
59
60impl PgpKeyPair {
61    fn new(factory: Factory, label: &str, spec: PgpSpec) -> Self {
62        let inner = load_inner(&factory, label, spec, "good");
63        Self {
64            factory,
65            label: label.to_string(),
66            spec,
67            inner,
68        }
69    }
70
71    fn load_variant(&self, variant: &str) -> Arc<Inner> {
72        load_inner(&self.factory, &self.label, self.spec, variant)
73    }
74
75    /// Returns the fixture spec.
76    pub fn spec(&self) -> PgpSpec {
77        self.spec
78    }
79
80    /// Returns the label used to create this keypair.
81    ///
82    /// # Examples
83    ///
84    /// ```
85    /// # use uselesskey_core::Factory;
86    /// # use uselesskey_pgp::{PgpFactoryExt, PgpSpec};
87    /// let fx = Factory::random();
88    /// let kp = fx.pgp("my-svc", PgpSpec::ed25519());
89    /// assert_eq!(kp.label(), "my-svc");
90    /// ```
91    pub fn label(&self) -> &str {
92        &self.label
93    }
94
95    /// Returns the generated OpenPGP user id.
96    pub fn user_id(&self) -> &str {
97        &self.inner.user_id
98    }
99
100    /// Returns the OpenPGP key fingerprint.
101    pub fn fingerprint(&self) -> &str {
102        &self.inner.fingerprint
103    }
104
105    /// Binary transferable secret key bytes.
106    pub fn private_key_binary(&self) -> &[u8] {
107        &self.inner.private_binary
108    }
109
110    /// Armored transferable secret key.
111    pub fn private_key_armored(&self) -> &str {
112        &self.inner.private_armor
113    }
114
115    /// Binary transferable public key bytes.
116    pub fn public_key_binary(&self) -> &[u8] {
117        &self.inner.public_binary
118    }
119
120    /// Armored transferable public key.
121    pub fn public_key_armored(&self) -> &str {
122        &self.inner.public_armor
123    }
124
125    /// Write the armored private key to a tempfile.
126    pub fn write_private_key_armored(&self) -> Result<TempArtifact, Error> {
127        TempArtifact::new_string("uselesskey-", ".pgp.priv.asc", self.private_key_armored())
128    }
129
130    /// Write the armored public key to a tempfile.
131    pub fn write_public_key_armored(&self) -> Result<TempArtifact, Error> {
132        TempArtifact::new_string("uselesskey-", ".pgp.pub.asc", self.public_key_armored())
133    }
134
135    /// Produce a corrupted armored private key variant.
136    pub fn private_key_armored_corrupt(&self, how: CorruptPem) -> String {
137        corrupt_pem(self.private_key_armored(), how)
138    }
139
140    /// Produce a deterministic corrupted armored private key using a variant string.
141    pub fn private_key_armored_corrupt_deterministic(&self, variant: &str) -> String {
142        corrupt_pem_deterministic(self.private_key_armored(), variant)
143    }
144
145    /// Produce a truncated private key binary variant.
146    pub fn private_key_binary_truncated(&self, len: usize) -> Vec<u8> {
147        truncate_der(self.private_key_binary(), len)
148    }
149
150    /// Produce a deterministic corrupted private key binary using a variant string.
151    pub fn private_key_binary_corrupt_deterministic(&self, variant: &str) -> Vec<u8> {
152        corrupt_der_deterministic(self.private_key_binary(), variant)
153    }
154
155    /// Return a valid (parseable) public key that does not match this private key.
156    pub fn mismatched_public_key_binary(&self) -> Vec<u8> {
157        let other = self.load_variant("mismatch");
158        other.public_binary.as_ref().to_vec()
159    }
160
161    /// Return an armored public key that does not match this private key.
162    pub fn mismatched_public_key_armored(&self) -> String {
163        let other = self.load_variant("mismatch");
164        other.public_armor.clone()
165    }
166}
167
168fn load_inner(factory: &Factory, label: &str, spec: PgpSpec, variant: &str) -> Arc<Inner> {
169    let spec_bytes = spec.stable_bytes();
170
171    factory.get_or_init(DOMAIN_PGP_KEYPAIR, label, &spec_bytes, variant, |seed| {
172        let mut rng = ChaCha20Rng::from_seed(*seed.bytes());
173        let user_id = build_user_id(label);
174
175        let mut key_params = SecretKeyParamsBuilder::default();
176        key_params
177            .key_type(spec_to_key_type(spec))
178            .can_certify(true)
179            .can_sign(true)
180            .can_encrypt(EncryptionCaps::None)
181            .primary_user_id(user_id.clone());
182
183        let secret_key_params = key_params
184            .build()
185            .expect("failed to build OpenPGP secret key params");
186
187        let secret_key = secret_key_params
188            .generate(&mut rng)
189            .expect("OpenPGP key generation failed");
190        let public_key = SignedPublicKey::from(secret_key.clone());
191
192        let mut private_binary = Vec::new();
193        secret_key
194            .to_writer(&mut private_binary)
195            .expect("failed to encode OpenPGP private key bytes");
196
197        let mut public_binary = Vec::new();
198        public_key
199            .to_writer(&mut public_binary)
200            .expect("failed to encode OpenPGP public key bytes");
201
202        let private_armor = secret_key
203            .to_armored_string(None.into())
204            .expect("failed to armor OpenPGP private key");
205        let public_armor = public_key
206            .to_armored_string(None.into())
207            .expect("failed to armor OpenPGP public key");
208
209        Inner {
210            user_id,
211            fingerprint: secret_key.fingerprint().to_string(),
212            private_binary: Arc::from(private_binary),
213            private_armor,
214            public_binary: Arc::from(public_binary),
215            public_armor,
216        }
217    })
218}
219
220fn spec_to_key_type(spec: PgpSpec) -> KeyType {
221    match spec {
222        PgpSpec::Rsa2048 => KeyType::Rsa(2048),
223        PgpSpec::Rsa3072 => KeyType::Rsa(3072),
224        PgpSpec::Ed25519 => KeyType::Ed25519,
225    }
226}
227
228fn build_user_id(label: &str) -> String {
229    let display = if label.trim().is_empty() {
230        "fixture"
231    } else {
232        label.trim()
233    };
234
235    let mut local = String::new();
236    for ch in display.chars() {
237        if ch.is_ascii_alphanumeric() {
238            local.push(ch.to_ascii_lowercase());
239        } else if !local.ends_with('-') {
240            local.push('-');
241        }
242    }
243
244    let local = local.trim_matches('-');
245    let local = if local.is_empty() { "fixture" } else { local };
246
247    format!("{display} <{local}@uselesskey.test>")
248}
249
250#[cfg(test)]
251mod tests {
252    use std::io::Cursor;
253
254    use pgp::composed::{Deserializable, SignedPublicKey, SignedSecretKey};
255    use pgp::types::KeyDetails;
256    use uselesskey_core::Seed;
257
258    use super::*;
259
260    #[test]
261    fn deterministic_key_is_stable() {
262        let fx = Factory::deterministic(Seed::from_env_value("pgp-det").unwrap());
263        let a = fx.pgp("issuer", PgpSpec::ed25519());
264        let b = fx.pgp("issuer", PgpSpec::ed25519());
265
266        assert_eq!(a.private_key_armored(), b.private_key_armored());
267        assert_eq!(a.public_key_armored(), b.public_key_armored());
268        assert_eq!(a.fingerprint(), b.fingerprint());
269    }
270
271    #[test]
272    fn random_mode_caches_per_identity() {
273        let fx = Factory::random();
274        let a = fx.pgp("issuer", PgpSpec::rsa_2048());
275        let b = fx.pgp("issuer", PgpSpec::rsa_2048());
276
277        assert_eq!(a.private_key_armored(), b.private_key_armored());
278    }
279
280    #[test]
281    fn different_labels_produce_different_keys() {
282        let fx = Factory::deterministic(Seed::from_env_value("pgp-label").unwrap());
283        let a = fx.pgp("a", PgpSpec::rsa_3072());
284        let b = fx.pgp("b", PgpSpec::rsa_3072());
285
286        assert_ne!(a.private_key_binary(), b.private_key_binary());
287        assert_ne!(a.fingerprint(), b.fingerprint());
288    }
289
290    #[test]
291    fn armored_outputs_have_expected_headers() {
292        let fx = Factory::random();
293        let key = fx.pgp("issuer", PgpSpec::ed25519());
294
295        assert!(
296            key.private_key_armored()
297                .contains("BEGIN PGP PRIVATE KEY BLOCK")
298        );
299        assert!(
300            key.public_key_armored()
301                .contains("BEGIN PGP PUBLIC KEY BLOCK")
302        );
303    }
304
305    #[test]
306    fn armored_outputs_parse_and_match_fingerprint() {
307        let fx = Factory::random();
308        let key = fx.pgp("parser", PgpSpec::ed25519());
309
310        let (secret, _) =
311            SignedSecretKey::from_armor_single(Cursor::new(key.private_key_armored()))
312                .expect("parse armored private key");
313        secret.verify_bindings().expect("verify private bindings");
314
315        let (public, _) = SignedPublicKey::from_armor_single(Cursor::new(key.public_key_armored()))
316            .expect("parse armored public key");
317        public.verify_bindings().expect("verify public bindings");
318
319        assert_eq!(secret.fingerprint().to_string(), key.fingerprint());
320        assert_eq!(public.fingerprint().to_string(), key.fingerprint());
321    }
322
323    #[test]
324    fn binary_outputs_parse() {
325        let fx = Factory::random();
326        let key = fx.pgp("binary", PgpSpec::rsa_2048());
327
328        let secret = SignedSecretKey::from_bytes(Cursor::new(key.private_key_binary()))
329            .expect("parse private key bytes");
330        let public = SignedPublicKey::from_bytes(Cursor::new(key.public_key_binary()))
331            .expect("parse public key bytes");
332
333        assert_eq!(secret.fingerprint().to_string(), key.fingerprint());
334        assert_eq!(public.fingerprint().to_string(), key.fingerprint());
335    }
336
337    #[test]
338    fn mismatched_public_key_differs() {
339        let fx = Factory::deterministic(Seed::from_env_value("pgp-mismatch").unwrap());
340        let key = fx.pgp("issuer", PgpSpec::ed25519());
341
342        let mismatch = key.mismatched_public_key_binary();
343        assert_ne!(mismatch, key.public_key_binary());
344    }
345
346    #[test]
347    fn user_id_is_exposed_and_sanitized() {
348        let fx = Factory::deterministic(Seed::from_env_value("pgp-user-id").unwrap());
349        let key = fx.pgp("Test User!@#", PgpSpec::ed25519());
350        let blank = fx.pgp("   ", PgpSpec::ed25519());
351
352        assert_eq!(key.user_id(), "Test User!@# <test-user@uselesskey.test>");
353        assert_eq!(blank.user_id(), "fixture <fixture@uselesskey.test>");
354    }
355
356    #[test]
357    fn armored_corruption_helpers_are_invalid_and_stable() {
358        let fx = Factory::deterministic(Seed::from_env_value("pgp-corrupt-armor").unwrap());
359        let key = fx.pgp("issuer", PgpSpec::ed25519());
360
361        let bad = key.private_key_armored_corrupt(CorruptPem::BadBase64);
362        assert_ne!(bad, key.private_key_armored());
363        assert!(bad.contains("THIS_IS_NOT_BASE64!!!"));
364        assert!(SignedSecretKey::from_armor_single(Cursor::new(&bad)).is_err());
365
366        let det_a = key.private_key_armored_corrupt_deterministic("corrupt:v1");
367        let det_b = key.private_key_armored_corrupt_deterministic("corrupt:v1");
368        assert_eq!(det_a, det_b);
369        assert_ne!(det_a, key.private_key_armored());
370        assert!(det_a.starts_with('-'));
371        assert!(SignedSecretKey::from_armor_single(Cursor::new(&det_a)).is_err());
372    }
373
374    #[test]
375    fn binary_corruption_helpers_are_invalid_and_stable() {
376        let fx = Factory::deterministic(Seed::from_env_value("pgp-corrupt-bin").unwrap());
377        let key = fx.pgp("issuer", PgpSpec::ed25519());
378
379        let truncated = key.private_key_binary_truncated(32);
380        assert_eq!(truncated.len(), 32);
381        assert!(SignedSecretKey::from_bytes(Cursor::new(&truncated)).is_err());
382
383        let det_a = key.private_key_binary_corrupt_deterministic("corrupt:v1");
384        let det_b = key.private_key_binary_corrupt_deterministic("corrupt:v1");
385        assert_eq!(det_a, det_b);
386        assert_ne!(det_a, key.private_key_binary());
387        assert_eq!(det_a.len(), key.private_key_binary().len());
388    }
389
390    #[test]
391    fn mismatched_public_key_variants_parse_and_fingerprint_differs() {
392        let fx = Factory::deterministic(Seed::from_env_value("pgp-mismatch-parse").unwrap());
393        let key = fx.pgp("issuer", PgpSpec::ed25519());
394
395        let mismatch_bin = key.mismatched_public_key_binary();
396        let mismatch_pub = SignedPublicKey::from_bytes(Cursor::new(&mismatch_bin))
397            .expect("parse mismatched public binary");
398        assert_ne!(mismatch_pub.fingerprint().to_string(), key.fingerprint());
399
400        let mismatch_arm = key.mismatched_public_key_armored();
401        assert_ne!(mismatch_arm, key.public_key_armored());
402        let (mismatch_pub_arm, _) = SignedPublicKey::from_armor_single(Cursor::new(&mismatch_arm))
403            .expect("parse mismatched public armor");
404        assert_ne!(
405            mismatch_pub_arm.fingerprint().to_string(),
406            key.fingerprint()
407        );
408    }
409
410    #[test]
411    fn debug_does_not_leak_key_material() {
412        let fx = Factory::random();
413        let key = fx.pgp("debug", PgpSpec::ed25519());
414        let dbg = format!("{key:?}");
415
416        assert!(dbg.contains("PgpKeyPair"));
417        assert!(dbg.contains("debug"));
418        assert!(!dbg.contains("BEGIN PGP PRIVATE KEY BLOCK"));
419    }
420}