valiss Wire Specification, Version 1
Status: normative. This document specifies the valiss authentication scheme at
the byte and algorithm level, independent of any implementation language. The
Go reference implementation under valiss-go/ is the source of truth; where
this document and the code disagree, the code is canonical and this document is
in error. File:line citations to valiss-go/ are given for traceability and
are not themselves normative.
The key words MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL are to be interpreted as described in RFC 2119 and RFC 8174.
Throughout, “base64url” means RFC 4648 base64 with the URL-safe alphabet and no
padding (Go base64.RawURLEncoding). “base64std” means RFC 4648 base64 with the
standard alphabet and padding (Go base64.StdEncoding). “base32” means RFC 4648
base32 with the standard uppercase alphabet (A–Z, 2–7) and no padding
(Go base32.StdEncoding.WithPadding(base32.NoPadding)). “SHA-256” is FIPS 180-4.
“Ed25519” is RFC 8032. Byte strings shown in code font are exact; \n denotes
a single line-feed (0x0A) byte and no other whitespace is implied.
1. Overview
valiss provides offline-verifiable delegated tenant authentication. A server can authenticate a request, and attribute it to a tenant and an end user, knowing only one pinned public key: the operator’s. No network round-trip to an issuer is required to verify a credential.
1.1 Key hierarchy
Identities are Ed25519 key pairs encoded in the NATS nkey text format (section 3.6). There are three delegation levels plus an optional per-message level:
- Operator. Holds an operator nkey. Its public key is the trust anchor that servers pin. The operator MAY publish a self-signed operator token stating trust-domain policy (epoch, validity window, extensions).
- Account (tenant). Holds an account nkey. The operator signs each tenant an account token whose subject is the tenant’s account public key. Issued account tokens are recorded in a server-side allowlist (section 6.4) keyed by token id, so a token can be revoked before its expiry.
- User. Holds a user nkey. A tenant MAY delegate to end users by signing user tokens with its account key.
- Message (optional). A user key MAY additionally mint per-message tokens:
short-lived, self-signed proofs of origin binding a token to a destination
and a payload checksum. Message tokens are proofs, never credentials:
possession grants nothing, and a request verifier MUST NOT accept one
(
valiss-go/message.go:26-27,valiss-go/verifier.goaccepts only account/user tokens).
The public key at each level is the on-wire identity. A subject proves possession of its private key either per request (a request signature, section 5) or, for bearer user tokens, by presenting the token alone.
Key roles are strict (valiss-go/token.go issue/verify functions,
valiss-go/docs/VERIFYING.md “Role requirements”):
- An operator key signs account tokens; an operator token is self-signed
(
iss == sub). - An account key signs user tokens.
- A user key signs message tokens; a message token is self-signed
(
iss == sub).
A verifier MUST reject a token whose issuer or subject key is of the wrong role for its level (section 6).
1.2 The three serialized artifacts
The scheme defines three independently versioned wire artifacts:
- Tokens — nkey-signed JWTs (sections 2, 3).
- Credentials file — a client’s token(s) plus its seed (section 4).
- Request signature — the per-request proof of possession (section 5).
Each carries its own version discriminator (section 8). This document specifies version 1 of all three.
2. Wire version and envelope
2.1 JWS-compact envelope
Every token, of any version, is a JWS Compact Serialization string of exactly
three parts separated by ASCII period (.):
base64url(header) "." base64url(payload) "." base64url(signature)All three parts MUST use base64url (no padding) (valiss-go/token.go:169-175,
227-252). A verifier MUST reject a token that does not split into exactly
three .-separated parts (valiss-go/token.go:185-187).
2.2 Header
For version 1 the header, before base64url encoding, MUST be the byte-exact JSON:
{
"typ": "JWT",
"alg": "ed25519-nkey",
"ver": 1
}This exact byte string is the frozen version-1 header
(valiss-go/token.go:33). Producers MUST emit it verbatim.
A verifier reads the header before parsing the payload
(valiss-go/token.go:183-205, peekVersion). It:
- base64url-decodes part 1 and parses it as JSON.
- MUST reject unless
typ == "JWT"andalg == "ed25519-nkey"(valiss-go/token.go:201-203). - Reads the integer
verand dispatches to the matching per-version parser. Version 1 is parsed per section 3. An unrecognizedverMUST be rejected without parsing the payload (valiss-go/token.go:216-221).
A verifier MUST NOT skip signature verification on the basis of the header:
the header is unauthenticated until the signature over it is checked, so version
dispatch selects the parser but the signature MUST always be verified by that
parser (valiss-go/token.go per-version decoders always verify; section 8).
Because alg identifies the algorithm and ver identifies the format version,
a change to the payload layout is expressed by bumping ver, never by
overloading alg.
2.3 Signing input and signature
The signing input is the ASCII bytes:
base64url(header) "." base64url(payload)that is, parts 1 and 2 of the envelope joined by a period, exactly as they
appear on the wire (valiss-go/token.go:169-170). The signature is the raw
64-byte Ed25519 signature of the signing input under the issuer’s private key,
base64url-encoded as part 3 (valiss-go/token.go:171-175).
To verify, a verifier reconstructs the signing input as part1 "." part2
(the received bytes, not a re-serialization), decodes the issuer public key from
the payload’s iss field (section 3.6), and verifies the Ed25519 signature
(valiss-go/token.go:243-253). Verification against the issuer embedded in the
token establishes authenticity only, not trust; trust is a chain-level check
(section 6).
3. Token format
A token payload is a JSON object using RFC 7519 registered claim names for its
standard fields, plus a nested valiss object carrying the level-specific
claim body.
3.1 Field set and order
The version-1 payload object MUST serialize its fields in exactly this order
(valiss-go/token.go:115-125, the wireV1 struct):
| # | JSON name | JSON type | omit-when-empty | meaning |
|---|---|---|---|---|
| 1 | jti | string | yes | content-derived token id (section 3.5) |
| 2 | iat | number | yes (omit when 0) | issued-at, Unix seconds |
| 3 | iss | string | yes | issuer public key, nkey-encoded |
| 4 | name | string | yes | human-readable subject label |
| 5 | sub | string | yes | subject public key, nkey-encoded |
| 6 | aud | string | yes | audience/destination (message tokens) |
| 7 | exp | number | yes (omit when 0) | expiry, Unix seconds |
| 8 | nbf | number | yes (omit when 0) | not-before, Unix seconds |
| 9 | valiss | object | no (always present) | level-specific body (section 3.4) |
Field order is fixed and load-bearing: the jti derivation (section 3.5)
depends on byte-identical serialization. Every standard field except valiss
is omit-when-empty, so a level that does not set a field emits no bytes for it,
leaving other levels’ jti derivations unaffected.
iat, exp, and nbf are signed 64-bit integers of seconds since the Unix
epoch. exp == 0 (absent) means the token never expires; nbf == 0 (absent)
means it is valid immediately (valiss-go/claims.go:437-449).
3.2 JSON serialization rules
To reproduce a byte-identical payload (required for jti, section 3.5), a
producer MUST serialize with:
- No insignificant whitespace between tokens.
- Fields in the order of section 3.1; nested object fields in the order given by their body definition (section 3.4).
- Go
encoding/jsonstring escaping. In particular the characters<,>, and&are escaped as<,>,&respectively (valiss-go/docs/VERIFYING.mdjti derivation). Implementations in other languages MUST replicate this HTML-escaping to reproducejti.
A verifier that only checks signatures MAY treat jti as an opaque string and
need not reproduce this serialization; a verifier that re-derives jti (for
example to dedup keyring entries) MUST.
3.3 The valiss body and type discriminator
The valiss object is discriminated by its string field type, whose value is
one of (valiss-go/token.go:35-38):
type value | level | signing rule |
|---|---|---|
"operator" | operator | self-signed, iss == sub, operator-role key |
"account" | account | signed by an operator-role key |
"user" | user | signed by an account-role key |
"message" | message | self-signed, iss == sub, user-role key |
A verifier MUST reject a token whose valiss.type does not match the level it
expects (valiss-go/token.go:356-357, 381, 406; valiss-go/message.go:294-295).
3.4 Per-level bodies
Field names and types are exact. All fields except type are omit-when-empty.
The ext field is an object mapping extension names to arbitrary JSON values,
carried opaquely by the scheme (section 3.7).
Operator body (valiss-go/token.go:43-51), fields in order:
| name | type | notes |
|---|---|---|
type | string | "operator" |
epoch | number (uint64) | trust-domain current epoch; omit when 0 |
ext | object | extension claims; omit when empty |
Account body (valiss-go/token.go:60-67), fields in order:
| name | type | notes |
|---|---|---|
type | string | "account" |
epoch | number (uint64) | epoch the token was issued in; omit when 0 |
ext | object | extension claims; omit when empty |
User body (valiss-go/token.go:70-80), fields in order:
| name | type | notes |
|---|---|---|
type | string | "user" |
epoch | number (uint64) | epoch; omit when 0 |
bearer | boolean | true marks a token the server accepts without per-request signatures; omit when false |
ext | object | extension claims; omit when empty |
Message body (valiss-go/token.go:94-106), fields in order:
| name | type | notes |
|---|---|---|
type | string | "message" |
epoch | number (uint64) | epoch; omit when 0 |
checksum | string | lowercase-hex SHA-256 of the message payload; omit when empty |
chain | object | embedded provenance chain (below); omit when absent |
ext | object | extension claims; omit when empty |
The message chain object (valiss-go/token.go:85-90), fields in order:
| name | type | notes |
|---|---|---|
account | string | the operator-signed account token, verbatim; omit when empty |
user | string | the account-signed user token of the emitter, verbatim; omit when empty |
epoch is uint64. epoch == 0 (absent) is the default epoch. When a verifier
enforces an epoch (section 6.5), every level in a chain MUST agree on it.
3.5 jti derivation
jti is content-derived, not random (valiss-go/token.go:158-164):
- Build the payload object with
jtiset to the empty string (so, being omit-when-empty, it emits no bytes), all other fields set to their final values includingissandiat. - Serialize it to JSON per section 3.2. Call these bytes
U. - Compute
digest = SHA-256(U)(32 bytes). jti = base32(digest)(RFC 4648 uppercase alphabet, no padding; 52 characters).- Re-serialize the payload with
jtinow set to that value to obtain the final payload bytes that are signed (valiss-go/token.go:165-168).
Because jti is content-derived, two tokens with identical claims (including
the same iat second) share a jti; keyring de-duplication relies on this
(valiss-go/keyring.go:57-59).
3.6 nkey encoding of iss and sub
iss and sub carry Ed25519 public keys in the NATS nkey text format
(valiss-go/docs/VERIFYING.md “Decoding an nkey public key”):
base32( prefix_byte(1) || raw_ed25519_public_key(32) || crc16_le(2) )- base32: RFC 4648 uppercase alphabet, no padding.
- The single prefix byte encodes the role. Public-key prefixes:
operator =
112(renders with leadingO), account =0(A), user =160(U). Seed prefixes render asSO,SA,SU. - The CRC-16 is CCITT/XMODEM (polynomial
0x1021, init0x0000) computed overprefix_byte || raw_public_key(the first 33 bytes) and appended little-endian.
To decode: base32-decode; require exactly 35 bytes; verify the trailing 2-byte
CRC-16 over the first 33 bytes; check the prefix byte matches the role required
by context; take bytes 1..33 as the raw 32-byte Ed25519 public key. A verifier
MUST perform the role check (nkeys.IsValidPublicOperatorKey /
...AccountKey / ...UserKey at valiss-go/token.go:362, 387, 412;
valiss-go/message.go:300).
3.7 Extensions (ext)
ext maps a string extension name to an arbitrary JSON value
(valiss-go/token.go:57). The scheme signs and transports extension values
untouched and assigns them no meaning; meaning is defined by whoever registered
the name (a transport layer, or a consumer). A producer MUST NOT emit two
entries with the same name (valiss-go/claims.go:219-221). A verifier MAY
require named extensions to decode into an expected shape (section 6.9); absent
that, it MUST carry them through opaquely.
3.8 Level constraints on standard fields
- Operator, account, and user tokens carry
name; message tokens MUST NOT (valiss-go/message.go:97-99). - Only message tokens carry
aud(valiss-go/claims.go:167-173,238-241). beareris valid only on user tokens (valiss-go/claims.go:262-263,295-296;valiss-go/message.go:94-96).- Message tokens MUST carry an
exp(valiss-go/message.go:100-102); they are short-lived proofs. The reference transports mint them with a 30-second TTL (DefaultMessageTTL,valiss-go/message.go:16). - When
nameis absent, a consumer displays the subject public key in its place (valiss-go/claims.go:451-457); this is a presentation fallback, not a wire value.
4. Credentials file format
A credentials (“creds”) file is a marker-delimited UTF-8 text file holding a
subject’s token(s) and the seed that signs its requests
(valiss-go/creds/creds.go).
4.1 Version header
The file SHOULD begin with a version line (valiss-go/creds/creds.go:26-29,
57-59):
VALISS-CREDS-VERSION: 1The marker string is VALISS-CREDS-VERSION: and the version for this spec is
1. A parser MUST read this line, if present, before parsing the payload, and
MUST reject a version it does not implement (valiss-go/creds/creds.go:124-140).
An absent header MUST be read as the current version (the pre-versioned format
is otherwise identical). This header versions the file container only; the
tokens inside carry their own token version (section 2).
4.2 Section markers
Three optional sections are delimited by these exact marker lines
(valiss-go/creds/creds.go:33-39). Note the asymmetry: BEGIN markers use five
leading and trailing dashes; END markers use six.
| section | BEGIN marker | END marker |
|---|---|---|
| account token | -----BEGIN VALISS ACCOUNT TOKEN----- | ------END VALISS ACCOUNT TOKEN------ |
| user token | -----BEGIN VALISS USER TOKEN----- | ------END VALISS USER TOKEN------ |
| seed | -----BEGIN VALISS SEED----- | ------END VALISS SEED------ |
4.3 Parse rules
- Each present section MUST contain exactly one non-empty payload line strictly
between its BEGIN and END markers (
valiss-go/creds/creds.go:148-175,between). Blank lines inside a section are ignored. A section that is empty, unclosed, or holds more than one payload line MUST be rejected. - Lines are matched after trimming surrounding whitespace.
- Content outside any section (including the version line and any human-readable notes) is ignored during payload extraction.
- At least one token section (account or user) MUST be present; a file with no
token MUST be rejected (
valiss-go/creds/creds.go:98-100). - The seed section is OPTIONAL. A creds file with tokens but no seed is a
bearer creds file: its holder cannot sign requests, so the server accepts it
only when the effective token is a bearer user token
(
valiss-go/creds/creds.go:8-11).
4.4 Creds shapes
- Account-level creds: an account token plus the account seed.
- User-level creds: a user token plus the user seed; the account token is resolved server-side (section 6.2).
- Bundle: user-level creds that additionally embed the upstream account token, for servers that do not resolve it.
- Bearer creds: token(s) only, no seed.
The seed line, when present, holds the nkey seed text (an S…-prefixed string)
that signs requests as the file’s subject: the account seed in account-level
creds, the user seed in user-level creds.
4.5 Rendering
When rendering, a producer emits the version line, then any present sections
separated by a single blank line, each as BEGIN / single payload line / END,
in the order account token, user token, seed
(valiss-go/creds/creds.go:57-75). A producer MAY append a human-readable
warning after the seed; parsers MUST ignore it.
5. Request signature format
A request signature is a per-request proof that the sender holds the subject’s private key. It is used by the credential transports; bearer requests carry no signature (section 6.7).
5.1 Canonical signed bytes
The signed byte string for version 1 is (valiss-go/sign.go:23, 41-44):
"valiss-req-v1\n" + timestamp + "\n" + lowercasehex(SHA-256(request_context))where:
"valiss-req-v1\n"is the literal version tag including its trailing line-feed. It is part of the signed bytes, so a signature made under any other version cannot match a v1 reconstruction; v1 therefore fails closed against version confusion without any transport-carried version signal (valiss-go/sign.go:20-23).timestampis the request time formatted as RFC 3339 with nanosecond precision, in UTC (Gotime.RFC3339Nanoafter.UTC(),valiss-go/sign.go:43,56).request_contextis the transport’s canonical byte description of the request (section 5.3). Its SHA-256 digest is rendered as lowercase hexadecimal.
The signature is the raw 64-byte Ed25519 signature of these bytes under the subject’s private key.
5.2 Transmission
The transport transmits two values (valiss-go/sign.go:51-57):
- timestamp: the same RFC 3339 nanosecond UTC string used in the signed bytes.
- signature: the raw signature encoded with base64std (standard alphabet, with padding) — note this differs from the base64url used inside tokens.
The reference transports carry these as valiss-timestamp and
valiss-signature headers / metadata, alongside valiss-account-token,
valiss-user-token, and optionally valiss-nonce
(valiss-go/verifier.go:11-31).
5.3 request_context
request_context is transport-defined and MUST be reconstructed identically by
client and server; a mismatch fails the signature. The reference transports use
(valiss-go/contrib/httpauth/transport.go:93-99,
valiss-go/contrib/grpcauth/credentials.go:98):
- HTTP:
"http\n" + method + "\n" + host + "\n" + path + "\n" + nonce. The query string is excluded;hostis the request Host (falling back to the URL host);pathis matched exactly. - gRPC:
"grpc\n" + full_method + "\n" + nonce.
nonce is the empty string when replay suppression is not in use. A transport
MAY define its own request_context; passing empty context binds nothing beyond
the version tag and timestamp (valiss-go/sign.go:50).
5.4 Skew window check
On verification (valiss-go/sign.go:63-83):
- Parse
timestampas RFC 3339 nanosecond. A malformed timestamp MUST be rejected. - Compute
d = now - timestamp. Ifd > skeword < -skew(a symmetric window), the request MUST be rejected. The defaultskewis 2 minutes (DefaultSkew,valiss-go/sign.go:15). - base64std-decode the signature; a decode failure MUST be rejected.
- Decode the subject public key (nkey) and verify the Ed25519 signature over
the bytes of section 5.1 reconstructed from the received timestamp and the
locally derived
request_context.
6. Verification algorithm
This section specifies the full per-request verification a server MUST perform, and the message-token verification. Any failing check MUST cause the request to be rejected as unauthenticated; a verifier MUST NOT return a partial identity.
6.1 Inputs
A verifier is configured with either:
- a single pinned operator public key, optionally plus the operator’s
self-signed token to enforce policy (
valiss-go/verifier.go:219-236); or - a keyring of trusted operator tokens (section 6.6,
valiss-go/verifier.go:250-265).
Plus an allowlist (section 6.4), an optional account-token resolver (section 6.2), an optional replay cache (section 6.8), optional extension-type checks (section 6.9), and optional custom validators.
A request carries: an account token (optional if a resolver is configured), an
optional user token, an optional timestamp+signature (+nonce), and the
request_context bytes.
6.2 Resolving the account token
If the request carries no account token
(valiss-go/verifier.go:279-295):
- If it also carries no user token, reject (
missing). - Otherwise, if no resolver is configured, reject (
no_resolver). - Otherwise derive the account public key as the user token’s issuer
(
IssuerOf, which verifies the user token against its own embedded issuer) and ask the resolver for the matching account token. The resolved token then goes through the full verification below (signature, epoch, expiry, allowlist).
6.3 Establishing the trust anchor and verifying the account token
Single-anchor verifier (valiss-go/verifier.go:315-320): verify the account
token with iss == pinned operator key, type == "account", and sub a valid
account-role nkey (valiss-go/token.go:376-396). A wrong issuer, wrong type, or
bad signature MUST be rejected.
Keyring verifier (valiss-go/verifier.go:302-314): read the account token’s
own issuer, verify the token against that issuer, then look up the keyring entry
for (issuer key, account.epoch). If no such entry exists, reject
(unknown_operator). The looked-up entry becomes the operator policy for the
rest of verification. Entry selection is by name, not trial: the credential
names its operator and epoch.
6.4 Allowlist
The account token’s jti (its ID) MUST be present in the allowlist, or the
request MUST be rejected (valiss-go/verifier.go:339-341;
valiss-go/allowlist.go). The allowlist is the revocation mechanism: removing a
jti revokes that account token before its exp. A development-only
AllowAll accepts everything (valiss-go/allowlist.go:74-78); signature and
expiry still gate access.
6.5 Epoch enforcement
When an operator policy is in force (a configured operator token on a
single-anchor verifier, or any keyring entry), the verifier MUST
(valiss-go/verifier.go:322-332, 348-350):
- reject if the operator token is expired or not-yet-valid (with skew, section 6.10);
- require
account.epoch == operator.epoch; - require
user.epoch == operator.epochwhen a user token is present.
Bumping the operator epoch and re-minting therefore rotates the whole trust
domain at once. When no operator policy is configured on a single-anchor
verifier, epochs are not enforced. (A misconfigured operator token, one not
self-signed by the pinned key, MUST poison the verifier so every request fails
rather than silently skipping policy: valiss-go/verifier.go:276-278,
219-223.)
6.6 User token
If the request carries a user token (valiss-go/verifier.go:343-358), verify it
with iss == account.sub (the delegating account key), type == "user", and
sub a valid user-role nkey (valiss-go/token.go:401-422). Enforce its epoch
(section 6.5) and its validity window (section 6.10). The effective subject for
the request signature is the user token’s sub; without a user token it is the
account token’s sub (valiss-go/verifier.go:363-366).
6.7 Request signature / bearer
Possession MUST be proven before any consumer-supplied hook runs
(valiss-go/verifier.go:360-383):
- If both timestamp and signature are empty, the request is a bearer request.
It is accepted only when a user token is present and its
bearerflag is true; otherwise reject (not_bearer). Account-level requests MUST always sign. - Otherwise verify the request signature (section 5) against the effective subject key within the skew window. A failure MUST be rejected.
6.8 Replay / nonce
When a replay cache is configured (valiss-go/verifier.go:375-382):
- A signed request MUST carry a nonce; a missing nonce MUST be rejected.
- If the cache reports the nonce as already seen, reject (
replay). - Otherwise record the nonce with an expiry of
now + 2*skew(the longest a replay could still land inside a valid timestamp window). The nonce is folded intorequest_context(section 5.3) so it is covered by the signature. Bearer requests, carrying no signature, are unaffected.
A nonce is a per-request unique value; the reference client uses 128 random bits
rendered as hex (valiss-go/sign.go:26-33).
6.9 Extension checks and validators
After possession is proven, the verifier runs, in order
(valiss-go/verifier.go:384-398):
- registered extension-type checks against both the account and user token
extmaps: a present extension MUST decode into its registered shape or the request is rejected; - custom validators in registration order; the first error rejects the request.
6.10 Validity windows and skew
For a claims set with expiry exp and not-before nbf
(valiss-go/claims.go:459-468), given the verification instant now and skew
skew:
- expired iff
expis present andnow > exp + skew; - not yet valid iff
nbfis present andnow + skew < nbf.
Absent exp/nbf impose no constraint. The default skew is 2 minutes
(DefaultSkew). These checks apply to the operator, account, user, and message
tokens as each appears in a chain.
6.11 Result
On success the verifier returns the verified identity: the account claims,
the user claims (if any), and the operator claims (the keyring entry, or the
configured operator token, else none) (valiss-go/verifier.go:342, 356,
399; valiss-go/verifier.go:59-71). Consumers segment tenant data by the
account identity and distinguish trust domains by the operator name.
6.12 Message-token verification
A message token is verified against a pinned operator key (or a keyring) as a
full chain (valiss-go/message.go:232-386; valiss-go/docs/VERIFYING.md
“Verifying a message token”):
- Decode and verify the message token:
type == "message",iss == sub,suba valid user-role nkey (valiss-go/message.go:294-302). - Obtain the chain: the token’s embedded
valiss.chain, or a chain supplied out of band. If neither exists, fail with the distinctno_chaincondition (ErrNoChain,valiss-go/message.go:22,304-306) so a receiver can request retransmission with the chain. If both exist they MUST match exactly, field for field (valiss-go/message.go:308-311). - Verify the chain account token:
type == "account", signed by a trusted operator key (the pinned key, or, for a keyring, the entry selected by the token’s issuer and epoch),suban account-role nkey (valiss-go/message.go:233-246,261-278). - Verify the chain user token:
iss == account.sub,type == "user",suba user-role nkey (valiss-go/message.go:320-323). - Require the chain user token’s
subto equal the message token’siss(the chain must delegate to exactly the signing key) (valiss-go/message.go:324-326). - Require the message, account, and user tokens to agree on
epoch. If an operator policy is in force, additionally require the message epoch to equal the operator epoch and the operator token to be within its validity window (valiss-go/message.go:327-343). - Check every validity window at the verification instant (which for a stored
message SHOULD be the receipt instant, not the current time)
(
valiss-go/message.go:354-371). - If an expected audience is configured, require
audto equal it; a token bound to a different audience, or to none, MUST be rejected (valiss-go/message.go:372-374). - If a payload is supplied, require
valiss.checksumto equal the lowercase-hex SHA-256 of the payload as received; a token without a checksum MUST be rejected when a payload or an explicit checksum requirement is present (valiss-go/message.go:375-384). Checksum is computed aslowercasehex(SHA-256(payload))(valiss-go/message.go:57-60).
A verified message token proves origin only and MUST NOT be treated as a credential.
7. Reason codes / error taxonomy
A conformant verifier distinguishes the following failure conditions so that negative conformance tests can assert the same reason across implementations. The short code is stable; the human message is illustrative. Codes are grouped by the stage that raises them.
7.1 Envelope / decode
| code | condition | reference |
|---|---|---|
malformed | not three .-separated parts; header/payload/signature not valid base64url; header or payload not valid JSON | valiss-go/token.go:185-199, 228-231, 247-250 |
unsupported_type | header typ != "JWT" or alg != "ed25519-nkey" | valiss-go/token.go:201-203 |
unsupported_version | header ver is not a supported wire version | valiss-go/token.go:219-221 |
bad_issuer_key | iss is not a decodable nkey public key | valiss-go/token.go:243-245 |
bad_signature | Ed25519 verification over the signing input fails | valiss-go/token.go:251-252 |
7.2 Token semantics
| code | condition | reference |
|---|---|---|
wrong_type | valiss.type is not the expected level | valiss-go/token.go:356, 381, 406; message.go:294 |
wrong_issuer | iss is not the expected issuer key for the level (operator not self-signed; account not operator-signed; user not account-signed; message not signed by the chain user key) | valiss-go/token.go:359, 384, 409; message.go:297, 324 |
wrong_subject_role | sub is not a valid nkey of the level’s role | valiss-go/token.go:362, 387, 412; message.go:300 |
expired | now past exp + skew (operator, account, user, or message) | valiss-go/verifier.go:323, 333, 351; message.go windows |
not_yet_valid | now before nbf - skew | valiss-go/verifier.go:326, 336, 354 |
epoch_mismatch | a chain level’s epoch disagrees with the operator or another level | valiss-go/verifier.go:329, 348; message.go:334, 338, 341 |
7.3 Request / credential
| code | condition | reference |
|---|---|---|
missing | neither account nor user token present | valiss-go/verifier.go:280-281 |
no_resolver | user-only request but no account-token resolver configured | valiss-go/verifier.go:283-285 |
unknown_operator | keyring has no entry for the credential’s (issuer, epoch) | valiss-go/verifier.go:310-313; message.go:273-276 |
not_allowlisted | account token jti not in the allowlist | valiss-go/verifier.go:339-341 |
not_bearer | no request signature and the effective token is not a bearer user token | valiss-go/verifier.go:367-369 |
skew | request timestamp outside the symmetric skew window, or unparsable | valiss-go/sign.go:64-70 |
bad_signature_encoding | request signature not valid base64std | valiss-go/sign.go:71-73 |
bad_request_signature | request Ed25519 signature verification fails | valiss-go/sign.go:79-81 |
nonce_required | replay cache configured but signed request carries no nonce | valiss-go/verifier.go:376-377 |
replay | nonce already seen within the retention window | valiss-go/verifier.go:379-380 |
operator_misconfigured | configured operator token is not self-signed by the pinned key | valiss-go/verifier.go:276-277, 219-223 |
extension_invalid | a registered extension fails to decode | valiss-go/verifier.go:385-392 |
validator_rejected | a custom validator returned an error | valiss-go/verifier.go:394-398 |
7.4 Message-specific
| code | condition | reference |
|---|---|---|
no_chain | message token embeds no chain and none was supplied | valiss-go/message.go:22, 305-306 |
chain_mismatch | embedded and supplied chains differ | valiss-go/message.go:309-311 |
chain_user_mismatch | chain user sub does not equal message iss | valiss-go/message.go:324-326 |
wrong_audience | aud does not equal the expected audience (or is absent when one is expected) | valiss-go/message.go:372-374 |
checksum_missing | payload/checksum required but token carries no checksum | valiss-go/message.go:376-377, 382-383 |
checksum_mismatch | checksum does not match the payload hash | valiss-go/message.go:379-381 |
An implementation MAY use finer subdivisions internally but MUST be able to map each of its failures to one of the codes above for cross-implementation conformance testing.
8. Versioning and forward-compatibility
The rules of ADR 0009 are normative:
- The JWS-compact envelope is fixed across all versions. Every version is
base64url(header) "." base64url(payload) "." base64url(signature)with a JSON header.verlives in the header and is only readable while that shape holds; committing to the shape is deliberate. - The version selects the parser; the signature is always verified. A
verifier reads
verfrom the (unauthenticated) header and dispatches to the matching per-version parser, then that parser verifies the signature. A verifier MUST NOT skip verification based on the header: a forged version either routes to a real parser that rejects the bad signature, or is an unknown version rejected outright. Because the request-signature version tag is part of the signed bytes (section 5.1), downgrade is blocked. - Adding a version is additive. A new version adds a new set of wire types,
a new decoder that normalizes into the version-neutral internal view, and one
dispatch case; nothing outside that set changes.
algMUST NOT be overloaded to express a format-version change. - Each artifact versions independently. The token
verheader (section 2.2), theVALISS-CREDS-VERSIONline (section 4.1), and thevaliss-req-v1signed prefix (section 5.1) advance separately; the creds container version is independent of the token version it carries.
For version 1 there is no second version to coexist with, so the request
signature carries no transport-level version signal: it fails closed by way of
the signed prefix. A second version would introduce explicit dispatch (for
example a transport valiss-version header) as an additive step.
Appendix A. Constants summary
| constant | value | reference |
|---|---|---|
| wire version | 1 | valiss-go/token.go:32 |
| token header (v1) | {"typ":"JWT","alg":"ed25519-nkey","ver":1} | valiss-go/token.go:33 |
| request signed prefix (v1) | valiss-req-v1\n | valiss-go/sign.go:23 |
| creds version marker | VALISS-CREDS-VERSION: = 1 | valiss-go/creds/creds.go:26-29 |
| default skew | 2 minutes | valiss-go/sign.go:15 |
| default message TTL (transports) | 30 seconds | valiss-go/message.go:16 |
| replay retention | 2 * skew | valiss-go/verifier.go:379 |
| nkey public prefixes | operator 112 (O), account 0 (A), user 160 (U) | valiss-go/docs/VERIFYING.md |
| jti encoding | base32 (RFC 4648 upper, no pad) of SHA-256 | valiss-go/token.go:164 |
| request timestamp format | RFC 3339 nanosecond, UTC | valiss-go/sign.go:43, 56 |
| token part encoding | base64url, no padding | valiss-go/token.go:169-175 |
| request signature encoding | base64std, padded | valiss-go/sign.go:56 |