metrale_bench/gate/
signing.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: Ed25519 signatures binding a gate record to the commit it
4//! measured.
5//!
6//! The key is generated on the box on first use, so a signature does not
7//! prove who ran a benchmark. It proves that the record file and the sha
8//! passed to [`verify_record`] are the ones the key holder signed: editing
9//! the file or re-pointing it at another commit breaks the signature. A new
10//! signer's public key is a one-line `.pub` file under
11//! `.github/record-signers/`, which a reviewer sees in the diff.
12//!
13//! Owner: bench gate (signing).
14//! Invariants:
15//! - Private key material is written only by `write_private`. On unix it
16//!   creates a new file with mode `0600`; elsewhere it is a plain write.
17//! - [`verify_record`] returns `Ok` only for a valid signature under a key
18//!   file present in [`REGISTRY_DIR`], or for an unsigned record recorded
19//!   before [`SIGNATURE_REQUIRED_AFTER`].
20
21use anyhow::{Context, Result, bail};
22use ring::signature::{ED25519, Ed25519KeyPair, KeyPair, UnparsedPublicKey};
23use std::path::{Path, PathBuf};
24
25/// 2026-09-26: Where a signer's public key is published, relative to the repo
26/// root.
27pub const REGISTRY_DIR: &str = ".github/record-signers";
28
29/// 2026-09-26: Sidecar format version. `sign_record` writes it and
30/// `verify_record` refuses any other.
31const SIG_VERSION: u32 = 1;
32
33/// 2026-09-26: Unix seconds (2026-09-01T13:13:20Z). A record whose
34/// `recorded_at` is below this and that has no `.sig` verifies as
35/// [`Verified::Exempt`]; at or after it, a `.sig` is required.
36/// `signing_tests::no_committed_record_escapes_the_cutover_unsigned` checks
37/// every committed record against it.
38pub const SIGNATURE_REQUIRED_AFTER: u64 = 1_788_268_400;
39
40/// 2026-09-26: The signing identity for this machine.
41pub struct Identity {
42    keypair: Ed25519KeyPair,
43    fingerprint: String,
44}
45
46impl Identity {
47    pub fn fingerprint(&self) -> &str {
48        &self.fingerprint
49    }
50
51    pub fn public_key_bytes(&self) -> &[u8] {
52        self.keypair.public_key().as_ref()
53    }
54}
55
56/// 2026-09-26: Short name for a public key: the first 16 hex digits (8 bytes)
57/// of its SHA-256. It names the key file and appears in messages; the full key
58/// is what verifies.
59pub fn fingerprint_of(public_key: &[u8]) -> String {
60    use sha2::{Digest, Sha256};
61    let digest = Sha256::digest(public_key);
62    digest.iter().take(8).map(|b| format!("{b:02x}")).collect()
63}
64
65/// 2026-09-26: `<record>.json` → `<record>.json.sig`: appends `.sig` to the
66/// whole file name.
67pub fn sig_path(record: &Path) -> PathBuf {
68    let mut s = record.to_path_buf().into_os_string();
69    s.push(".sig");
70    PathBuf::from(s)
71}
72
73/// 2026-09-26: The bytes a signature covers: the record file exactly as
74/// written, then the commit sha.
75fn message(record_bytes: &[u8], git_sha: &str) -> Vec<u8> {
76    let mut msg = Vec::with_capacity(record_bytes.len() + git_sha.len());
77    msg.extend_from_slice(record_bytes);
78    msg.extend_from_slice(git_sha.as_bytes());
79    msg
80}
81
82/// 2026-09-26: Load this machine's identity from
83/// `<metrale_home>/identity/ed25519.pk8`, generating and writing one on first
84/// use. Prints nothing; an unreadable, unwritable or unusable key is an error.
85pub fn load_or_create(metrale_home: &Path) -> Result<Identity> {
86    let dir = metrale_home.join("identity");
87    let key_path = dir.join("ed25519.pk8");
88
89    let pkcs8 = if key_path.exists() {
90        std::fs::read(&key_path).with_context(|| format!("reading {}", key_path.display()))?
91    } else {
92        std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
93        let rng = ring::rand::SystemRandom::new();
94        let doc = Ed25519KeyPair::generate_pkcs8(&rng)
95            .map_err(|_| anyhow::anyhow!("generating an Ed25519 key"))?;
96        write_private(&key_path, doc.as_ref())?;
97        doc.as_ref().to_vec()
98    };
99
100    let keypair = Ed25519KeyPair::from_pkcs8(&pkcs8)
101        .map_err(|e| anyhow::anyhow!("{} is not a usable Ed25519 key: {e}", key_path.display()))?;
102    let fingerprint = fingerprint_of(keypair.public_key().as_ref());
103    Ok(Identity {
104        keypair,
105        fingerprint,
106    })
107}
108
109/// 2026-09-26: Write key material to a new file created with mode `0600`, so
110/// the key is never readable by others between creation and a later chmod.
111#[cfg(unix)]
112fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
113    use std::io::Write;
114    use std::os::unix::fs::OpenOptionsExt;
115    let mut f = std::fs::OpenOptions::new()
116        .write(true)
117        .create_new(true)
118        .mode(0o600)
119        .open(path)
120        .with_context(|| format!("creating {}", path.display()))?;
121    f.write_all(bytes)
122        .with_context(|| format!("writing {}", path.display()))
123}
124
125#[cfg(not(unix))]
126fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
127    std::fs::write(path, bytes).with_context(|| format!("writing {}", path.display()))
128}
129
130/// 2026-09-26: Write this identity's public key to
131/// `<root>/.github/record-signers/<fingerprint>.pub` unless that file exists.
132/// Returns whether it wrote, so the caller asks for a commit only once.
133pub fn register(root: &Path, identity: &Identity) -> Result<bool> {
134    let dir = root.join(REGISTRY_DIR);
135    let path = dir.join(format!("{}.pub", identity.fingerprint()));
136    if path.exists() {
137        return Ok(false);
138    }
139    std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
140    let armored = format!(
141        "# Metrale Engine record signer {}\n# Ed25519 public key, base64. Added automatically on first use.\n{}\n",
142        identity.fingerprint(),
143        b64(identity.public_key_bytes())
144    );
145    std::fs::write(&path, armored).with_context(|| format!("writing {}", path.display()))?;
146    Ok(true)
147}
148
149/// 2026-09-26: Fingerprints of the `.pub` files git tracks under
150/// `.github/record-signers/`. Asks `git ls-files`, so a key `register` wrote
151/// but nobody committed is absent. Errors when git cannot answer.
152pub fn committed_signers(root: &Path) -> Result<Vec<String>> {
153    let out = std::process::Command::new("git")
154        .arg("-C")
155        .arg(root)
156        .args(["ls-files", "--", REGISTRY_DIR])
157        .output()
158        .with_context(|| format!("listing tracked files under {REGISTRY_DIR}"))?;
159    if !out.status.success() {
160        bail!(
161            "git ls-files {REGISTRY_DIR} failed: {}",
162            String::from_utf8_lossy(&out.stderr).trim()
163        );
164    }
165    Ok(String::from_utf8_lossy(&out.stdout)
166        .lines()
167        .filter_map(|l| l.rsplit('/').next())
168        .filter_map(|f| f.strip_suffix(".pub"))
169        .map(str::to_owned)
170        .collect())
171}
172
173/// 2026-09-26: The operator notice when `fingerprint` is not among the
174/// `committed` signers, or `None` when it is. Pure.
175pub fn signer_notice(committed: &[String], fingerprint: &str) -> Option<String> {
176    if committed.iter().any(|c| c == fingerprint) {
177        return None;
178    }
179    Some(format!(
180        "gate: NOTE — this box signs as {fingerprint}, which is not committed in \
181         {REGISTRY_DIR} ({} signer(s) are).\n\
182         gate: a record signed by an uncommitted key needs its one-line .pub \
183         committed alongside, AND every record one PR adds must carry the SAME \
184         fingerprint — CI rejects a set spanning signers, so a campaign split \
185         across boxes cannot be certified.",
186        committed.len()
187    ))
188}
189
190/// 2026-09-26: Sign a record file already on disk with its sha; writes and
191/// returns the `.sig` sidecar path.
192pub fn sign_record(identity: &Identity, record: &Path, git_sha: &str) -> Result<PathBuf> {
193    let bytes = std::fs::read(record).with_context(|| format!("reading {}", record.display()))?;
194    let sig = identity.keypair.sign(&message(&bytes, git_sha));
195    let out = sig_path(record);
196    let body = format!(
197        "{{\"v\":{SIG_VERSION},\"key\":\"{}\",\"sig\":\"{}\"}}\n",
198        identity.fingerprint(),
199        b64(sig.as_ref())
200    );
201    std::fs::write(&out, body).with_context(|| format!("writing {}", out.display()))?;
202    Ok(out)
203}
204
205/// 2026-09-26: The outcome of a successful [`verify_record`].
206#[derive(Debug, PartialEq, Eq)]
207pub enum Verified {
208    /// 2026-09-26: The signature verifies under a key in [`REGISTRY_DIR`].
209    Signed { fingerprint: String },
210    /// 2026-09-26: No `.sig`, and recorded before
211    /// [`SIGNATURE_REQUIRED_AFTER`].
212    Exempt,
213}
214
215/// 2026-09-26: Verify a record's `.sig` sidecar against `git_sha`.
216///
217/// `recorded_at` is used only for the [`SIGNATURE_REQUIRED_AFTER`] exemption.
218/// Errors name the cause: no sidecar after the cutover, an unknown format
219/// version, a key file missing from [`REGISTRY_DIR`], or a signature that
220/// does not match. `check_one` adds any error to the record's failures.
221pub fn verify_record(
222    root: &Path,
223    record: &Path,
224    git_sha: &str,
225    recorded_at: u64,
226) -> Result<Verified> {
227    let sidecar = sig_path(record);
228    if !sidecar.exists() {
229        if recorded_at < SIGNATURE_REQUIRED_AFTER {
230            return Ok(Verified::Exempt);
231        }
232        bail!(
233            "{} has no signature ({}). Re-run the benchmark, or commit the .sig \
234             the CLI wrote beside the record.",
235            record.file_name().unwrap_or_default().to_string_lossy(),
236            sidecar.file_name().unwrap_or_default().to_string_lossy()
237        );
238    }
239
240    let raw = std::fs::read_to_string(&sidecar)
241        .with_context(|| format!("reading {}", sidecar.display()))?;
242    let parsed: serde_json::Value =
243        serde_json::from_str(&raw).with_context(|| format!("parsing {}", sidecar.display()))?;
244    let version = parsed.get("v").and_then(serde_json::Value::as_u64);
245    if version != Some(u64::from(SIG_VERSION)) {
246        bail!(
247            "{} is signature format v{:?}; this build understands v{SIG_VERSION}",
248            sidecar.display(),
249            version
250        );
251    }
252    let fingerprint = parsed
253        .get("key")
254        .and_then(serde_json::Value::as_str)
255        .context("signature names no key")?;
256    let sig = unb64(
257        parsed
258            .get("sig")
259            .and_then(serde_json::Value::as_str)
260            .context("signature has no sig field")?,
261    )?;
262
263    let key_path = root.join(REGISTRY_DIR).join(format!("{fingerprint}.pub"));
264    if !key_path.exists() {
265        bail!(
266            "{} is signed by {fingerprint}, which is not in {REGISTRY_DIR}. A new \
267             signer must be committed there — it is one line, and reviewing it is \
268             the point.",
269            record.file_name().unwrap_or_default().to_string_lossy()
270        );
271    }
272    let public = read_registered_key(&key_path)?;
273
274    let bytes = std::fs::read(record).with_context(|| format!("reading {}", record.display()))?;
275    UnparsedPublicKey::new(&ED25519, &public)
276        .verify(&message(&bytes, git_sha), &sig)
277        .map_err(|_| {
278            anyhow::anyhow!(
279                "{} does not match its signature. The record, or the commit it \
280                 names, was changed after it was measured.",
281                record.file_name().unwrap_or_default().to_string_lossy()
282            )
283        })?;
284
285    Ok(Verified::Signed {
286        fingerprint: fingerprint.to_string(),
287    })
288}
289
290/// 2026-09-26: The base64 key on the first line of a registered key file
291/// that is neither blank nor a `#` comment.
292fn read_registered_key(path: &Path) -> Result<Vec<u8>> {
293    let text =
294        std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
295    let line = text
296        .lines()
297        .map(str::trim)
298        .find(|l| !l.is_empty() && !l.starts_with('#'))
299        .with_context(|| format!("{} holds no key line", path.display()))?;
300    unb64(line)
301}
302
303fn b64(bytes: &[u8]) -> String {
304    use base64::Engine;
305    base64::engine::general_purpose::STANDARD.encode(bytes)
306}
307
308fn unb64(s: &str) -> Result<Vec<u8>> {
309    use base64::Engine;
310    base64::engine::general_purpose::STANDARD
311        .decode(s.trim())
312        .context("decoding base64")
313}