Skip to main content

sui_compat/
signature.rs

1//! Ed25519 store path signatures.
2//!
3//! Nix uses Ed25519 to sign store paths. Format: `keyname:base64sig`.
4
5use ed25519_dalek::{Signature, Verifier, VerifyingKey};
6use thiserror::Error;
7
8use crate::hash::base64_encode;
9
10/// Trait for verifying store path signatures.
11///
12/// Implementations can use different Ed25519 backends (ed25519-dalek, ring, aws-lc-rs)
13/// or implement custom trust policies (multi-key quorum, key rotation, HSM).
14pub trait SignatureVerifier: Send + Sync {
15    /// Verify a signature against a fingerprint and public key.
16    fn verify(&self, fingerprint: &[u8], signature: &[u8], public_key: &[u8]) -> Result<(), SignatureError>;
17}
18
19/// Default Ed25519 signature verifier using ed25519-dalek.
20pub struct Ed25519Verifier;
21
22impl SignatureVerifier for Ed25519Verifier {
23    fn verify(&self, fingerprint: &[u8], signature: &[u8], public_key: &[u8]) -> Result<(), SignatureError> {
24        let key_bytes: [u8; 32] = public_key.try_into()
25            .map_err(|_| SignatureError::InvalidPublicKey)?;
26        let sig_bytes: [u8; 64] = signature.try_into()
27            .map_err(|_| SignatureError::InvalidFormat("signature must be 64 bytes".to_string()))?;
28
29        let verifying_key = VerifyingKey::from_bytes(&key_bytes)
30            .map_err(|_| SignatureError::InvalidPublicKey)?;
31        let sig = Signature::from_bytes(&sig_bytes);
32
33        verifying_key.verify(fingerprint, &sig)
34            .map_err(|_| SignatureError::VerificationFailed)
35    }
36}
37
38#[derive(Debug, Error)]
39pub enum SignatureError {
40    #[error("invalid signature format: {0}")]
41    InvalidFormat(String),
42    #[error("base64 decode error")]
43    Base64Decode,
44    #[error("invalid public key")]
45    InvalidPublicKey,
46    #[error("verification failed")]
47    VerificationFailed,
48}
49
50/// A parsed store path signature.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct StorePathSignature {
53    /// The key name (e.g., "cache.nixos.org-1").
54    pub key_name: String,
55    /// The raw Ed25519 signature bytes (64 bytes).
56    pub signature: Vec<u8>,
57}
58
59impl StorePathSignature {
60    /// Parse a signature from the `keyname:base64sig` format.
61    pub fn parse(s: &str) -> Result<Self, SignatureError> {
62        let (key_name, b64) = s
63            .split_once(':')
64            .ok_or_else(|| SignatureError::InvalidFormat("missing colon".to_string()))?;
65
66        let signature = b64_decode(b64)
67            .map_err(|_| SignatureError::Base64Decode)?;
68
69        Ok(Self {
70            key_name: key_name.to_string(),
71            signature,
72        })
73    }
74
75    /// Serialize to the `keyname:base64sig` format.
76    #[must_use]
77    pub fn to_string_repr(&self) -> String {
78        self.to_string()
79    }
80
81    /// Verify this signature against a fingerprint and public key.
82    ///
83    /// Uses the default Ed25519 verifier. For custom verification strategies,
84    /// use `verify_with()` with a custom `SignatureVerifier` implementation.
85    pub fn verify(&self, fingerprint: &str, public_key: &[u8; 32]) -> Result<(), SignatureError> {
86        self.verify_with(fingerprint, public_key, &Ed25519Verifier)
87    }
88
89    /// Verify using a custom `SignatureVerifier` implementation.
90    pub fn verify_with(
91        &self,
92        fingerprint: &str,
93        public_key: &[u8],
94        verifier: &dyn SignatureVerifier,
95    ) -> Result<(), SignatureError> {
96        verifier.verify(fingerprint.as_bytes(), &self.signature, public_key)
97    }
98}
99
100impl std::fmt::Display for StorePathSignature {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        write!(f, "{}:{}", self.key_name, base64_encode(&self.signature))
103    }
104}
105
106/// Compute the fingerprint string that Nix signs.
107///
108/// Format: `1;{storePath};{narHash};{narSize};{sortedReferences}`.
109///
110/// The references are sorted into Nix's canonical (lexicographic) order
111/// **inside** this function, so every caller — signer and verifier alike —
112/// fingerprints the same bytes regardless of the order it happens to hold
113/// the references in. This is the single canonical fingerprint: a signer
114/// that passes references in arbitrary order and a verifier that passes
115/// them in a different order compute the identical string, so a valid
116/// signature always verifies. (Nix itself sorts references before signing;
117/// matching that here is what makes sui-signed paths verifiable by any Nix
118/// consumer and vice versa.)
119///
120/// Historically this function did *not* sort, while
121/// `BinaryCacheStore::verify_narinfo_signatures` sorted before calling it —
122/// so a sui-signed path whose references were not already in canonical
123/// order failed its own verifier. Sorting here closes that mismatch at the
124/// one place the order is decided.
125#[must_use]
126pub fn compute_fingerprint(
127    store_path: &str,
128    nar_hash: &str,
129    nar_size: u64,
130    references: &[String],
131) -> String {
132    let mut sorted_refs: Vec<&str> = references.iter().map(String::as_str).collect();
133    sorted_refs.sort_unstable();
134    let refs = sorted_refs.join(",");
135    format!("1;{store_path};{nar_hash};{nar_size};{refs}")
136}
137
138/// Base64 decode using the `base64` crate.
139fn b64_decode(input: &str) -> Result<Vec<u8>, ()> {
140    crate::hash::base64_decode(input).map_err(|_| ())
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use ed25519_dalek::SigningKey;
147
148    #[test]
149    fn parse_signature() {
150        // 64 bytes = 86 base64 chars + "==" padding
151        let sig_str = "cache.nixos.org-1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==";
152        let sig = StorePathSignature::parse(sig_str).unwrap();
153        assert_eq!(sig.key_name, "cache.nixos.org-1");
154        assert_eq!(sig.signature.len(), 64);
155    }
156
157    #[test]
158    fn roundtrip_signature_format() {
159        let sig = StorePathSignature {
160            key_name: "test-key-1".to_string(),
161            signature: vec![0u8; 64],
162        };
163        let s = sig.to_string_repr();
164        let parsed = StorePathSignature::parse(&s).unwrap();
165        assert_eq!(parsed.key_name, sig.key_name);
166        assert_eq!(parsed.signature, sig.signature);
167    }
168
169    #[test]
170    fn sign_and_verify() {
171        use ed25519_dalek::Signer;
172
173        let signing_key = SigningKey::from_bytes(&[1u8; 32]);
174        let verifying_key = signing_key.verifying_key();
175
176        let fingerprint = compute_fingerprint(
177            "/nix/store/abc-hello-2.12.1",
178            "sha256:deadbeef",
179            226552,
180            &["glibc-2.37".to_string()],
181        );
182
183        let sig = signing_key.sign(fingerprint.as_bytes());
184
185        let store_sig = StorePathSignature {
186            key_name: "test-key".to_string(),
187            signature: sig.to_bytes().to_vec(),
188        };
189
190        assert!(store_sig.verify(&fingerprint, verifying_key.as_bytes()).is_ok());
191    }
192
193    #[test]
194    fn verify_wrong_fingerprint() {
195        use ed25519_dalek::Signer;
196
197        let signing_key = SigningKey::from_bytes(&[2u8; 32]);
198        let verifying_key = signing_key.verifying_key();
199
200        let fingerprint = "1;/nix/store/abc;sha256:aaa;100;";
201        let sig = signing_key.sign(fingerprint.as_bytes());
202
203        let store_sig = StorePathSignature {
204            key_name: "test-key".to_string(),
205            signature: sig.to_bytes().to_vec(),
206        };
207
208        assert!(store_sig.verify("wrong fingerprint", verifying_key.as_bytes()).is_err());
209    }
210
211    #[test]
212    fn compute_fingerprint_format() {
213        let fp = compute_fingerprint(
214            "/nix/store/abc-hello",
215            "sha256:deadbeef",
216            1024,
217            &["dep1".to_string(), "dep2".to_string()],
218        );
219        assert_eq!(fp, "1;/nix/store/abc-hello;sha256:deadbeef;1024;dep1,dep2");
220    }
221
222    #[test]
223    fn invalid_signature_format() {
224        assert!(StorePathSignature::parse("no-colon-here").is_err());
225    }
226
227    #[test]
228    fn multiple_signatures_on_same_path() {
229        use ed25519_dalek::Signer;
230
231        let fingerprint = compute_fingerprint(
232            "/nix/store/abc-hello-2.12.1",
233            "sha256:deadbeef",
234            226552,
235            &["glibc-2.37".to_string()],
236        );
237
238        // Two different signing keys
239        let key1 = SigningKey::from_bytes(&[1u8; 32]);
240        let key2 = SigningKey::from_bytes(&[2u8; 32]);
241        let vk1 = key1.verifying_key();
242        let vk2 = key2.verifying_key();
243
244        let sig1 = StorePathSignature {
245            key_name: "cache.nixos.org-1".to_string(),
246            signature: key1.sign(fingerprint.as_bytes()).to_bytes().to_vec(),
247        };
248        let sig2 = StorePathSignature {
249            key_name: "my-cache-1".to_string(),
250            signature: key2.sign(fingerprint.as_bytes()).to_bytes().to_vec(),
251        };
252
253        // Both should verify against their own key
254        assert!(sig1.verify(&fingerprint, vk1.as_bytes()).is_ok());
255        assert!(sig2.verify(&fingerprint, vk2.as_bytes()).is_ok());
256
257        // Cross-verification should fail
258        assert!(sig1.verify(&fingerprint, vk2.as_bytes()).is_err());
259        assert!(sig2.verify(&fingerprint, vk1.as_bytes()).is_err());
260    }
261
262    #[test]
263    fn signature_with_very_long_key_name() {
264        let long_name = "a".repeat(500);
265        let sig = StorePathSignature {
266            key_name: long_name.clone(),
267            signature: vec![0u8; 64],
268        };
269        let s = sig.to_string_repr();
270        assert!(s.starts_with(&long_name));
271        let parsed = StorePathSignature::parse(&s).unwrap();
272        assert_eq!(parsed.key_name, long_name);
273    }
274
275    #[test]
276    fn fingerprint_with_empty_references() {
277        let fp = compute_fingerprint(
278            "/nix/store/abc-hello",
279            "sha256:deadbeef",
280            1024,
281            &[],
282        );
283        assert_eq!(fp, "1;/nix/store/abc-hello;sha256:deadbeef;1024;");
284    }
285
286    #[test]
287    fn fingerprint_with_many_references() {
288        let refs: Vec<String> = (0..20)
289            .map(|i| format!("dep-{i:02}"))
290            .collect();
291        let fp = compute_fingerprint(
292            "/nix/store/abc-hello",
293            "sha256:deadbeef",
294            1024,
295            &refs,
296        );
297        // Should contain all 20 references comma-separated
298        let parts: Vec<&str> = fp.split(';').collect();
299        assert_eq!(parts.len(), 5);
300        let ref_part = parts[4];
301        let ref_entries: Vec<&str> = ref_part.split(',').collect();
302        assert_eq!(ref_entries.len(), 20);
303        assert_eq!(ref_entries[0], "dep-00");
304        assert_eq!(ref_entries[19], "dep-19");
305    }
306
307    #[test]
308    fn signature_roundtrip_with_nonzero_bytes() {
309        let sig = StorePathSignature {
310            key_name: "test-key-1".to_string(),
311            signature: (0..64).collect::<Vec<u8>>(),
312        };
313        let s = sig.to_string_repr();
314        let parsed = StorePathSignature::parse(&s).unwrap();
315        assert_eq!(parsed.key_name, sig.key_name);
316        assert_eq!(parsed.signature, sig.signature);
317    }
318
319    // ── Mock verifiers ────────────────────────────────────
320
321    struct AlwaysValidVerifier;
322    impl SignatureVerifier for AlwaysValidVerifier {
323        fn verify(&self, _: &[u8], _: &[u8], _: &[u8]) -> Result<(), SignatureError> { Ok(()) }
324    }
325
326    struct AlwaysInvalidVerifier;
327    impl SignatureVerifier for AlwaysInvalidVerifier {
328        fn verify(&self, _: &[u8], _: &[u8], _: &[u8]) -> Result<(), SignatureError> {
329            Err(SignatureError::VerificationFailed)
330        }
331    }
332
333    #[test]
334    fn verify_with_always_valid() {
335        let sig = StorePathSignature { key_name: "k".into(), signature: vec![0; 64] };
336        assert!(sig.verify_with("fp", &[0; 32], &AlwaysValidVerifier).is_ok());
337    }
338
339    #[test]
340    fn verify_with_always_invalid() {
341        let sig = StorePathSignature { key_name: "k".into(), signature: vec![0; 64] };
342        assert!(sig.verify_with("fp", &[0; 32], &AlwaysInvalidVerifier).is_err());
343    }
344
345    #[test]
346    fn verifier_object_safe() {
347        fn _assert(_: &dyn SignatureVerifier) {}
348        _assert(&AlwaysValidVerifier);
349    }
350
351    // ── StorePathSignature error cases ───────────────────
352
353    #[test]
354    fn parse_empty_string() {
355        assert!(StorePathSignature::parse("").is_err());
356    }
357
358    #[test]
359    fn parse_only_colon() {
360        let sig = StorePathSignature::parse(":");
361        // ":" has empty key_name and empty base64 → decode yields empty vec
362        assert!(sig.is_ok());
363        let s = sig.unwrap();
364        assert_eq!(s.key_name, "");
365        assert!(s.signature.is_empty());
366    }
367
368    #[test]
369    fn parse_invalid_base64_after_colon() {
370        let result = StorePathSignature::parse("key:!!!not-base64!!!");
371        assert!(result.is_err());
372    }
373
374    // ── compute_fingerprint edge cases ──────────────────
375
376    #[test]
377    fn fingerprint_with_single_reference() {
378        let fp = compute_fingerprint("/nix/store/abc", "sha256:xxx", 500, &["dep".to_string()]);
379        assert_eq!(fp, "1;/nix/store/abc;sha256:xxx;500;dep");
380    }
381
382    #[test]
383    fn fingerprint_with_zero_nar_size() {
384        let fp = compute_fingerprint("/nix/store/empty", "sha256:000", 0, &[]);
385        assert_eq!(fp, "1;/nix/store/empty;sha256:000;0;");
386    }
387
388    #[test]
389    fn fingerprint_with_large_nar_size() {
390        let fp = compute_fingerprint("/nix/store/big", "sha256:aaa", u64::MAX, &[]);
391        assert!(fp.contains(&u64::MAX.to_string()));
392    }
393
394    // ── Ed25519Verifier direct tests ────────────────────
395
396    #[test]
397    fn ed25519_verifier_invalid_key_length() {
398        let verifier = Ed25519Verifier;
399        let result = verifier.verify(b"data", &[0; 64], &[0; 16]);
400        assert!(result.is_err());
401    }
402
403    #[test]
404    fn ed25519_verifier_invalid_signature_length() {
405        let verifier = Ed25519Verifier;
406        let result = verifier.verify(b"data", &[0; 32], &[0; 32]);
407        assert!(result.is_err());
408    }
409
410    // ── to_string_repr / parse roundtrip ────────────────
411
412    #[test]
413    fn to_string_repr_format() {
414        let sig = StorePathSignature {
415            key_name: "cache.nixos.org-1".to_string(),
416            signature: vec![1; 64],
417        };
418        let s = sig.to_string_repr();
419        assert!(s.starts_with("cache.nixos.org-1:"));
420        assert!(s.len() > "cache.nixos.org-1:".len());
421    }
422
423    // ── Display trait ────────────────────────────────────
424
425    #[test]
426    fn display_trait_matches_to_string_repr() {
427        let sig = StorePathSignature {
428            key_name: "k".to_string(),
429            signature: vec![0xab; 64],
430        };
431        let displayed = format!("{sig}");
432        let manual = sig.to_string_repr();
433        assert_eq!(displayed, manual);
434    }
435
436    // ── Tampered signatures ──────────────────────────────
437
438    #[test]
439    fn verify_with_tampered_signature() {
440        use ed25519_dalek::Signer;
441        let signing_key = SigningKey::from_bytes(&[3u8; 32]);
442        let verifying_key = signing_key.verifying_key();
443
444        let fingerprint = "1;/nix/store/abc;sha256:def;100;";
445        let mut sig_bytes = signing_key.sign(fingerprint.as_bytes()).to_bytes().to_vec();
446
447        // Flip a bit in the signature
448        sig_bytes[0] ^= 0x01;
449
450        let store_sig = StorePathSignature {
451            key_name: "k".to_string(),
452            signature: sig_bytes,
453        };
454
455        let result = store_sig.verify(fingerprint, verifying_key.as_bytes());
456        assert!(result.is_err());
457        match result {
458            Err(SignatureError::VerificationFailed) => {}
459            other => panic!("expected VerificationFailed, got {other:?}"),
460        }
461    }
462
463    #[test]
464    fn verify_with_truncated_signature_fails() {
465        let store_sig = StorePathSignature {
466            key_name: "k".to_string(),
467            signature: vec![0u8; 32], // half size
468        };
469        let result = store_sig.verify("fp", &[0u8; 32]);
470        assert!(result.is_err());
471    }
472
473    #[test]
474    fn verify_with_oversized_signature_fails() {
475        let store_sig = StorePathSignature {
476            key_name: "k".to_string(),
477            signature: vec![0u8; 128], // too big
478        };
479        let result = store_sig.verify("fp", &[0u8; 32]);
480        assert!(result.is_err());
481    }
482
483    // ── Tampered public keys ─────────────────────────────
484
485    #[test]
486    fn verify_with_zero_public_key_fails() {
487        use ed25519_dalek::Signer;
488        let signing_key = SigningKey::from_bytes(&[5u8; 32]);
489        let fingerprint = "fingerprint";
490        let sig = signing_key.sign(fingerprint.as_bytes());
491
492        let store_sig = StorePathSignature {
493            key_name: "k".to_string(),
494            signature: sig.to_bytes().to_vec(),
495        };
496
497        // All-zero key — even if technically a valid Ed25519 point form,
498        // the signature should not verify against it.
499        let result = store_sig.verify(fingerprint, &[0u8; 32]);
500        assert!(result.is_err());
501    }
502
503    #[test]
504    fn verify_with_random_public_key_fails() {
505        use ed25519_dalek::Signer;
506        let signing_key = SigningKey::from_bytes(&[6u8; 32]);
507        let other_key = SigningKey::from_bytes(&[7u8; 32]);
508
509        let fingerprint = "fp";
510        let sig = signing_key.sign(fingerprint.as_bytes());
511        let store_sig = StorePathSignature {
512            key_name: "k".to_string(),
513            signature: sig.to_bytes().to_vec(),
514        };
515
516        let result = store_sig.verify(fingerprint, other_key.verifying_key().as_bytes());
517        assert!(result.is_err());
518    }
519
520    // ── parse() error variants ───────────────────────────
521
522    #[test]
523    fn parse_signature_no_colon_error_variant() {
524        match StorePathSignature::parse("just-a-key-name") {
525            Err(SignatureError::InvalidFormat(s)) => assert!(s.contains("colon")),
526            other => panic!("expected InvalidFormat, got {other:?}"),
527        }
528    }
529
530    #[test]
531    fn parse_signature_invalid_base64_error_variant() {
532        match StorePathSignature::parse("k:!!!not-base64!!!") {
533            Err(SignatureError::Base64Decode) => {}
534            other => panic!("expected Base64Decode, got {other:?}"),
535        }
536    }
537
538    // ── compute_fingerprint various inputs ───────────────
539
540    #[test]
541    fn fingerprint_format_with_spaces_in_path() {
542        let fp = compute_fingerprint("/nix/store/abc with spaces", "sha256:abc", 1, &[]);
543        assert_eq!(fp, "1;/nix/store/abc with spaces;sha256:abc;1;");
544    }
545
546    #[test]
547    fn fingerprint_with_three_references_comma_separated() {
548        let refs = vec!["a".to_string(), "b".to_string(), "c".to_string()];
549        let fp = compute_fingerprint("/p", "h", 1, &refs);
550        assert!(fp.ends_with(";a,b,c"));
551    }
552
553    // ── Ref-ordering canonicalization (the latent-bug fix) ──────
554
555    #[test]
556    fn fingerprint_sorts_unsorted_references() {
557        // Unsorted input must produce the SAME canonical fingerprint as the
558        // sorted input — this is the property the signer and verifier rely on.
559        let unsorted = vec!["c".to_string(), "a".to_string(), "b".to_string()];
560        let fp = compute_fingerprint("/p", "h", 1, &unsorted);
561        assert!(fp.ends_with(";a,b,c"), "references must be sorted, got {fp}");
562    }
563
564    #[test]
565    fn fingerprint_order_independent() {
566        // Any permutation of the same references yields the identical
567        // fingerprint — so a signature made over one order verifies against
568        // any other order.
569        let a = compute_fingerprint("/p", "h", 1, &["z".into(), "a".into(), "m".into()]);
570        let b = compute_fingerprint("/p", "h", 1, &["a".into(), "m".into(), "z".into()]);
571        let c = compute_fingerprint("/p", "h", 1, &["m".into(), "z".into(), "a".into()]);
572        assert_eq!(a, b);
573        assert_eq!(b, c);
574    }
575
576    #[test]
577    fn fingerprint_sorts_full_store_paths() {
578        // Real references are full /nix/store paths; canonical order is
579        // lexicographic over the full string.
580        let refs = vec![
581            "/nix/store/zzz-b".to_string(),
582            "/nix/store/aaa-a".to_string(),
583        ];
584        let fp = compute_fingerprint("/nix/store/x-pkg", "sha256:h", 1, &refs);
585        assert!(fp.ends_with(";/nix/store/aaa-a,/nix/store/zzz-b"), "got {fp}");
586    }
587
588    // ── Ed25519Verifier trait method tests ───────────────
589
590    #[test]
591    fn ed25519_verifier_correct_size_inputs_with_invalid_data() {
592        let verifier = Ed25519Verifier;
593        // Correct sizes (64-byte sig, 32-byte key) but garbage data
594        let result = verifier.verify(b"data", &[0u8; 64], &[0u8; 32]);
595        // Should fail at verification step since data doesn't match
596        assert!(result.is_err());
597    }
598
599    #[test]
600    fn ed25519_verifier_short_key_returns_invalid_public_key() {
601        let verifier = Ed25519Verifier;
602        let result = verifier.verify(b"data", &[0u8; 64], &[0u8; 16]);
603        match result {
604            Err(SignatureError::InvalidPublicKey) => {}
605            other => panic!("expected InvalidPublicKey, got {other:?}"),
606        }
607    }
608
609    #[test]
610    fn ed25519_verifier_short_signature_returns_invalid_format() {
611        let verifier = Ed25519Verifier;
612        let result = verifier.verify(b"data", &[0u8; 32], &[0u8; 32]);
613        match result {
614            Err(SignatureError::InvalidFormat(s)) => assert!(s.contains("64 bytes")),
615            other => panic!("expected InvalidFormat, got {other:?}"),
616        }
617    }
618
619    // ── verify_with custom verifier ──────────────────────
620
621    struct CountingVerifier {
622        count: std::cell::Cell<usize>,
623    }
624
625    impl CountingVerifier {
626        fn new() -> Self {
627            Self { count: std::cell::Cell::new(0) }
628        }
629    }
630
631    // SAFETY: Cell is not Sync, so we can't use it through Send + Sync.
632    // For the test we use atomics instead.
633    struct AtomicCountingVerifier {
634        count: std::sync::atomic::AtomicUsize,
635    }
636
637    impl AtomicCountingVerifier {
638        fn new() -> Self {
639            Self { count: std::sync::atomic::AtomicUsize::new(0) }
640        }
641        fn count(&self) -> usize {
642            self.count.load(std::sync::atomic::Ordering::SeqCst)
643        }
644    }
645
646    impl SignatureVerifier for AtomicCountingVerifier {
647        fn verify(&self, _: &[u8], _: &[u8], _: &[u8]) -> Result<(), SignatureError> {
648            self.count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
649            Ok(())
650        }
651    }
652
653    #[test]
654    fn verify_with_custom_verifier_invokes_verify_method() {
655        let counter = AtomicCountingVerifier::new();
656        let sig = StorePathSignature {
657            key_name: "k".to_string(),
658            signature: vec![0u8; 64],
659        };
660        sig.verify_with("fp", &[0u8; 32], &counter).unwrap();
661        sig.verify_with("fp", &[0u8; 32], &counter).unwrap();
662        assert_eq!(counter.count(), 2);
663    }
664
665    // Counts unused but non-blocking — silence dead-code warning.
666    #[test]
667    fn _counting_verifier_unused_field_workaround() {
668        let v = CountingVerifier::new();
669        v.count.set(1);
670        assert_eq!(v.count.get(), 1);
671    }
672
673    // ── Roundtrip via Display ────────────────────────────
674
675    #[test]
676    fn signature_clone_eq() {
677        let sig = StorePathSignature {
678            key_name: "k".to_string(),
679            signature: vec![1, 2, 3, 4, 5],
680        };
681        let cloned = sig.clone();
682        assert_eq!(sig, cloned);
683    }
684
685    // ── compute_fingerprint with very long inputs ────────
686
687    #[test]
688    fn fingerprint_with_long_store_path_and_hash() {
689        let path = format!("/nix/store/{}", "a".repeat(200));
690        let hash = format!("sha256:{}", "0".repeat(64));
691        let fp = compute_fingerprint(&path, &hash, 999_999_999, &[]);
692        assert!(fp.contains(&path));
693        assert!(fp.contains(&hash));
694    }
695
696    // ── Real signing-then-verify with multiple key bytes ──
697
698    #[test]
699    fn sign_verify_multiple_distinct_keys() {
700        use ed25519_dalek::Signer;
701        for seed_byte in [10u8, 20, 30, 40, 50] {
702            let signing_key = SigningKey::from_bytes(&[seed_byte; 32]);
703            let verifying_key = signing_key.verifying_key();
704            let fingerprint = format!("1;/nix/store/p-{seed_byte};sha256:h;100;");
705            let sig = signing_key.sign(fingerprint.as_bytes());
706            let store_sig = StorePathSignature {
707                key_name: format!("k{seed_byte}"),
708                signature: sig.to_bytes().to_vec(),
709            };
710            assert!(
711                store_sig.verify(&fingerprint, verifying_key.as_bytes()).is_ok(),
712                "key seed {seed_byte} should verify",
713            );
714        }
715    }
716
717    // ── parse + verify integration ───────────────────────
718
719    #[test]
720    fn parse_then_verify_roundtrip() {
721        use ed25519_dalek::Signer;
722        let signing_key = SigningKey::from_bytes(&[42u8; 32]);
723        let verifying_key = signing_key.verifying_key();
724
725        let fingerprint = "1;/nix/store/abc;sha256:def;1024;";
726        let raw_sig = signing_key.sign(fingerprint.as_bytes());
727        let original = StorePathSignature {
728            key_name: "test-k".to_string(),
729            signature: raw_sig.to_bytes().to_vec(),
730        };
731
732        // Serialize, then parse, then verify
733        let s = original.to_string_repr();
734        let parsed = StorePathSignature::parse(&s).unwrap();
735        assert_eq!(parsed, original);
736
737        assert!(parsed.verify(fingerprint, verifying_key.as_bytes()).is_ok());
738    }
739
740    // ── b64_decode wrapper test ──────────────────────────
741
742    #[test]
743    fn b64_decode_helper_returns_error_on_garbage() {
744        // Test the b64_decode helper indirectly via parse()
745        let result = StorePathSignature::parse("k:zzz!!!");
746        assert!(result.is_err());
747    }
748}