Skip to main content

punkgo_kernel/
signing.rs

1//! Ed25519 signing for checkpoint authentication.
2//!
3//! On first boot, a keypair is generated and stored at `{state_dir}/signing_key`.
4//! Every checkpoint is signed. The public key is embedded in the checkpoint
5//! extension line for third-party verification.
6//!
7//! Format: `sig/ed25519:<pubkey_hex>:<signature_hex>\n`
8//!
9//! This module does NOT provide PKI, key rotation, or witness cosigning.
10//! Those are Phase 2+ concerns. What it provides:
11//! - Identity binding (this checkpoint came from this kernel instance)
12//! - Format readiness (checkpoint structure is ready for TSA/witness upgrades)
13
14use std::path::Path;
15
16use anyhow::{Context, Result};
17use ed25519_dalek::{Signature, Signer, SigningKey as DalekSigningKey, Verifier, VerifyingKey};
18
19/// Wrapper around Ed25519 keypair for checkpoint signing.
20#[derive(Clone)]
21pub struct SigningKey {
22    inner: DalekSigningKey,
23}
24
25impl SigningKey {
26    /// Generate a new random keypair and save the 32-byte seed to disk.
27    pub fn generate_and_save(path: &Path) -> Result<Self> {
28        let mut rng = rand::thread_rng();
29        let inner = DalekSigningKey::generate(&mut rng);
30        std::fs::write(path, inner.to_bytes())
31            .with_context(|| format!("failed to write signing key to {}", path.display()))?;
32        Ok(Self { inner })
33    }
34
35    /// Load an existing keypair from the 32-byte seed on disk.
36    pub fn load(path: &Path) -> Result<Self> {
37        let bytes = std::fs::read(path)
38            .with_context(|| format!("failed to read signing key from {}", path.display()))?;
39        let bytes: [u8; 32] = bytes
40            .try_into()
41            .map_err(|_| anyhow::anyhow!("signing key must be exactly 32 bytes"))?;
42        let inner = DalekSigningKey::from_bytes(&bytes);
43        Ok(Self { inner })
44    }
45
46    /// Load from disk if exists, otherwise generate and save.
47    pub fn load_or_generate(path: &Path) -> Result<Self> {
48        if path.exists() {
49            Self::load(path)
50        } else {
51            if let Some(parent) = path.parent() {
52                std::fs::create_dir_all(parent)?;
53            }
54            Self::generate_and_save(path)
55        }
56    }
57
58    /// Sign a message. Returns the 64-byte signature.
59    pub fn sign(&self, msg: &[u8]) -> [u8; 64] {
60        self.inner.sign(msg).to_bytes()
61    }
62
63    /// Verify a signature against this key's public key.
64    pub fn verify(&self, msg: &[u8], sig_bytes: &[u8; 64]) -> bool {
65        let Ok(sig) = Signature::from_slice(sig_bytes) else {
66            return false;
67        };
68        self.inner.verifying_key().verify(msg, &sig).is_ok()
69    }
70
71    /// Public key as 32-byte array.
72    pub fn public_key_bytes(&self) -> [u8; 32] {
73        self.inner.verifying_key().to_bytes()
74    }
75
76    /// Public key as hex string (64 chars).
77    pub fn public_key_hex(&self) -> String {
78        hex::encode(self.public_key_bytes())
79    }
80
81    /// Build the checkpoint extension line: `sig/ed25519:<pubkey>:<sig>\n`
82    pub fn sign_checkpoint(&self, checkpoint_body: &[u8]) -> String {
83        let sig = self.sign(checkpoint_body);
84        format!(
85            "sig/ed25519:{}:{}\n",
86            self.public_key_hex(),
87            hex::encode(sig)
88        )
89    }
90}
91
92/// Verify a checkpoint signature given raw components.
93/// Used by jack for offline verification without the private key.
94pub fn verify_checkpoint_signature(pubkey_hex: &str, msg: &[u8], sig_hex: &str) -> bool {
95    let Ok(pk_bytes) = hex::decode(pubkey_hex) else {
96        return false;
97    };
98    let Ok(pk_arr): Result<[u8; 32], _> = pk_bytes.try_into() else {
99        return false;
100    };
101    let Ok(vk) = VerifyingKey::from_bytes(&pk_arr) else {
102        return false;
103    };
104    let Ok(sig_bytes) = hex::decode(sig_hex) else {
105        return false;
106    };
107    let Ok(sig) = Signature::from_slice(&sig_bytes) else {
108        return false;
109    };
110    vk.verify(msg, &sig).is_ok()
111}
112
113/// Parse a `sig/ed25519:<pubkey>:<sig>` extension line.
114/// Returns (pubkey_hex, sig_hex) if valid.
115pub fn parse_sig_extension(line: &str) -> Option<(&str, &str)> {
116    let rest = line.strip_prefix("sig/ed25519:")?;
117    let rest = rest.strip_suffix('\n').unwrap_or(rest);
118    let (pubkey, sig) = rest.split_once(':')?;
119    if pubkey.len() == 64 && sig.len() == 128 {
120        Some((pubkey, sig))
121    } else {
122        None
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use tempfile::tempdir;
130
131    #[test]
132    fn generate_and_load_roundtrip() {
133        let dir = tempdir().unwrap();
134        let path = dir.path().join("signing_key");
135        let sk1 = SigningKey::generate_and_save(&path).unwrap();
136        let sk2 = SigningKey::load(&path).unwrap();
137        assert_eq!(sk1.public_key_bytes(), sk2.public_key_bytes());
138    }
139
140    #[test]
141    fn sign_and_verify() {
142        let dir = tempdir().unwrap();
143        let path = dir.path().join("signing_key");
144        let sk = SigningKey::generate_and_save(&path).unwrap();
145        let msg = b"punkgo/kernel\n42\nhash=\n";
146        let sig = sk.sign(msg);
147        assert!(sk.verify(msg, &sig));
148    }
149
150    #[test]
151    fn wrong_message_fails_verify() {
152        let dir = tempdir().unwrap();
153        let path = dir.path().join("signing_key");
154        let sk = SigningKey::generate_and_save(&path).unwrap();
155        let sig = sk.sign(b"correct");
156        assert!(!sk.verify(b"wrong", &sig));
157    }
158
159    #[test]
160    fn load_or_generate_creates_on_first_call() {
161        let dir = tempdir().unwrap();
162        let path = dir.path().join("signing_key");
163        assert!(!path.exists());
164        let sk = SigningKey::load_or_generate(&path).unwrap();
165        assert!(path.exists());
166        assert_eq!(sk.public_key_hex().len(), 64);
167    }
168
169    #[test]
170    fn sign_checkpoint_produces_valid_extension() {
171        let dir = tempdir().unwrap();
172        let path = dir.path().join("signing_key");
173        let sk = SigningKey::generate_and_save(&path).unwrap();
174
175        let body = b"punkgo/kernel\n100\naBcDeFgH=\n";
176        let ext = sk.sign_checkpoint(body);
177
178        assert!(ext.starts_with("sig/ed25519:"));
179        assert!(ext.ends_with('\n'));
180
181        // Parse and verify
182        let (pubkey, sig) = parse_sig_extension(&ext).unwrap();
183        assert!(verify_checkpoint_signature(pubkey, body, sig));
184    }
185
186    #[test]
187    fn parse_sig_extension_rejects_garbage() {
188        assert!(parse_sig_extension("garbage").is_none());
189        assert!(parse_sig_extension("sig/ed25519:short:short").is_none());
190        assert!(parse_sig_extension("sig/rsa:abc:def").is_none());
191    }
192
193    #[test]
194    fn verify_checkpoint_signature_rejects_wrong_key() {
195        let dir = tempdir().unwrap();
196        let sk1 = SigningKey::generate_and_save(&dir.path().join("k1")).unwrap();
197        let sk2 = SigningKey::generate_and_save(&dir.path().join("k2")).unwrap();
198
199        let msg = b"test message";
200        let sig = sk1.sign(msg);
201        // Verify with sk2's public key should fail
202        assert!(!verify_checkpoint_signature(
203            &sk2.public_key_hex(),
204            msg,
205            &hex::encode(sig)
206        ));
207    }
208}