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 testsvaliss.message— message tokens (proof-of-origin) and their full-chain offline verificationvaliss.verifier— the integrated requestVerifier(chain + allowlist + epoch + replay + extensions + validators),Request,Identityvaliss.allowlist/valiss.replay— account-token allowlist (revocation) and nonce replay suppressionvaliss.keyring/valiss.chain— multi-operator trust (Verifier.with_keyring) and the negotiated-chain cache the message-token transports usevaliss.creds— client creds file (tokens + seed)valiss.nkeys— minimal Ed25519 nkeys (operator/account/user)valiss.grpcauth— gRPC call credentials, the serverAuthenticatorinterceptor, and thegrpcextension claimvaliss.httpauth— HTTP client headers / httpxAuth, thehttpextension 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:
- Bump
versioninpyproject.toml(semver) and merge tomainvia PR. - Confirm
mainis green. - Tag the merge commit and push — the tag must equal the
pyproject.tomlversion with avprefix:git tag v1.2.3 && git push origin v1.2.3
- 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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9dffe0561d7f965a3fda80cc49354645af26c19991512bc87b6df0c724c45f9a
|
|
| MD5 |
18916bf9706c22f7ffbc84cf67a6db89
|
|
| BLAKE2b-256 |
f5c8a1c8225e959d1f4bdc0c5dffddfb7a2551fb6e2f3d92cf835c3bf06970f6
|
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1f03b9d8417476e4257e17e9ab89dafc48f1ffdf03a5764c54c6c6c6de0193b1
|
|
| MD5 |
d53a5421de5beca60d52e9bf51fc8d13
|
|
| BLAKE2b-256 |
5f7da9ea0dc2b2b964072766e15b284be19774301c8f349c00cedbc08207b1d1
|