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