Messages
Entities names a fourth, optional level below the user: a proof
a user key issues for a single artifact it emits. Tokens shows how
that message token sits at the bottom of the signing hierarchy, self-signed
(iss == sub) by the user key. This page is about what that level is for:
attaching a verifiable origin to a message that travels on its own, long after
the request that produced it has ended.
A per-request credential answers “who is calling this server right now?” It lives for the length of one connection and proves possession of a key against a live verifier. A webhook, a queue message, or an exported document has no such moment. It is produced now, delivered later, and read by a party that never spoke to the sender. The message token is the proof that rides along with it: a short-lived, self-signed claim that binds the payload and its destination to the user that emitted them, verifiable offline by anyone holding the operator public key.
A proof, not a credential
This is the distinction that separates the message level from the three above it. A user token is a credential: it delegates authority, and its holder signs live requests with the matching seed (or, for a bearer token, presents it directly). A message token is a proof of origin: it makes a statement about one specific payload sent to one specific destination, and possessing it grants nothing at all.
The library enforces the asymmetry. Verifier.VerifyRequest, the entry point
for per-request credentials, never accepts a message token; a receiver that
verifies a message token must not treat it as a bearer credential. Capturing a
message token in flight lets an attacker prove that a message was emitted, no
more. It does not let them act as the user, reach the server, or forge a
different payload.
To keep that exposure small, a message token must carry an expiry: IssueMessage
refuses to issue one without a validity window, and the contrib transports default
to a 30-second window (valiss.DefaultMessageTTL). An eternal proof of origin
only widens the window in which a captured token still verifies.
What a message token binds
A message token is issued from the emitter’s user key and pins the message down along several axes at once:
- The payload, through a SHA-256 checksum (
valiss.Checksum). A receiver that re-hashes the bytes it actually received and compares closes payload tampering. - The destination, through an audience (
aud). A token issued for one receiver does not verify at another, which closes cross-destination replay. - The epoch, so a message is bound to the same trust-domain epoch as the credentials that authorized its emitter (see Rotation).
- The provenance chain: the account and user tokens that delegate down to the signing key, so a receiver can walk operator to account to user to message from the operator key alone.
Caution
The audience closes cross-destination replay, but nothing closes same-destination replay within the validity window. A message token carries no nonce and the verifier keeps no seen-token cache, so the same proof re-presented to the same destination verifies again until it expires. A receiver that must act on each message once has to deduplicate itself, keyed on the payload or the token id.
The emitter chooses those bindings at issue time; the receiver decides which of
them to insist on at verification time. The Go guide
covers the IssueMessage and VerifyMessage calls; Verifying tokens
gives the byte-level algorithm for porting to another language.
How keyrings verify message-level signatures
Verifying a message token is a whole-chain check, exactly as it is for a per-request credential: the receiver walks operator to account to user to message, checks each signature and role, requires every level to agree on the epoch, and checks every validity window at the verification instant. A stored message verifies as of its receipt instant, not now, so a token that has since expired still checks out against the moment it arrived.
A receiver that trusts a single trust domain roots the walk in one pinned operator public key. A receiver that trusts several roots it in a keyring instead. The chain names its own domain: the account token’s issuer and epoch select exactly one keyring entry, so the receiver never trials keys. A message whose chain names an unknown operator, or a known operator at an unregistered epoch, fails immediately. The matched entry also carries policy that is always enforced, its validity window and its exact epoch, and the same entry surfaces on the verified claims so a handler that serves several domains can tell them apart.
Offline receivers hold no allowlist, so a verified message token is not subject to per-tenant revocation on its own. A receiver that wants revocation checks the chain account token’s id against its own allowlist (see Allowlist), just as a request verifier does.
Message-token transports
Two contrib packages carry message tokens over the wire, mirroring the
credential transports but proving origin rather than authenticating a caller.
Per the naming split, the sig suffix marks message-token verification that
grants no identity, against the auth suffix that grants one:
contrib/httpsigattaches a freshly issued token to each outgoing HTTP request and verifies it on the receiving side, binding the audience to the request’s host and path and the checksum to the body.contrib/grpcsigdoes the same per gRPC call, binding the checksum to the request message’s deterministic protobuf encoding.
A handler reads the verified message claims from its context with
valiss.MessageFromContext, which returns claims that prove origin and
explicitly are not an identity. Because origin and caller-authentication are
separate questions, a route that needs both pairs a sig transport with the
matching auth one.
Chain negotiation
A self-contained message token embeds its whole provenance chain, so a receiver verifies it with nothing but the operator key. That self-containment costs bytes: the account and user tokens ride along with every single message.
Chain negotiation trades those repeated bytes for a one-time handshake. The
emitter sends a chainless token; a receiver that cannot resolve the chain
answers with a “chain required” signal; the emitter retransmits once with the
chain attached in detached headers. A receiver that keeps a chain cache
remembers the emitter’s chain after the first exchange, so the steady state is a
bare token per message and the retransmit happens once per emitter rather than
once per message. valiss.ErrNoChain is the one verification failure a
retransmit can cure, which is what lets the transports drive this automatically.
The Go guide shows how to turn negotiation on.
Related
- Creds: the token-and-seed file a client holds to sign the requests and messages it emits.