Chio/Docs
LOGIN · JOIN

Signed watermarks

Mark content with a signed, sequence-bound watermark and verify later appearances without mistaking a detector failure for a clean result.

Concept chio@5f63e6e

On this page

Content an agent hands out can carry a marker that names its origin. When the marker turns up later, in a log, a comment on a public pull request, or another tenant's document, the scanner attributes it by checking a signature, so an attacker who types the same tenant and session identifiers produces an invalid candidate and no hit.

A signed watermark is a decoy surface on the tripwire detection path with three additions: an issuer key, a source context, and a sequence number that a later appearance must satisfy before it counts as a match.

Watermark scanChoose a case.
Reviewed report[[chio-wm1:…]]candidatewtokenσissuer keysignaturecsource, key windowcontextℓmarker lifecycleregistryρActiveHitReviewed report[[chio-wm1:…]]candidatewtokenσsignatureissuer keyccontextsource, key windowℓregistrymarker lifecycleρActiveHit

An issued marker passes all three checks

The signature verifies under the trusted issuer key, the payload matches its source context and key window, and the registry resolves an active marker. scan_text reports ActiveHit and records the observation.

One watermark token checked in order by the scanner, in four cases from the issue-and-scan comparison. Token bytes are elided; issuance, sequence reservation, and observation storage are left out.

The signature covers the marker and its source context

The envelope binds the tenant, application, session, source receipt, tool, registry reference, and sequence under an independently trusted issuer key.

crates/security/chio-decoy/src/watermark.rs81–86rust
pub struct SignedWatermarkEnvelope {
    pub schema: String,
    pub payload: WatermarkPayload,
    pub encoded_payload: String,
    pub signature: Signature,
}

The schema is the constant chio.signed-watermark-envelope.v1, a value checked in code with no separate JSON schema file. The payload is a WatermarkPayload, signed under the null-terminated chio.signed-watermark.v1 domain and carried beside the signature over it. In free text the envelope travels as a [[chio-wm1:...]] token, so a scanner finds candidates without parsing the surrounding content.

The scope matters when writing detection rules. A marker found inside a document shows that the marker appeared there; who wrote the rest of the document, whether its other claims are true, and why someone copied it are outside the signature. A watermark contributes one attributable observation to a larger sequence, and other events establish the action and destination under investigation.

Issuance reserves the sequence number before it signs

WatermarkIssuer::issue signs only when its configured key is active and the requested marker's registry entry is matchable (armed, triggered, or rotating) and unexpired. It reserves the sequence number durably first, then signs the canonical payload, so a signing failure never leaves a valid signature over a sequence the registry has not reserved.

If issuance fails after the reservation, or an acknowledgment is lost, inspect the same original operation. A new sequence would hide the ambiguity; the original operation ID and sequence survive retries.

A scan returns a verdict only when the detector could finish

For each candidate token, the verifier checks the signature against a trusted, non-rejected issuer key, matches the payload to the resolved source context and key window, and resolves the marker in the registry. WatermarkVerifier::scan_text then reports one of three verdicts.

crates/security/chio-decoy/src/watermark.rs398–402rust
pub enum WatermarkScanVerdict {
    Clear,
    Advisory,
    ActiveHit,
}

Any active hit reports ActiveHit, even when another candidate in the same text failed for an infrastructure reason. Short of an active hit, an unresolved infrastructure failure returns Err(WatermarkScanError::DetectorUnavailable) in place of a verdict, so a detector failure never reads as Clear. A kernel that runs the raw-output tripwire hook blocks the response when the scan fails.

Read signature validity, current registry lifecycle, and scan health as three separate facts. An authentic expired marker is an inactive observation, and a failed registry lookup or observation write stays visible beside any active hit in the same report.

Each appearance is recorded once under its observation identity

WatermarkObservation identifies an appearance by its source tenant, public reference, and observation id. The observing service supplies the identity, evidence, and time.

crates/security/chio-security-types/src/deception.rs407–416rust
pub struct WatermarkObservation {
    pub source_tenant_id: TenantId,
    pub observing_tenant_id: TenantId,
    pub public_ref_token: Digest32,
    pub observation_id: RecordId,
    pub payload_digest: Digest32,
    pub token_digest: Digest32,
    pub evidence_ref: RecordId,
    pub observed_at_unix_ms: u64,
}

The durable store loads any existing observation under that identity before inserting. An exact resend resolves to the first attribution as a duplicate, and a resend that changes context or evidence under the same identity is rejected, so a repeat or a substitution adds no second incident. Invalid candidates stay separate from authentic inactive, retired, or expired observations, and a later lifecycle change leaves the original signature meaningful.

The store records active and inactive candidates alike; only an active match is published, as the watermark_observation security event kind, under the same tenant-partition and lifecycle checks as the rest of the detection path.

One issued marker, scanned four ways, keeps every result

The comparison issues one marker for Harbor Goods' price sheet, scans it in the sheet and again where it surfaced in a comment on PR #421, then scans two altered envelopes. Download watermark_review.rs; the function returns each scan's report or error, so a failure in one scan never erases the observation from another.

The complete issue-and-scan comparison

The host supplies a WatermarkIssuer with an active registered SignedWatermark artifact, a source-receipt context resolver, a durable sequence store, a trusted signing key, and a clock. The WatermarkVerifier uses independently selected keys, source context, registry, and observation store.

security-host/watermark_review.rsrust
use chio_decoy::{
    SignedWatermarkEnvelope, WatermarkIssueRequest, WatermarkIssuer, WatermarkObservationContext,
    WatermarkScanError, WatermarkScanReport, WatermarkVerifier,
};
use chio_security_types::ports::RecordId;

pub struct WatermarkComparison {
    pub original: Result<WatermarkScanReport, WatermarkScanError>,
    pub changed_document: Result<WatermarkScanReport, WatermarkScanError>,
    pub changed_payload: Result<WatermarkScanReport, WatermarkScanError>,
    pub changed_key_reference: Result<WatermarkScanReport, WatermarkScanError>,
}

/// An existing configured issuer owns source context, active registry entry,
/// sequence store and signing key. The verifier has independent key/context pins
/// and a durable observation store. Reports retain persistence failures too.
pub fn issue_and_compare(
    issuer: &WatermarkIssuer,
    verifier: &WatermarkVerifier,
    original_request: WatermarkIssueRequest,
    observed: &WatermarkObservationContext,
) -> Result<WatermarkComparison, Box<dyn std::error::Error>> {
    let token = issuer.issue(original_request)?;
    let original = verifier.scan_text(&format!("Harbor Goods price sheet, sku-204\n{token}"), observed);
    // The marker authenticates its own payload; surrounding document text is
    // not covered by this signature. Repeating the same observation is retained.
    let changed_document =
        verifier.scan_text(&format!("Pasted into a comment on PR #421\n{token}"), observed);
    let mut changed = SignedWatermarkEnvelope::decode_token(&token)?;
    changed.payload.session_id = RecordId::new(
        if changed.payload.session_id.as_str() == "substituted-session" {
            "another-substituted-session"
        } else {
            "substituted-session"
        },
    )?;
    changed.encoded_payload = SignedWatermarkEnvelope::encode_payload(&changed.payload)?;
    let changed_payload = verifier.scan_text(&changed.encode_token()?, observed);
    let mut changed = SignedWatermarkEnvelope::decode_token(&token)?;
    changed.payload.key_id =
        RecordId::new(if changed.payload.key_id.as_str() == "changed-marker-key" {
            "another-changed-marker-key"
        } else {
            "changed-marker-key"
        })?;
    changed.encoded_payload = SignedWatermarkEnvelope::encode_payload(&changed.payload)?;
    let changed_key_reference = verifier.scan_text(&changed.encode_token()?, observed);
    // Interpret every report before deciding whether an authentic hit is usable.
    // A detector/store failure is not converted into Clear or successful delivery.
    Ok(WatermarkComparison {
        original,
        changed_document,
        changed_payload,
        changed_key_reference,
    })
}

You should see four reports, read whole. ActiveHit can coexist with a detector failure or a failed observation write, and durable event delivery is a separate fact from an authentic token.

  1. Original marker. Active hits, with key and registry status and observation persistence. When every configured binding and owner verifies, this is authentic current provenance.
  2. Same marker, changed document. The marker still matches: the signature covers the marker payload, and the two unchanged-token scans share one observation identity.
  3. Changed payload. An invalid candidate and no authenticated hit. Re-encoding a payload leaves the original signature over different bytes.
  4. Changed key reference. An invalid candidate or a detector failure, depending on key resolution. An unknown reference names no registered signing key.

Inactive keys, reused sequences, and failed scans never yield a clean result

Issuance refuses before it signs, and a scan that cannot finish returns an error in place of a verdict.

ResultConditionCaller
WatermarkIssueError::KeyNotActive
Issuer key is not active
WatermarkIssuer::issue
WatermarkIssueError::SequenceReplay
Sequence number already used
WatermarkIssuer::issue
Err(WatermarkScanError::DetectorUnavailable)
Scan could not complete; no verdict
scan_text
WatermarkScanVerdict::Advisory
A candidate envelope is not canonical
scan_text

scan_text never returns WatermarkCandidateError::NonCanonicalEnvelope to its caller: it counts the candidate in invalid_candidates, which folds into the Advisory verdict. Retain the observation identity and original evidence after any scan. Investigate an ActiveHit through session investigation and verified event ingress, since it establishes contact and leaves intent open; examine the invalid candidate behind an Advisory; repair the detector behind DetectorUnavailable before treating any content as clear.

Conformance vectors for issuance and scanning

Before arming a watermark, exercise an altered signature and a replayed observation alongside the marker cases on Decoys and tripwires. The shared vectors in crates/tooling/chio-conformance/vectors/security/watermark/v1.json and v1-rejections.json pin the canonical bytes and the safe-integer boundary for issuance, and chio security selftest deception runs the signed watermark lifecycle, vector, and post-response suites together.

Next: decide what a watermark hit permits