SDK reference
Rust SDK
The messh crate signs requests from your agent. messh::verify checks them from any framework’s handler or middleware.
Install#
The client goes in the agent:
$ cargo add messh # features: fetch (default), http
The verifier goes in your API. It’s in the same package:
# 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:
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.
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
messh::Client::builder(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.send(method, url, body)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.messh::KeySourceWhere the private key comes from. The default is~/.ssh/id_ed25519. Keys are used locally and never go to messh.
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#
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 inSignature-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().
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.
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,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. - The body is hashed for
Content-Digest; passNonefor none.
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
hostif you send a Host header that differs from the URL’s. - Signing is local and takes microseconds; there is no network call.
messh::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. | KeySource::File(path) |
| Environment | CI and servers: PEM text, or base64 of PEM or PKCS#8 DER, in an environment variable. | KeySource::Env("MESSH_AGENT_KEY".into()) |
| Seed | A raw 32-byte Ed25519 seed from your own secret store. | KeySource::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. | KeySource::SshAgent |
| Your signer | A 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#
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 403untrusted.required .registry()&str, &str- 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_skew()Duration- How far
createdmay 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
trustare 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;
verifychecks 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
verifyorverify_httpfrom your framework’s middleware.
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.
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
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. 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.