SDK reference
Python SDK
messh-sdk (import messh) signs requests from your agent. messh.verify checks them in your API, with ASGI middleware for FastAPI and Starlette.
Install#
The client goes in the agent:
$ pip install messh-sdk # import messh · extras: [httpx], [requests], [fastapi]
The verifier goes in your API. It’s in the same package:
# same package: from messh.verify import MesshAuthMiddleware $ pip install "messh-sdk[fastapi]"
Python 3.9+. One dependency, cryptography; the httpx, requests and Starlette integrations are extras.
Quick start#
Sign in the agent:
from messh import Client, key_file # Signs as mukul with the build-bot key. Create one client per process. client = Client("mukul", key=key_file("~/.ssh/build-bot")) res = client.post("https://api.acme.dev/deployments", json={"env": "staging"}) print(res.status, res.json())
Verify in your API. can() stands for your own authorization: messh says who, you decide what.
from fastapi import Depends, FastAPI, HTTPException from messh.verify import Agent, MesshAuthMiddleware, current_agent app = FastAPI() app.add_middleware(MesshAuthMiddleware, trust=["mukul"], paths=["/api/"]) @app.post("/api/deployments") def deploy(body: dict, agent: Agent = Depends(current_agent)): if not can(agent, "deploy", body["env"]): raise HTTPException(403) return {"ok": True}
For the whole setup, from publishing the agent’s key to choosing who to trust, see API authentication.
Topics#
Agent side
messh.Client(identity)Loads the signing key once and returns a client that signs every request it sends.client.whoami()Checks that the key is published and active on messh, and shows how APIs will see the agent. Call it once at startup.client.request(method, url, …)Signs a request and sends it.client.sign(method, url, body)Keeps your own HTTP stack. Returns, or adds, the signature headers for requests you build yourself.KeySourceWhere the private key comes from. The default is~/.ssh/id_ed25519. Keys are used locally and never go to messh.
API side
MesshAuthMiddlewareChecks every incoming request before your handlers run. Valid requests carry the verified agent; everything else gets a JSON 401 or 403.Verifier.verify(method, url, headers, body)Verifies one request and returns who signed it. Use it where middleware doesn’t fit: serverless handlers, webhooks, queue consumers.messh.verify.AgentWho signed the request. messh tells you who; what they may do is up to your code.
Agent side#
messh.Client(identity)
Loads the signing key once and returns a client that signs every request it sends.
class Client: def __init__( self, identity: str, *, key: KeySource | None = None, # default: key_file("~/.ssh/id_ed25519") registry: str = "https://messh.io", expires_in: int = 60, # seconds, max 300 timeout: float = 30.0, # seconds, for request() ) -> None: ... @classmethod # MESSH_IDENTITY, MESSH_KEY or MESSH_KEY_FILE, MESSH_REGISTRY def from_env(cls, **overrides) -> Client: ...
Parameters
identitystr- Your messh username, such as
mukul. Requests name it inSignature-Agent: "<registry>/<identity>".required keyKeySource- Where the private key comes from; see Key sources. It must be the ssh-ed25519 key you published with
messh publish.~/.ssh/id_ed25519 registryURL- The messh registry that serves your key directory.https://messh.io
expires_inint (s)- How long each signature is valid, 1 to 300 s. Verifiers also reject anything outside their clock-skew window.60 s
Returns
A client that is safe to share across threads. Create one per process and reuse it.
Errors
invalid_identity Not a messh username: 3 to 30 lowercase letters, digits and inner hyphens; or, from the environment, MESSH_IDENTITY isn’t set.invalid_key The key can’t be read, is passphrase-protected or isn’t Ed25519. Load a passphrase-protected key into ssh-agent; the message says what to do.invalid_option The registry isn’t an http(s) URL, or the expiry is outside 1 to 300 s.Discussion
- Raises
messh.MesshError(.code). - For agents and CI, configure the client with environment variables instead of code:
Client.from_env().
client.whoami()
Checks that the key is published and active on messh, and shows how APIs will see the agent. Call it once at startup.
def whoami(self) -> PublishedKey: ... fingerprint: str # SHA256:…, as messh keys and ssh-keygen -l print it @dataclass(frozen=True) class PublishedKey: identity: str # "mukul" key_name: str # "build-bot" key_id: str # RFC 7638 thumbprint, sent as keyid fingerprint: str # "SHA256:…" agent_url: str # "https://messh.io/mukul"
Returns
The key as messh lists it: the identity and key name an API’s verifier will report.
Errors
not_published The key isn’t an active key of the identity, or there’s no such user. Every API would answer 401 unknown_key; publish it with messh publish.registry_unreachable messh couldn’t be reached, or didn’t answer with a key directory.Discussion
- One request to the registry for the identity’s key directory; nothing is signed or sent to an API.
- Agents can run it at startup and stop with a clear message instead of failing on every call.
client.request(method, url, …)
Signs a request and sends it.
def request( self, method: str, url: str, *, json=None, data: bytes | str | None = None, headers: Mapping[str, str] | None = None, ) -> Response: ... def get(self, url: str, **kw) -> Response: ... def post(self, url: str, **kw) -> Response: ... # Response: status, headers, body, .ok, .json(), .text()
Returns
A messh.Response. A 401 or 403 from the API is a normal response, not an error.
Discussion
- Adds
Signature-Agent,Signature-Input,Signatureand, with a body,Content-Digest. Your own headers are kept. - Every call signs afresh, with a new nonce and
createdtime, so a retry is never a replay. - Pass the body as
json=ordata=: it is hashed forContent-Digest.
client.sign(method, url, body)
Keeps your own HTTP stack. Returns, or adds, the signature headers for requests you build yourself.
def sign( self, method: str, url: str, body: bytes | str | None = None, host: str | None = None, # if the Host header differs from the URL's ) -> dict[str, str]: ... # httpx (pip install "messh-sdk[httpx]") and requests ("messh-sdk[requests]") http = httpx.Client(auth=messh.MesshAuth(client)) s = requests.Session(); s.auth = messh.requests.MesshAuth(client)
Discussion
- Sign last. Changing the method, URL, a covered header or the body afterwards breaks the signature.
- Pass
hostif you send a Host header that differs from the URL’s. - Signing is local and takes microseconds; there is no network call.
KeySource
Where the private key comes from. The default is ~/.ssh/id_ed25519. Keys are used locally and never go to messh.
| Source | Use it for | In code |
|---|---|---|
| Key file | The ssh-ed25519 key you published: an unencrypted OpenSSH key, or PKCS#8 PEM. | key_file("~/.ssh/id_ed25519") |
| Environment | CI and servers: PEM text, or base64 of PEM or PKCS#8 DER, in an environment variable. | key_env("MESSH_AGENT_KEY") |
| Seed | A raw 32-byte Ed25519 seed from your own secret store. | key_seed(seed) |
| ssh-agent | A passphrase-protected key, or one held by 1Password, Secretive or a YubiKey agent. The key stays in the agent; with several loaded, pick yours by fingerprint. | ssh_agent() |
| Your signer | A KMS or an HSM: implement the public key and sign; the SDK never sees the private key. | Signer: public_key(), sign() |
OS keychains, cloud secret managers and PKCS#11 aren’t built in. Any of them can sign through your own signer.
API side#
MesshAuthMiddleware
Checks every incoming request before your handlers run. Valid requests carry the verified agent; everything else gets a JSON 401 or 403.
class MesshAuthMiddleware: # ASGI: FastAPI, Starlette def __init__( self, app, verifier: Verifier | None = None, paths: Sequence[str] = ("/",), **verifier_options, # or Verifier's options, below ) -> None: ... class Verifier: def __init__( self, trust: Sequence[str], # usernames, or "*" *, registries: Sequence[str] | Mapping[str, str] | None = None, max_skew: int = 60, cache_ttl: float = 300, stale_if_error: float = 3600, nonce_store: NonceStore | None = None, timeout: float = 5.0, # directory fetch max_body_bytes: int = 1 << 20, # then 413 body_too_large ) -> None: ...
Parameters
trustSequence[str]- Who may call this API: messh usernames, or
"*"for anyone with a valid messh signature. Valid signatures from anyone else get 403untrusted.required registriesSequence | Mapping- Accepted
Signature-Agentorigins, optionally mapped to where key directories are fetched, such as an internal hostname. Agents from other origins get 403untrustedand are never fetched.https://messh.io max_skewint (s)- How far
createdmay be from this server’s clock, either way. Nonces are remembered for twice this long.60 s cache_ttlfloat (s)- How long a key directory is reused. A revoked key leaves the directory at once, so this bounds how long it can still verify. An unknown keyid refetches sooner, at most every 30 s, so a newly published key works right away.5 min
stale_if_errorfloat (s)- How long cached keys keep working while messh is unreachable.1 h
nonce_storeNonceStore- Remembers nonces to stop replays. In memory suits one instance; behind a load balancer, implement the one-method interface on a shared store such as Redis.in memory
Discussion
- Callers outside
trustare refused before any key lookup, so they can’t make your API fetch directories. One fetch per directory runs at a time. - Bodies over
max_body_bytes(1 MiB) get 413body_too_large. - Unsigned requests get 401 with
Accept-Signature, so clients learn what to send (RFC 9421 §5.1). - Errors are JSON:
{"error": "unknown_key", "message": "…"}. See Errors. - Set
pathsto protect only some prefixes. Handlers get the caller withDepends(current_agent).
Verifier.verify(method, url, headers, body)
Verifies one request and returns who signed it. Use it where middleware doesn’t fit: serverless handlers, webhooks, queue consumers.
def verify( self, method: str, url: str, # the full request URL headers: Mapping[str, str] | Iterable[tuple[str, str]], body: bytes = b"", ) -> Agent: ... # the same for asyncio: key directories are fetched in a thread async def averify(self, method, url, headers, body=b"") -> Agent: ...
Returns
The verified Agent.
Discussion
- Raised as
messh.VerifyErrorwith.codeand.status. - The authority is the Host header if present, else the URL’s host.
- Nothing is sent to the client for you: return your own 401 or 403 with the error code.
messh.verify.Agent
Who signed the request. messh tells you who; what they may do is up to your code.
@dataclass(frozen=True) class Agent: identity: str # "mukul" key_name: str # "build-bot": the key's name on messh key_id: str # RFC 7638 thumbprint fingerprint: str # "SHA256:…" agent_url: str # "https://messh.io/mukul" created: int # unix seconds # FastAPI: agent: Agent = Depends(current_agent) def current_agent(request: Request) -> Agent: ...
Fields
identitystring- The signer’s messh username. Use it in your authorization checks.
key_namestring- The key’s name on messh, such as
laptoporbuild-bot. An agent is a named key. key_idstring- Exactly which key signed: its RFC 7638 thumbprint. Log it.
fingerprintstring- The SSH fingerprint, as
messh resolveprints it. agent_urlstring- The
Signature-Agentthe request named. createdtime- When the request was signed.
Errors#
In the agent. Raises messh.MesshError (.code): invalid_identity, invalid_key, invalid_option or sign_failed; whoami adds not_published and registry_unreachable. A 401 or 403 from the API is a normal response, not an error.
In your API. Raised as messh.VerifyError with .code and .status.
The codes, their causes and fixes are the same in every SDK; see Errors.