use crate::models::field_names;
use anyhow::{Context, Result};
use rusqlite::{Connection, OptionalExtension, params};
use serde::{Deserialize, Serialize};
pub(crate) const ATTEST_OPERATOR_SIGNED: &str = "operator_signed";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Rule {
pub id: String,
pub kind: String,
pub matcher: String,
pub severity: String,
pub reason: String,
pub namespace: String,
pub created_by: String,
pub created_at: i64,
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature: Option<Vec<u8>>,
pub attest_level: String,
}
pub fn insert(conn: &Connection, rule: &Rule) -> Result<()> {
conn.execute(
"INSERT INTO governance_rules (
id, kind, matcher, severity, reason, namespace,
created_by, created_at, enabled, signature, attest_level
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
params![
rule.id,
rule.kind,
rule.matcher,
rule.severity,
rule.reason,
rule.namespace,
rule.created_by,
rule.created_at,
i64::from(rule.enabled),
rule.signature,
rule.attest_level,
],
)
.with_context(|| format!("rules_store::insert: id={}", rule.id))?;
Ok(())
}
pub fn get(conn: &Connection, id: &str) -> Result<Option<Rule>> {
let row = conn
.query_row(
"SELECT id, kind, matcher, severity, reason, namespace,
created_by, created_at, enabled, signature, attest_level
FROM governance_rules WHERE id = ?1",
params![id],
row_to_rule,
)
.optional()
.with_context(|| format!("rules_store::get: id={id}"))?;
Ok(row)
}
pub fn list(conn: &Connection) -> Result<Vec<Rule>> {
let mut stmt = conn
.prepare(
"SELECT id, kind, matcher, severity, reason, namespace,
created_by, created_at, enabled, signature, attest_level
FROM governance_rules ORDER BY id ASC",
)
.context("rules_store::list: prepare")?;
let rows = stmt
.query_map([], row_to_rule)
.context("rules_store::list: query_map")?;
let mut out = Vec::new();
for r in rows {
out.push(r.context("rules_store::list: row")?);
}
Ok(out)
}
pub fn list_enabled_by_kind(conn: &Connection, kind: &str) -> Result<Vec<Rule>> {
let mut stmt = conn
.prepare(
"SELECT id, kind, matcher, severity, reason, namespace,
created_by, created_at, enabled, signature, attest_level
FROM governance_rules
WHERE kind = ?1 AND enabled = 1
ORDER BY id ASC",
)
.context("rules_store::list_enabled_by_kind: prepare")?;
let rows = stmt
.query_map(params![kind], row_to_rule)
.context("rules_store::list_enabled_by_kind: query_map")?;
let operator_pubkey = resolve_operator_pubkey();
let mut out = Vec::new();
for r in rows {
let rule = r.context("rules_store::list_enabled_by_kind: row")?;
if enforced_rule_passes(&rule, operator_pubkey.as_ref()) {
out.push(rule);
}
}
Ok(out)
}
#[must_use]
pub fn enforced_rule_passes(
rule: &Rule,
operator_pubkey: Option<&ed25519_dalek::VerifyingKey>,
) -> bool {
match (operator_pubkey, rule.attest_level.as_str()) {
(Some(pk), ATTEST_OPERATOR_SIGNED) => match verify_rule_signature(rule, pk) {
Ok(()) => true,
Err(_) => {
tracing::error!(
rule_id = %rule.id,
"L1-6: operator_signed rule failed signature verification — \
skipping. Tampered row OR rule was directly modified after \
signing (e.g. `UPDATE governance_rules SET enabled = 1`). \
Re-sign with `ai-memory rules sign-seed` after audit."
);
false
}
},
(Some(_), _) => {
tracing::warn!(
rule_id = %rule.id,
attest_level = %rule.attest_level,
"L1-6: enabled rule is not operator_signed — skipping. Run \
`ai-memory rules sign-seed` to commit the operator signature."
);
false
}
(None, _) => {
true
}
}
}
const OPERATOR_PUBKEY_KEYGEN_FILE: &str = "operator.key.pub";
const OPERATOR_PUBKEY_LEGACY_FILE: &str = "operator.pub";
pub const OPERATOR_SIGNED_ATTEST_LEVEL: &str = "operator_signed";
#[must_use]
pub fn resolve_operator_pubkey() -> Option<ed25519_dalek::VerifyingKey> {
#[cfg(test)]
if force_no_operator_pubkey_active() {
return None;
}
use base64::Engine;
let from_bytes = |bytes: &[u8]| -> Option<ed25519_dalek::VerifyingKey> {
if bytes.len() != ed25519_dalek::PUBLIC_KEY_LENGTH {
return None;
}
let mut arr = [0u8; ed25519_dalek::PUBLIC_KEY_LENGTH];
arr.copy_from_slice(bytes);
ed25519_dalek::VerifyingKey::from_bytes(&arr).ok()
};
let try_decode = |s: &str| -> Option<ed25519_dalek::VerifyingKey> {
let trimmed = s.trim();
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(trimmed)
.or_else(|_| base64::engine::general_purpose::STANDARD.decode(trimmed))
.ok()?;
from_bytes(&bytes)
};
let try_pub_file = |path: &std::path::Path| -> Option<ed25519_dalek::VerifyingKey> {
let raw = std::fs::read(path).ok()?;
if let Ok(text) = std::str::from_utf8(&raw)
&& let Some(pk) = try_decode(text)
{
return Some(pk);
}
from_bytes(&raw)
};
if let Ok(v) = std::env::var("AI_MEMORY_OPERATOR_PUBKEY")
&& !v.is_empty()
&& let Some(pk) = try_decode(&v)
{
return Some(pk);
}
let key_dir = crate::identity::keypair::default_key_dir().ok()?;
let mut candidates: Vec<std::path::PathBuf> = vec![
key_dir.join(OPERATOR_PUBKEY_KEYGEN_FILE),
key_dir.join(OPERATOR_PUBKEY_LEGACY_FILE),
];
if let Some(parent) = key_dir.parent() {
candidates.push(parent.join(OPERATOR_PUBKEY_KEYGEN_FILE));
candidates.push(parent.join(OPERATOR_PUBKEY_LEGACY_FILE));
}
candidates.iter().find_map(|p| try_pub_file(p))
}
#[cfg(test)]
fn force_no_operator_pubkey_active() -> bool {
FORCE_NO_OPERATOR_PUBKEY.with(std::cell::Cell::get)
}
#[cfg(test)]
thread_local! {
static FORCE_NO_OPERATOR_PUBKEY: std::cell::Cell<bool> =
const { std::cell::Cell::new(false) };
}
#[cfg(test)]
#[must_use = "the guard must be held for its scope to suppress pubkey resolution"]
pub fn force_no_operator_pubkey_for_test() -> ForceNoOperatorPubkeyGuard {
let prior = FORCE_NO_OPERATOR_PUBKEY.with(|c| c.replace(true));
ForceNoOperatorPubkeyGuard { prior }
}
#[cfg(test)]
pub struct ForceNoOperatorPubkeyGuard {
prior: bool,
}
#[cfg(test)]
impl Drop for ForceNoOperatorPubkeyGuard {
fn drop(&mut self) {
FORCE_NO_OPERATOR_PUBKEY.with(|c| c.set(self.prior));
}
}
pub fn count_enabled_rules(conn: &Connection) -> Result<i64> {
let result = conn.query_row(
"SELECT COUNT(*) FROM governance_rules WHERE enabled = 1",
[],
|row| row.get::<_, i64>(0),
);
match result {
Ok(n) => Ok(n),
Err(rusqlite::Error::SqliteFailure(_, Some(msg)))
if msg.contains("no such table") || msg.contains("does not exist") =>
{
Ok(0)
}
Err(rusqlite::Error::SqliteFailure(_, None)) => Ok(0),
Err(e) => Err(anyhow::Error::new(e).context("rules_store::count_enabled_rules")),
}
}
#[must_use]
pub fn l1_6_attest_active() -> bool {
resolve_operator_pubkey().is_some()
}
pub fn log_missing_operator_pubkey_once(enabled_rule_count: i64) {
use std::sync::OnceLock;
static LOGGED: OnceLock<()> = OnceLock::new();
if LOGGED.set(()).is_err() {
return;
}
tracing::error!(
enabled_rule_count,
"SEC-2: governance_rules contains {enabled_rule_count} enabled row(s) but no operator \
pubkey is resolved (AI_MEMORY_OPERATOR_PUBKEY unset AND \
~/.config/ai-memory/operator.key.pub absent). Substrate is in FAIL-OPEN posture: every \
enabled rule passes through without signature verification, so a SQL-write gadget that \
can mutate `governance_rules` can install or flip rules without operator consent. \
Activate L1-6 by either (a) running `ai-memory rules keygen` + `ai-memory rules \
sign-seed` to place an operator key + sign the existing rows, or (b) setting `[governance] \
require_operator_pubkey = true` in config.toml to refuse boot until the pubkey is in \
place."
);
}
pub fn remove(conn: &Connection, id: &str) -> Result<bool> {
let affected = conn
.execute("DELETE FROM governance_rules WHERE id = ?1", params![id])
.with_context(|| format!("rules_store::remove: id={id}"))?;
Ok(affected > 0)
}
pub fn remove_signed(
conn: &Connection,
id: &str,
signing_key: &ed25519_dalek::SigningKey,
operator_agent_id: &str,
) -> Result<bool> {
use ed25519_dalek::Signer;
let tx = rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Immediate)
.context("rules_store::remove_signed: begin IMMEDIATE tx")?;
let Some(rule) = get(&tx, id)? else {
tx.commit()
.context("rules_store::remove_signed: commit empty tx")?;
return Ok(false);
};
let canonical = canonical_bytes_for_signing(&rule)?;
let payload_hash = crate::signed_events::payload_hash(&canonical);
let signature = signing_key.sign(&payload_hash);
let event = crate::signed_events::SignedEvent {
id: uuid::Uuid::new_v4().to_string(),
agent_id: operator_agent_id.to_string(),
event_type: crate::signed_events::event_types::GOVERNANCE_RULE_REMOVED.to_string(),
payload_hash,
signature: Some(signature.to_bytes().to_vec()),
attest_level: OPERATOR_SIGNED_ATTEST_LEVEL.to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
..crate::signed_events::SignedEvent::default()
};
crate::signed_events::append_signed_event_no_tx(&tx, &event)?;
let affected = tx
.execute("DELETE FROM governance_rules WHERE id = ?1", params![id])
.with_context(|| format!("rules_store::remove_signed: delete id={id}"))?;
tx.commit()
.context("rules_store::remove_signed: commit tx")?;
Ok(affected > 0)
}
pub fn set_enabled(conn: &Connection, id: &str, enabled: bool) -> Result<bool> {
let affected = conn
.execute(
"UPDATE governance_rules SET enabled = ?1 WHERE id = ?2",
params![i64::from(enabled), id],
)
.with_context(|| format!("rules_store::set_enabled: id={id} enabled={enabled}"))?;
Ok(affected > 0)
}
pub fn update_signature(
conn: &Connection,
id: &str,
signature: &[u8],
attest_level: &str,
) -> Result<bool> {
let affected = conn
.execute(
"UPDATE governance_rules
SET signature = ?1, attest_level = ?2
WHERE id = ?3",
params![signature, attest_level, id],
)
.with_context(|| format!("rules_store::update_signature: id={id}"))?;
Ok(affected > 0)
}
pub fn canonical_bytes(rule: &Rule) -> Result<Vec<u8>> {
let canonical = serde_json::json!({
"id": rule.id,
"kind": rule.kind,
"matcher": rule.matcher,
"severity": rule.severity,
"reason": rule.reason,
"namespace": rule.namespace,
(field_names::CREATED_BY): rule.created_by,
(field_names::CREATED_AT): rule.created_at,
});
serde_json::to_vec(&canonical).context("rules_store::canonical_bytes: serialize")
}
pub fn canonical_bytes_for_signing(rule: &Rule) -> Result<Vec<u8>> {
let canonical = serde_json::json!({
"id": rule.id,
"kind": rule.kind,
"matcher": rule.matcher,
"severity": rule.severity,
"reason": rule.reason,
"namespace": rule.namespace,
(field_names::CREATED_BY): rule.created_by,
"enabled": rule.enabled,
});
serde_json::to_vec(&canonical).context("rules_store::canonical_bytes_for_signing: serialize")
}
pub fn verify_rule_signature(
rule: &Rule,
operator_pubkey: &ed25519_dalek::VerifyingKey,
) -> Result<(), ed25519_dalek::SignatureError> {
use ed25519_dalek::{Signature, Verifier};
let Some(sig_bytes) = rule.signature.as_ref() else {
return 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 signature = Signature::from_bytes(&sig_arr);
let canonical =
canonical_bytes_for_signing(rule).map_err(|_| ed25519_dalek::SignatureError::new())?;
operator_pubkey.verify(&canonical, &signature)
}
fn row_to_rule(row: &rusqlite::Row<'_>) -> rusqlite::Result<Rule> {
Ok(Rule {
id: row.get(0)?,
kind: row.get(1)?,
matcher: row.get(2)?,
severity: row.get(3)?,
reason: row.get(4)?,
namespace: row.get(5)?,
created_by: row.get(6)?,
created_at: row.get(7)?,
enabled: row.get::<_, i64>(8)? != 0,
signature: row.get(9)?,
attest_level: row.get(10)?,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn fresh_conn() -> Connection {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE governance_rules (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
matcher TEXT NOT NULL,
severity TEXT NOT NULL CHECK (severity IN ('refuse','warn','log')),
reason TEXT NOT NULL,
namespace TEXT NOT NULL DEFAULT '_global',
created_by TEXT NOT NULL,
created_at INTEGER NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
signature BLOB,
attest_level TEXT NOT NULL DEFAULT 'unsigned'
);",
)
.unwrap();
conn
}
fn make_rule(id: &str, kind: &str, enabled: bool) -> Rule {
Rule {
id: id.to_string(),
kind: kind.to_string(),
matcher: r#"{"k":"v"}"#.to_string(),
severity: "refuse".to_string(),
reason: "test".to_string(),
namespace: "_global".to_string(),
created_by: "test".to_string(),
created_at: 12345,
enabled,
signature: None,
attest_level: "unsigned".to_string(),
}
}
#[test]
fn insert_then_get_roundtrip() {
let conn = fresh_conn();
let rule = make_rule("R1", "bash", true);
insert(&conn, &rule).unwrap();
let got = get(&conn, "R1").unwrap();
assert_eq!(got.as_ref(), Some(&rule));
}
#[test]
fn get_returns_none_when_missing() {
let conn = fresh_conn();
assert_eq!(get(&conn, "nope").unwrap(), None);
}
#[test]
fn insert_duplicate_id_errors() {
let conn = fresh_conn();
let rule = make_rule("R1", "bash", true);
insert(&conn, &rule).unwrap();
assert!(insert(&conn, &rule).is_err());
}
#[test]
fn list_orders_by_id_ascending() {
let conn = fresh_conn();
insert(&conn, &make_rule("R3", "bash", true)).unwrap();
insert(&conn, &make_rule("R1", "bash", true)).unwrap();
insert(&conn, &make_rule("R2", "bash", true)).unwrap();
let all = list(&conn).unwrap();
let ids: Vec<&str> = all.iter().map(|r| r.id.as_str()).collect();
assert_eq!(ids, vec!["R1", "R2", "R3"]);
}
#[test]
fn list_enabled_by_kind_filters_correctly() {
let _no_pubkey = force_no_operator_pubkey_for_test();
let conn = fresh_conn();
insert(&conn, &make_rule("R1", "bash", true)).unwrap();
insert(&conn, &make_rule("R2", "bash", false)).unwrap();
insert(&conn, &make_rule("R3", "filesystem_write", true)).unwrap();
let bash_rules = list_enabled_by_kind(&conn, "bash").unwrap();
assert_eq!(bash_rules.len(), 1);
assert_eq!(bash_rules[0].id, "R1");
let fs_rules = list_enabled_by_kind(&conn, "filesystem_write").unwrap();
assert_eq!(fs_rules.len(), 1);
assert_eq!(fs_rules[0].id, "R3");
let other = list_enabled_by_kind(&conn, "network_request").unwrap();
assert!(other.is_empty());
}
#[test]
fn remove_returns_true_on_hit_false_on_miss() {
let conn = fresh_conn();
insert(&conn, &make_rule("R1", "bash", true)).unwrap();
assert!(remove(&conn, "R1").unwrap());
assert!(!remove(&conn, "R1").unwrap());
assert_eq!(get(&conn, "R1").unwrap(), None);
}
fn fresh_conn_with_audit() -> Connection {
let conn = fresh_conn();
conn.execute_batch(
"CREATE TABLE signed_events (
id TEXT PRIMARY KEY,
agent_id TEXT NOT NULL,
event_type TEXT NOT NULL,
payload_hash BLOB NOT NULL,
signature BLOB,
attest_level TEXT NOT NULL DEFAULT 'unsigned',
timestamp TEXT NOT NULL,
prev_hash BLOB,
sequence INTEGER
);
CREATE UNIQUE INDEX idx_signed_events_sequence ON signed_events(sequence);",
)
.unwrap();
conn
}
#[test]
fn remove_signed_emits_operator_signed_audit_event_and_deletes() {
use ed25519_dalek::Verifier;
let conn = fresh_conn_with_audit();
let rule = make_rule("R1", "bash", true);
insert(&conn, &rule).unwrap();
let mut csprng = rand_core::OsRng;
let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
let operator_pubkey = signing.verifying_key();
let removed = remove_signed(&conn, "R1", &signing, "operator").unwrap();
assert!(removed, "remove_signed must report the row was deleted");
assert_eq!(get(&conn, "R1").unwrap(), None, "rule row must be gone");
let (event_type, attest_level, payload_hash, sig): (String, String, Vec<u8>, Vec<u8>) =
conn.query_row(
"SELECT event_type, attest_level, payload_hash, signature FROM signed_events",
[],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Vec<u8>>(2)?,
row.get::<_, Vec<u8>>(3)?,
))
},
)
.expect("exactly one signed_events row must exist");
assert_eq!(
event_type,
crate::signed_events::event_types::GOVERNANCE_RULE_REMOVED
);
assert_eq!(attest_level, OPERATOR_SIGNED_ATTEST_LEVEL);
let expected_hash =
crate::signed_events::payload_hash(&canonical_bytes_for_signing(&rule).unwrap());
assert_eq!(payload_hash, expected_hash);
assert_eq!(
sig.len(),
ed25519_dalek::SIGNATURE_LENGTH,
"signature must be 64 bytes"
);
let mut sig_arr = [0u8; ed25519_dalek::SIGNATURE_LENGTH];
sig_arr.copy_from_slice(&sig);
let signature = ed25519_dalek::Signature::from_bytes(&sig_arr);
operator_pubkey
.verify(&payload_hash, &signature)
.expect("operator signature over payload_hash must verify");
}
#[test]
fn remove_signed_missing_id_returns_false_and_emits_nothing() {
let conn = fresh_conn_with_audit();
let mut csprng = rand_core::OsRng;
let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
let removed = remove_signed(&conn, "nope", &signing, "operator").unwrap();
assert!(!removed);
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM signed_events", [], |row| row.get(0))
.unwrap();
assert_eq!(count, 0, "no audit row may be emitted for a no-op removal");
}
#[test]
fn set_enabled_toggles() {
let conn = fresh_conn();
insert(&conn, &make_rule("R1", "bash", false)).unwrap();
assert!(set_enabled(&conn, "R1", true).unwrap());
assert!(get(&conn, "R1").unwrap().unwrap().enabled);
assert!(set_enabled(&conn, "R1", false).unwrap());
assert!(!get(&conn, "R1").unwrap().unwrap().enabled);
}
#[test]
fn set_enabled_missing_returns_false() {
let conn = fresh_conn();
assert!(!set_enabled(&conn, "nope", true).unwrap());
}
#[test]
fn update_signature_persists_blob_and_attest_level() {
let conn = fresh_conn();
insert(&conn, &make_rule("R1", "bash", true)).unwrap();
let sig = vec![1u8, 2, 3, 4];
assert!(update_signature(&conn, "R1", &sig, "operator_signed").unwrap());
let got = get(&conn, "R1").unwrap().unwrap();
assert_eq!(got.signature, Some(sig));
assert_eq!(got.attest_level, "operator_signed");
}
#[test]
fn update_signature_missing_returns_false() {
let conn = fresh_conn();
assert!(!update_signature(&conn, "nope", &[1, 2, 3], "operator_signed").unwrap());
}
#[test]
fn canonical_bytes_excludes_signature_fields() {
let mut rule = make_rule("R1", "bash", true);
rule.signature = Some(vec![9, 9, 9]);
rule.attest_level = "operator_signed".to_string();
let bytes = canonical_bytes(&rule).unwrap();
let s = std::str::from_utf8(&bytes).unwrap();
assert!(!s.contains("signature"));
assert!(!s.contains("attest_level"));
assert!(s.contains("\"id\":\"R1\""));
assert!(s.contains("\"kind\":\"bash\""));
}
#[test]
fn severity_check_constraint_rejects_unknown() {
let conn = fresh_conn();
let mut rule = make_rule("R1", "bash", true);
rule.severity = "unknown".to_string();
assert!(insert(&conn, &rule).is_err());
}
#[test]
fn rule_serde_roundtrip() {
let rule = make_rule("R1", "bash", true);
let v = serde_json::to_value(&rule).unwrap();
let back: Rule = serde_json::from_value(v).unwrap();
assert_eq!(back, rule);
}
#[test]
fn canonical_bytes_for_signing_includes_enabled() {
let mut rule = make_rule("R1", "bash", true);
let bytes_enabled = canonical_bytes_for_signing(&rule).unwrap();
rule.enabled = false;
let bytes_disabled = canonical_bytes_for_signing(&rule).unwrap();
assert_ne!(
bytes_enabled, bytes_disabled,
"flipping `enabled` must change canonical bytes"
);
for b in [&bytes_enabled, &bytes_disabled] {
let s = std::str::from_utf8(b).unwrap();
assert!(s.contains("\"enabled\""), "missing enabled in: {s}");
}
}
#[test]
fn canonical_bytes_for_signing_excludes_signature_and_attest_level() {
let mut rule = make_rule("R1", "bash", true);
rule.signature = Some(vec![1, 2, 3, 4]);
rule.attest_level = "operator_signed".to_string();
let bytes = canonical_bytes_for_signing(&rule).unwrap();
let s = std::str::from_utf8(&bytes).unwrap();
assert!(!s.contains("signature"), "got: {s}");
assert!(!s.contains("attest_level"), "got: {s}");
assert!(!s.contains("created_at"), "got: {s}");
}
#[test]
fn verify_rule_signature_round_trips_under_correct_key() {
use ed25519_dalek::Signer;
let mut rule = make_rule("R1", "bash", false);
let mut csprng = rand_core::OsRng;
let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
let verifying = signing.verifying_key();
let canonical = canonical_bytes_for_signing(&rule).unwrap();
let sig = signing.sign(&canonical);
rule.signature = Some(sig.to_bytes().to_vec());
assert!(verify_rule_signature(&rule, &verifying).is_ok());
}
#[test]
fn verify_rule_signature_fails_on_enabled_flip() {
use ed25519_dalek::Signer;
let mut rule = make_rule("R1", "bash", false);
let mut csprng = rand_core::OsRng;
let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
let verifying = signing.verifying_key();
let canonical = canonical_bytes_for_signing(&rule).unwrap();
let sig = signing.sign(&canonical);
rule.signature = Some(sig.to_bytes().to_vec());
rule.enabled = true;
assert!(
verify_rule_signature(&rule, &verifying).is_err(),
"signature must not verify after `enabled` flip"
);
}
#[test]
fn verify_rule_signature_fails_on_matcher_tamper() {
use ed25519_dalek::Signer;
let mut rule = make_rule("R1", "bash", false);
let mut csprng = rand_core::OsRng;
let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
let verifying = signing.verifying_key();
let canonical = canonical_bytes_for_signing(&rule).unwrap();
let sig = signing.sign(&canonical);
rule.signature = Some(sig.to_bytes().to_vec());
rule.matcher = r#"{"k":"tampered"}"#.to_string();
assert!(verify_rule_signature(&rule, &verifying).is_err());
}
#[test]
fn verify_rule_signature_fails_under_wrong_key() {
use ed25519_dalek::Signer;
let mut rule = make_rule("R1", "bash", false);
let mut csprng = rand_core::OsRng;
let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
let other = ed25519_dalek::SigningKey::generate(&mut csprng);
let canonical = canonical_bytes_for_signing(&rule).unwrap();
let sig = signing.sign(&canonical);
rule.signature = Some(sig.to_bytes().to_vec());
assert!(verify_rule_signature(&rule, &other.verifying_key()).is_err());
}
#[test]
fn verify_rule_signature_fails_on_missing_signature() {
let mut csprng = rand_core::OsRng;
let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
let rule = make_rule("R1", "bash", false);
assert!(rule.signature.is_none());
assert!(verify_rule_signature(&rule, &signing.verifying_key()).is_err());
}
#[test]
fn verify_rule_signature_fails_on_wrong_length_signature() {
let mut csprng = rand_core::OsRng;
let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
let mut rule = make_rule("R1", "bash", false);
rule.signature = Some(vec![0u8; 8]); assert!(verify_rule_signature(&rule, &signing.verifying_key()).is_err());
}
fn signed_rule(id: &str, enabled: bool, signing: &ed25519_dalek::SigningKey) -> Rule {
use ed25519_dalek::Signer;
let mut rule = make_rule(id, "bash", enabled);
rule.attest_level = "operator_signed".to_string();
let canonical = canonical_bytes_for_signing(&rule).unwrap();
let sig = signing.sign(&canonical);
rule.signature = Some(sig.to_bytes().to_vec());
rule
}
#[test]
fn enforced_rule_passes_when_no_pubkey_configured() {
let rule = make_rule("R1", "bash", true);
assert!(enforced_rule_passes(&rule, None));
let mut signed_ish = make_rule("R2", "bash", true);
signed_ish.attest_level = "operator_signed".to_string();
assert!(enforced_rule_passes(&signed_ish, None));
}
#[test]
fn enforced_rule_passes_signed_under_correct_key() {
let mut csprng = rand_core::OsRng;
let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
let pk = signing.verifying_key();
let rule = signed_rule("R1", false, &signing);
assert!(enforced_rule_passes(&rule, Some(&pk)));
}
#[test]
fn enforced_rule_passes_rejects_tampered_signed_row() {
let mut csprng = rand_core::OsRng;
let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
let pk = signing.verifying_key();
let mut rule = signed_rule("R1", false, &signing);
rule.enabled = true;
assert!(!enforced_rule_passes(&rule, Some(&pk)));
}
#[test]
fn enforced_rule_passes_rejects_unsigned_with_pubkey_configured() {
let mut csprng = rand_core::OsRng;
let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
let pk = signing.verifying_key();
let rule = make_rule("R1", "bash", true); assert!(!enforced_rule_passes(&rule, Some(&pk)));
}
#[test]
fn enforced_rule_passes_rejects_signed_under_wrong_key() {
let mut csprng = rand_core::OsRng;
let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
let other = ed25519_dalek::SigningKey::generate(&mut csprng);
let rule = signed_rule("R1", false, &signing);
assert!(!enforced_rule_passes(&rule, Some(&other.verifying_key())));
}
#[test]
fn count_enabled_rules_returns_zero_when_table_empty() {
let conn = fresh_conn();
assert_eq!(count_enabled_rules(&conn).unwrap(), 0);
}
#[test]
fn count_enabled_rules_returns_zero_when_table_missing() {
let conn = Connection::open_in_memory().unwrap();
assert_eq!(count_enabled_rules(&conn).unwrap(), 0);
}
#[test]
fn count_enabled_rules_counts_only_enabled_rows() {
let conn = fresh_conn();
insert(&conn, &make_rule("R1", "bash", true)).unwrap();
insert(&conn, &make_rule("R2", "bash", false)).unwrap();
insert(&conn, &make_rule("R3", "filesystem_write", true)).unwrap();
assert_eq!(count_enabled_rules(&conn).unwrap(), 2);
}
#[test]
fn count_enabled_rules_single_enabled_row() {
let conn = fresh_conn();
insert(&conn, &make_rule("R1", "bash", true)).unwrap();
assert_eq!(count_enabled_rules(&conn).unwrap(), 1);
}
#[test]
fn log_missing_operator_pubkey_once_is_idempotent() {
log_missing_operator_pubkey_once(7);
log_missing_operator_pubkey_once(99);
}
#[test]
fn resolve_operator_pubkey_returns_none_when_env_and_file_absent() {
let prior = std::env::var("AI_MEMORY_OPERATOR_PUBKEY").ok();
unsafe { std::env::remove_var("AI_MEMORY_OPERATOR_PUBKEY") };
let _ = resolve_operator_pubkey();
let _ = l1_6_attest_active();
if let Some(v) = prior {
unsafe { std::env::set_var("AI_MEMORY_OPERATOR_PUBKEY", v) };
}
}
#[test]
fn resolve_operator_pubkey_accepts_url_safe_no_pad_base64() {
use base64::Engine;
let mut csprng = rand_core::OsRng;
let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
let vk = signing.verifying_key();
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(vk.as_bytes());
let prior = std::env::var("AI_MEMORY_OPERATOR_PUBKEY").ok();
unsafe { std::env::set_var("AI_MEMORY_OPERATOR_PUBKEY", &encoded) };
let got = resolve_operator_pubkey();
assert!(got.is_some(), "expected to decode URL_SAFE_NO_PAD pubkey");
assert_eq!(got.unwrap().as_bytes(), vk.as_bytes());
match prior {
Some(v) => unsafe { std::env::set_var("AI_MEMORY_OPERATOR_PUBKEY", v) },
None => unsafe { std::env::remove_var("AI_MEMORY_OPERATOR_PUBKEY") },
}
}
#[test]
fn resolve_operator_pubkey_reads_keygen_layout_from_key_dir() {
use base64::Engine;
let _lock = crate::identity::keypair::key_dir_env_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut csprng = rand_core::OsRng;
let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
let vk = signing.verifying_key();
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(vk.as_bytes());
let dir = tempfile::TempDir::new().expect("tempdir");
std::fs::write(dir.path().join(OPERATOR_PUBKEY_KEYGEN_FILE), encoded).unwrap();
let prior_pubkey = std::env::var("AI_MEMORY_OPERATOR_PUBKEY").ok();
let prior_key_dir = std::env::var("AI_MEMORY_KEY_DIR").ok();
unsafe {
std::env::remove_var("AI_MEMORY_OPERATOR_PUBKEY");
std::env::set_var("AI_MEMORY_KEY_DIR", dir.path());
}
let got = resolve_operator_pubkey();
unsafe {
match prior_pubkey {
Some(v) => std::env::set_var("AI_MEMORY_OPERATOR_PUBKEY", v),
None => std::env::remove_var("AI_MEMORY_OPERATOR_PUBKEY"),
}
match prior_key_dir {
Some(v) => std::env::set_var("AI_MEMORY_KEY_DIR", v),
None => std::env::remove_var("AI_MEMORY_KEY_DIR"),
}
}
assert!(
got.is_some(),
"verifier must resolve operator.key.pub from AI_MEMORY_KEY_DIR"
);
assert_eq!(got.unwrap().as_bytes(), vk.as_bytes());
}
#[test]
fn resolve_operator_pubkey_reads_legacy_raw_layout_from_key_dir() {
let _lock = crate::identity::keypair::key_dir_env_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut csprng = rand_core::OsRng;
let signing = ed25519_dalek::SigningKey::generate(&mut csprng);
let vk = signing.verifying_key();
let dir = tempfile::TempDir::new().expect("tempdir");
std::fs::write(dir.path().join(OPERATOR_PUBKEY_LEGACY_FILE), vk.as_bytes()).unwrap();
let prior_pubkey = std::env::var("AI_MEMORY_OPERATOR_PUBKEY").ok();
let prior_key_dir = std::env::var("AI_MEMORY_KEY_DIR").ok();
unsafe {
std::env::remove_var("AI_MEMORY_OPERATOR_PUBKEY");
std::env::set_var("AI_MEMORY_KEY_DIR", dir.path());
}
let got = resolve_operator_pubkey();
unsafe {
match prior_pubkey {
Some(v) => std::env::set_var("AI_MEMORY_OPERATOR_PUBKEY", v),
None => std::env::remove_var("AI_MEMORY_OPERATOR_PUBKEY"),
}
match prior_key_dir {
Some(v) => std::env::set_var("AI_MEMORY_KEY_DIR", v),
None => std::env::remove_var("AI_MEMORY_KEY_DIR"),
}
}
assert!(
got.is_some(),
"verifier must resolve legacy operator.pub from AI_MEMORY_KEY_DIR"
);
assert_eq!(got.unwrap().as_bytes(), vk.as_bytes());
}
}