Documentation

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.

Node 20+No dependenciesv0.1.0Apache-2.0

Install#

The client goes in the agent:

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

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

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

server.ts · Express
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#

Agent side·Static method

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 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
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().
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.

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

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, 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 a string or bytes, not a stream: it is hashed for Content-Digest.
Agent side·Method

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 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.keyFile("~/.ssh/id_ed25519")
EnvironmentCI and servers: PEM text, or base64 of PEM or PKCS#8 DER, in an environment variable.keyEnv("MESSH_AGENT_KEY")
SeedA raw 32-byte Ed25519 seed from your own secret store.keySeed(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.sshAgent()
Your signerA 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#

API side·Express middleware

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 403 untrusted.required
registriesstring[] | Record
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
maxSkewnumber (s)
How far created may 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 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 maxBodyBytes (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.
  • Express and Connect. Mount it before express.json(). For fetch-style runtimes use verifyRequest.
API side·Method

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 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·Interface

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 laptop or build-bot. An agent is a named key.
keyIdstring
Exactly which key signed: its RFC 7638 thumbprint. Log it.
fingerprintstring
The SSH fingerprint, as messh resolve prints it.
agentUrlstring
The Signature-Agent the 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.

↑ ↓ to move↵ to openesc to close