use anyhow::{Context, Result};
use hmac::{Hmac, Mac};
use rand::RngExt;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::Path;
type HmacSha256 = Hmac<Sha256>;
pub const MANIFEST_FILENAME: &str = ".localgpt_manifest.json";
const DEVICE_KEY_FILENAME: &str = ".device_key";
const DEVICE_KEY_LEN: usize = 32;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
pub version: u8,
pub hmac_sha256: String,
pub signed_at: String,
pub signed_by: String,
pub content_sha256: String,
}
pub fn ensure_device_key(state_dir: &Path) -> Result<()> {
let key_path = state_dir.join(DEVICE_KEY_FILENAME);
if key_path.exists() {
return Ok(());
}
let mut key = [0u8; DEVICE_KEY_LEN];
rand::rng().fill(&mut key);
fs::write(&key_path, key).context("Failed to write device key")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600))
.context("Failed to set device key permissions")?;
}
tracing::info!("Generated device key at {}", key_path.display());
Ok(())
}
pub fn read_device_key(state_dir: &Path) -> Result<[u8; DEVICE_KEY_LEN]> {
let key_path = state_dir.join(DEVICE_KEY_FILENAME);
let bytes = fs::read(&key_path).context("Failed to read device key. Run `localgpt init`.")?;
if bytes.len() != DEVICE_KEY_LEN {
anyhow::bail!(
"Device key has unexpected length {} (expected {})",
bytes.len(),
DEVICE_KEY_LEN
);
}
let mut key = [0u8; DEVICE_KEY_LEN];
key.copy_from_slice(&bytes);
Ok(key)
}
pub fn content_sha256(content: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
hex_encode(&hasher.finalize())
}
pub fn compute_hmac(key: &[u8; DEVICE_KEY_LEN], content: &str) -> Result<String> {
let mut mac =
HmacSha256::new_from_slice(key).context("HMAC key initialization should not fail")?;
mac.update(content.as_bytes());
Ok(hex_encode(&mac.finalize().into_bytes()))
}
pub fn sign_policy(state_dir: &Path, workspace: &Path, signed_by: &str) -> Result<Manifest> {
let policy_path = workspace.join(super::localgpt::POLICY_FILENAME);
let content = fs::read_to_string(&policy_path)
.with_context(|| format!("Failed to read {}", policy_path.display()))?;
let key = read_device_key(state_dir)?;
let sha256 = content_sha256(&content);
let hmac = compute_hmac(&key, &content)?;
let now = chrono::Utc::now().to_rfc3339();
let manifest = Manifest {
version: 1,
hmac_sha256: hmac,
signed_at: now,
signed_by: signed_by.to_string(),
content_sha256: sha256,
};
let manifest_path = workspace.join(MANIFEST_FILENAME);
let json = serde_json::to_string_pretty(&manifest).context("Failed to serialize manifest")?;
fs::write(&manifest_path, json).context("Failed to write manifest")?;
Ok(manifest)
}
pub fn verify_signature(state_dir: &Path, workspace: &Path) -> Result<bool> {
let policy_path = workspace.join(super::localgpt::POLICY_FILENAME);
let content = fs::read_to_string(&policy_path)
.with_context(|| format!("Failed to read {}", policy_path.display()))?;
let manifest = read_manifest(workspace)?;
let key = read_device_key(state_dir)?;
let sha256 = content_sha256(&content);
if sha256 != manifest.content_sha256 {
return Ok(false);
}
let hmac = compute_hmac(&key, &content)?;
Ok(hmac == manifest.hmac_sha256)
}
pub fn read_manifest(workspace: &Path) -> Result<Manifest> {
let manifest_path = workspace.join(MANIFEST_FILENAME);
let json = fs::read_to_string(&manifest_path)
.with_context(|| format!("Failed to read {}", manifest_path.display()))?;
serde_json::from_str(&json).context("Failed to parse manifest JSON")
}
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn device_key_generation() {
let tmp = tempfile::tempdir().unwrap();
ensure_device_key(tmp.path()).unwrap();
let key_path = tmp.path().join(DEVICE_KEY_FILENAME);
assert!(key_path.exists());
let bytes = fs::read(&key_path).unwrap();
assert_eq!(bytes.len(), DEVICE_KEY_LEN);
}
#[test]
fn device_key_idempotent() {
let tmp = tempfile::tempdir().unwrap();
ensure_device_key(tmp.path()).unwrap();
let key1 = read_device_key(tmp.path()).unwrap();
ensure_device_key(tmp.path()).unwrap();
let key2 = read_device_key(tmp.path()).unwrap();
assert_eq!(key1, key2);
}
#[cfg(unix)]
#[test]
fn device_key_permissions() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
ensure_device_key(tmp.path()).unwrap();
let key_path = tmp.path().join(DEVICE_KEY_FILENAME);
let perms = fs::metadata(&key_path).unwrap().permissions();
assert_eq!(perms.mode() & 0o777, 0o600);
}
#[test]
fn sign_and_verify_roundtrip() {
let tmp = tempfile::tempdir().unwrap();
let state_dir = tmp.path().join("state");
let workspace = tmp.path().join("workspace");
fs::create_dir_all(&state_dir).unwrap();
fs::create_dir_all(&workspace).unwrap();
ensure_device_key(&state_dir).unwrap();
let policy_content = "# Security Policy\n\n- Do not access /etc/passwd\n";
fs::write(
workspace.join(super::super::localgpt::POLICY_FILENAME),
policy_content,
)
.unwrap();
let manifest = sign_policy(&state_dir, &workspace, "cli").unwrap();
assert_eq!(manifest.version, 1);
assert_eq!(manifest.signed_by, "cli");
assert!(!manifest.hmac_sha256.is_empty());
assert!(!manifest.content_sha256.is_empty());
assert!(verify_signature(&state_dir, &workspace).unwrap());
}
#[test]
fn tampered_content_fails_verification() {
let tmp = tempfile::tempdir().unwrap();
let state_dir = tmp.path().join("state");
let workspace = tmp.path().join("workspace");
fs::create_dir_all(&state_dir).unwrap();
fs::create_dir_all(&workspace).unwrap();
ensure_device_key(&state_dir).unwrap();
let policy_path = workspace.join(super::super::localgpt::POLICY_FILENAME);
fs::write(&policy_path, "Original content").unwrap();
sign_policy(&state_dir, &workspace, "cli").unwrap();
fs::write(&policy_path, "Tampered content").unwrap();
assert!(!verify_signature(&state_dir, &workspace).unwrap());
}
#[test]
fn content_sha256_deterministic() {
let hash1 = content_sha256("hello world");
let hash2 = content_sha256("hello world");
assert_eq!(hash1, hash2);
assert_eq!(hash1.len(), 64); }
#[test]
fn hmac_deterministic() {
let key = [42u8; DEVICE_KEY_LEN];
let hmac1 = compute_hmac(&key, "test data").unwrap();
let hmac2 = compute_hmac(&key, "test data").unwrap();
assert_eq!(hmac1, hmac2);
}
#[test]
fn hmac_differs_for_different_content() {
let key = [42u8; DEVICE_KEY_LEN];
let hmac1 = compute_hmac(&key, "content A").unwrap();
let hmac2 = compute_hmac(&key, "content B").unwrap();
assert_ne!(hmac1, hmac2);
}
#[test]
fn hmac_differs_for_different_keys() {
let key1 = [1u8; DEVICE_KEY_LEN];
let key2 = [2u8; DEVICE_KEY_LEN];
let hmac1 = compute_hmac(&key1, "same content").unwrap();
let hmac2 = compute_hmac(&key2, "same content").unwrap();
assert_ne!(hmac1, hmac2);
}
}