Documentation

SDK reference

Rust SDK

The messh crate signs requests from your agent. messh::verify checks them from any framework’s handler or middleware.

Rust 1.85+ureqv0.1.0Apache-2.0

Install#

The client goes in the agent:

Terminal
$ cargo add messh            # features: fetch (default), http

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

Terminal
# same crate: use messh::verify::Verifier
$ cargo add messh --features http

Rust 1.85+. ed25519-dalek; ureq fetches key directories (feature fetch, on by default). Feature http adds http::Request support.

Quick start#

Sign in the agent:

main.rs
use messh::{Client, KeySource};

fn main() -> Result<(), messh::Error> {
    // Signs as mukul with the build-bot key. Create one client per process.
    let client = Client::builder("mukul")
        .key(KeySource::File("~/.ssh/build-bot".into()))
        .build()?;

    let url = "https://api.acme.dev/deployments";
    let (status, body) = client.send("POST", url, Some(br#"{"env":"staging"}"#))?;
    println!("{status} {}", String::from_utf8_lossy(&body));
    Ok(())
}

Verify in your API. can() stands for your own authorization: messh says who, you decide what.

main.rs · any framework
use messh::verify::{Request, VerifyConfig, Verifier};

// Create once and share: it caches key directories.
let verifier = Verifier::new(VerifyConfig::trust(["mukul"]));

// In your handler or middleware (axum, actix, hyper): the method, the full
// URL, the headers and the raw body. It blocks on a cache miss, so on
// tokio run it in spawn_blocking.
let req = Request { method: "POST", url: &url, headers: &headers, body: &body };
match verifier.verify(&req) {
    Ok(agent) if can(&agent, "deploy") => respond(200, r#"{"ok":true}"#),
    Ok(_) => respond(403, r#"{"error":"forbidden"}"#),
    Err(e) => respond(e.status(), &e.to_json()), // 401 or 403, JSON body
}

For the whole setup, from publishing the agent’s key to choosing who to trust, see API authentication.

Topics#

Agent side

API side

  • Verifier::new(config)Checks every incoming request before your handlers run. Valid requests carry the verified agent; everything else gets a JSON 401 or 403.
  • Verifier::verify(&Request)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·Builder

messh::Client::builder(identity)

Loads the signing key once and returns a client that signs every request it sends.

impl Client {
    pub fn builder(identity: impl Into<String>) -> ClientBuilder;
    /// MESSH_IDENTITY, MESSH_KEY or MESSH_KEY_FILE, MESSH_REGISTRY.
    pub fn from_env() -> Result<Client, messh::Error>;
    pub fn builder_from_env() -> Result<ClientBuilder, messh::Error>;
}

impl ClientBuilder {
    /// Default: KeySource::File("~/.ssh/id_ed25519").
    pub fn key(self, key: KeySource) -> Self;
    /// Default: "https://messh.io".
    pub fn registry(self, url: impl Into<String>) -> Self;
    /// Default: 60 s, at most 300 s.
    pub fn expires_in(self, d: Duration) -> Self;
    pub fn build(self) -> Result<Client, messh::Error>;
}

Parameters

identityimpl Into<String>
Your messh username, such as mukul. Requests name it in Signature-Agent: "<registry>/<identity>".required
.key()KeySource
Where the private key comes from; see Key sources. It must be the ssh-ed25519 key you published with messh publish.~/.ssh/id_ed25519
.registry()URL
The messh registry that serves your key directory.https://messh.io
.expires_in()Duration
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

  • Returns messh::Error (.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.

impl Client {
    /// Feature "fetch" (default).
    pub fn whoami(&self) -> Result<PublishedKey, messh::Error>;
    /// SHA256:…, as messh keys and ssh-keygen -l print it.
    pub fn fingerprint(&self) -> String;
}

pub struct PublishedKey {
    pub identity: String,     // "mukul"
    pub key_name: String,     // "build-bot"
    pub key_id: String,       // RFC 7638 thumbprint, sent as keyid
    pub fingerprint: String,  // "SHA256:…"
    pub agent_url: 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.send(method, url, body)

Signs a request and sends it.

impl Client {
    /// Feature "fetch" (default): sends with ureq.
    pub fn send(
        &self,
        method: &str,
        url: &str,
        body: Option<&[u8]>,
    ) -> Result<(u16, Vec<u8>), messh::Error>;
}

Returns

The status code and body. 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.
  • The body is hashed for Content-Digest; pass None for none.
Agent side·Method

client.sign(method, url, body)

Keeps your own HTTP stack. Returns, or adds, the signature headers for requests you build yourself.

impl Client {
    pub fn sign(
        &self,
        method: &str,
        url: &str,
        body: Option<&[u8]>,
    ) -> Result<Vec<(String, String)>, messh::Error>;

    /// Like sign, for a Host header that differs from the URL's.
    pub fn sign_with_host(
        &self,
        method: &str,
        url: &str,
        body: Option<&[u8]>,
        host: Option<&str>,
    ) -> Result<Vec<(String, String)>, messh::Error>;

    /// Feature "http": signs an http::Request in place.
    pub fn sign_request<B: AsRef<[u8]>>(&self, req: &mut http::Request<B>)
        -> Result<(), messh::Error>;
}

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

messh::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.KeySource::File(path)
EnvironmentCI and servers: PEM text, or base64 of PEM or PKCS#8 DER, in an environment variable.KeySource::Env("MESSH_AGENT_KEY".into())
SeedA raw 32-byte Ed25519 seed from your own secret store.KeySource::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.KeySource::SshAgent
Your signerA KMS or an HSM: implement the public key and sign; the SDK never sees the private key.KeySource::Signer(Arc::new(s))

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

Verifier::new(config)

Checks every incoming request before your handlers run. Valid requests carry the verified agent; everything else gets a JSON 401 or 403.

impl Verifier {
    pub fn new(config: VerifyConfig) -> Verifier;

    /// Feature "http".
    pub fn verify_http<B>(&self, req: &http::Request<B>, body: &[u8])
        -> Result<Agent, VerifyError>;
}

impl VerifyConfig {
    pub fn trust<I, S>(patterns: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<String>;
    pub fn registry(self, origin: &str, fetch_base: &str) -> Self;
    pub fn max_skew(self, d: Duration) -> Self;
    pub fn cache_ttl(self, d: Duration) -> Self;
    pub fn stale_if_error(self, d: Duration) -> Self;
    pub fn nonce_store(self, s: Arc<dyn NonceStore>) -> Self;
    pub fn timeout(self, d: Duration) -> Self; // directory fetch, default: 5 s
}

Parameters

trustIntoIterator
Who may call this API: messh usernames, or "*" for anyone with a valid messh signature. Valid signatures from anyone else get 403 untrusted.required
.registry()&str, &str
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_skew()Duration
How far created may be from this server’s clock, either way. Nonces are remembered for twice this long.60 s
.cache_ttl()Duration
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_error()Duration
How long cached keys keep working while messh is unreachable.1 h
.nonce_store()NonceStore
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.
  • Limit body size in your framework; verify checks the body you pass.
  • 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.
  • There is no tower layer: call verify or verify_http from your framework’s middleware.
API side·Method

Verifier::verify(&Request)

Verifies one request and returns who signed it. Use it where middleware doesn’t fit: serverless handlers, webhooks, queue consumers.

pub struct Request<'a> {
    pub method: &'a str,
    pub url: &'a str,                     // the full request URL
    pub headers: &'a [(String, String)],
    pub body: &'a [u8],
}

impl Verifier {
    /// Blocks while it fetches a key directory on a cache miss.
    pub fn verify(&self, req: &Request<'_>) -> Result<Agent, VerifyError>;
}

Returns

The verified Agent.

Discussion

  • Returned as VerifyError, with .code(), .status() and .to_json().
  • 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·Struct

messh::verify::Agent

Who signed the request. messh tells you who; what they may do is up to your code.

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Agent {
    pub identity: String,     // "mukul"
    pub key_name: String,     // "build-bot": the key's name on messh
    pub key_id: String,       // RFC 7638 thumbprint
    pub fingerprint: String,  // "SHA256:…"
    pub agent_url: String,    // "https://messh.io/mukul"
    pub created: i64,         // unix seconds
}

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. Returns messh::Error (.code()): invalid_identity, invalid_key, invalid_option or sign_failed, or send_failed from send; whoami adds not_published and registry_unreachable. A 401 or 403 from the API is a normal response, not an error.

In your API. Returned as VerifyError, with .code(), .status() and .to_json().

The codes, their causes and fixes are the same in every SDK; see Errors.

Updated Oct 22, 2026

↑ ↓ to move↵ to openesc to close