Skip to main content

sui_cache/
resign.rs

1//! Re-sign every narinfo already in a cache.
2//!
3//! ── WHY THIS EXISTS ───────────────────────────────────────────────────────
4//! A cache's stored `Sig:` lines are only as good as the fingerprint the
5//! signer computed when the entry was INGESTED. When that computation is
6//! corrected — or when the signing key rotates — every entry written before
7//! the change carries a signature that will never verify, and nothing in the
8//! normal serving path repairs it.
9//!
10//! `server::sign_narinfo_text` deliberately will not: it skips any narinfo
11//! that already carries a signature under our key name, so it does not
12//! double-sign on re-ingest. That guard is correct for ingest and is exactly
13//! what makes a BAD signature permanent — the stale signature is by our key,
14//! so it reads as "already signed" forever.
15//!
16//! Measured 2026-08-08 on the fleet origin (rio): 6,668 narinfos signed over a
17//! hex fingerprint while Nix fingerprints in Nix-base32, so every consumer
18//! discarded every path with *"not signed by any of the keys in
19//! trusted-public-keys"*. Only paths that happened to be REBUILT and re-pushed
20//! after the fix were repaired; the rest needed this.
21//!
22//! ── WHY REWRITE THE narinfo AND NOT RE-PUSH ───────────────────────────────
23//! A re-push re-uploads the NAR — 12 GiB on that origin — to change one line
24//! of text, and it can only cover paths whose store path still exists locally.
25//! Re-signing reads and rewrites the narinfo alone: O(entries), not O(bytes),
26//! and it works for entries whose original store path is long gone.
27
28use crate::signing::CacheSigner;
29use sui_castore::storage::StorageBackend;
30use sui_compat::narinfo::NarInfo;
31
32/// Outcome of a re-sign sweep.
33#[derive(Debug, Default, Clone, PartialEq, Eq)]
34pub struct ResignReport {
35    /// Entries examined.
36    pub total: usize,
37    /// Entries whose `Sig:` under our key changed and were written back.
38    pub resigned: usize,
39    /// Entries already carrying the correct signature — a re-run resigns
40    /// nothing, which is what makes this safe to schedule.
41    pub unchanged: usize,
42    /// Entries that could not be read or parsed. Reported, never fatal: one
43    /// corrupt entry must not abort the sweep for the other 6,667.
44    pub failed: usize,
45}
46
47/// Re-sign every narinfo in `storage` under `signer`'s key.
48///
49/// Signatures by OTHER key names are preserved — a cache may legitimately
50/// carry `cache.nixos.org-1` alongside ours, and dropping those would strip
51/// upstream provenance. Only our own key's signature is replaced.
52///
53/// # Errors
54///
55/// Returns a [`crate::CacheError`] only if the entry listing itself fails.
56/// Per-entry read/parse failures increment `failed` and the sweep continues.
57pub async fn resign_all(
58    storage: &dyn StorageBackend,
59    signer: &CacheSigner,
60) -> Result<ResignReport, crate::CacheError> {
61    let hashes = storage
62        .list_narinfos()
63        .await
64        .map_err(|e| crate::CacheError::NarInfo(e.to_string()))?;
65
66    let key_prefix = format!("{}:", signer.key_name());
67    let mut report = ResignReport {
68        total: hashes.len(),
69        ..Default::default()
70    };
71
72    for hash in hashes {
73        let Ok(Some(content)) = storage.get_narinfo(&hash).await else {
74            report.failed += 1;
75            continue;
76        };
77        let Ok(mut info) = NarInfo::parse(&content) else {
78            report.failed += 1;
79            continue;
80        };
81
82        let before: Vec<String> = info
83            .signatures
84            .iter()
85            .filter(|s| s.starts_with(&key_prefix))
86            .cloned()
87            .collect();
88
89        // Drop OUR signature(s), keep everyone else's, then re-sign. This is
90        // the one behavioural difference from the ingest path, and the whole
91        // point of the command.
92        info.signatures.retain(|s| !s.starts_with(&key_prefix));
93        let sig = signer.sign_narinfo(&info);
94
95        if before.len() == 1 && before[0] == sig {
96            report.unchanged += 1;
97            continue;
98        }
99
100        info.signatures.push(sig);
101        if storage
102            .put_narinfo_record(&hash, &info.serialize())
103            .await
104            .is_err()
105        {
106            report.failed += 1;
107        } else {
108            report.resigned += 1;
109        }
110    }
111
112    Ok(report)
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use sui_castore::storage::LocalStorage;
119
120    const SECRET: &str =
121        "test-key-1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==";
122
123    fn narinfo_with_sig(sig: Option<&str>) -> String {
124        // FileHash and FileSize are REQUIRED by NarInfo::parse — omitting
125        // them makes every entry a parse failure, which surfaces as
126        // `failed`, not `resigned`, and reads exactly like "the sweep found
127        // nothing".
128        let mut s = String::from(
129            "StorePath: /nix/store/00000000000000000000000000000000-x\n\
130             URL: nar/x.nar\n\
131             Compression: none\n\
132             FileHash: sha256:0000000000000000000000000000000000000000000000000000000000000000\n\
133             FileSize: 1\n\
134             NarHash: sha256:0000000000000000000000000000000000000000000000000000000000000000\n\
135             NarSize: 1\n\
136             References: \n",
137        );
138        if let Some(sig) = sig {
139            s.push_str(&format!("Sig: {sig}\n"));
140        }
141        s
142    }
143
144    async fn seed(dir: &std::path::Path, hash: &str, body: &str) -> LocalStorage {
145        let st = LocalStorage::new(dir);
146        st.put_narinfo_record(hash, body).await.unwrap();
147        st
148    }
149
150    /// The regression this module exists for: an entry already signed under
151    /// OUR key with a WRONG signature must be replaced, not skipped. The
152    /// ingest path skips it (see `server::sign_narinfo_text`), which is
153    /// precisely why a bad signature is otherwise permanent.
154    #[tokio::test]
155    async fn replaces_a_stale_signature_under_our_own_key() {
156        let dir = tempfile::tempdir().unwrap();
157        let hash = "00000000000000000000000000000000";
158        let stale = "test-key-1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==";
159        let st = seed(dir.path(), hash, &narinfo_with_sig(Some(stale))).await;
160        let signer = CacheSigner::from_secret_key_string(SECRET).unwrap();
161
162        let r = resign_all(&st, &signer).await.unwrap();
163        assert_eq!(r.resigned, 1, "a stale same-key signature must be replaced");
164        assert_eq!(r.unchanged, 0);
165
166        let out = st.get_narinfo(hash).await.unwrap().unwrap();
167        assert!(!out.contains(stale), "the stale signature must be gone");
168        assert!(out.contains("test-key-1:"), "a fresh one must be present");
169    }
170
171    /// Idempotence — a second sweep must resign nothing. This is what makes
172    /// the command safe to run on a schedule or twice by accident.
173    #[tokio::test]
174    async fn second_sweep_is_a_no_op() {
175        let dir = tempfile::tempdir().unwrap();
176        let hash = "00000000000000000000000000000000";
177        let st = seed(dir.path(), hash, &narinfo_with_sig(None)).await;
178        let signer = CacheSigner::from_secret_key_string(SECRET).unwrap();
179
180        assert_eq!(resign_all(&st, &signer).await.unwrap().resigned, 1);
181        let second = resign_all(&st, &signer).await.unwrap();
182        assert_eq!(second.resigned, 0);
183        assert_eq!(second.unchanged, 1);
184    }
185
186    /// Another cache's signature is provenance, not noise — preserve it.
187    #[tokio::test]
188    async fn preserves_signatures_by_other_keys() {
189        let dir = tempfile::tempdir().unwrap();
190        let hash = "00000000000000000000000000000000";
191        let foreign = "cache.nixos.org-1:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB==";
192        let st = seed(dir.path(), hash, &narinfo_with_sig(Some(foreign))).await;
193        let signer = CacheSigner::from_secret_key_string(SECRET).unwrap();
194
195        resign_all(&st, &signer).await.unwrap();
196        let out = st.get_narinfo(hash).await.unwrap().unwrap();
197        assert!(out.contains(foreign), "a foreign signature must survive");
198        assert!(out.contains("test-key-1:"), "ours must be added");
199    }
200}