Chio/Docs

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.

Concept chio@5f63e6e

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.

Event ingressChoose an outcome.
producer keybody bindingfreshnesssigned eventappendspermitsrefusedpproducerdetectorvingresseverified storeInternalDetectorπcorrelationrule matchingkeybindingfreshsigned eventappendspermitsrefusedpproducerdetectorvingresseverified storeInternalDetectorπcorrelationrule matching

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.

One security event from a producer through ingress to correlation, in four outcomes. The rule, the finding, and response authority that follow correlation are left out.

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.

crates/security/chio-security-types/src/event.rs54–68rust
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.

crates/security/chio-security-types/src/event.rs71–85rust
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
crates/security/chio-security-types/src/event.rs20–30rust
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.

  1. Internal events. NativeSecurityEventVerifier::verify_internal_event checks a directly signed event against a configured producer key.
  2. Receipt projections. verify_receipt_event checks 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.
  3. Freshness. Both call verify_time_bounds, which rejects an event whose time or ingest time falls more than max_event_age_ms behind now or more than max_future_skew_ms ahead 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.

crates/security/chio-security-types/src/ports_parts/part_01.rs1513–1516rust
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
security-host/security_event_cases.rsrust
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(())
}
Output
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.

  1. Wrong pin. A valid signature under an unregistered key fails the configured verifier. Preserve the rejected source and repair the registration.
  2. Changed tenant. Rewriting the envelope's outer tenant breaks its binding to the body.
  3. Changed session. Editing the body's session and recomputing its hash still fails, because the original signed envelope covers different bytes.
  4. 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 outcomeConditionOwner
SecurityEventValidationError::EventAfterIngest
Event time follows ingest time
Producer construction
SecurityEventValidationError::MissingEvidence
No evidence reference
Producer construction
SecurityEventVerifierConfigError::MissingTrustedProducer
No trusted producer or receipt signer configured
Verifier configuration
CorrelationOutcomeStatus::AdvisoryOnly
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.

Next: turn verified events into a finding