1use ed25519_dalek::{Signer, SigningKey, VerifyingKey};
8use sui_compat::hash::base64_encode;
9use sui_compat::narinfo::NarInfo;
10use sui_compat::signature::compute_fingerprint;
11
12#[derive(Debug)]
14pub struct CacheSigner {
15 key_name: String,
17 secret_key: SigningKey,
19}
20
21impl CacheSigner {
22 #[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 #[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 #[must_use]
43 pub fn key_name(&self) -> &str {
44 &self.key_name
45 }
46
47 #[must_use]
49 pub fn public_key(&self) -> VerifyingKey {
50 self.secret_key.verifying_key()
51 }
52
53 #[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 #[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 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 #[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
121pub 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 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 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 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 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 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}