Signed ingest (SEC-015)

Embed
Preview of the card
<a href="https://watchtowerops.net/docs/signed-ingest"><img src="https://watchtowerops.net/docs/_api/public/orgs/aa5959cb-1a0b-4a9a-bc9a-913e7ac31cd9/spaces/b7aac181-9916-4805-b253-becc5fd2ca62/pages/c878da2c-afac-49b3-8fb5-01af58a0f9cc/social-card.svg?v=3e7ae33eec8b" alt="Signed ingest (SEC-015)" width="600" height="315"></a>

An ingest credential can carry the Ed25519 public key its senders sign with: PUT /v1/projects/{prj}/credentials/{cred}/signing with {"public_key": "<base64 32 bytes>", "required": true|false}. Only the public key is stored. A present signature must always verify; required also refuses unsigned data.

Transport

Carrier

Signed bytes

OTLP/HTTP and the HTTP push receivers

headers X-WT-Signature-Timestamp: <unix s>, X-WT-Signature: ed25519=<base64>

<timestamp>.<request body as sent> (compressed bytes included)

OTLP gRPC Export (traces, logs, metrics)

metadata x-wt-signature-timestamp, x-wt-signature (same values)

<timestamp>.<protobuf encoding of the uncompressed Export request>

OTel Arrow streams, Jaeger/OpenCensus/profiles/push gRPC, Firehose, Pub/Sub, syslog, Kafka

none

cannot carry a per-payload signature

  • The timestamp must be within 5 minutes of the gateway clock.

  • With required, a credential is refused on every transport without a carrier (gRPC UNAUTHENTICATED, HTTP 401, signature required). Send such data over OTLP/HTTP or OTLP gRPC Export instead. Arrow exporters must be set to plain OTLP.

  • A signature sent on a gRPC method other than Export is refused, because it cannot be checked.

  • gRPC: sign the encoding your protobuf library produces for the request (field-number order; OTLP has no map fields). In Go: proto.MarshalOptions{Deterministic: true}.Marshal(req), signed in a unary client interceptor that sets both metadata keys. The gateway checks it against its own encoding of the decoded request, so build against an OTLP proto version no newer than the gateway's.

  • Refusals count in wt_gateway_signature_denied_total{signal="http"|"grpc"} (HTTP and gRPC verification failures).

Per-record integrity

Logs that arrive with a verified signature keep a proof of it. After its pipeline runs, the worker stamps each record with:

Attribute

Value

audit.integrity.hash

hex SHA-256 of the RFC 8785 (JCS) form of the stored record: time (time_unix_nano, a string), service, severity number and text, body, trace and span id (lower-case hex), resource and attributes (without audit.integrity.*)

audit.integrity.signer

ed25519:<first 8 bytes of SHA-256(public key), hex>

audit.integrity.signature

base64 HMAC-SHA256 of v1.<hash>.<signer> under a key derived from WT_SECRET_KEY

audit.integrity.key_id, audit.integrity.alg

key id and jcs-sha256+hmac-sha256 (jcs-sha256 when the deployment has no WT_SECRET_KEY: hash only)

audit.integrity.* attributes sent by clients are always dropped, signed or not.

POST /v1/projects/{prj}/logs/integrity/verify takes the same body as logs/search, checks one page and returns counts (valid, unsigned, modified, bad_signature, unknown_key), the failing records and next_cursor. A record changed after ingest (for example by a redaction job) reports modified. Rotating WT_SECRET_KEY makes older stamps unknown_key. Only logs carry the stamp; spans and metrics are verified in transit only.

Updated