Skip to content
SPEC-1: valiss Wire Specification

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 (AZ, 27) 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:

  1. 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).
  2. 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.
  3. User. Holds a user nkey. A tenant MAY delegate to end users by signing user tokens with its account key.
  4. 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.go accepts 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:

  1. base64url-decodes part 1 and parses it as JSON.
  2. MUST reject unless typ == "JWT" and alg == "ed25519-nkey" (valiss-go/token.go:201-203).
  3. Reads the integer ver and dispatches to the matching per-version parser. Version 1 is parsed per section 3. An unrecognized ver MUST 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 nameJSON typeomit-when-emptymeaning
1jtistringyescontent-derived token id (section 3.5)
2iatnumberyes (omit when 0)issued-at, Unix seconds
3issstringyesissuer public key, nkey-encoded
4namestringyeshuman-readable subject label
5substringyessubject public key, nkey-encoded
6audstringyesaudience/destination (message tokens)
7expnumberyes (omit when 0)expiry, Unix seconds
8nbfnumberyes (omit when 0)not-before, Unix seconds
9valissobjectno (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/json string escaping. In particular the characters <, >, and & are escaped as <, >, & respectively (valiss-go/docs/VERIFYING.md jti derivation). Implementations in other languages MUST replicate this HTML-escaping to reproduce jti.

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 valuelevelsigning rule
"operator"operatorself-signed, iss == sub, operator-role key
"account"accountsigned by an operator-role key
"user"usersigned by an account-role key
"message"messageself-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:

nametypenotes
typestring"operator"
epochnumber (uint64)trust-domain current epoch; omit when 0
extobjectextension claims; omit when empty

Account body (valiss-go/token.go:60-67), fields in order:

nametypenotes
typestring"account"
epochnumber (uint64)epoch the token was issued in; omit when 0
extobjectextension claims; omit when empty

User body (valiss-go/token.go:70-80), fields in order:

nametypenotes
typestring"user"
epochnumber (uint64)epoch; omit when 0
bearerbooleantrue marks a token the server accepts without per-request signatures; omit when false
extobjectextension claims; omit when empty

Message body (valiss-go/token.go:94-106), fields in order:

nametypenotes
typestring"message"
epochnumber (uint64)epoch; omit when 0
checksumstringlowercase-hex SHA-256 of the message payload; omit when empty
chainobjectembedded provenance chain (below); omit when absent
extobjectextension claims; omit when empty

The message chain object (valiss-go/token.go:85-90), fields in order:

nametypenotes
accountstringthe operator-signed account token, verbatim; omit when empty
userstringthe 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):

  1. Build the payload object with jti set to the empty string (so, being omit-when-empty, it emits no bytes), all other fields set to their final values including iss and iat.
  2. Serialize it to JSON per section 3.2. Call these bytes U.
  3. Compute digest = SHA-256(U) (32 bytes).
  4. jti = base32(digest) (RFC 4648 uppercase alphabet, no padding; 52 characters).
  5. Re-serialize the payload with jti now 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 leading O), account = 0 (A), user = 160 (U). Seed prefixes render as SO, SA, SU.
  • The CRC-16 is CCITT/XMODEM (polynomial 0x1021, init 0x0000) computed over prefix_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).
  • bearer is 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 name is 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: 1

The 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.

sectionBEGIN markerEND 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).
  • timestamp is the request time formatted as RFC 3339 with nanosecond precision, in UTC (Go time.RFC3339Nano after .UTC(), valiss-go/sign.go:43, 56).
  • request_context is 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; host is the request Host (falling back to the URL host); path is 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):

  1. Parse timestamp as RFC 3339 nanosecond. A malformed timestamp MUST be rejected.
  2. Compute d = now - timestamp. If d > skew or d < -skew (a symmetric window), the request MUST be rejected. The default skew is 2 minutes (DefaultSkew, valiss-go/sign.go:15).
  3. base64std-decode the signature; a decode failure MUST be rejected.
  4. 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.epoch when 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 bearer flag 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 into request_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):

  1. registered extension-type checks against both the account and user token ext maps: a present extension MUST decode into its registered shape or the request is rejected;
  2. 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 exp is present and now > exp + skew;
  • not yet valid iff nbf is present and now + 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”):

  1. Decode and verify the message token: type == "message", iss == sub, sub a valid user-role nkey (valiss-go/message.go:294-302).
  2. Obtain the chain: the token’s embedded valiss.chain, or a chain supplied out of band. If neither exists, fail with the distinct no_chain condition (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).
  3. 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), sub an account-role nkey (valiss-go/message.go:233-246, 261-278).
  4. Verify the chain user token: iss == account.sub, type == "user", sub a user-role nkey (valiss-go/message.go:320-323).
  5. Require the chain user token’s sub to equal the message token’s iss (the chain must delegate to exactly the signing key) (valiss-go/message.go:324-326).
  6. 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).
  7. 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).
  8. If an expected audience is configured, require aud to equal it; a token bound to a different audience, or to none, MUST be rejected (valiss-go/message.go:372-374).
  9. If a payload is supplied, require valiss.checksum to 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 as lowercasehex(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

codeconditionreference
malformednot three .-separated parts; header/payload/signature not valid base64url; header or payload not valid JSONvaliss-go/token.go:185-199, 228-231, 247-250
unsupported_typeheader typ != "JWT" or alg != "ed25519-nkey"valiss-go/token.go:201-203
unsupported_versionheader ver is not a supported wire versionvaliss-go/token.go:219-221
bad_issuer_keyiss is not a decodable nkey public keyvaliss-go/token.go:243-245
bad_signatureEd25519 verification over the signing input failsvaliss-go/token.go:251-252

7.2 Token semantics

codeconditionreference
wrong_typevaliss.type is not the expected levelvaliss-go/token.go:356, 381, 406; message.go:294
wrong_issueriss 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_rolesub is not a valid nkey of the level’s rolevaliss-go/token.go:362, 387, 412; message.go:300
expirednow past exp + skew (operator, account, user, or message)valiss-go/verifier.go:323, 333, 351; message.go windows
not_yet_validnow before nbf - skewvaliss-go/verifier.go:326, 336, 354
epoch_mismatcha chain level’s epoch disagrees with the operator or another levelvaliss-go/verifier.go:329, 348; message.go:334, 338, 341

7.3 Request / credential

codeconditionreference
missingneither account nor user token presentvaliss-go/verifier.go:280-281
no_resolveruser-only request but no account-token resolver configuredvaliss-go/verifier.go:283-285
unknown_operatorkeyring has no entry for the credential’s (issuer, epoch)valiss-go/verifier.go:310-313; message.go:273-276
not_allowlistedaccount token jti not in the allowlistvaliss-go/verifier.go:339-341
not_bearerno request signature and the effective token is not a bearer user tokenvaliss-go/verifier.go:367-369
skewrequest timestamp outside the symmetric skew window, or unparsablevaliss-go/sign.go:64-70
bad_signature_encodingrequest signature not valid base64stdvaliss-go/sign.go:71-73
bad_request_signaturerequest Ed25519 signature verification failsvaliss-go/sign.go:79-81
nonce_requiredreplay cache configured but signed request carries no noncevaliss-go/verifier.go:376-377
replaynonce already seen within the retention windowvaliss-go/verifier.go:379-380
operator_misconfiguredconfigured operator token is not self-signed by the pinned keyvaliss-go/verifier.go:276-277, 219-223
extension_invalida registered extension fails to decodevaliss-go/verifier.go:385-392
validator_rejecteda custom validator returned an errorvaliss-go/verifier.go:394-398

7.4 Message-specific

codeconditionreference
no_chainmessage token embeds no chain and none was suppliedvaliss-go/message.go:22, 305-306
chain_mismatchembedded and supplied chains differvaliss-go/message.go:309-311
chain_user_mismatchchain user sub does not equal message issvaliss-go/message.go:324-326
wrong_audienceaud does not equal the expected audience (or is absent when one is expected)valiss-go/message.go:372-374
checksum_missingpayload/checksum required but token carries no checksumvaliss-go/message.go:376-377, 382-383
checksum_mismatchchecksum does not match the payload hashvaliss-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:

  1. The JWS-compact envelope is fixed across all versions. Every version is base64url(header) "." base64url(payload) "." base64url(signature) with a JSON header. ver lives in the header and is only readable while that shape holds; committing to the shape is deliberate.
  2. The version selects the parser; the signature is always verified. A verifier reads ver from 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.
  3. 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. alg MUST NOT be overloaded to express a format-version change.
  4. Each artifact versions independently. The token ver header (section 2.2), the VALISS-CREDS-VERSION line (section 4.1), and the valiss-req-v1 signed 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

constantvaluereference
wire version1valiss-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\nvaliss-go/sign.go:23
creds version markerVALISS-CREDS-VERSION: = 1valiss-go/creds/creds.go:26-29
default skew2 minutesvaliss-go/sign.go:15
default message TTL (transports)30 secondsvaliss-go/message.go:16
replay retention2 * skewvaliss-go/verifier.go:379
nkey public prefixesoperator 112 (O), account 0 (A), user 160 (U)valiss-go/docs/VERIFYING.md
jti encodingbase32 (RFC 4648 upper, no pad) of SHA-256valiss-go/token.go:164
request timestamp formatRFC 3339 nanosecond, UTCvaliss-go/sign.go:43, 56
token part encodingbase64url, no paddingvaliss-go/token.go:169-175
request signature encodingbase64std, paddedvaliss-go/sign.go:56