Skip to main content

valiss tenant authentication: nkey-signed HTTP and gRPC requests with server-side verification (wire spec 1)

Project description

valiss-py

Python client for valiss-go (VALidator-ISSuer): tenant authentication for gRPC and HTTP services, modeled on NATS operator/account/user credentials. Implements wire spec version 1 (valiss-dev/spec, SPEC-1.md) and is wire-compatible with the Go reference (v0.12.0): creds files, tokens, request signatures, and message tokens interchange freely between the two, proven against the shared conformance vectors.

  • An operator holds an Ed25519 nkey; its public key is the trust anchor.
  • The operator signs each account (tenant) a time-limited token that binds the account's own nkey public key. Issued token ids go in a server-side allowlist.
  • An account delegates: it signs user tokens with its account seed. A bearer user token authenticates by the token alone, without per-request signatures.
  • The client signs every request with its nkey over a timestamp bound to the request context (method/host/path for HTTP, full method for gRPC; bearer tokens excepted), so a captured signature cannot authorize a different operation. The Go server verifies the chain up to the pinned operator key.

This port is full-parity with the Go reference on both sides of the wire: minting tokens (all levels, including bearer and message tokens), attaching credentials to httpx and gRPC clients, and verifying requests itself — the integrated Verifier, the multi-operator keyring, HTTP (Django / ASGI) and gRPC server middleware, and the httpsig / grpcsig message-token transports. Only key generation and production account minting stay with the Go valiss CLI.

Install

uv add valiss              # core: creds parsing, token minting, request signing, verification
uv add 'valiss[httpx]'     # + httpx auth hook and the httpsig client
uv add 'valiss[grpc]'      # + gRPC call credentials and the server interceptor
uv add 'valiss[grpcsig]'   # + gRPC message-token transport (grpcio + protobuf)
uv add 'valiss[django]'    # + HTTP server middleware for Django
uv add 'valiss[fastapi]'   # + HTTP server middleware for FastAPI / any ASGI app

Issue short-lived user tokens

From account creds (operator-signed account token + account seed):

from datetime import timedelta
from valiss import creds, httpauth, nkeys, token

account = creds.load("acme.creds")

# Signing user: keeps its seed, signs every request.
user = nkeys.create_user()
alice = creds.Creds(
    account_token=account.account_token,
    user_token=token.issue_user(
        account.signer(), "alice", user.public_key,
        ttl=timedelta(minutes=15),
        extensions=[httpauth.Ext(paths=["/v1/*"])]),
    seed=user.seed,
)

# Bearer user: the generated seed is discarded, the token is the sole
# credential. Pair with TLS and short ttl.
bearer_kp = nkeys.create_user()
bob = creds.Creds(
    account_token=account.account_token,
    user_token=token.issue_user(
        account.signer(), "bob", bearer_kp.public_key,
        ttl=timedelta(minutes=15), bearer=True),
)

Go servers enforce transport extensions fail-closed: mint httpauth.Ext (hosts/methods/paths) or grpcauth.Ext (methods) into every token that must pass an extension-enforcing middleware.

Client (HTTP)

import httpx
from valiss import creds, httpauth

c = creds.load("alice.creds")
client = httpx.Client(auth=httpauth.Auth(c))
client.get("https://api.example.com/v1/whoami")

If the server runs a replay cache, enable per-request nonces: httpauth.Auth(c, nonce=True). Any other HTTP client works through httpauth.credential_headers(c, method, host, path); the signature is bound to those values, so pass the real ones and build a fresh header set per request.

Client (gRPC)

import grpc
from valiss import creds, grpcauth

c = creds.load("alice.creds")
channel_creds = grpc.composite_channel_credentials(
    grpc.ssl_channel_credentials(), grpcauth.call_credentials(c))
channel = grpc.secure_channel("api.example.com:443", channel_creds)

gRPC sends call credentials only over secure channels; for local plaintext development compose with grpc.local_channel_credentials() instead. The per-call signature is bound to the called method; grpcauth.call_credentials(c, nonce=True) adds per-call nonces for replay-cache servers.

Server: verify a request

A Python service can authenticate a request itself — no round-trip to Go. The Verifier pins the operator key and turns the credential a transport pulled off a request into a verified Identity: it verifies the account token, checks expiry/epoch, the allowlist (revocation), the optional user-token chain, and the request signature (or a bearer waiver), and suppresses replay.

from valiss.verifier import Verifier, Request
from valiss.allowlist import StaticAllowlist
from valiss.replay import MemoryReplayCache

verifier = Verifier(
    operator_pub,                      # the pinned trust anchor
    StaticAllowlist(account_jti),      # revocation: drop the id to revoke
    replay_cache=MemoryReplayCache(),  # reject a replayed nonce
)

@verifier.validator                    # custom checks run after possession is proven
def tenant_is_active(request, identity): ...

@verifier.extension(httpauth.Ext)      # typed extension enforcement
def enforce_paths(request, identity, account_ext, user_ext): ...

identity = verifier.verify(Request(
    account_token=..., user_token=..., timestamp=..., signature=..., context=..., nonce=...,
))  # -> Identity(account, user, operator) | raises ValissError(reason=...)

Pass operator_token= to enforce the trust domain's epoch and validity window, or resolver= (a callable or {account_pub: token} mapping) to accept user-only credentials. clock= injects time in tests. For a service trusting several operators, Verifier.with_keyring(Keyring(*operator_tokens), allowlist) selects the trust domain the credential names.

Server: HTTP and gRPC middleware

The middleware wraps the Verifier with header extraction, status-code mapping, and fail-closed extension enforcement, so the handler only ever sees an authenticated request. Verified identity is handed off framework-natively.

# FastAPI / any ASGI app  (valiss[fastapi])
from fastapi import Depends, FastAPI
from valiss import ALLOW_ALL, Identity, Verifier
from valiss.httpauth.asgi import Middleware, valiss_identity

app = FastAPI()
app.add_middleware(Middleware, verifier=Verifier(operator_pub, ALLOW_ALL))

@app.get("/v1/whoami")
def whoami(identity: Identity = Depends(valiss_identity)):
    return {"tenant": identity.account.name}

Django has a sibling adapter (valiss.httpauth.django: a middleware factory, request.valiss_identity, @valiss_required). For gRPC, the interceptor authenticates every RPC and the handler reads the tenant from a context var:

import grpc
from valiss import ALLOW_ALL, Verifier, grpcauth

server = grpc.server(executor, interceptors=[
    grpcauth.Authenticator(Verifier(operator_pub, ALLOW_ALL))])

def GetWidget(self, request, context):
    identity = grpcauth.identity_from_context()   # the verified tenant

Both take allow_missing_extension=True to accept tokens carrying no transport extension (authorization handled elsewhere).

Message tokens

A message token is a short-lived, self-signed proof of origin: a user key binds a payload checksum and a destination (audience) and, by embedding its provenance chain, lets a receiver verify offline with only the operator public key — no per-request signature, no allowlist. A message token is a proof, never a credential: possession grants nothing, and the request verifier never accepts one.

from valiss import message

proof = message.issue_message(
    user_kp,                                   # signs over itself (iss == sub)
    audience="https://api.example.com/ingest",
    checksum=message.checksum(body),           # lowercase-hex SHA-256 of the bytes
    chain=(account_token, user_token),         # provenance, for offline verify
    ttl=message.DEFAULT_MESSAGE_TTL,
)

claims = message.verify_message(
    proof, operator_pub,
    audience="https://api.example.com/ingest", payload=body,
)  # walks operator -> account -> user -> message; checks epoch, windows, audience, checksum

The httpsig and grpcsig transports carry message tokens over HTTP and gRPC: a client that mints a proof per request and server middleware that verifies it offline, with chain negotiation (a chainless token is answered valiss-chain: required, then retransmitted with the chain) and an optional chain cache so an emitter pays that retransmit once. This is the webhook case — the receiver authenticates the message, not a caller.

# emitter (valiss[httpx])
import httpx
from valiss import httpsig
client = httpx.Client(auth=httpsig.Transport(creds.load("emitter.creds")))
client.post("https://receiver.example/hook", json=event)

# receiver (valiss[fastapi]) — verifies offline against the operator key
from valiss.httpsig.asgi import Middleware
app.add_middleware(Middleware, operator_pub_key=operator_pub)

grpcsig is the gRPC sibling (unary_client_interceptor / unary_server_interceptor), binding the checksum to the request's deterministic protobuf encoding.

Wire version

Tokens, creds files, and request signatures each carry their own version discriminator (SPEC-1.md §8), so a future spec version can coexist with this one. The current version is 1 and appears on the wire only as an integer: the "ver":1 JWT header field, the VALISS-CREDS-VERSION: 1 creds line, and the valiss-req-v1 prefix bound into the signed request bytes. A reader peeks the version before parsing and dispatches to the matching decoder, rejecting an unrecognized version cleanly. On failure, ValissError.reason carries the spec §7 reason code the failure reduces to.

Layout

  • valiss.token — token minting (operator, account, and user level, with ttl/expiry, epoch, bearer, and extension claims), request signing, per-token verify helpers for tooling and tests
  • valiss.message — message tokens (proof-of-origin) and their full-chain offline verification
  • valiss.verifier — the integrated request Verifier (chain + allowlist + epoch + replay + extensions + validators), Request, Identity
  • valiss.allowlist / valiss.replay — account-token allowlist (revocation) and nonce replay suppression
  • valiss.keyring / valiss.chain — multi-operator trust (Verifier.with_keyring) and the negotiated-chain cache the message-token transports use
  • valiss.creds — client creds file (tokens + seed)
  • valiss.nkeys — minimal Ed25519 nkeys (operator/account/user)
  • valiss.grpcauth — gRPC call credentials, the server Authenticator interceptor, and the grpc extension claim
  • valiss.httpauth — HTTP client headers / httpx Auth, the http extension claim, and Django / ASGI server middleware (.django, .asgi)
  • valiss.httpsig / valiss.grpcsig — message-token transports: a client that mints a proof per request and server middleware/interceptors that verify it offline, with chain negotiation

Examples

uv run --group dev examples/issue_user.py       # mint tokens + wire a client
uv run --group dev examples/verify_request.py   # the Verifier: chain, revocation, replay
uv run --group dev examples/http_server.py      # ASGI middleware end-to-end
uv run --group dev examples/grpc_server.py      # gRPC interceptor end-to-end
uv run --group dev examples/webhook.py          # httpsig message-token webhook
uv run --group dev examples/grpc_webhook.py     # grpcsig message-token over gRPC

Conformance and interop

tests/test_conformance.py runs the language-neutral spec-1 vectors (a frozen copy under tests/vectors/, from valiss-dev/spec) and must pass every case: positive cases verify with the expected claims, negative cases map to the spec §7 reason code. tests/test_interop.py additionally round-trips real credentials and message tokens against the Go reference; it needs the Go toolchain and a sibling valiss-go checkout (or VALISS_GO_DIR), and skips otherwise.

Releasing

Releases are tag-driven: pushing a vX.Y.Z tag runs the Release workflow, which fails fast if the tag and the packaged version disagree, gates on the full CI (pyright + tests + Go interop + examples), then builds and publishes to PyPI via trusted publishing (OIDC, no stored token) and cuts a GitHub Release with the sdist + wheel and auto-generated notes. The package version is independent of the wire spec version, which stays 1 until the format changes.

Checklist:

  1. Bump version in pyproject.toml (semver) and merge to main via PR.
  2. Confirm main is green.
  3. Tag the merge commit and push — the tag must equal the pyproject.toml version with a v prefix:
    git tag v1.2.3 && git push origin v1.2.3
    
  4. Watch the Release workflow; on success confirm the PyPI release and the GitHub Release.

One-time setup: configure a PyPI trusted publisher for the valiss project pointing at this repo's release.yaml under the pypi environment. PyPI versions are immutable — to fix a bad release, bump to the next version and re-tag (and yank the bad one on PyPI to discourage installs).

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

valiss-0.8.0.tar.gz (139.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

valiss-0.8.0-py3-none-any.whl (69.2 kB view details)

Uploaded Python 3

File details

Details for the file valiss-0.8.0.tar.gz.

File metadata

  • Download URL: valiss-0.8.0.tar.gz
  • Upload date:
  • Size: 139.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.29 {"installer":{"name":"uv","version":"0.11.29","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for valiss-0.8.0.tar.gz
Algorithm Hash digest
SHA256 9dffe0561d7f965a3fda80cc49354645af26c19991512bc87b6df0c724c45f9a
MD5 18916bf9706c22f7ffbc84cf67a6db89
BLAKE2b-256 f5c8a1c8225e959d1f4bdc0c5dffddfb7a2551fb6e2f3d92cf835c3bf06970f6

See more details on using hashes here.

File details

Details for the file valiss-0.8.0-py3-none-any.whl.

File metadata

  • Download URL: valiss-0.8.0-py3-none-any.whl
  • Upload date:
  • Size: 69.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.29 {"installer":{"name":"uv","version":"0.11.29","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for valiss-0.8.0-py3-none-any.whl
Algorithm Hash digest
SHA256 1f03b9d8417476e4257e17e9ab89dafc48f1ffdf03a5764c54c6c6c6de0193b1
MD5 d53a5421de5beca60d52e9bf51fc8d13
BLAKE2b-256 5f7da9ea0dc2b2b964072766e15b284be19774301c8f349c00cedbc08207b1d1

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page