Security events
Turn an observation into a signed, verified security event, and decide from its producer's trust class whether it may drive automatic response.
On this page
Automatic containment can act on what a detector reports because each report arrives as a signed security event. Ingress checks the signature against a producer the operator registered, so a forged event cannot suspend another tenant or invent a suspicious sequence.
Authentication settles who made the observation. Policy then decides whether that producer's trust class may feed correlation, and a permitted event still has to satisfy a rule, produce a finding, and receive response authority before any control is applied.
A registered detector's event feeds correlation
verify_internal_event checks the signature against the configured producer key, the body binding, and the time bounds. The event is appended as InternalDetector, and CorrelationPolicy::permits always passes that class.
A signed event binds one observation to its tenant, subject, and producer
The body binds tenant, subject, session, capability lineage, source receipt, producer identity, event time, and ingest time. Both times travel with the event, so later detection can reason about ordered history.
pub struct SecurityEventBody {
pub event_id: EventId,
pub event_time_unix_ms: u64,
pub ingest_time_unix_ms: u64,
pub tenant_id: TenantId,
pub subject: SecuritySubject,
pub source_receipt_id: OpaqueReceiptRef,
pub event_kind: SecurityEventKind,
pub severity: SecuritySeverity,
pub evidence_references: EventEvidenceReferences,
pub producer_id: ProducerId,
pub producer_key_id: RecordId,
pub trust_class: ProducerTrustClass,
pub policy_version: RecordId,
}A producer establishes what it saw: a marker was encountered, a tool was invoked. A temporal rule interprets a sequence of those observations, and the response authority evaluates a proposed action. Each step keeps the original evidence, so an investigator can challenge an interpretation without losing the facts beneath it. Tripwire observations and signed watermarks carry evidence of contact with a controlled artifact: their signatures and registry bindings establish the contact, and intent stays a question for the investigator.
Read more in Decoys and tripwires, which covers where those artifacts are placed.
Construction checks the shape of an event and grants it nothing
Every body names exactly one SecurityEventKind, chosen when its producer constructs it: canary_invocation, credential_access, declassification_attempt, detector_health, egress_attempt, flow_denial, tool_invocation, tripwire_observation, or watermark_observation.
pub struct SecurityEventBodyInput {
pub event_id: EventId,
pub event_time_unix_ms: u64,
pub ingest_time_unix_ms: u64,
pub tenant_id: TenantId,
pub subject: SecuritySubject,
pub source_receipt_id: OpaqueReceiptRef,
pub event_kind: SecurityEventKind,
pub severity: SecuritySeverity,
pub evidence_references: Vec<OpaqueReceiptRef>,
pub producer_id: ProducerId,
pub producer_key_id: RecordId,
pub trust_class: ProducerTrustClass,
pub policy_version: RecordId,
}The validated input type takes ordinary vectors for evidence references and builds the bounded body the event protocol uses. The constructor checks temporal shape and evidence bounds: an event time after its ingest time fails with EventAfterIngest, and a body with no evidence reference fails with MissingEvidence. A body that passes is structurally acceptable and still unauthenticated.
Resolve the tenant and subject from the trusted host context, the source receipt from the observed operation, and the producer identity from the emitting service, and keep the policy version that interpreted the observation. A model-generated alert copied into these fields is useful investigation input and remains an unauthenticated claim.
In Rust: the nine event kinds
pub enum SecurityEventKind {
CanaryInvocation,
CredentialAccess,
DeclassificationAttempt,
DetectorHealth,
EgressAttempt,
FlowDenial,
ToolInvocation,
TripwireObservation,
WatermarkObservation,
}Ingress authenticates the producer before an event counts as evidence
A producer wraps the body in a SignedSecurityEvent and signs it under the domain chio:security-event:v1. Deserializing the envelope establishes its wire shape; the body becomes evidence only after verify_trusted_producer runs with independently trusted producer identity and key material.
- Internal events.
NativeSecurityEventVerifier::verify_internal_eventchecks a directly signed event against a configured producer key. - Receipt projections.
verify_receipt_eventchecks an event projected from a kernel receipt against a configured receipt signer. The verifier selects that receipt authority itself and verifies the exact projection, so the producer never chooses its own trust class. - Freshness. Both call
verify_time_bounds, which rejects an event whose time or ingest time falls more thanmax_event_age_msbehind now or more thanmax_future_skew_msahead of it.
The wire schemas security-event-body-v1 and signed-security-event-envelope-v1 fix the shapes these checks parse. Production calls VerifiedSecurityEventIngress::verify_and_append to authenticate and durably queue the original envelope; its Inserted or Duplicate result acknowledges that the input is retained. Correlation, signed finding publication, plan authorization, and effects each report their own outcome, as the queue-and-inspect path shows.
The producer's trust class decides whether an event may drive response
ProducerTrustClass has two values: InternalDetector for a directly signed event, and VerifiedReceipt for one projected from a kernel receipt.
pub enum ProducerTrustClass {
InternalDetector,
VerifiedReceipt,
}The durable store keeps the two outcomes apart. append_verified takes a VerifiedSecurityEvent, which carries the trust class verification established; append_advisory takes an AdvisorySecurityEvent, which carries none. CorrelationPolicy::permits then decides per class: an InternalDetector event always passes, and a VerifiedReceipt event passes only when the policy sets allow_verified_receipt.
When permits returns false, the correlator reports CorrelationOutcomeStatus::AdvisoryOnly and skips rule matching, so the event stays useful for investigation without authorizing a response. A permitted event still has to satisfy an ordered, windowed rule in temporal detection before it becomes a finding, and a failing detector reports itself through detector health and suppresses automatic response.
The signed-event comparison accepts one observation and refuses its substitutes
The pricing project's detector observes the fixer reading a credential in session review-1. The comparison builds that synthetic observation, signs it with the producer backend, verifies it through NativeSecurityEventVerifier, and then presents the same claim under a different pin, tenant, session, and time.
Download security_event_cases.rs into a Rust host that depends on chio-control-plane, chio-core-types, chio-security-kernel, chio-security-types with std, and serde_json, then call compare_security_events(). The fixture owner creates the trusted registration apart from the incoming envelope; the fixed clock and receipt references belong to the fixture, and no physical credential read takes place.
The complete signed-event comparison
use chio_control_plane::security::{NativeSecurityEventVerifier, TrustedSecurityEventProducer};
use chio_core_types::{canonical_json_bytes, sha256, Ed25519Backend, Keypair, SignedSecurityEvent};
use chio_security_kernel::SecurityClock;
use chio_security_types::ports::{
CanonicalBody, Digest32, EventId, OpaqueReceiptRef, PortResult, ProducerId, ProducerTrustClass,
RecordId, SecurityEventVerifierPort, SessionId, TenantId, UnverifiedSecurityEvent,
};
use chio_security_types::{
SecurityEventBody, SecurityEventBodyInput, SecurityEventKind, SecuritySeverity, SecuritySubject,
};
use std::sync::Arc;
pub const OBSERVATION_TIME: u64 = 1_800_000_000_000;
pub struct FixtureClock;
impl SecurityClock for FixtureClock {
fn now_unix_ms(&self) -> PortResult<u64> {
Ok(OBSERVATION_TIME + 12_000)
}
}
/// Explicit synthetic observation. Signing does not establish a physical
/// credential read; production obtains these fields from trusted host context.
pub fn signed_observation(
key: &Keypair,
id: &str,
session: &str,
kind: SecurityEventKind,
time: u64,
) -> Result<UnverifiedSecurityEvent, Box<dyn std::error::Error>> {
let body = SecurityEventBody::new(SecurityEventBodyInput {
event_id: EventId::new(id)?,
event_time_unix_ms: time,
ingest_time_unix_ms: time + 100,
tenant_id: TenantId::new("larkfield")?,
subject: SecuritySubject {
subject_id: RecordId::new("pricing-worker")?,
agent_id: RecordId::new("fixer")?,
session_id: SessionId::new(session)?,
capability_id: RecordId::new("terms-read")?,
lineage_seed: chio_security_types::ports::LineageId::new("issue-417-lineage")?,
},
source_receipt_id: OpaqueReceiptRef::new(format!("source-{id}"))?,
event_kind: kind,
severity: SecuritySeverity::High,
evidence_references: vec![OpaqueReceiptRef::new(format!("evidence-{id}"))?],
producer_id: ProducerId::new("pricing-detector")?,
producer_key_id: RecordId::new("detector-key-1")?,
trust_class: ProducerTrustClass::InternalDetector,
policy_version: RecordId::new("pricing-v1")?,
})?;
let canonical = canonical_json_bytes(&body)?;
let signed =
SignedSecurityEvent::sign_with_backend(body.clone(), &Ed25519Backend::new(key.clone()))?;
Ok(UnverifiedSecurityEvent {
tenant_id: body.tenant_id,
event_id: body.event_id,
producer_id: body.producer_id,
event_time_unix_ms: body.event_time_unix_ms,
received_at_unix_ms: body.ingest_time_unix_ms,
body_hash: Digest32::new(*sha256(&canonical).as_bytes()),
canonical_body: CanonicalBody::new(canonical)?,
source_evidence: CanonicalBody::new(canonical_json_bytes(&signed)?)?,
})
}
/// The fixture owner selects the producer registration, not the incoming JSON.
pub fn fixture_verifier(
key: &Keypair,
) -> Result<NativeSecurityEventVerifier, Box<dyn std::error::Error>> {
Ok(NativeSecurityEventVerifier::new(
Arc::new(FixtureClock),
vec![TrustedSecurityEventProducer {
tenant_id: TenantId::new("larkfield")?,
producer_id: ProducerId::new("pricing-detector")?,
producer_key_id: RecordId::new("detector-key-1")?,
policy_version: RecordId::new("pricing-v1")?,
producer_key: key.public_key(),
}],
vec![],
60_000,
1_000,
)?)
}
pub fn compare_security_events() -> Result<(), Box<dyn std::error::Error>> {
let producer = Keypair::generate();
let verifier = fixture_verifier(&producer)?;
let original = signed_observation(
&producer,
"credential-1",
"review-1",
SecurityEventKind::CredentialAccess,
OBSERVATION_TIME,
)?;
let verified = verifier.verify(&original)?;
assert_eq!(verified.trust_class, ProducerTrustClass::InternalDetector);
assert_eq!(verified.body_hash, original.body_hash);
assert!(fixture_verifier(&Keypair::generate())?
.verify(&original)
.is_err());
let mut tenant = original.clone();
tenant.tenant_id = TenantId::new("another-team")?;
assert!(verifier.verify(&tenant).is_err());
let mut context = original.clone();
let mut body: SecurityEventBody = serde_json::from_slice(context.canonical_body.as_bytes())?;
body.subject.session_id = SessionId::new("another-session")?;
let bytes = canonical_json_bytes(&body)?;
context.body_hash = Digest32::new(*sha256(&bytes).as_bytes());
context.canonical_body = CanonicalBody::new(bytes)?;
assert!(verifier.verify(&context).is_err()); // the original envelope binds another body
let stale = signed_observation(
&producer,
"stale-1",
"review-1",
SecurityEventKind::CredentialAccess,
OBSERVATION_TIME - 120_000,
)?;
assert!(verifier.verify(&stale).is_err());
println!("VERIFY original signed observation against the registered producer");
println!("REFUSE wrong pin, substituted tenant/session and stale signed observation");
Ok(())
}VERIFY original signed observation against the registered producer REFUSE wrong pin, substituted tenant/session and stale signed observation
You should see VERIFY original signed observation against the registered producer, then REFUSE wrong pin, substituted tenant/session and stale signed observation. The accepted event keeps the original body hash and the InternalDetector class, and it goes on to authenticated durable ingress.
- Wrong pin. A valid signature under an unregistered key fails the configured verifier. Preserve the rejected source and repair the registration.
- Changed tenant. Rewriting the envelope's outer tenant breaks its binding to the body.
- Changed session. Editing the body's session and recomputing its hash still fails, because the original signed envelope covers different bytes.
- Stale observation. The event is correctly signed but outside the freshness window. Inspect its original durable delivery and leave its signed time as it is.
Malformed, unregistered, and unpermitted events stop before correlation
Each outcome below leaves the event out of rule matching. Caller-written JSON shaped like a verified event is in the same position: its type name carries no producer authority.
| Refusal or outcome | Condition | Owner |
|---|---|---|
Security | Event time follows ingest time | Producer construction |
Security | No evidence reference | Producer construction |
Security | No trusted producer or receipt signer configured | Verifier configuration |
Correlation | Trust class not permitted to drive response | Correlation policy |
Repair a construction refusal by comparing authenticated producer time with ingest time, or by supplying the original operation's evidence references, without rewriting retained evidence. Provision at least one independently trusted producer or receipt signer from reviewed deployment configuration. Use an advisory event for investigation, and change whether its trust class may drive correlation only through a deliberate policy review.
Read more in Session investigation, which follows the original evidence when correlation is suppressed or incomplete.