metrale_bench/gate/
signing.rs1use anyhow::{Context, Result, bail};
22use ring::signature::{ED25519, Ed25519KeyPair, KeyPair, UnparsedPublicKey};
23use std::path::{Path, PathBuf};
24
25pub const REGISTRY_DIR: &str = ".github/record-signers";
28
29const SIG_VERSION: u32 = 1;
32
33pub const SIGNATURE_REQUIRED_AFTER: u64 = 1_788_268_400;
39
40pub 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
56pub 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
65pub 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
73fn 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
82pub 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#[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
130pub 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
149pub 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
173pub 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
190pub 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#[derive(Debug, PartialEq, Eq)]
207pub enum Verified {
208 Signed { fingerprint: String },
210 Exempt,
213}
214
215pub 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
290fn 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}