SDK reference
JS / TS SDK
@messh/sdk signs requests from your agent. @messh/sdk/verify checks them in your API: Express, or fetch-style runtimes such as Workers, Hono and Next.js.
Install#
The client goes in the agent:
$ npm install @messh/sdk # or: pnpm add @messh/sdk · bun add @messh/sdk
The verifier goes in your API. It’s in the same package:
# same package: import { messhAuth } from "@messh/sdk/verify" $ npm install @messh/sdk
Node 20+. ESM, types included, no dependencies: signing uses node:crypto.
Quick start#
Sign in the agent:
import { MesshClient, keyFile } from "@messh/sdk" // Signs as mukul with the build-bot key. Create one client per process. const client = await MesshClient.create({ identity: "mukul", key: keyFile("~/.ssh/build-bot"), }) const res = await client.fetch("https://api.acme.dev/deployments", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ env: "staging" }), }) console.log(res.status)
Verify in your API. can() stands for your own authorization: messh says who, you decide what.
import express from "express" import { messhAuth } from "@messh/sdk/verify" const app = express() // Mount before express.json(): it reads the raw body to check Content-Digest, // then sets req.body for JSON. app.use("/api", messhAuth({ trust: ["mukul"] })) app.post("/api/deployments", (req, res) => { const agent = req.agent // { identity: "mukul", keyName: "build-bot", … } if (!can(agent, "deploy", req.body.env)) return res.status(403).end() res.json({ ok: true }) }) app.listen(3000)
For the whole setup, from publishing the agent’s key to choosing who to trust, see API authentication.
Topics#
Agent side
MesshClient.create(options)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.fetch(input, init)Signs a request and sends it.client.sign(input)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
messhAuth(options)Checks every incoming request before your handlers run. Valid requests carry the verified agent; everything else gets a JSON 401 or 403.verifier.verify(input)Verifies one request and returns who signed it. Use it where middleware doesn’t fit: serverless handlers, webhooks, queue consumers.AgentWho signed the request. messh tells you who; what they may do is up to your code.
Agent side#
MesshClient.create(options)
Loads the signing key once and returns a client that signs every request it sends.
static create(options: ClientOptions): Promise<MesshClient> // MESSH_IDENTITY, MESSH_KEY or MESSH_KEY_FILE, MESSH_REGISTRY; options win static fromEnv(overrides?: Partial<ClientOptions>): Promise<MesshClient> interface ClientOptions { identity: string // messh username, e.g. "mukul" key?: KeySource // default: keyFile("~/.ssh/id_ed25519") registry?: string // default: "https://messh.io" expiresIn?: number // seconds, default: 60, max 300 }
Parameters
identitystring- 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
expiresInnumber (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
- Rejects with
MesshError(.code). - For agents and CI, configure the client with environment variables instead of code:
MesshClient.fromEnv().
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.
whoami(): Promise<PublishedKey> fingerprint: string // SHA256:…, as messh keys and ssh-keygen -l print it interface PublishedKey { identity: string // "mukul" keyName: string // "build-bot" keyId: string // RFC 7638 thumbprint, sent as keyid fingerprint: string // "SHA256:…" agentUrl: string // "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.fetch(input, init)
Signs a request and sends it.
fetch(input: string | URL, init?: RequestInit): Promise<Response>
Returns
A standard 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 a string or bytes, not a stream: it is hashed for
Content-Digest.
client.sign(input)
Keeps your own HTTP stack. Returns, or adds, the signature headers for requests you build yourself.
sign(input: {
method: string
url: string | URL
body?: Uint8Array | string | null
host?: string // if the Host header differs from the URL's
}): Promise<Record<string, string>>
signRequest(request: Request): Promise<Request> // a signed copy
const headers = await client.sign({ method: "POST", url, body })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. | keyFile("~/.ssh/id_ed25519") |
| Environment | CI and servers: PEM text, or base64 of PEM or PKCS#8 DER, in an environment variable. | keyEnv("MESSH_AGENT_KEY") |
| Seed | A raw 32-byte Ed25519 seed from your own secret store. | keySeed(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. | sshAgent() |
| Your signer | A KMS or an HSM: implement the public key and sign; the SDK never sees the private key. | { publicKey(), sign(data) } |
OS keychains, cloud secret managers and PKCS#11 aren’t built in. Any of them can sign through your own signer.
API side#
messhAuth(options)
Checks every incoming request before your handlers run. Valid requests carry the verified agent; everything else gets a JSON 401 or 403.
function messhAuth( options: VerifierOptions & { verifier?: Verifier; trustProxy?: boolean }, ): RequestHandler // sets req.agent, req.rawBody and, for JSON, req.body interface VerifierOptions { trust: string[] // usernames, or "*" registries?: string[] | Record<string, string> // default: ["https://messh.io"] maxSkew?: number // seconds, default: 60 cacheTtl?: number // seconds, default: 300 staleIfError?: number // seconds, default: 3600 nonceStore?: NonceStore // default: in memory timeoutMs?: number // directory fetch, default: 5000 maxBodyBytes?: number // default: 1 MiB, then 413 }
Parameters
truststring[]- Who may call this API: messh usernames, or
"*"for anyone with a valid messh signature. Valid signatures from anyone else get 403untrusted.required registriesstring[] | Record- 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 maxSkewnumber (s)- How far
createdmay be from this server’s clock, either way. Nonces are remembered for twice this long.60 s cacheTtlnumber (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
staleIfErrornumber (s)- How long cached keys keep working while messh is unreachable.1 h
nonceStoreNonceStore- 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
maxBodyBytes(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. - Express and Connect. Mount it before
express.json(). For fetch-style runtimes useverifyRequest.
verifier.verify(input)
Verifies one request and returns who signed it. Use it where middleware doesn’t fit: serverless handlers, webhooks, queue consumers.
verify(input: {
method: string
url: string | URL // the full request URL
headers: Headers | Record<string, string | string[] | undefined>
body?: Uint8Array | string | null
}): Promise<Agent>
// fetch-style runtimes: Workers, Hono, Next.js, Deno, Bun
verifyRequest(request: Request): Promise<Agent>
const verifier = new Verifier({ trust: ["mukul"] })
export async function POST(request: Request) {
const agent = await verifier.verifyRequest(request)
return Response.json({ caller: agent.identity })
}Returns
The verified Agent.
Discussion
- Thrown as
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.
Agent
Who signed the request. messh tells you who; what they may do is up to your code.
interface Agent { identity: string // "mukul" keyName: string // "build-bot": the key's name on messh keyId: string // RFC 7638 thumbprint fingerprint: string // "SHA256:…" agentUrl: string // "https://messh.io/mukul" created: number // signature time, seconds since epoch }
Fields
identitystring- The signer’s messh username. Use it in your authorization checks.
keyNamestring- The key’s name on messh, such as
laptoporbuild-bot. An agent is a named key. keyIdstring- Exactly which key signed: its RFC 7638 thumbprint. Log it.
fingerprintstring- The SSH fingerprint, as
messh resolveprints it. agentUrlstring- The
Signature-Agentthe request named. createdtime- When the request was signed.
Errors#
In the agent. Rejects with 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. Thrown as VerifyError with .code and .status.
The codes, their causes and fixes are the same in every SDK; see Errors.