use serde::{Deserialize, Serialize};
use thiserror::Error;
pub const GUARD_SCHEMA_VERSION: u32 = 1;
pub const GUARD_REPORT_SEMANTICS_VERSION: u32 = 2;
pub const GUARD_DECODE_POLICY_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum GitHashAlgorithm {
Sha1,
Sha256,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum GuardRootMode {
Repo,
Filesystem,
}
impl GuardRootMode {
pub fn all() -> &'static [GuardRootMode] {
&[GuardRootMode::Repo, GuardRootMode::Filesystem]
}
pub fn label(self) -> &'static str {
match self {
GuardRootMode::Repo => "repo",
GuardRootMode::Filesystem => "filesystem",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum GuardRootState {
Indexing,
Current,
Dirty,
Blocked,
Degraded,
StalePolicy,
Stopped,
}
impl GuardRootState {
pub fn may_authorize_commit(self) -> bool {
false
}
pub fn needs_repair(self) -> bool {
matches!(self, GuardRootState::Degraded | GuardRootState::StalePolicy)
}
pub fn all() -> &'static [GuardRootState] {
&[
GuardRootState::Indexing,
GuardRootState::Current,
GuardRootState::Dirty,
GuardRootState::Blocked,
GuardRootState::Degraded,
GuardRootState::StalePolicy,
GuardRootState::Stopped,
]
}
pub fn label(self) -> &'static str {
match self {
GuardRootState::Indexing => "indexing",
GuardRootState::Current => "current",
GuardRootState::Dirty => "dirty",
GuardRootState::Blocked => "blocked",
GuardRootState::Degraded => "degraded",
GuardRootState::StalePolicy => "stale-policy",
GuardRootState::Stopped => "stopped",
}
}
}
impl std::fmt::Display for GuardRootState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.label())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum GuardTransition {
ReconciliationStarted,
ReconciliationClean,
ReconciliationFindings,
ReconciliationDegraded,
EventAccepted,
EventsClean,
EventsFindings,
EventsDegraded,
CoverageLost,
PolicyChanged,
RepairStarted,
Stopped,
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum TransitionError {
#[error("illegal guard transition: {event} from state {from}. Fix: run `keyhog guard status <root>` or reconcile the root before dispatching events")]
Illegal {
event: GuardTransition,
from: GuardRootState,
},
}
impl GuardTransition {
pub fn all() -> &'static [GuardTransition] {
&[
GuardTransition::ReconciliationStarted,
GuardTransition::ReconciliationClean,
GuardTransition::ReconciliationFindings,
GuardTransition::ReconciliationDegraded,
GuardTransition::EventAccepted,
GuardTransition::EventsClean,
GuardTransition::EventsFindings,
GuardTransition::EventsDegraded,
GuardTransition::CoverageLost,
GuardTransition::PolicyChanged,
GuardTransition::RepairStarted,
GuardTransition::Stopped,
]
}
pub fn label(&self) -> &'static str {
match self {
GuardTransition::ReconciliationStarted => "reconciliation-started",
GuardTransition::ReconciliationClean => "reconciliation-clean",
GuardTransition::ReconciliationFindings => "reconciliation-findings",
GuardTransition::ReconciliationDegraded => "reconciliation-degraded",
GuardTransition::EventAccepted => "event-accepted",
GuardTransition::EventsClean => "events-clean",
GuardTransition::EventsFindings => "events-findings",
GuardTransition::EventsDegraded => "events-degraded",
GuardTransition::CoverageLost => "coverage-lost",
GuardTransition::PolicyChanged => "policy-changed",
GuardTransition::RepairStarted => "repair-started",
GuardTransition::Stopped => "stopped",
}
}
}
impl std::fmt::Display for GuardTransition {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.label())
}
}
impl GuardRootState {
pub fn transition(self, event: &GuardTransition) -> Result<GuardRootState, TransitionError> {
use GuardRootState as S;
use GuardTransition as T;
let result = match (self, event) {
(S::Stopped, T::ReconciliationStarted) => Some(S::Indexing),
(S::Indexing, T::ReconciliationClean) => Some(S::Current),
(S::Indexing, T::ReconciliationFindings) => Some(S::Blocked),
(S::Indexing, T::ReconciliationDegraded) => Some(S::Degraded),
(S::Current, T::EventAccepted) => Some(S::Dirty),
(S::Blocked, T::EventAccepted) => Some(S::Dirty),
(S::Dirty, T::EventsClean) => Some(S::Current),
(S::Dirty, T::EventsFindings) => Some(S::Blocked),
(S::Dirty, T::EventsDegraded) => Some(S::Degraded),
(S::Indexing, T::CoverageLost) => Some(S::Degraded),
(S::Current, T::CoverageLost) => Some(S::Degraded),
(S::Dirty, T::CoverageLost) => Some(S::Degraded),
(S::Blocked, T::CoverageLost) => Some(S::Degraded),
(S::StalePolicy, T::CoverageLost) => Some(S::Degraded),
(S::Indexing, T::PolicyChanged) => Some(S::StalePolicy),
(S::Current, T::PolicyChanged) => Some(S::StalePolicy),
(S::Dirty, T::PolicyChanged) => Some(S::StalePolicy),
(S::Blocked, T::PolicyChanged) => Some(S::StalePolicy),
(S::Degraded, T::RepairStarted) => Some(S::Indexing),
(S::StalePolicy, T::RepairStarted) => Some(S::Indexing),
(S::Stopped, T::Stopped) => Some(S::Stopped),
(S::Indexing, T::Stopped) => Some(S::Stopped),
(S::Current, T::Stopped) => Some(S::Stopped),
(S::Dirty, T::Stopped) => Some(S::Stopped),
(S::Blocked, T::Stopped) => Some(S::Stopped),
(S::Degraded, T::Stopped) => Some(S::Stopped),
(S::StalePolicy, T::Stopped) => Some(S::Stopped),
(S::Degraded, T::CoverageLost) => Some(S::Degraded),
(S::StalePolicy, T::PolicyChanged) => Some(S::StalePolicy),
_ => None,
};
result.ok_or(TransitionError::Illegal {
event: *event,
from: self,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct GuardPolicyIdentity {
pub build_identity: String,
pub detector_digest: String,
pub suppression_digest: String,
pub keyhogignore_digest: String,
pub config_digest: String,
pub decode_policy_version: u32,
pub source_policy_digest: String,
pub guard_schema_version: u32,
pub report_semantics_version: u32,
}
impl GuardPolicyIdentity {
pub fn compute_digest(domain: &str, content: &[u8]) -> String {
let mut hasher = blake3::Hasher::new();
hasher.update(domain.as_bytes());
hasher.update(b":");
hasher.update(content);
hex::encode(hasher.finalize().as_bytes())
}
pub fn default_suppression_digest() -> String {
Self::compute_digest("keyhog-suppressions-v1", b"default")
}
pub fn default_keyhogignore_digest() -> String {
Self::compute_digest("keyhog-ignore-v1", b"none")
}
pub fn default_config_digest() -> String {
Self::compute_digest("keyhog-config-v1", b"default")
}
pub fn default_source_policy_digest() -> String {
Self::compute_digest("keyhog-source-policy-v1", b"default")
}
pub fn from_build_and_detectors(
build_identity: impl Into<String>,
detector_digest: impl Into<String>,
) -> Self {
Self {
build_identity: build_identity.into(),
detector_digest: detector_digest.into(),
suppression_digest: Self::default_suppression_digest(),
keyhogignore_digest: Self::default_keyhogignore_digest(),
config_digest: Self::default_config_digest(),
decode_policy_version: GUARD_DECODE_POLICY_VERSION,
source_policy_digest: Self::default_source_policy_digest(),
guard_schema_version: GUARD_SCHEMA_VERSION,
report_semantics_version: GUARD_REPORT_SEMANTICS_VERSION,
}
}
pub fn short_digest(&self) -> Result<String, serde_json::Error> {
let bytes = serde_json::to_vec(self)?;
let hash = blake3::hash(&bytes);
Ok(hex::encode(&hash.as_bytes()[..6]))
}
pub fn is_compatible_with(&self, other: &GuardPolicyIdentity) -> bool {
self == other
}
}
impl Default for GuardPolicyIdentity {
fn default() -> Self {
Self::from_build_and_detectors(
"unknown",
Self::compute_digest("keyhog-detectors-v1", b"default"),
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct GitCleanAttestation {
pub hash_algorithm: GitHashAlgorithm,
pub blob_oid: String,
pub object_size: u64,
pub policy_identity: GuardPolicyIdentity,
pub last_seen_sequence: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct GitCleanAttestationKey<'a> {
pub hash_algorithm: GitHashAlgorithm,
pub blob_oid: &'a str,
pub policy_short_digest: &'a str,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GuardReceipt {
pub objects_requested: u64,
pub objects_hit: u64,
pub objects_scanned: u64,
pub objects_skipped: u64,
pub bytes_requested: u64,
pub bytes_hit: u64,
pub bytes_scanned: u64,
pub findings_count: u64,
pub coverage_gaps: u64,
pub terminal_state: GuardRootState,
pub policy_identity: GuardPolicyIdentity,
pub terminal_sequence: u64,
}
impl GuardReceipt {
pub fn validate_conservation(&self) -> Result<(), ReceiptError> {
let obj_sum = self.objects_hit + self.objects_scanned + self.objects_skipped;
if obj_sum != self.objects_requested {
return Err(ReceiptError::ObjectMismatch {
requested: self.objects_requested,
accounted: obj_sum,
});
}
let byte_sum = self.bytes_hit + self.bytes_scanned;
if byte_sum != self.bytes_requested {
return Err(ReceiptError::ByteMismatch {
requested: self.bytes_requested,
accounted: byte_sum,
});
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum ReceiptError {
#[error("receipt object mismatch: requested {requested}, accounted {accounted}. Fix: ensure all transaction items are accounted for before finalizing the guard receipt")]
ObjectMismatch {
requested: u64,
accounted: u64,
},
#[error("receipt byte mismatch: requested {requested}, accounted {accounted}. Fix: ensure all byte ranges are accounted for before finalizing the guard receipt")]
ByteMismatch {
requested: u64,
accounted: u64,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct FilesystemAuthority {
pub filesystem_type: String,
pub authoritative: bool,
pub unauthoritative_reason: Option<String>,
}
impl Default for FilesystemAuthority {
fn default() -> Self {
Self {
filesystem_type: "unknown".to_string(),
authoritative: false,
unauthoritative_reason: Some(
"unprobed filesystem defaults to unauthoritative".to_string(),
),
}
}
}
impl FilesystemAuthority {
#[must_use]
pub fn authoritative(filesystem_type: impl Into<String>) -> Self {
Self {
filesystem_type: filesystem_type.into(),
authoritative: true,
unauthoritative_reason: None,
}
}
#[must_use]
pub fn unauthoritative(filesystem_type: impl Into<String>, reason: impl Into<String>) -> Self {
Self {
filesystem_type: filesystem_type.into(),
authoritative: false,
unauthoritative_reason: Some(reason.into()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GuardRootRecord {
pub canonical_path: Vec<u8>,
pub filesystem_identity: FilesystemIdentity,
#[serde(default)]
pub filesystem_authority: FilesystemAuthority,
pub mode: GuardRootMode,
pub state: GuardRootState,
pub terminal_sequence: u64,
pub accepted_event_sequence: u64,
pub completed_event_sequence: u64,
pub initial_reconciliation_time: Option<u64>,
pub last_reconciliation_time: Option<u64>,
pub backend_route_label: String,
pub last_receipt: Option<GuardReceipt>,
#[serde(default)]
pub recent_transitions: Vec<GuardTransitionRecord>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GuardTransitionRecord {
pub canonical_path: Vec<u8>,
pub sequence: u64,
pub timestamp: u64,
pub from_state: GuardRootState,
pub to_state: GuardRootState,
pub event: GuardTransition,
pub cause: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct FilesystemIdentity {
pub device: u64,
pub inode: u64,
}