Skip to content

Signed statements & keys

Each action record is signed as an RFC 9943 (SCITT) Signed Statement: a COSE_Sign1 (RFC 9052) whose payload is not the record but its digest (RFC 9995 hash envelope). The JSON is stored once; the statement beside it is about 200 bytes. Any COSE library can check the signature, and recomputing the payload from the JSON is one hash.

protected = { 1: -8, ; alg: EdDSA (Ed25519)
4: <raw 32-byte Ed25519 public key>, ; kid
15: { 1: <iss>, 2: <sub> }, ; CWT claims
258: -16, ; payload hash alg: SHA-256
259: "application/vnd.brutor.action-record+json" } ; preimage content type
unprotected = {} ; receipts are attached to heads, not records
payload = <raw 32 bytes: SHA-256 of the JCS record bytes> ; == record_id
signature = Ed25519( cbor(["Signature1", protected_bstr, h'', payload]) )
  • No label 3 (content type) in hash-envelope mode; the preimage type is label 259.
  • payload equals the record’s record_id decoded from hex. A verifier recomputes the record id from the JSON and compares — statement_payload_mismatch if it differs.
  • kid is the raw public key itself, so a statement names the key it was signed with; a verifier decides separately whether it trusts that key.
Claim Value
iss (CWT 1) BRUTOR_EVIDENCE_ISSUER when set (e.g. a did:web: identity), else urn:brutor:deployment:<BRUTOR_DEPLOYMENT_ID>
sub (CWT 2) ai-system:<ai_system_id> for a governed record, tenant:<tenant_id> otherwise

A signed tree head is a Signed Statement in full-content mode: label 3 is application/vnd.brutor.tree-head+cbor, the payload is a canonical CBOR map {log_id, tree_size, root, prev_size, prev_root, issued_at, consistency_proof}, and the CWT claims are iss = <log_id>, sub = "<log_id>#<tree_size>". It is signed with the same key as the tenant’s records. See tenant logs.

GET /.well-known/brutor-evidence-keys.json on the Core Proxy is public and read-only. It lists every key this deployment signs or signed with:

{
"issuer": "urn:brutor:deployment:prod-eu-1",
"record_schema": "ai.brutor/action-record/v1",
"statement_content_type": "application/vnd.brutor.action-record+json",
"tree_head_content_type": "application/vnd.brutor.tree-head+cbor",
"keys": [
{ "kid": "3b6a27bc…", "kty": "OKP", "crv": "Ed25519", "alg": "EdDSA",
"x": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik", "use": "sig",
"issuer": "urn:brutor:deployment:prod-eu-1",
"status": "active", "scope": "deployment" },
{ "kid": "9f1c…", "kty": "OKP", "crv": "Ed25519", "alg": "EdDSA", "x": "…", "use": "sig",
"issuer": "urn:brutor:deployment:prod-eu-1",
"status": "active", "scope": "tenant", "log_id": "5e0d…" }
]
}

kid is the lowercase hex of the raw public key; x is the same key base64url (a JWK). Tenant keys are listed by log_id — an opaque hash — never by tenant id, because the document is public. Retired keys stay listed with status: "retired" and stay trusted for what they signed. Hand this document to a verifier as its trust anchor (brutor-verify --keys); pin it out-of-band rather than fetching it from the party whose evidence you are checking.

The deployment key (BRUTOR_EVIDENCE_SIGNING_KEY, an Ed25519 seed) is the default and the master switch: without it nothing is sealed, the console says evidence is disabled, and traffic is unaffected. It is deliberately separate from the audit key (BRUTOR_AUDIT_SIGNING_KEY): each key signs a closed set of shapes (the audit key signs row-chain checkpoints and manifests; the evidence key signs record statements and tree heads), so neither is a signing oracle for the other. The key never leaves the Core Proxy; the Control Plane holds no key and does no cryptography.

A tenant can have its own key. Turn on Settings → Evidence → per-tenant key (evidence_settings.per_tenant_key), then rotate once: the first rotation creates the tenant key, stores its seed encrypted under ENCRYPTION_KEY (Fernet, the same envelope as provider secrets) and seals a hand-over record signed by the deployment key. From then on that tenant’s records and heads are signed with its own key.

  1. POST /v1/admin/evidence/keys/rotate (console: Settings → Evidence → Rotate key), which calls the core’s POST /v1/evidence/keys/{tenant_id}/rotate.

  2. The core creates the new key and seals an ai.brutor.key-rotation record signed by the old key, carrying old_kid and new_kid in clear (public keys are clear-safe) and a SHA-256 reference to the new public key.

  3. The old key is marked retired; it stays trusted for verification forever.

Returns 409 per_tenant_key_disabled when the setting is off. Key resolution is cached for 60 seconds per replica and invalidated in-process on rotation, so another replica may sign with the just-retired key for up to a minute — harmless, because retired keys stay trusted.

To rotate the deployment key, add the old public key to BRUTOR_EVIDENCE_RETIRED_KEYS first, then set the new seed. Never edit the seed in place without retiring the old public key: records it signed would no longer verify against this deployment’s trust set.

All on the Core Proxy.

Variable Example Meaning
BRUTOR_EVIDENCE_SIGNING_KEY 9d61b19d…7f60 (64 hex) Ed25519 seed of the deployment evidence key. Empty or invalid ⇒ evidence off (no records minted; traffic unaffected; seal endpoints answer 503 evidence_disabled)
BRUTOR_EVIDENCE_RETIRED_KEYS 3b6a27bc…,d75a9801… Comma-separated raw public keys (hex) of retired deployment keys. Still trusted for verification; never signed with
BRUTOR_DEPLOYMENT_ID prod-eu-1 Hashed into every tenant’s log_id and the default issuer. Default default. Set once per install, never change it — a new value cannot open the existing tenants’ logs and sealing fails until it is restored
BRUTOR_EVIDENCE_ISSUER did:web:evidence.example.com CWT iss on every record statement. Default urn:brutor:deployment:<BRUTOR_DEPLOYMENT_ID>
BRUTOR_EVIDENCE_HEAD_CADENCE_ENTRIES 100 Issue a tree head after this many new entries
BRUTOR_EVIDENCE_HEAD_CADENCE_SECONDS 900 …or when the first uncovered entry is this old
BRUTOR_EVIDENCE_WITNESS on off / 0 / false / no stops all witness registration (kill switch)
BRUTOR_EVIDENCE_PROFILE_AAC 0 1 enables the optional Agent Action Capsule export
ENCRYPTION_KEY (shared with the Control Plane) Encrypts per-tenant key seeds

Control Plane variables for the trial bundle’s local witness:

Variable Example Meaning
BRUTOR_LOCAL_WITNESS_URL http://witness:8000 Registers the bundled brutor-witness as a platform witness flagged same_operator on first boot (create-once). Empty ⇒ not seeded
BRUTOR_LOCAL_WITNESS_PINNED_KEY 8a88e3dd… (64 hex) Pin for its Ed25519 key; when unset it is fetched once from the witness’s /anchor/authority-pubkey
BRUTOR_LOCAL_WITNESS_OPERATOR self-hosted Brutor trial Operator label

Generate a seed:

Terminal window
openssl rand -hex 32