Skip to main content

macula_rust/node_key/
seed_form.rs

1//! macula's seed form, as macula-go writes it: the magic, the purpose,
2//! profile and half count, then each half as its algorithm tag and its public
3//! and private keys, each length-prefixed in four big-endian bytes. An
4//! ML-DSA-87 half keeps its 32-byte seed, and an RSA-PSS half its PKCS #1 key.
5//! A key file holds these bytes (`key_file`, unix only), and so does a key
6//! store ([`crate::keystore`]), which is where a Windows node keeps its key.
7
8use std::fmt;
9use std::path::PathBuf;
10
11use aws_lc_rs::encoding::AsDer;
12use aws_lc_rs::rsa::KeyPair as RsaKeyPair;
13use aws_lc_rs::signature::KeyPair as _;
14use macula_mldsa::{PrivateKey, Zeroizing, ML_DSA_87};
15
16use super::{der, verify, KeyError, NodeKey, Purpose, RsaHalf};
17use crate::keystore::{KeyStore, KeyStoreError};
18use crate::profile::Profile;
19
20/// Opens every key file this crate writes. macula's own key files hold the
21/// expanded ML-DSA-87 key and open with "macula-node-key-v1", so one is never
22/// taken for the other.
23const MAGIC: &[u8] = b"macula-node-key-seed-v1\0";
24
25/// Opens what a key store keeps: the key-file layout with every public key
26/// left empty, derived again on load (macula-rust#19). Windows Credential
27/// Manager keeps at most 2,560 bytes, which a key file with its 2,592-byte
28/// ML-DSA-87 public key alone exceeds.
29const STORE_MAGIC: &[u8] = b"macula-node-key-private-v1\0";
30
31/// More than the longest layout, a pq_hybrid key file.
32const LAYOUT_CAPACITY: usize = 8 * 1024;
33
34/// Which layout a key is read or written in: a key file's, with its public
35/// keys, or a key store's, with none.
36#[derive(Clone, Copy, PartialEq, Eq)]
37enum Form {
38    File,
39    Store,
40}
41
42const TAG_MLDSA_SEED: u8 = 1;
43const TAG_RSA_PSS: u8 = 2;
44/// Why a key file was not saved or loaded.
45#[derive(Debug)]
46pub enum KeyFileError {
47    /// Reading or writing the file failed.
48    Io(std::io::Error),
49    /// The path names something other than a regular file, directly or
50    /// through a symlink.
51    NotRegular,
52    /// Another user than the effective user owns the file.
53    Owner,
54    /// The file's group or others can read it.
55    Permissions,
56    /// The file is longer than 64 KiB, which no key file is.
57    TooLarge,
58    /// The file is not a key file in the seed form.
59    BadKeyFile,
60    /// The file holds a key for another purpose, named here.
61    WrongPurpose(Purpose),
62    /// The file holds a key for another profile, named here.
63    WrongProfile(Profile),
64    /// The key's halves do not fit its profile.
65    WrongAlgorithms,
66    /// The RSA-PSS half is not a 4,096-bit key with exponent 65537.
67    WrongKeySize,
68    /// A private key does not decode.
69    PrivateKeyInvalid,
70    /// A stored public key is not the one its private key derives.
71    PublicKeyMismatch,
72    /// The key does not sign and verify as a whole.
73    RoundTripFailed,
74    /// The key store could not save or load the key.
75    KeyStore(KeyStoreError),
76    /// A new key could not be made.
77    Generate(KeyError),
78    /// Key files are not kept on this platform: on Windows a node key lives
79    /// in Credential Manager only, through
80    /// [`KeyringStore`](crate::keystore::KeyringStore) and
81    /// [`NodeKey::save_to_keystore`]/[`NodeKey::load_from_keystore`]
82    /// (macula-rust#19).
83    NoKeyFile,
84    /// A key store holds a key in the key-file form, as macula-rust 0.7.0
85    /// kept it: refused (macula-rust#19).
86    KeptInTheKeyFileForm,
87    /// An identity name that is not 1 to 64 lowercase ASCII letters, digits,
88    /// `-` and `_`, starting with a letter or digit (identity layout v1).
89    IdentityName(String),
90    /// The old single key file `from` was not moved into the identity
91    /// layout, for `reason`; it is left as it is.
92    OldKey {
93        from: PathBuf,
94        reason: Box<KeyFileError>,
95    },
96    /// The old single key file `from` was not moved to `to`, which holds
97    /// another key; both are left as they are.
98    OldKeyPlaceTaken { from: PathBuf, to: PathBuf },
99    /// The identity stored at `path` does not load, for `reason`; it is
100    /// never replaced.
101    StoredKey {
102        path: PathBuf,
103        reason: Box<KeyFileError>,
104    },
105}
106
107impl fmt::Display for KeyFileError {
108    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109        match self {
110            KeyFileError::Io(e) => write!(f, "key file: {e}"),
111            KeyFileError::NotRegular => f.write_str("the key file is not a regular file"),
112            KeyFileError::Owner => f.write_str("the key file is owned by another user"),
113            KeyFileError::Permissions => {
114                f.write_str("the key file can be read by its group or others")
115            }
116            KeyFileError::TooLarge => f.write_str("the key file is longer than 64 KiB"),
117            KeyFileError::BadKeyFile => f.write_str("not a key file in the seed form"),
118            KeyFileError::WrongPurpose(p) => write!(f, "the key file holds a key for {p}"),
119            KeyFileError::WrongProfile(p) => write!(f, "the key file holds a key for {p}"),
120            KeyFileError::WrongAlgorithms => f.write_str("the key's halves do not fit its profile"),
121            KeyFileError::WrongKeySize => {
122                f.write_str("the RSA-PSS half is not a 4096-bit key with exponent 65537")
123            }
124            KeyFileError::PrivateKeyInvalid => {
125                f.write_str("the key file's private key is not valid")
126            }
127            KeyFileError::PublicKeyMismatch => {
128                f.write_str("the stored public key is not the one its private key derives")
129            }
130            KeyFileError::RoundTripFailed => f.write_str("the key does not sign and verify"),
131            KeyFileError::KeyStore(e) => write!(f, "key store: {e}"),
132            KeyFileError::Generate(e) => write!(f, "a new key: {e}"),
133            KeyFileError::KeptInTheKeyFileForm => f.write_str(
134                "the key store holds a key in the key-file form macula-rust 0.7.0 kept; \
135                 it no longer loads: create the identity again \
136                 (NodeKey::generate_identity, then save_to_keystore)",
137            ),
138            KeyFileError::IdentityName(name) => write!(
139                f,
140                "not an identity name: {name:?} (1 to 64 lowercase letters, digits, - and _, \
141                 starting with a letter or digit)"
142            ),
143            KeyFileError::OldKey { from, reason } => {
144                write!(
145                    f,
146                    "the old identity key {} was not moved: {reason}",
147                    from.display()
148                )
149            }
150            KeyFileError::OldKeyPlaceTaken { from, to } => write!(
151                f,
152                "the old identity key {} was not moved: {} holds another key; both are kept",
153                from.display(),
154                to.display()
155            ),
156            KeyFileError::StoredKey { path, reason } => {
157                write!(
158                    f,
159                    "the identity stored at {} does not load: {reason}",
160                    path.display()
161                )
162            }
163            KeyFileError::NoKeyFile => f.write_str(
164                "no key file on this platform: keep the key in Credential Manager \
165                 through keystore::KeyringStore (NodeKey::save_to_keystore)",
166            ),
167        }
168    }
169}
170
171impl std::error::Error for KeyFileError {}
172
173impl From<std::io::Error> for KeyFileError {
174    fn from(e: std::io::Error) -> Self {
175        KeyFileError::Io(e)
176    }
177}
178
179impl NodeKey {
180    /// Keeps the key in `store` in one write, as its private part only: the
181    /// ML-DSA-87 seed, and in pq_hybrid the RSA-PSS key as PKCS #1, at most
182    /// 2,431 bytes, within Windows Credential Manager's 2,560 (see
183    /// `crate::keystore`). The public keys are derived again on load.
184    pub fn save_to_keystore(&self, store: &dyn KeyStore) -> Result<(), KeyFileError> {
185        store
186            .save_key(&self.laid_out(Form::Store)?)
187            .map_err(KeyFileError::KeyStore)
188    }
189
190    /// The key kept in `store` for `purpose` in `profile`, its public keys
191    /// derived from its private part, then checked as a key file's is, but
192    /// for the file's owner and permissions, which the store keeps. A key
193    /// kept in the key-file form, as macula-rust 0.7.0 kept it, is refused
194    /// [`KeyFileError::KeptInTheKeyFileForm`].
195    pub fn load_from_keystore(
196        store: &dyn KeyStore,
197        purpose: Purpose,
198        profile: Profile,
199    ) -> Result<NodeKey, KeyFileError> {
200        let contents = store.load_key().map_err(KeyFileError::KeyStore)?;
201        if contents.starts_with(MAGIC) {
202            return Err(KeyFileError::KeptInTheKeyFileForm);
203        }
204        let key = parse_form(&contents, Form::Store, purpose, profile)?;
205        round_trip(&key)?;
206        Ok(key)
207    }
208
209    /// The key laid out as a key file.
210    pub(super) fn file_bytes(&self) -> Result<Zeroizing<Vec<u8>>, KeyFileError> {
211        self.laid_out(Form::File)
212    }
213
214    /// The key laid out in `form`: a key store's leaves every public key
215    /// empty. It holds the private keys, so it is wiped when dropped.
216    fn laid_out(&self, form: Form) -> Result<Zeroizing<Vec<u8>>, KeyFileError> {
217        let (magic, with_public) = match form {
218            Form::File => (MAGIC, true),
219            Form::Store => (STORE_MAGIC, false),
220        };
221        let public = |key: &[u8]| -> Vec<u8> {
222            match with_public {
223                true => key.to_vec(),
224                false => Vec::new(),
225            }
226        };
227        // Room for the longest layout (a pq_hybrid key file, about 5.6 KiB),
228        // so no growth leaves an unwiped copy of the private keys behind.
229        let mut out = Zeroizing::new(Vec::with_capacity(LAYOUT_CAPACITY));
230        out.extend_from_slice(magic);
231        out.extend([
232            purpose_tag(self.purpose),
233            profile_tag(self.profile),
234            if self.rsa.is_some() { 2 } else { 1 },
235        ]);
236        append_half(
237            &mut out,
238            TAG_MLDSA_SEED,
239            &public(&self.mldsa_public),
240            &self.mldsa_seed[..],
241        );
242        if let Some(rsa) = &self.rsa {
243            let private = rsa_private_pkcs1(&rsa.pair)?;
244            append_half(&mut out, TAG_RSA_PSS, &public(&rsa.public_der), &private);
245        }
246        Ok(out)
247    }
248}
249
250fn purpose_tag(purpose: Purpose) -> u8 {
251    match purpose {
252        Purpose::Identity => 1,
253        Purpose::Connect => 2,
254    }
255}
256
257fn profile_tag(profile: Profile) -> u8 {
258    match profile {
259        Profile::PqPure => 1,
260        Profile::PqHybrid => 2,
261    }
262}
263
264fn append_half(out: &mut Vec<u8>, tag: u8, public: &[u8], private: &[u8]) {
265    out.push(tag);
266    out.extend((public.len() as u32).to_be_bytes());
267    out.extend_from_slice(public);
268    out.extend((private.len() as u32).to_be_bytes());
269    out.extend_from_slice(private);
270}
271
272/// The RSA half's private key as PKCS #1, which aws-lc-rs hands out inside a
273/// PKCS #8 `PrivateKeyInfo`.
274fn rsa_private_pkcs1(pair: &RsaKeyPair) -> Result<Zeroizing<Vec<u8>>, KeyFileError> {
275    let pkcs8 = pair.as_der().map_err(|_| KeyFileError::PrivateKeyInvalid)?;
276    der::pkcs1_of_pkcs8(pkcs8.as_ref())
277        .map(Zeroizing::new)
278        .ok_or(KeyFileError::PrivateKeyInvalid)
279}
280
281/// One half as a key file holds it.
282struct StoredHalf<'a> {
283    tag: u8,
284    public: &'a [u8],
285    private: &'a [u8],
286}
287
288/// A key file's key, checked for `purpose` and `profile`.
289pub(super) fn parse(
290    bytes: &[u8],
291    purpose: Purpose,
292    profile: Profile,
293) -> Result<NodeKey, KeyFileError> {
294    parse_form(bytes, Form::File, purpose, profile)
295}
296
297/// A key laid out in `form`, checked for `purpose` and `profile`: a key
298/// file's stored public keys must be the ones its private keys derive, a key
299/// store's must be empty.
300fn parse_form(
301    bytes: &[u8],
302    form: Form,
303    purpose: Purpose,
304    profile: Profile,
305) -> Result<NodeKey, KeyFileError> {
306    let magic = match form {
307        Form::File => MAGIC,
308        Form::Store => STORE_MAGIC,
309    };
310    let rest = bytes.strip_prefix(magic).ok_or(KeyFileError::BadKeyFile)?;
311    let [purpose_byte, profile_byte, count, halves_bytes @ ..] = rest else {
312        return Err(KeyFileError::BadKeyFile);
313    };
314    let stored_purpose = match purpose_byte {
315        1 => Purpose::Identity,
316        2 => Purpose::Connect,
317        _ => return Err(KeyFileError::BadKeyFile),
318    };
319    let stored_profile = match profile_byte {
320        1 => Profile::PqPure,
321        2 => Profile::PqHybrid,
322        _ => return Err(KeyFileError::BadKeyFile),
323    };
324    let halves = parse_halves(halves_bytes)?;
325    if halves.len() != *count as usize {
326        return Err(KeyFileError::BadKeyFile);
327    }
328    if stored_purpose != purpose {
329        return Err(KeyFileError::WrongPurpose(stored_purpose));
330    }
331    if stored_profile != profile {
332        return Err(KeyFileError::WrongProfile(stored_profile));
333    }
334    let fits = match profile {
335        Profile::PqPure => halves.len() == 1 && halves[0].tag == TAG_MLDSA_SEED,
336        Profile::PqHybrid => {
337            halves.len() == 2 && halves[0].tag == TAG_MLDSA_SEED && halves[1].tag == TAG_RSA_PSS
338        }
339    };
340    if !fits {
341        return Err(KeyFileError::WrongAlgorithms);
342    }
343    let (mldsa_seed, mldsa_public) = mldsa_from_half(&halves[0], form)?;
344    let rsa = if profile.hybrid() {
345        Some(rsa_from_half(&halves[1], form)?)
346    } else {
347        None
348    };
349    Ok(NodeKey {
350        purpose,
351        profile,
352        mldsa_seed,
353        mldsa_public,
354        rsa,
355    })
356}
357
358fn parse_halves(mut bytes: &[u8]) -> Result<Vec<StoredHalf<'_>>, KeyFileError> {
359    let mut halves = Vec::new();
360    while let Some((&tag, rest)) = bytes.split_first() {
361        if tag != TAG_MLDSA_SEED && tag != TAG_RSA_PSS {
362            return Err(KeyFileError::BadKeyFile);
363        }
364        let (public, rest) = length_prefixed(rest)?;
365        let (private, rest) = length_prefixed(rest)?;
366        halves.push(StoredHalf {
367            tag,
368            public,
369            private,
370        });
371        bytes = rest;
372    }
373    Ok(halves)
374}
375
376fn length_prefixed(bytes: &[u8]) -> Result<(&[u8], &[u8]), KeyFileError> {
377    let (len, rest) = bytes
378        .split_first_chunk::<4>()
379        .ok_or(KeyFileError::BadKeyFile)?;
380    let len = u32::from_be_bytes(*len) as usize;
381    if len > rest.len() {
382        return Err(KeyFileError::BadKeyFile);
383    }
384    Ok(rest.split_at(len))
385}
386
387/// The stored public key checked against the one derived: equal in a key
388/// file, absent in a key store.
389fn stored_public_fits(stored: &[u8], derived: &[u8], form: Form) -> Result<(), KeyFileError> {
390    let fits = match form {
391        Form::File => stored == derived,
392        Form::Store => stored.is_empty(),
393    };
394    match fits {
395        true => Ok(()),
396        false => Err(KeyFileError::PublicKeyMismatch),
397    }
398}
399
400fn mldsa_from_half(
401    half: &StoredHalf<'_>,
402    form: Form,
403) -> Result<(Zeroizing<[u8; 32]>, Vec<u8>), KeyFileError> {
404    let seed: [u8; 32] = half
405        .private
406        .try_into()
407        .map_err(|_| KeyFileError::PrivateKeyInvalid)?;
408    let seed = Zeroizing::new(seed);
409    let derived = macula_mldsa::public_key(ML_DSA_87, PrivateKey::Seed(&seed))
410        .map_err(|_| KeyFileError::PrivateKeyInvalid)?;
411    stored_public_fits(half.public, &derived, form)?;
412    Ok((seed, derived))
413}
414
415fn rsa_from_half(half: &StoredHalf<'_>, form: Form) -> Result<RsaHalf, KeyFileError> {
416    let pair = RsaKeyPair::from_der(half.private).map_err(|_| KeyFileError::PrivateKeyInvalid)?;
417    let derived = pair.public_key().as_ref().to_vec();
418    stored_public_fits(half.public, &derived, form)?;
419    if !der::rsa_public_key_is_4096_f4(&derived) {
420        return Err(KeyFileError::WrongKeySize);
421    }
422    Ok(RsaHalf {
423        pair,
424        public_der: derived,
425    })
426}
427
428/// Signs a random message with the whole key and verifies it. A hybrid key
429/// signs its composite, never one half on its own.
430pub(super) fn round_trip(key: &NodeKey) -> Result<(), KeyFileError> {
431    let mut message = [0u8; 32];
432    aws_lc_rs::rand::fill(&mut message).map_err(|_| KeyFileError::RoundTripFailed)?;
433    let signature = key
434        .sign(&message)
435        .map_err(|_| KeyFileError::RoundTripFailed)?;
436    if verify(&message, &signature, &key.public_key(), key.profile) {
437        Ok(())
438    } else {
439        Err(KeyFileError::RoundTripFailed)
440    }
441}
442
443#[cfg(test)]
444mod tests {
445    //! What a key store keeps (macula-rust#19): the private part only, small
446    //! enough for Windows Credential Manager's 2,560-byte secret in either
447    //! profile, and a key kept by 0.7.0 in the key-file form refused with its
448    //! cause and fix.
449
450    use std::sync::Mutex;
451
452    use super::*;
453
454    /// Credential Manager's limit on one secret (CRED_MAX_CREDENTIAL_BLOB_SIZE).
455    const CREDENTIAL_MANAGER_MAX: usize = 2560;
456
457    /// The longest PKCS #1 RSAPrivateKey of a 4,096-bit key with exponent
458    /// 65537, in DER: the SEQUENCE header (4) around version (3), modulus
459    /// (4 + 513), publicExponent (2 + 3), privateExponent (4 + 513), and
460    /// prime1, prime2, exponent1, exponent2 and coefficient, each at most
461    /// 2,048 bits (4 + 257 with a sign byte).
462    const MAX_RSA4096_PKCS1: usize = 4 + 3 + (4 + 513) + (2 + 3) + (4 + 513) + 5 * (4 + 257);
463
464    /// A key store in memory.
465    #[derive(Default)]
466    struct Memory(Mutex<Option<Vec<u8>>>);
467
468    impl KeyStore for Memory {
469        fn save_key(&self, key: &[u8]) -> Result<(), KeyStoreError> {
470            *self.0.lock().unwrap() = Some(key.to_vec());
471            Ok(())
472        }
473
474        fn load_key(&self) -> Result<Zeroizing<Vec<u8>>, KeyStoreError> {
475            let held = self.0.lock().unwrap().clone();
476            held.map(Zeroizing::new).ok_or(KeyStoreError::NotFound)
477        }
478
479        fn delete_key(&self) -> Result<(), KeyStoreError> {
480            *self.0.lock().unwrap() = None;
481            Ok(())
482        }
483    }
484
485    fn kept(store: &Memory) -> usize {
486        store.0.lock().unwrap().as_ref().map_or(0, Vec::len)
487    }
488
489    /// The bytes a store keeps of a new `profile` key, its PKCS #1 RSA key
490    /// counted at its longest, and the key loaded back from the store.
491    fn kept_at_worst(profile: Profile) -> usize {
492        let key = NodeKey::generate_identity(profile, 0).unwrap();
493        let store = Memory::default();
494        key.save_to_keystore(&store).unwrap();
495        let loaded = NodeKey::load_from_keystore(&store, Purpose::Identity, profile).unwrap();
496        assert_eq!(loaded.public_key(), key.public_key(), "{profile:?}");
497        let Some(rsa) = &key.rsa else {
498            return kept(&store);
499        };
500        let pkcs1 = rsa_private_pkcs1(&rsa.pair).unwrap().len();
501        assert!(pkcs1 <= MAX_RSA4096_PKCS1, "{pkcs1}");
502        kept(&store) - pkcs1 + MAX_RSA4096_PKCS1
503    }
504
505    #[test]
506    fn a_kept_key_fits_credential_manager_at_the_worst_rsa_size() {
507        for profile in [Profile::PqPure, Profile::PqHybrid] {
508            let worst = kept_at_worst(profile);
509            assert!(
510                worst <= CREDENTIAL_MANAGER_MAX,
511                "{profile:?}: {worst} bytes"
512            );
513        }
514    }
515
516    #[test]
517    fn a_key_kept_in_the_0_7_0_form_is_refused_naming_the_fix() {
518        let key = NodeKey::generate_identity(Profile::PqPure, 0).unwrap();
519        let store = Memory::default();
520        store.save_key(&key.file_bytes().unwrap()).unwrap();
521        let Err(e) = NodeKey::load_from_keystore(&store, Purpose::Identity, Profile::PqPure) else {
522            panic!("the 0.7.0 form is refused");
523        };
524        assert!(matches!(e, KeyFileError::KeptInTheKeyFileForm), "{e:?}");
525        assert!(e.to_string().contains("create the identity again"), "{e}");
526    }
527}