Skip to main content

lean_ctx/core/context_package/
keys.rs

1//! Publisher signing-key management for ctxpkg (GL #406).
2//!
3//! One ed25519 keypair per machine user, stored as a 32-byte hex seed under the
4//! XDG data dir at `<data_dir>/keys/ctxpkg-ed25519.key` (mode 0600 on unix).
5//! Created lazily on first `pack export --sign`. The public key identifies the
6//! publisher across releases — registries and clients surface it per version.
7
8use std::path::PathBuf;
9
10use ed25519_dalek::SigningKey;
11
12pub const KEY_REL_PATH: &str = "keys/ctxpkg-ed25519.key";
13
14/// Resolves through the XDG data dir (not a hardcoded `~/.lean-ctx`) so the key
15/// follows the migrated layout and never re-creates the legacy dir (GH #436).
16pub fn key_path() -> Result<PathBuf, String> {
17    Ok(crate::core::paths::data_dir()?.join(KEY_REL_PATH))
18}
19
20/// Load the signing key, creating it on first use. Returns the key and
21/// whether it was newly generated (so the CLI can tell the user once).
22pub fn load_or_create() -> Result<(SigningKey, bool), String> {
23    let path = key_path()?;
24    if path.exists() {
25        let hex_seed =
26            std::fs::read_to_string(&path).map_err(|e| format!("read signing key: {e}"))?;
27        let seed = parse_seed(hex_seed.trim())?;
28        return Ok((SigningKey::from_bytes(&seed), false));
29    }
30
31    let dir = path.parent().expect("key path has a parent");
32    std::fs::create_dir_all(dir).map_err(|e| format!("create key dir: {e}"))?;
33
34    let mut seed = [0u8; 32];
35    getrandom::fill(&mut seed).map_err(|e| format!("entropy source failed: {e}"))?;
36    let encoded = hex_encode(&seed);
37
38    std::fs::write(&path, &encoded).map_err(|e| format!("write signing key: {e}"))?;
39    #[cfg(unix)]
40    {
41        use std::os::unix::fs::PermissionsExt;
42        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
43            .map_err(|e| format!("chmod signing key: {e}"))?;
44    }
45    Ok((SigningKey::from_bytes(&seed), true))
46}
47
48/// Hex of the public verifying key — the publisher's stable identity.
49pub fn public_key_hex(key: &SigningKey) -> String {
50    hex_encode(key.verifying_key().as_bytes())
51}
52
53fn parse_seed(s: &str) -> Result<[u8; 32], String> {
54    if s.len() != 64 || !s.chars().all(|c| c.is_ascii_hexdigit()) {
55        return Err(format!(
56            "signing key file is corrupt (expected 64 hex chars, got {} chars) — \
57             delete it to regenerate (this changes your publisher identity!)",
58            s.len()
59        ));
60    }
61    let mut seed = [0u8; 32];
62    for (i, byte) in seed.iter_mut().enumerate() {
63        *byte = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16)
64            .map_err(|e| format!("signing key hex: {e}"))?;
65    }
66    Ok(seed)
67}
68
69fn hex_encode(bytes: &[u8]) -> String {
70    use std::fmt::Write;
71    let mut s = String::with_capacity(bytes.len() * 2);
72    for b in bytes {
73        let _ = write!(s, "{b:02x}");
74    }
75    s
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    #[test]
83    fn seed_roundtrip() {
84        let seed = [42u8; 32];
85        let encoded = hex_encode(&seed);
86        assert_eq!(parse_seed(&encoded).expect("parses"), seed);
87    }
88
89    #[test]
90    fn corrupt_seed_rejected() {
91        assert!(parse_seed("zz").is_err());
92        assert!(parse_seed(&"a".repeat(63)).is_err());
93    }
94
95    #[test]
96    fn public_key_is_stable_for_seed() {
97        let k1 = SigningKey::from_bytes(&[7u8; 32]);
98        let k2 = SigningKey::from_bytes(&[7u8; 32]);
99        assert_eq!(public_key_hex(&k1), public_key_hex(&k2));
100        assert_eq!(public_key_hex(&k1).len(), 64);
101    }
102}