Verifying tokens
valiss tokens are structurally JWTs but not verifiable with stock JWT
libraries: the algorithm identifier and key encoding are valiss-specific. In
Go the valiss.dev/valiss package does all of this for you, Decode and the
Verify* functions for identity tokens, VerifyMessage for message tokens,
VerifySignature for request signatures. This guide is the specification-level
recipe underneath those functions. Read it when you verify offline and want to
know exactly what is checked, or when you port verification to another
language. Everything here is derivable from the Go source, which is canonical
if they ever disagree.
Wire format
A token is JWS Compact Serialization: base64url(header) "." base64url(payload) "." base64url(signature), no padding.
The header is byte-exact:
{"typ":"JWT","alg":"ed25519-nkey","ver":1}ver is the wire-format version. Read it before parsing the payload and
reject a version you do not implement; the current version is 1.
The payload is a JSON object with RFC 7519 field names, all optional except
valiss, serialized in this order:
| field | meaning |
|---|---|
jti | content-derived token id (see below) |
iat | issue time, Unix seconds |
iss | issuer public key, nkey-encoded |
name | optional human label of the subject |
sub | subject public key, nkey-encoded |
aud | destination identity (message tokens) |
exp | expiry, Unix seconds; absent = never expires |
nbf | not-before, Unix seconds; absent = immediately valid |
valiss | the level-specific claim body |
The valiss section is discriminated by its type field:
operator-{type, epoch?, ext?}; self-signed (iss == sub).account-{type, epoch?, ext?}; signed by an operator key.user-{type, epoch?, bearer?, ext?}; signed by an account key.message-{type, epoch?, checksum?, chain?, ext?}; self-signed by a user key.checksumis the lowercase-hex SHA-256 of the message payload.chainis{account, user}: the embedded provenance tokens, verbatim.
ext is an object of named, consumer-defined claim payloads; transport
carries them opaquely. The registry of names is open: a verifier that does not
recognize an extension name must carry it through untouched rather than reject
it, so a port that ignores unknown extensions stays forward-compatible.
Fail-closed decoding applies only to names a verifier explicitly opts to check;
under those, a present entry that does not match the expected shape rejects the
token.
Important
A verifier must carry an extension name it does not recognize through untouched rather than reject it. SPEC-1 keeps the extension registry open, and a port that drops or refuses unknown names breaks forward compatibility with tokens issued against a newer vocabulary. Fail-closed shape checking applies only to the names a verifier explicitly opts to enforce.
Signature
Standard Ed25519 (RFC 8032) over the signing input base64url(header) "." base64url(payload), the ASCII bytes exactly as they appear in the token. The
signature part is the raw 64-byte Ed25519 signature, base64url-encoded without
padding. Any Ed25519 implementation verifies it once you extract the raw
public key from the nkey encoding.
Decoding an nkey public key
iss and sub carry public keys in the NATS nkey text format:
base32( prefix_byte || raw_ed25519_public_key[32] || crc16_le[2] )- Base32: RFC 4648 alphabet, uppercase, no padding.
- Prefix byte encodes the key role: operator
112(renders as leadingO), account0(A), user160(U). - CRC16: CCITT/XMODEM (polynomial 0x1021, init 0x0000), computed over
prefix || key, appended little-endian.
To decode: base32-decode, check length 35, verify the trailing CRC16 over the first 33 bytes, check the prefix byte matches the role required by context, take bytes 1..33 as the raw Ed25519 public key.
Role requirements are strict: an operator key signs account tokens, an account
key signs user tokens, a user key signs message tokens; operator and message
tokens are self-signed (iss == sub).
jti derivation
jti is content-derived, not random: serialize the payload with jti
omitted (every field is omit-when-empty), SHA-256 the JSON bytes, and
base32-encode the digest (RFC 4648, uppercase, no padding). Reproducing it
requires byte-identical JSON: fields in the table’s order, no whitespace, and
Go encoding/json escaping rules (which HTML-escape <, >, and &), so
re-deriving from a reparsed payload is only possible if your serializer
reproduces that exact shape. Two tokens with identical claims issued within
the same second therefore share a jti; the allowlist’s registration dedup
relies on this. Verifiers that only check signatures may treat jti as
opaque.
Verifying a token (one level)
- Split on
.; require exactly three parts. - Base64url-decode the header; require
typ == "JWT",alg == "ed25519-nkey", andver == 1. Reject an unrecognizedverbefore parsing the payload. - Base64url-decode the payload; parse JSON.
- Decode the
issnkey; verify the Ed25519 signature overpart1 "." part2. - Require
valiss.typeto match the expected level,issto equal the expected issuer key (established by the level above, or pinned), andsubto be a valid nkey of the level’s subject role.
This establishes authenticity only. Trust and freshness are chain-level checks
below. Validity windows: a token is expired when now > exp + skew, not yet
valid when now + skew < nbf; absent fields impose nothing. valiss uses a
default skew of 2 minutes.
Note
Freshness rests entirely on the verifier’s own clock. There is no issuer to consult, so the skew window is measured against local time, and a verifier with a wrong clock silently accepts expired tokens or rejects valid ones. Keep the verifier’s clock disciplined: it is in the trusted computing base.
Verifying a message token (full chain)
Given a message token, the operator public key(s) you trust, and the received payload bytes:
- Verify the message token per above:
type == "message",iss == sub,subis a user-role nkey. - Obtain the chain:
valiss.chainembedded in the token, or supplied out of band. If both exist they must match byte-for-byte. - Verify the chain account token:
type == "account", signed by a trusted operator key,suban account-role nkey. - Verify the chain user token:
type == "user",issequal to the account token’ssub,suba user-role nkey. - Require the chain user token’s
subto equal the message token’siss: the chain must delegate to exactly the key that signed the message. - Require all three tokens to agree on
valiss.epoch(absent = 0). If you hold the domain’s self-signed operator token, additionally require the message epoch to equal its epoch and the operator token to be within its own validity window. - Check all three validity windows at the verification instant. For stored messages, that is the instant of receipt, not now.
- If the token carries
aud, require it to equal the destination you are verifying for. Reject tokens withoutaudif you expect one. - If you require payload binding, hash the payload exactly as received and
require
valiss.checksumto equal its lowercase-hex SHA-256, rejecting a token that carries no checksum. Checksum verification is receiver-opt-in (the Go transports gate it behindWithPayload/RequireChecksum); a receiver that does not ask for it leaves the claim unchecked.
A verified message token proves origin only. It is not a credential: grant
nothing for possession of one. Offline receivers hold no allowlist; an online
receiver that wants revocation checks the chain account token’s jti against
its own allowlist. The allowlist is a predicate over the jti, not a fixed
data structure, so a port can back it with a file, a database, or a live policy
call; see Allowlist for the shape of that seam.
Verifying a request signature
Per-request proof of possession, separate from tokens. This is the mechanism
of the credential transports (contrib/httpauth, contrib/grpcauth); the
message-token transports (contrib/httpsig, contrib/grpcsig) carry no
request signature, since their binding lives inside the message token
(audience and checksum, above).
The client sends a timestamp (RFC 3339 with nanoseconds, UTC) and a signature (base64 standard encoding, unlike the token’s base64url). The signed payload is:
"valiss-req-v1\n" + timestamp + "\n" + lowercase_hex(sha256(request_context))The valiss-req-v1\n prefix is the version tag, bound into the signed bytes.
A verifier that reconstructs v1 bytes fails closed on any other version.
request_context is the transport’s canonical request description:
- HTTP:
"http\n" + method + "\n" + host + "\n" + path + "\n" + nonce - gRPC:
"grpc\n" + full_method + "\n" + nonce
The nonce is empty when replay suppression is off. Whether to suppress replay
at all is left to the implementation, but the behavior is pinned once you do: a
signed request must then carry a nonce, a nonce seen before is rejected, and an
entry is retained for 2 * skew (the longest a replay could still land inside a
valid timestamp window). Verify the Ed25519
signature against the effective subject key (the user token’s sub when a
chain is present, else the account token’s sub) and bound the timestamp to a
skew window around now.