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