1use anyhow::{Result, anyhow};
51use std::sync::Arc;
52
53#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct AttestedWeights {
64 pub sha256: String,
66 pub signature: Option<String>,
69 pub label: String,
72}
73
74pub trait InferenceBackend: Send + Sync {
81 fn embed(&self, text: &str) -> Result<Vec<f32>>;
89
90 fn chat(&self, prompt: &str) -> Result<String>;
99
100 fn attested_weights(&self) -> Option<AttestedWeights> {
104 None
105 }
106}
107
108pub struct CpuBackend {
112 embedder: Arc<dyn crate::embeddings::Embed>,
113 llm: Option<Arc<crate::llm::OllamaClient>>,
114 attested: Option<AttestedWeights>,
118}
119
120impl CpuBackend {
121 #[must_use]
123 pub fn new(
124 embedder: Arc<dyn crate::embeddings::Embed>,
125 llm: Option<Arc<crate::llm::OllamaClient>>,
126 ) -> Self {
127 Self {
128 embedder,
129 llm,
130 attested: None,
131 }
132 }
133
134 #[must_use]
141 pub fn with_attested_weights(mut self, attested: AttestedWeights) -> Self {
142 self.attested = Some(attested);
143 self
144 }
145}
146
147impl InferenceBackend for CpuBackend {
148 fn embed(&self, text: &str) -> Result<Vec<f32>> {
149 self.embedder.embed(text)
150 }
151
152 fn chat(&self, prompt: &str) -> Result<String> {
153 let llm = self
154 .llm
155 .as_ref()
156 .ok_or_else(|| anyhow!("CpuBackend: chat unavailable (no OllamaClient configured)"))?;
157 llm.generate(prompt, None)
158 }
159
160 fn attested_weights(&self) -> Option<AttestedWeights> {
161 self.attested.clone()
162 }
163}
164
165#[derive(Default)]
170pub struct GpuBackend {
171 pub label: String,
175}
176
177impl GpuBackend {
178 #[must_use]
181 pub fn new(label: impl Into<String>) -> Self {
182 Self {
183 label: label.into(),
184 }
185 }
186}
187
188impl InferenceBackend for GpuBackend {
189 fn embed(&self, _text: &str) -> Result<Vec<f32>> {
190 Err(anyhow!(
191 "GpuBackend::embed not implemented (v0.8 work — issue #651 Phase 1; \
192 see docs/v0.7.0/inference-attestation.md for the rollout plan)"
193 ))
194 }
195
196 fn chat(&self, _prompt: &str) -> Result<String> {
197 Err(anyhow!(
198 "GpuBackend::chat not implemented (v0.8 work — issue #651 Phase 1)"
199 ))
200 }
201}
202
203pub fn compute_attested_weights(
210 path: &std::path::Path,
211 label: impl Into<String>,
212 signature: Option<String>,
213) -> Result<AttestedWeights> {
214 use sha2::{Digest, Sha256};
215 let bytes = std::fs::read(path)
216 .map_err(|e| anyhow!("compute_attested_weights: read {}: {e}", path.display()))?;
217 let mut hasher = Sha256::new();
218 hasher.update(&bytes);
219 let digest = hasher.finalize();
220 Ok(AttestedWeights {
221 sha256: hex::encode(digest),
222 signature,
223 label: label.into(),
224 })
225}
226
227pub fn verify_attested_weights(path: &std::path::Path, expected: &AttestedWeights) -> Result<()> {
250 let operator_pubkey = crate::governance::rules_store::resolve_operator_pubkey();
251 verify_attested_weights_with_key(path, expected, operator_pubkey.as_ref())
252}
253
254pub fn verify_attested_weights_with_key(
263 path: &std::path::Path,
264 expected: &AttestedWeights,
265 operator_pubkey: Option<&ed25519_dalek::VerifyingKey>,
266) -> Result<()> {
267 let recomputed = compute_attested_weights(path, &expected.label, None)?;
268 if recomputed.sha256 != expected.sha256 {
269 return Err(anyhow!(
270 "verify_attested_weights: hash mismatch for {} (expected {}, got {}) — \
271 refusing to serve from a tampered weight file (issue #654)",
272 path.display(),
273 expected.sha256,
274 recomputed.sha256,
275 ));
276 }
277
278 if let Some(sig_b64) = expected.signature.as_deref() {
283 let Some(verifying_key) = operator_pubkey else {
284 return Err(anyhow!(
285 "verify_attested_weights: record for {} carries a signature but no operator \
286 public key could be resolved — refusing to serve (fail-CLOSED, issue #654)",
287 path.display(),
288 ));
289 };
290 verify_attested_weights_signature(&recomputed.sha256, sig_b64, verifying_key).map_err(
291 |e| {
292 anyhow!(
293 "verify_attested_weights: signature verification failed for {} ({e}) — \
294 refusing to serve (issue #654)",
295 path.display(),
296 )
297 },
298 )?;
299 }
300
301 Ok(())
302}
303
304fn verify_attested_weights_signature(
309 sha256: &str,
310 signature: &str,
311 verifying_key: &ed25519_dalek::VerifyingKey,
312) -> Result<(), ed25519_dalek::SignatureError> {
313 use base64::Engine;
314 use ed25519_dalek::{Signature, Verifier};
315
316 let trimmed = signature.trim();
317 let sig_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
318 .decode(trimmed)
319 .or_else(|_| base64::engine::general_purpose::STANDARD.decode(trimmed))
320 .map_err(|_| ed25519_dalek::SignatureError::new())?;
321 if sig_bytes.len() != ed25519_dalek::SIGNATURE_LENGTH {
322 return Err(ed25519_dalek::SignatureError::new());
323 }
324 let mut sig_arr = [0u8; ed25519_dalek::SIGNATURE_LENGTH];
325 sig_arr.copy_from_slice(&sig_bytes);
326 let sig = Signature::from_bytes(&sig_arr);
327 verifying_key.verify(sha256.as_bytes(), &sig)
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333 use std::io::Write;
334
335 struct MockEmbedder;
336 impl crate::embeddings::Embed for MockEmbedder {
337 fn embed(&self, text: &str) -> Result<Vec<f32>> {
338 Ok(vec![text.len() as f32; 4])
339 }
340 }
341
342 #[test]
343 fn cpu_backend_round_trips_embed() {
344 let be: Arc<dyn InferenceBackend> = Arc::new(CpuBackend::new(Arc::new(MockEmbedder), None));
345 let v = be.embed("hello").expect("embed ok");
346 assert_eq!(v, vec![5.0_f32; 4]);
347 }
348
349 #[test]
350 fn cpu_backend_chat_without_llm_errors() {
351 let be = CpuBackend::new(Arc::new(MockEmbedder), None);
352 let err = be.chat("anything").expect_err("must err");
353 assert!(err.to_string().contains("chat unavailable"));
354 }
355
356 #[test]
357 fn gpu_backend_returns_not_implemented() {
358 let be: Arc<dyn InferenceBackend> = Arc::new(GpuBackend::new("test-gpu"));
359 let err = be.embed("x").expect_err("gpu embed must err");
360 assert!(err.to_string().contains("not implemented"));
361 let err = be.chat("x").expect_err("gpu chat must err");
362 assert!(err.to_string().contains("not implemented"));
363 assert!(be.attested_weights().is_none());
364 }
365
366 #[test]
367 fn compute_and_verify_attested_weights_round_trip() {
368 let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".local-runs");
371 std::fs::create_dir_all(&dir).expect("mkdir .local-runs");
372 let path = dir.join(format!(
373 "inference-attest-fixture-{}.bin",
374 uuid::Uuid::new_v4()
375 ));
376 let mut f = std::fs::File::create(&path).expect("create fixture");
377 f.write_all(b"a tiny attested model weight blob")
378 .expect("write fixture");
379 f.sync_all().expect("sync fixture");
380 drop(f);
381
382 let attested =
383 compute_attested_weights(&path, "fixture", None).expect("compute_attested_weights ok");
384 assert_eq!(attested.sha256.len(), 64, "sha256 hex must be 64 chars");
385
386 verify_attested_weights(&path, &attested).expect("verify ok");
387
388 let mut f = std::fs::OpenOptions::new()
390 .append(true)
391 .open(&path)
392 .expect("open append");
393 f.write_all(b"--tampered--").expect("tamper write");
394 f.sync_all().expect("sync tamper");
395 drop(f);
396 let err = verify_attested_weights(&path, &attested)
397 .expect_err("verify must refuse tampered file");
398 assert!(err.to_string().contains("hash mismatch"));
399
400 let _ = std::fs::remove_file(&path);
401 }
402
403 #[test]
404 fn cpu_backend_with_attested_weights_round_trip() {
405 let attested = AttestedWeights {
406 sha256: "0".repeat(64),
407 signature: None,
408 label: "test".into(),
409 };
410 let be =
411 CpuBackend::new(Arc::new(MockEmbedder), None).with_attested_weights(attested.clone());
412 assert_eq!(be.attested_weights(), Some(attested));
413 }
414
415 fn write_attest_fixture(content: &[u8]) -> std::path::PathBuf {
425 let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".local-runs");
426 std::fs::create_dir_all(&dir).expect("mkdir .local-runs");
427 let path = dir.join(format!("inference-attest-sig-{}.bin", uuid::Uuid::new_v4()));
428 let mut f = std::fs::File::create(&path).expect("create fixture");
429 f.write_all(content).expect("write fixture");
430 f.sync_all().expect("sync fixture");
431 path
432 }
433
434 fn sign_b64(signing_key: &ed25519_dalek::SigningKey, message: &[u8]) -> String {
435 use base64::Engine;
436 use ed25519_dalek::Signer;
437 base64::engine::general_purpose::STANDARD.encode(signing_key.sign(message).to_bytes())
438 }
439
440 #[test]
441 fn verify_attested_weights_accepts_valid_operator_signature() {
442 let mut csprng = rand_core::OsRng;
443 let signing_key = ed25519_dalek::SigningKey::generate(&mut csprng);
444 let verifying_key = signing_key.verifying_key();
445
446 let path = write_attest_fixture(b"signed weight blob");
447 let unsigned = compute_attested_weights(&path, "fixture", None).expect("compute ok");
448 let signature = sign_b64(&signing_key, unsigned.sha256.as_bytes());
451 let attested = AttestedWeights {
452 signature: Some(signature),
453 ..unsigned
454 };
455
456 verify_attested_weights_with_key(&path, &attested, Some(&verifying_key))
457 .expect("valid signature must verify");
458
459 let _ = std::fs::remove_file(&path);
460 }
461
462 #[test]
463 fn verify_attested_weights_rejects_forged_signature() {
464 let mut csprng = rand_core::OsRng;
465 let operator_key = ed25519_dalek::SigningKey::generate(&mut csprng);
466 let attacker_key = ed25519_dalek::SigningKey::generate(&mut csprng);
467
468 let path = write_attest_fixture(b"forged weight blob");
469 let unsigned = compute_attested_weights(&path, "fixture", None).expect("compute ok");
470 let signature = sign_b64(&attacker_key, unsigned.sha256.as_bytes());
472 let attested = AttestedWeights {
473 signature: Some(signature),
474 ..unsigned
475 };
476
477 let err =
478 verify_attested_weights_with_key(&path, &attested, Some(&operator_key.verifying_key()))
479 .expect_err("forged signature must be refused");
480 assert!(err.to_string().contains("signature verification failed"));
481
482 let _ = std::fs::remove_file(&path);
483 }
484
485 #[test]
486 fn verify_attested_weights_fails_closed_when_signed_but_no_key() {
487 let mut csprng = rand_core::OsRng;
488 let signing_key = ed25519_dalek::SigningKey::generate(&mut csprng);
489
490 let path = write_attest_fixture(b"orphan-signature weight blob");
491 let unsigned = compute_attested_weights(&path, "fixture", None).expect("compute ok");
492 let signature = sign_b64(&signing_key, unsigned.sha256.as_bytes());
493 let attested = AttestedWeights {
494 signature: Some(signature),
495 ..unsigned
496 };
497
498 let err = verify_attested_weights_with_key(&path, &attested, None)
500 .expect_err("present signature with no key must fail closed");
501 assert!(err.to_string().contains("no operator public key"));
502
503 let _ = std::fs::remove_file(&path);
504 }
505
506 #[test]
507 fn verify_attested_weights_unsigned_record_skips_signature_gate() {
508 let path = write_attest_fixture(b"unsigned weight blob");
510 let attested = compute_attested_weights(&path, "fixture", None).expect("compute ok");
511 assert!(attested.signature.is_none());
512 verify_attested_weights_with_key(&path, &attested, None)
513 .expect("unsigned record must verify on hash alone");
514 let _ = std::fs::remove_file(&path);
515 }
516}