Skip to main content

lonca_core/
keys.rs

1//! Ed25519 keys for the daemon and for (development) human signers.
2
3use std::fs;
4use std::io::Write;
5use std::path::Path;
6use std::sync::Arc;
7
8use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
9use zeroize::Zeroizing;
10
11use crate::canonical::random_bytes;
12use crate::error::{Error, Result};
13
14/// An Ed25519 signing key. Clones share one secret, which is wiped from
15/// memory when the last of them is dropped (`SigningKey` zeroizes itself).
16#[derive(Clone)]
17pub struct KeyPair {
18    sk: Arc<SigningKey>,
19}
20
21/// Group or other may do anything with the file.
22const OTHERS_ANY: u32 = 0o077;
23
24/// Refuses a key file that is not a regular file owned by `uid` and closed
25/// to group and others. A key anyone else can read is a key anyone else may
26/// have signed with; one owned by someone else was put there by them.
27#[cfg(unix)]
28pub fn check_private(path: &Path, uid: u32) -> Result<()> {
29    use std::os::unix::fs::MetadataExt;
30    let meta = fs::symlink_metadata(path)?;
31    let shown = path.display();
32    if !meta.file_type().is_file() {
33        return Err(Error::Invalid(format!(
34            "key file {shown} is not a regular file (a link is not followed)"
35        )));
36    }
37    if meta.uid() != uid {
38        return Err(Error::Invalid(format!(
39            "key file {shown} belongs to uid {}, not to this process (uid {uid}); \
40             a key must be created by the account that uses it",
41            meta.uid()
42        )));
43    }
44    let mode = meta.mode() & 0o777;
45    if mode & OTHERS_ANY != 0 {
46        return Err(Error::Invalid(format!(
47            "key file {shown} has mode {mode:o}, so others may read it: run `chmod 600 {shown}` \
48             if nobody else could have, or make a new key"
49        )));
50    }
51    Ok(())
52}
53
54#[cfg(not(unix))]
55pub fn check_private(_: &Path, _: u32) -> Result<()> {
56    Ok(())
57}
58
59impl KeyPair {
60    pub fn generate() -> Self {
61        Self::from_secret(Zeroizing::new(random_bytes::<32>()))
62    }
63
64    pub fn from_secret(bytes: Zeroizing<[u8; 32]>) -> Self {
65        Self {
66            sk: Arc::new(SigningKey::from_bytes(&bytes)),
67        }
68    }
69
70    /// Loads the key at `path`, creating it (mode 0600) if it does not
71    /// exist. An existing file is held to [`check_private`].
72    pub fn load_or_create(path: &Path, uid: u32) -> Result<Self> {
73        if path.exists() {
74            return Self::load(path, uid);
75        }
76        let key = Self::generate();
77        if let Some(dir) = path.parent() {
78            fs::create_dir_all(dir)?;
79        }
80        let mut opts = fs::OpenOptions::new();
81        opts.write(true).create_new(true);
82        #[cfg(unix)]
83        {
84            use std::os::unix::fs::OpenOptionsExt;
85            opts.mode(0o600);
86        }
87        let mut f = opts.open(path)?;
88        let text = Zeroizing::new(hex::encode(key.sk.to_bytes()));
89        f.write_all(text.as_bytes())?;
90        Ok(key)
91    }
92
93    /// Loads the key at `path`, which must be private to `uid`
94    /// ([`check_private`]).
95    pub fn load(path: &Path, uid: u32) -> Result<Self> {
96        check_private(path, uid)?;
97        Self::read(path)
98    }
99
100    /// Loads a key from wherever it was kept, a USB stick included, whose
101    /// file system may show every file as readable by all: only the owner
102    /// is checked. Says whether others may read the file, for a warning.
103    #[cfg(unix)]
104    pub fn load_carried(path: &Path, uid: u32) -> Result<(Self, bool)> {
105        use std::os::unix::fs::MetadataExt;
106        let open = match check_private(path, uid) {
107            Ok(()) => false,
108            Err(e) => {
109                let meta = fs::symlink_metadata(path)?;
110                if !meta.file_type().is_file() || meta.uid() != uid {
111                    return Err(e);
112                }
113                true
114            }
115        };
116        Ok((Self::read(path)?, open))
117    }
118
119    /// The secret, read so that no copy of it outlives this call.
120    fn read(path: &Path) -> Result<Self> {
121        let text = Zeroizing::new(fs::read_to_string(path)?);
122        let bytes = Zeroizing::new(
123            hex::decode(text.trim()).map_err(|e| Error::Invalid(format!("key file: {e}")))?,
124        );
125        let mut arr = Zeroizing::new([0u8; 32]);
126        if bytes.len() != arr.len() {
127            return Err(Error::Invalid("key file: expected 32 bytes".into()));
128        }
129        arr.copy_from_slice(&bytes);
130        Ok(Self::from_secret(arr))
131    }
132
133    pub fn public_hex(&self) -> String {
134        hex::encode(self.sk.verifying_key().to_bytes())
135    }
136
137    pub fn sign_hex(&self, msg: &[u8]) -> String {
138        hex::encode(self.sk.sign(msg).to_bytes())
139    }
140}
141
142pub fn verify_hex(public_hex: &str, msg: &[u8], sig_hex: &str) -> bool {
143    let Ok(pk) = hex::decode(public_hex) else {
144        return false;
145    };
146    let Ok(pk): Result<[u8; 32], _> = pk.try_into() else {
147        return false;
148    };
149    let Ok(vk) = VerifyingKey::from_bytes(&pk) else {
150        return false;
151    };
152    let Ok(sig) = hex::decode(sig_hex) else {
153        return false;
154    };
155    let Ok(sig): Result<[u8; 64], _> = sig.try_into() else {
156        return false;
157    };
158    vk.verify_strict(msg, &Signature::from_bytes(&sig)).is_ok()
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn sign_and_verify() {
167        let k = KeyPair::generate();
168        let sig = k.sign_hex(b"hello");
169        assert!(verify_hex(&k.public_hex(), b"hello", &sig));
170        assert!(!verify_hex(&k.public_hex(), b"hellO", &sig));
171        assert!(!verify_hex(
172            &KeyPair::generate().public_hex(),
173            b"hello",
174            &sig
175        ));
176    }
177
178    #[test]
179    fn key_file_is_private_and_reloads() {
180        let dir = tempfile::tempdir().unwrap();
181        let p = dir.path().join("k/daemon.key");
182        let uid = owner(dir.path());
183        let a = KeyPair::load_or_create(&p, uid).unwrap();
184        let b = KeyPair::load_or_create(&p, uid).unwrap();
185        assert_eq!(a.public_hex(), b.public_hex());
186        #[cfg(unix)]
187        {
188            use std::os::unix::fs::PermissionsExt;
189            assert_eq!(
190                fs::metadata(&p).unwrap().permissions().mode() & 0o777,
191                0o600
192            );
193        }
194    }
195
196    #[cfg(unix)]
197    fn owner(p: &Path) -> u32 {
198        use std::os::unix::fs::MetadataExt;
199        fs::metadata(p).unwrap().uid()
200    }
201
202    /// Mode 0644, a link, another owner: each is refused, and the error
203    /// says what to do. A carried key is held to its owner only.
204    #[cfg(unix)]
205    #[test]
206    fn a_key_file_must_be_private_to_its_user() {
207        use std::os::unix::fs::PermissionsExt;
208        let dir = tempfile::tempdir().unwrap();
209        let uid = owner(dir.path());
210        let p = dir.path().join("a.key");
211        let key = KeyPair::load_or_create(&p, uid).unwrap();
212        assert!(KeyPair::load(&p, uid).is_ok());
213
214        fs::set_permissions(&p, fs::Permissions::from_mode(0o644)).unwrap();
215        let e = KeyPair::load(&p, uid).err().unwrap().to_string();
216        assert!(e.contains("mode 644") && e.contains("chmod 600"), "{e}");
217        assert!(KeyPair::load_or_create(&p, uid).is_err());
218        let (carried, open) = KeyPair::load_carried(&p, uid).unwrap();
219        assert!(open);
220        assert_eq!(carried.public_hex(), key.public_hex());
221        fs::set_permissions(&p, fs::Permissions::from_mode(0o640)).unwrap();
222        assert!(KeyPair::load(&p, uid).is_err(), "group may read");
223        fs::set_permissions(&p, fs::Permissions::from_mode(0o602)).unwrap();
224        assert!(KeyPair::load(&p, uid).is_err(), "others may write");
225        fs::set_permissions(&p, fs::Permissions::from_mode(0o400)).unwrap();
226        assert!(KeyPair::load(&p, uid).is_ok(), "read-only to its owner");
227        let (_, open) = KeyPair::load_carried(&p, uid).unwrap();
228        assert!(!open);
229
230        let e = KeyPair::load(&p, uid + 1).err().unwrap().to_string();
231        assert!(e.contains(&format!("belongs to uid {uid}")), "{e}");
232        assert!(KeyPair::load_carried(&p, uid + 1).is_err());
233
234        let link = dir.path().join("link.key");
235        std::os::unix::fs::symlink(&p, &link).unwrap();
236        let e = KeyPair::load(&link, uid).err().unwrap().to_string();
237        assert!(e.contains("not a regular file"), "{e}");
238        assert!(KeyPair::load_carried(&link, uid).is_err());
239    }
240
241    #[test]
242    fn clones_share_one_secret() {
243        let a = KeyPair::generate();
244        let b = a.clone();
245        assert!(Arc::ptr_eq(&a.sk, &b.sk));
246        assert_eq!(a.sign_hex(b"x"), b.sign_hex(b"x"));
247    }
248
249    #[test]
250    fn a_key_file_holds_32_bytes_of_hex() {
251        let dir = tempfile::tempdir().unwrap();
252        let p = dir.path().join("short.key");
253        fs::write(&p, "abcd").unwrap();
254        assert!(
255            KeyPair::read(&p)
256                .err()
257                .unwrap()
258                .to_string()
259                .contains("32 bytes")
260        );
261        fs::write(&p, "zz").unwrap();
262        assert!(KeyPair::read(&p).is_err());
263        fs::write(&p, format!("{}\n", "11".repeat(32))).unwrap();
264        assert!(KeyPair::read(&p).is_ok());
265    }
266}