@envoys/sdk
v0.10.0
Published
Cryptographic identity for AI agents. Ed25519 keypairs, RFC 9421 HTTP Message Signatures, self-resolving keyids.
Maintainers
Readme
@envoys/sdk
Cryptographic identity for AI agents. Ed25519 keypairs, RFC 9421 HTTP Message Signatures, self-resolving public keys.
Live at envoys.me · Spec: /specs/signature/v1
What it is
Agents on the internet have no standard way to prove who they are. Envoys fixes that.
Register once to get an Ed25519 keypair. Your address ([email protected]) becomes a resolvable public key URL. Sign any outgoing HTTP request with RFC 9421. Any recipient can verify — no prior relationship with Envoys required, no API keys, no shared secrets.
The private key is generated in your process and never transmitted to Envoys.
Install
npm install @envoys/sdkFor the Agent2Agent (A2A) protocol adapter:
npm install @envoys/a2aQuickstart
import { Envoys } from '@envoys/sdk'
// 1. Register once (generates keypair locally; private key never leaves)
const { result } = await Envoys.register({
accountKey: process.env.ENVOYS_ACCOUNT_KEY,
name: 'researcher',
})
// → { address, agentKey, publicKey, privateKey }
// Save all four — privateKey is shown once.
// 2. Construct from env vars in your running agent
// Reads ENVOYS_AGENT_KEY / ADDRESS / PUBLIC_KEY / PRIVATE_KEY
const agent = Envoys.fromEnv()
// 3. Sign any outgoing HTTP request (RFC 9421 + Content-Digest)
const body = { task: 'summarize', url: 'https://example.com/doc' }
const headers = agent.signRequest('POST', '/api/task', body)
await fetch('https://other-agent.example.com/api/task', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify(body),
})
// 4. Verify incoming signed requests in any HTTP handler.
// Public-key pinning is on by default; pass an allowlist to gate authorization.
const result = await Envoys.verifyRequest(
req.method, req.path, req.headers, req.body,
{ allowlist: ['[email protected]'] }, // optional
)
if (!result.verified) return res.status(401).json({ error: result.error })
console.log(`Verified ${result.address}`, result.keyid, result.publicKey)How it works
- Register — Envoys stores your public key, indexed by your address.
- Sign — Attach
Signature-Input,Signature,Content-Digestheaders (RFC 9421). Thekeyidis your address URL:https://envoys.me/agents/[email protected]. Each signature includes a freshnonce. Bodies ≥4KB auto-promote fromsha-256tosha-512for the digest. - Verify — The recipient GETs your keyid URL to fetch your public key, reconstructs the signature base, and verifies. No Envoys account needed to verify.
Replay protection: the SDK rejects signatures older than 5 minutes, more than 30 seconds in the future, with a tampered Content-Digest, or any signature already accepted (in-process dedup cache keyed by (keyid, created, signature)).
Verifier-side safety defaults
Envoys.verifyRequest() enforces three checks on top of cryptographic verification:
- Component-coverage enforcement — signatures must cover
@methodand@path, pluscontent-digestwhenever the request has a body. A signature that omitscontent-digestleaves the body unauthenticated (an attacker can substitute body + matching header consistently), so it is rejected even if cryptographically valid. Spec §5.5. - Public-key pinning (on by default) — first-seen public key is auto-recorded per address; subsequent contact with a different key fails verification with a clear
Envoys.resetPin('<address>')hint. Catches account-compromise rotations that would otherwise resolve cleanly. - Optional allowlist — pass
{ allowlist: [...] }to reject cryptographically-valid requests from senders not on your list. The allowlist matches against keyid OR address.
Pin storage is pluggable via the PinStore interface; default is in-process Map.
Scaling note: the default pin store and the replay-dedup cache are both in-process. On a single instance they do what they promise; behind a load balancer each instance has its own view — a replayed signature hitting a different instance looks novel, and pins don't propagate. Horizontally-scaled verifiers should pass a shared
pinStore(Redis, a table, anything that implementsPinStore) and put replay dedup behind shared state or sticky routing.
Optional: bind the signature to the target host (@authority)
By default the signature covers method, path, and body — which means a signature minted for POST /rpc on one host is valid for the same method and path on any host within the 5-minute window. Pass authority to additionally cover RFC 9421 @authority and scope the signature to one receiving service:
const headers = agent.signRequest('POST', '/rpc', body, { authority: 'receiver.example.com' })The verifier reconstructs @authority from its own identity — options.authority if set (do this behind a proxy that rewrites Host), otherwise the request's Host header — so a relayed signature fails on any other host. The Host fallback is only sound when your server rejects or routes away requests whose Host doesn't match an authority it serves (standard name-based virtual hosting); if your server accepts arbitrary Host values, set options.authority explicitly. Opt-in for now: verifiers older than 0.9.0 reject signatures covering components they don't reconstruct, so only send it to receivers you know are current. Spec v1.6.0 §4.2.
Dual-shape keyid resolution (W3C DID interop)
The verifier accepts either shape served at a keyid URL:
application/did+json→ W3C DID Document with an Ed25519verificationMethod(publicKeyJwk)application/jsonor any other JSON content type → Envoys-native{ address, public_key }
No caller-side opt-in is required — Envoys.verifyRequest() sniffs Content-Type (with structural fallback) and routes to the right parser. Signatures from agents whose keyid happens to serve a DID Document verify the same as signatures from Envoys-native agents. Both ship with spec /specs/signature/v1 §6.
For explicit did:web bridging, Envoys.resolveDidWeb(domain) returns a PEM SPKI from a https://<domain>/.well-known/did.json document. The lower-level Envoys.resolveKeyFromKeyid(keyidUrl) does the dual-shape fetch directly and is what the verifier calls internally.
keyid resolution is SSRF-guarded (since 0.10.0)
The keyid is sender-controlled, so resolving it is an untrusted outbound request. The verifier (verifyRequest, verifyAgentCard, and the resolve* helpers) enforces, per spec v1.6.3 §5.4: https only, rejection of hosts that are or resolve to loopback/private/link-local (incl. the 169.254.169.254 cloud-metadata range)/CGNAT/non-global addresses, no redirects, a 16 KB response cap, and a 5 s timeout. Override via ResolverGuardOptions (options.resolver on verifyRequest, or the second arg to the resolve*/verifyAgentCard calls):
await Envoys.verifyRequest(method, path, headers, body, {
resolver: { timeoutMs: 3000, maxResponseBytes: 8192 },
})
// Local testing only — re-enables private hosts / http:
await Envoys.resolveKeyFromKeyid(url, { allowPrivateHosts: true, allowInsecureHttp: true })DNS rebinding is closed too: resolution is pinned to the validated public address at connect time (via an undici dispatcher whose connect.lookup re-validates and connects only to a public IP, with TLS SNI preserved), so the address connected to is the address validated. For hard isolation, also constrain egress at the network layer.
Optional: per-signature tag
signRequest accepts an optional tag parameter (RFC 9421 §2.3) to disambiguate signing purpose under one keyid — for example "task", "heartbeat", or "delegation". Verifiers MAY enforce that the tag matches the expected context. Absence is equivalent to tag="a2a-message".
const headers = agent.signRequest('POST', '/api/task', body, { tag: 'task' })Key rotation
Rotations are initiated from the dashboard or the API. The new private key is always generated client-side — Envoys never sees it.
const { rotated, publicKey, privateKey } = await agent.syncKeys()
if (rotated) saveToStorage({ publicKey, privateKey })Use it from any language
The verifier-side is just one HTTP GET (the keyid URL) plus a stdlib Ed25519 signature check. The signature spec is normative — RFC 9421 + Ed25519 implementations exist in Python, Go, Rust, and most major languages.
License
Apache-2.0
