use anyhow::{Result, anyhow};
use std::sync::Arc;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AttestedWeights {
pub sha256: String,
pub signature: Option<String>,
pub label: String,
}
pub trait InferenceBackend: Send + Sync {
fn embed(&self, text: &str) -> Result<Vec<f32>>;
fn chat(&self, prompt: &str) -> Result<String>;
fn attested_weights(&self) -> Option<AttestedWeights> {
None
}
}
pub struct CpuBackend {
embedder: Arc<dyn crate::embeddings::Embed>,
llm: Option<Arc<crate::llm::OllamaClient>>,
attested: Option<AttestedWeights>,
}
impl CpuBackend {
#[must_use]
pub fn new(
embedder: Arc<dyn crate::embeddings::Embed>,
llm: Option<Arc<crate::llm::OllamaClient>>,
) -> Self {
Self {
embedder,
llm,
attested: None,
}
}
#[must_use]
pub fn with_attested_weights(mut self, attested: AttestedWeights) -> Self {
self.attested = Some(attested);
self
}
}
impl InferenceBackend for CpuBackend {
fn embed(&self, text: &str) -> Result<Vec<f32>> {
self.embedder.embed(text)
}
fn chat(&self, prompt: &str) -> Result<String> {
let llm = self
.llm
.as_ref()
.ok_or_else(|| anyhow!("CpuBackend: chat unavailable (no OllamaClient configured)"))?;
llm.generate(prompt, None)
}
fn attested_weights(&self) -> Option<AttestedWeights> {
self.attested.clone()
}
}
#[derive(Default)]
pub struct GpuBackend {
pub label: String,
}
impl GpuBackend {
#[must_use]
pub fn new(label: impl Into<String>) -> Self {
Self {
label: label.into(),
}
}
}
impl InferenceBackend for GpuBackend {
fn embed(&self, _text: &str) -> Result<Vec<f32>> {
Err(anyhow!(
"GpuBackend::embed not implemented (v0.8 work — issue #651 Phase 1; \
see docs/v0.7.0/inference-attestation.md for the rollout plan)"
))
}
fn chat(&self, _prompt: &str) -> Result<String> {
Err(anyhow!(
"GpuBackend::chat not implemented (v0.8 work — issue #651 Phase 1)"
))
}
}
pub fn compute_attested_weights(
path: &std::path::Path,
label: impl Into<String>,
signature: Option<String>,
) -> Result<AttestedWeights> {
use sha2::{Digest, Sha256};
let bytes = std::fs::read(path)
.map_err(|e| anyhow!("compute_attested_weights: read {}: {e}", path.display()))?;
let mut hasher = Sha256::new();
hasher.update(&bytes);
let digest = hasher.finalize();
Ok(AttestedWeights {
sha256: hex::encode(digest),
signature,
label: label.into(),
})
}
pub fn verify_attested_weights(path: &std::path::Path, expected: &AttestedWeights) -> Result<()> {
let operator_pubkey = crate::governance::rules_store::resolve_operator_pubkey();
verify_attested_weights_with_key(path, expected, operator_pubkey.as_ref())
}
pub fn verify_attested_weights_with_key(
path: &std::path::Path,
expected: &AttestedWeights,
operator_pubkey: Option<&ed25519_dalek::VerifyingKey>,
) -> Result<()> {
let recomputed = compute_attested_weights(path, &expected.label, None)?;
if recomputed.sha256 != expected.sha256 {
return Err(anyhow!(
"verify_attested_weights: hash mismatch for {} (expected {}, got {}) — \
refusing to serve from a tampered weight file (issue #654)",
path.display(),
expected.sha256,
recomputed.sha256,
));
}
if let Some(sig_b64) = expected.signature.as_deref() {
let Some(verifying_key) = operator_pubkey else {
return Err(anyhow!(
"verify_attested_weights: record for {} carries a signature but no operator \
public key could be resolved — refusing to serve (fail-CLOSED, issue #654)",
path.display(),
));
};
verify_attested_weights_signature(&recomputed.sha256, sig_b64, verifying_key).map_err(
|e| {
anyhow!(
"verify_attested_weights: signature verification failed for {} ({e}) — \
refusing to serve (issue #654)",
path.display(),
)
},
)?;
}
Ok(())
}
fn verify_attested_weights_signature(
sha256: &str,
signature: &str,
verifying_key: &ed25519_dalek::VerifyingKey,
) -> Result<(), ed25519_dalek::SignatureError> {
use base64::Engine;
use ed25519_dalek::{Signature, Verifier};
let trimmed = signature.trim();
let sig_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(trimmed)
.or_else(|_| base64::engine::general_purpose::STANDARD.decode(trimmed))
.map_err(|_| ed25519_dalek::SignatureError::new())?;
if sig_bytes.len() != ed25519_dalek::SIGNATURE_LENGTH {
return Err(ed25519_dalek::SignatureError::new());
}
let mut sig_arr = [0u8; ed25519_dalek::SIGNATURE_LENGTH];
sig_arr.copy_from_slice(&sig_bytes);
let sig = Signature::from_bytes(&sig_arr);
verifying_key.verify(sha256.as_bytes(), &sig)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
struct MockEmbedder;
impl crate::embeddings::Embed for MockEmbedder {
fn embed(&self, text: &str) -> Result<Vec<f32>> {
Ok(vec![text.len() as f32; 4])
}
}
#[test]
fn cpu_backend_round_trips_embed() {
let be: Arc<dyn InferenceBackend> = Arc::new(CpuBackend::new(Arc::new(MockEmbedder), None));
let v = be.embed("hello").expect("embed ok");
assert_eq!(v, vec![5.0_f32; 4]);
}
#[test]
fn cpu_backend_chat_without_llm_errors() {
let be = CpuBackend::new(Arc::new(MockEmbedder), None);
let err = be.chat("anything").expect_err("must err");
assert!(err.to_string().contains("chat unavailable"));
}
#[test]
fn gpu_backend_returns_not_implemented() {
let be: Arc<dyn InferenceBackend> = Arc::new(GpuBackend::new("test-gpu"));
let err = be.embed("x").expect_err("gpu embed must err");
assert!(err.to_string().contains("not implemented"));
let err = be.chat("x").expect_err("gpu chat must err");
assert!(err.to_string().contains("not implemented"));
assert!(be.attested_weights().is_none());
}
#[test]
fn compute_and_verify_attested_weights_round_trip() {
let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".local-runs");
std::fs::create_dir_all(&dir).expect("mkdir .local-runs");
let path = dir.join(format!(
"inference-attest-fixture-{}.bin",
uuid::Uuid::new_v4()
));
let mut f = std::fs::File::create(&path).expect("create fixture");
f.write_all(b"a tiny attested model weight blob")
.expect("write fixture");
f.sync_all().expect("sync fixture");
drop(f);
let attested =
compute_attested_weights(&path, "fixture", None).expect("compute_attested_weights ok");
assert_eq!(attested.sha256.len(), 64, "sha256 hex must be 64 chars");
verify_attested_weights(&path, &attested).expect("verify ok");
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&path)
.expect("open append");
f.write_all(b"--tampered--").expect("tamper write");
f.sync_all().expect("sync tamper");
drop(f);
let err = verify_attested_weights(&path, &attested)
.expect_err("verify must refuse tampered file");
assert!(err.to_string().contains("hash mismatch"));
let _ = std::fs::remove_file(&path);
}
#[test]
fn cpu_backend_with_attested_weights_round_trip() {
let attested = AttestedWeights {
sha256: "0".repeat(64),
signature: None,
label: "test".into(),
};
let be =
CpuBackend::new(Arc::new(MockEmbedder), None).with_attested_weights(attested.clone());
assert_eq!(be.attested_weights(), Some(attested));
}
fn write_attest_fixture(content: &[u8]) -> std::path::PathBuf {
let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".local-runs");
std::fs::create_dir_all(&dir).expect("mkdir .local-runs");
let path = dir.join(format!("inference-attest-sig-{}.bin", uuid::Uuid::new_v4()));
let mut f = std::fs::File::create(&path).expect("create fixture");
f.write_all(content).expect("write fixture");
f.sync_all().expect("sync fixture");
path
}
fn sign_b64(signing_key: &ed25519_dalek::SigningKey, message: &[u8]) -> String {
use base64::Engine;
use ed25519_dalek::Signer;
base64::engine::general_purpose::STANDARD.encode(signing_key.sign(message).to_bytes())
}
#[test]
fn verify_attested_weights_accepts_valid_operator_signature() {
let mut csprng = rand_core::OsRng;
let signing_key = ed25519_dalek::SigningKey::generate(&mut csprng);
let verifying_key = signing_key.verifying_key();
let path = write_attest_fixture(b"signed weight blob");
let unsigned = compute_attested_weights(&path, "fixture", None).expect("compute ok");
let signature = sign_b64(&signing_key, unsigned.sha256.as_bytes());
let attested = AttestedWeights {
signature: Some(signature),
..unsigned
};
verify_attested_weights_with_key(&path, &attested, Some(&verifying_key))
.expect("valid signature must verify");
let _ = std::fs::remove_file(&path);
}
#[test]
fn verify_attested_weights_rejects_forged_signature() {
let mut csprng = rand_core::OsRng;
let operator_key = ed25519_dalek::SigningKey::generate(&mut csprng);
let attacker_key = ed25519_dalek::SigningKey::generate(&mut csprng);
let path = write_attest_fixture(b"forged weight blob");
let unsigned = compute_attested_weights(&path, "fixture", None).expect("compute ok");
let signature = sign_b64(&attacker_key, unsigned.sha256.as_bytes());
let attested = AttestedWeights {
signature: Some(signature),
..unsigned
};
let err =
verify_attested_weights_with_key(&path, &attested, Some(&operator_key.verifying_key()))
.expect_err("forged signature must be refused");
assert!(err.to_string().contains("signature verification failed"));
let _ = std::fs::remove_file(&path);
}
#[test]
fn verify_attested_weights_fails_closed_when_signed_but_no_key() {
let mut csprng = rand_core::OsRng;
let signing_key = ed25519_dalek::SigningKey::generate(&mut csprng);
let path = write_attest_fixture(b"orphan-signature weight blob");
let unsigned = compute_attested_weights(&path, "fixture", None).expect("compute ok");
let signature = sign_b64(&signing_key, unsigned.sha256.as_bytes());
let attested = AttestedWeights {
signature: Some(signature),
..unsigned
};
let err = verify_attested_weights_with_key(&path, &attested, None)
.expect_err("present signature with no key must fail closed");
assert!(err.to_string().contains("no operator public key"));
let _ = std::fs::remove_file(&path);
}
#[test]
fn verify_attested_weights_unsigned_record_skips_signature_gate() {
let path = write_attest_fixture(b"unsigned weight blob");
let attested = compute_attested_weights(&path, "fixture", None).expect("compute ok");
assert!(attested.signature.is_none());
verify_attested_weights_with_key(&path, &attested, None)
.expect("unsigned record must verify on hash alone");
let _ = std::fs::remove_file(&path);
}
}