Documentation

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.

Python 3.9+cryptographyv0.1.0Apache-2.0

Install#

The client goes in the agent:

Terminal
$ pip install messh-sdk      # import messh · extras: [httpx], [requests], [fastapi]

The verifier goes in your API. It’s in the same package:

Terminal
# 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:

agent.py
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.

main.py · FastAPI
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#

Agent side·Class

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 in Signature-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().
Agent side·Method

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.
Agent side·Method

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, Signature and, with a body, Content-Digest. Your own headers are kept.
  • Every call signs afresh, with a new nonce and created time, so a retry is never a replay.
  • Pass the body as json= or data=: it is hashed for Content-Digest.
Agent side·Method · httpx and requests auth

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 host if you send a Host header that differs from the URL’s.
  • Signing is local and takes microseconds; there is no network call.
Agent side·Key sources

KeySource

Where the private key comes from. The default is ~/.ssh/id_ed25519. Keys are used locally and never go to messh.

SourceUse it forIn code
Key fileThe ssh-ed25519 key you published: an unencrypted OpenSSH key, or PKCS#8 PEM.key_file("~/.ssh/id_ed25519")
EnvironmentCI and servers: PEM text, or base64 of PEM or PKCS#8 DER, in an environment variable.key_env("MESSH_AGENT_KEY")
SeedA raw 32-byte Ed25519 seed from your own secret store.key_seed(seed)
ssh-agentA 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 signerA 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#

API side·ASGI middleware

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 403 untrusted.required
registriesSequence | Mapping
Accepted Signature-Agent origins, optionally mapped to where key directories are fetched, such as an internal hostname. Agents from other origins get 403 untrusted and are never fetched.https://messh.io
max_skewint (s)
How far created may 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 trust are 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 413 body_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 paths to protect only some prefixes. Handlers get the caller with Depends(current_agent).
API side·Method

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.VerifyError with .code and .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.
API side·Dataclass

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 laptop or build-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 resolve prints it.
agent_urlstring
The Signature-Agent the 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.

Updated Oct 22, 2026

↑ ↓ to move↵ to openesc to close