Skip to main content

sui_cache/
signing.rs

1//! Ed25519 signing for narinfo metadata.
2//!
3//! Each cache has a key pair identified by a key name. The `CacheSigner`
4//! signs the narinfo fingerprint (the canonical string that Nix signs)
5//! and produces a `keyname:base64sig` string that goes into the `Sig:` field.
6
7use ed25519_dalek::{Signer, SigningKey, VerifyingKey};
8use sui_compat::hash::base64_encode;
9use sui_compat::narinfo::NarInfo;
10use sui_compat::signature::compute_fingerprint;
11
12/// Signs narinfo metadata with an ed25519 key pair.
13#[derive(Debug)]
14pub struct CacheSigner {
15    /// Human-readable key name (e.g. `my-cache-1`).
16    key_name: String,
17    /// The ed25519 signing (secret) key.
18    secret_key: SigningKey,
19}
20
21impl CacheSigner {
22    /// Create a signer from a key name and raw 32-byte secret key.
23    #[must_use]
24    pub fn new(key_name: String, secret_key: SigningKey) -> Self {
25        Self {
26            key_name,
27            secret_key,
28        }
29    }
30
31    /// Generate a new random signing key pair.
32    #[must_use]
33    pub fn generate(key_name: String) -> Self {
34        let secret_key = SigningKey::generate(&mut rand_core::OsRng);
35        Self {
36            key_name,
37            secret_key,
38        }
39    }
40
41    /// Return the key name.
42    #[must_use]
43    pub fn key_name(&self) -> &str {
44        &self.key_name
45    }
46
47    /// Return the public (verifying) key.
48    #[must_use]
49    pub fn public_key(&self) -> VerifyingKey {
50        self.secret_key.verifying_key()
51    }
52
53    /// Format the public key as `keyname:base64pubkey` for distribution.
54    #[must_use]
55    pub fn public_key_string(&self) -> String {
56        format!(
57            "{}:{}",
58            self.key_name,
59            base64_encode(self.public_key().as_bytes())
60        )
61    }
62
63    /// Format the secret key as `keyname:base64(secret||public)` (Nix format).
64    ///
65    /// Nix stores signing keys as 64 bytes: the 32-byte secret seed
66    /// concatenated with the 32-byte public key, then base64-encoded.
67    #[must_use]
68    pub fn secret_key_string(&self) -> String {
69        let mut combined = Vec::with_capacity(64);
70        combined.extend_from_slice(self.secret_key.as_bytes());
71        combined.extend_from_slice(self.public_key().as_bytes());
72        format!("{}:{}", self.key_name, base64_encode(&combined))
73    }
74
75    /// Parse a secret key from the `keyname:base64(secret||public)` format.
76    ///
77    /// # Errors
78    ///
79    /// Returns an error if the format is invalid or the base64 decoding fails.
80    pub fn from_secret_key_string(s: &str) -> Result<Self, crate::CacheError> {
81        let (key_name, b64) = s
82            .split_once(':')
83            .ok_or_else(|| crate::CacheError::Signing("missing colon in key string".to_string()))?;
84
85        let decoded = sui_compat::hash::base64_decode(b64)
86            .map_err(|_| crate::CacheError::Signing("invalid base64 in key".to_string()))?;
87
88        if decoded.len() != 64 {
89            return Err(crate::CacheError::Signing(format!(
90                "expected 64 bytes, got {}",
91                decoded.len()
92            )));
93        }
94
95        let secret_bytes: [u8; 32] = decoded[..32]
96            .try_into()
97            .map_err(|_| crate::CacheError::Signing("secret key slice error".to_string()))?;
98
99        let secret_key = SigningKey::from_bytes(&secret_bytes);
100
101        Ok(Self {
102            key_name: key_name.to_string(),
103            secret_key,
104        })
105    }
106
107    /// Sign a narinfo and return the signature string (`keyname:base64sig`).
108    #[must_use]
109    pub fn sign_narinfo(&self, info: &NarInfo) -> String {
110        let fingerprint = compute_fingerprint(
111            &info.store_path,
112            &info.nar_hash,
113            info.nar_size,
114            &info.references,
115        );
116        let sig = self.secret_key.sign(fingerprint.as_bytes());
117        format!("{}:{}", self.key_name, base64_encode(&sig.to_bytes()))
118    }
119}
120
121/// Verify that a narinfo signature is valid against a public key string.
122///
123/// The public key string is in `keyname:base64pubkey` format.
124pub fn verify_narinfo_signature(
125    info: &NarInfo,
126    signature: &str,
127    public_key_str: &str,
128) -> Result<bool, crate::CacheError> {
129    use ed25519_dalek::Verifier;
130    use sui_compat::signature::StorePathSignature;
131
132    let parsed_sig = StorePathSignature::parse(signature)
133        .map_err(|e| crate::CacheError::Signing(format!("bad signature: {e}")))?;
134
135    let (pk_name, pk_b64) = public_key_str
136        .split_once(':')
137        .ok_or_else(|| crate::CacheError::Signing("bad public key format".to_string()))?;
138
139    // Key names must match.
140    if parsed_sig.key_name != pk_name {
141        return Ok(false);
142    }
143
144    let pk_bytes = sui_compat::hash::base64_decode(pk_b64)
145        .map_err(|_| crate::CacheError::Signing("bad base64 in public key".to_string()))?;
146
147    if pk_bytes.len() != 32 {
148        return Err(crate::CacheError::Signing(format!(
149            "public key: expected 32 bytes, got {}",
150            pk_bytes.len()
151        )));
152    }
153
154    let pk_array: [u8; 32] = pk_bytes
155        .try_into()
156        .map_err(|_| crate::CacheError::Signing("public key conversion error".to_string()))?;
157
158    let verifying_key = VerifyingKey::from_bytes(&pk_array)
159        .map_err(|_| crate::CacheError::Signing("invalid public key".to_string()))?;
160
161    let fingerprint = compute_fingerprint(
162        &info.store_path,
163        &info.nar_hash,
164        info.nar_size,
165        &info.references,
166    );
167
168    let sig_array: [u8; 64] = parsed_sig
169        .signature
170        .try_into()
171        .map_err(|_| crate::CacheError::Signing("signature must be 64 bytes".to_string()))?;
172
173    let ed_sig = ed25519_dalek::Signature::from_bytes(&sig_array);
174
175    Ok(verifying_key
176        .verify(fingerprint.as_bytes(), &ed_sig)
177        .is_ok())
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    fn make_test_narinfo() -> NarInfo {
185        NarInfo {
186            store_path: "/nix/store/abc-hello-1.0".to_string(),
187            url: "nar/abc.nar.xz".to_string(),
188            compression: "xz".to_string(),
189            file_hash: "sha256:1111".to_string(),
190            file_size: 1000,
191            nar_hash: "sha256:2222".to_string(),
192            nar_size: 5000,
193            references: vec!["dep-1".to_string()],
194            deriver: Some("abc.drv".to_string()),
195            signatures: vec![],
196            ca: None,
197        }
198    }
199
200    #[test]
201    fn generate_and_sign() {
202        let signer = CacheSigner::generate("test-cache-1".to_string());
203        let info = make_test_narinfo();
204        let sig = signer.sign_narinfo(&info);
205        assert!(sig.starts_with("test-cache-1:"));
206        assert!(sig.len() > "test-cache-1:".len());
207    }
208
209    #[test]
210    fn sign_and_verify() {
211        let signer = CacheSigner::generate("test-cache-1".to_string());
212        let info = make_test_narinfo();
213        let sig = signer.sign_narinfo(&info);
214        let pk_str = signer.public_key_string();
215        let valid = verify_narinfo_signature(&info, &sig, &pk_str).unwrap();
216        assert!(valid);
217    }
218
219    #[test]
220    fn verify_wrong_key_fails() {
221        let signer = CacheSigner::generate("cache-a".to_string());
222        let other = CacheSigner::generate("cache-b".to_string());
223        let info = make_test_narinfo();
224        let sig = signer.sign_narinfo(&info);
225        // Verify with the wrong key name (different key).
226        let pk_str = other.public_key_string();
227        let valid = verify_narinfo_signature(&info, &sig, &pk_str).unwrap();
228        assert!(!valid);
229    }
230
231    #[test]
232    fn verify_tampered_narinfo_fails() {
233        let signer = CacheSigner::generate("cache-1".to_string());
234        let info = make_test_narinfo();
235        let sig = signer.sign_narinfo(&info);
236        let pk_str = signer.public_key_string();
237
238        // Tamper with the narinfo.
239        let mut tampered = info.clone();
240        tampered.nar_size = 9999;
241
242        let valid = verify_narinfo_signature(&tampered, &sig, &pk_str).unwrap();
243        assert!(!valid);
244    }
245
246    #[test]
247    fn public_key_string_format() {
248        let signer = CacheSigner::generate("my-cache-1".to_string());
249        let pk_str = signer.public_key_string();
250        assert!(pk_str.starts_with("my-cache-1:"));
251        // Base64 of 32 bytes = 44 chars.
252        let b64_part = pk_str.strip_prefix("my-cache-1:").unwrap();
253        assert_eq!(b64_part.len(), 44);
254    }
255
256    #[test]
257    fn secret_key_string_roundtrip() {
258        let signer = CacheSigner::generate("roundtrip-key".to_string());
259        let sk_str = signer.secret_key_string();
260        let restored = CacheSigner::from_secret_key_string(&sk_str).unwrap();
261        assert_eq!(restored.key_name(), "roundtrip-key");
262        // Sign the same narinfo and verify they produce the same signature.
263        let info = make_test_narinfo();
264        assert_eq!(signer.sign_narinfo(&info), restored.sign_narinfo(&info));
265    }
266
267    #[test]
268    fn from_secret_key_string_bad_format() {
269        let result = CacheSigner::from_secret_key_string("no-colon-here");
270        assert!(result.is_err());
271    }
272
273    #[test]
274    fn from_secret_key_string_bad_base64() {
275        let result = CacheSigner::from_secret_key_string("key:!!!bad!!!");
276        assert!(result.is_err());
277    }
278
279    #[test]
280    fn from_secret_key_string_wrong_length() {
281        let result = CacheSigner::from_secret_key_string("key:AAAA");
282        assert!(result.is_err());
283    }
284
285    #[test]
286    fn key_name_accessor() {
287        let signer = CacheSigner::generate("my-name".to_string());
288        assert_eq!(signer.key_name(), "my-name");
289    }
290
291    #[test]
292    fn sign_narinfo_with_no_references() {
293        let signer = CacheSigner::generate("k".to_string());
294        let mut info = make_test_narinfo();
295        info.references.clear();
296        let sig = signer.sign_narinfo(&info);
297        let pk = signer.public_key_string();
298        assert!(verify_narinfo_signature(&info, &sig, &pk).unwrap());
299    }
300
301    #[test]
302    fn sign_narinfo_with_many_references() {
303        let signer = CacheSigner::generate("k".to_string());
304        let mut info = make_test_narinfo();
305        info.references = (0..20).map(|i| format!("ref-{i:03}")).collect();
306        let sig = signer.sign_narinfo(&info);
307        let pk = signer.public_key_string();
308        assert!(verify_narinfo_signature(&info, &sig, &pk).unwrap());
309    }
310
311    #[test]
312    fn verify_bad_signature_string() {
313        let info = make_test_narinfo();
314        let result = verify_narinfo_signature(&info, "no-colon", "key:AAAA");
315        assert!(result.is_err());
316    }
317
318    #[test]
319    fn verify_bad_public_key_string() {
320        let signer = CacheSigner::generate("k".to_string());
321        let info = make_test_narinfo();
322        let sig = signer.sign_narinfo(&info);
323        let result = verify_narinfo_signature(&info, &sig, "no-colon-pk");
324        assert!(result.is_err());
325    }
326
327    #[test]
328    fn deterministic_signatures() {
329        let sk = SigningKey::from_bytes(&[42u8; 32]);
330        let signer = CacheSigner::new("det".to_string(), sk);
331        let info = make_test_narinfo();
332        let sig1 = signer.sign_narinfo(&info);
333        let sig2 = signer.sign_narinfo(&info);
334        assert_eq!(sig1, sig2);
335    }
336
337    #[test]
338    fn new_from_known_key() {
339        let sk = SigningKey::from_bytes(&[1u8; 32]);
340        let signer = CacheSigner::new("known-key".to_string(), sk);
341        assert_eq!(signer.key_name(), "known-key");
342        let info = make_test_narinfo();
343        let sig = signer.sign_narinfo(&info);
344        assert!(sig.starts_with("known-key:"));
345    }
346}