@agentproofid/check
v0.3.1
Published
Verify AI agent identity at the edge. RFC 9421 HTTP Message Signatures + DPoP. Zero dependencies, WebCrypto only.
Downloads
377
Maintainers
Readme
@agentproofid/check
Verify AI agent identity at the edge. Zero dependencies, WebCrypto only.
Implements RFC 9421 HTTP Message Signatures (web-bot-auth profile) and key-bound identity tokens (RFC 7800 cnf + RFC 9449 DPoP). Runs identically on Cloudflare Workers, Deno, Bun, Node 18+ and in the browser.
npm install @agentproofid/checkInteroperability
Verified against the public reference vector published by Cloudflare Research at
http-message-signatures-example.research.cloudflare.com:
expected kid : poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U
computed : poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U ✓ RFC 7638That's the Ed25519 test key from RFC 9421 Appendix B.1.4. This check runs in CI on every commit — if we ever stop being interoperable, the build breaks.
Usage
Cloudflare Workers
import { agentCheck, keyring } from '@agentproofid/check';
// Startup, once. Never on the request path.
keyring().addDirectory(
'https://agent.bot.goog/.well-known/http-message-signatures-directory',
'google.com'
);
await keyring().refresh();
export default {
async fetch(request) {
const auth = await agentCheck(request);
if (!auth.verified) return auth.challengeResponse();
return new Response(`Hello ${auth.agentId} from ${auth.ownerDomain}`);
}
}Express
const { expressAgentCheck } = require('@agentproofid/check');
app.use('/api', expressAgentCheck({ realm: 'shop.example' }));
app.get('/api/catalog', (req, res) => {
res.json({ servedTo: req.agent.agentId });
});Signing (agent side)
import { generateAgentKey, signRequest } from '@agentproofid/check/signing';
const key = await generateAgentKey('ed25519');
const { headers, signatureBase } = await signRequest(key, {
method: 'GET',
url: 'https://merchant.example/api/catalog',
tag: 'web-bot-auth',
});
await fetch('https://merchant.example/api/catalog', { headers });signatureBase is returned deliberately: it's the exact string that was signed, and it's the only practical way to debug a failing verification.
The 428 response
When verification fails, challengeResponse() returns 428 Precondition Required with machine-readable instructions:
WWW-Authenticate: AgentProofID realm="shop.example", challenge="Provide RFC9421 Signature",
alg="ed25519 ecdsa-p256-sha256", tag="web-bot-auth"The body carries the required covered components, accepted algorithms, and a worked header example. An honest agent reads it once and authenticates on the retry.
Why 428 and not 401 or 403. 401 implies a credential the client should already hold. 403 says no and closes the conversation. 428 says the request is legitimate but a precondition is missing, and here is exactly which one — the only one that describes the situation correctly to an automated client.
Design notes
No I/O on the request path. No fetch, no DNS, no KV. Keys live in memory and refresh on a separate cold path. If our service goes down, your site keeps verifying.
Verdict caching is keyed on signature + digest of the signature base, never on the signature alone. Keying on the signature alone would be a vulnerability: a signature valid for one request would be accepted for another.
Signature-Input params are reused verbatim for the @signature-params line, never re-serialized. Re-serializing means replicating the signer's canonicalization choices, and it's the leading cause of false negatives in RFC 9421 implementations.
Key validity windows are in milliseconds. The Web Bot Auth directory format uses millisecond timestamps for nbf/exp, while RFC 7519 JWT claims use seconds. Mixing the two makes a revoked key valid for millennia with no visible symptom. Validity is checked on every lookup, not just at load — otherwise a key can expire in the gap between refreshes.
ECDSA signatures are raw r||s, 64 bytes — not DER. WebCrypto rejects DER. Conversion is handled for you.
API
| Export | Purpose |
|---|---|
| agentCheck(request, options) | Main entry. Never throws; always returns a result. |
| keyring() | Process-wide shared KeyRing. |
| KeyRing | Trusted keys. addJwk, addDirectory, refresh, prune. |
| VerdictCache | Bounded verdict cache. |
| buildSignatureBase(entry, ctx) | Exported for debugging failed verifications. |
| parseSignatureInput(header) | RFC 8941 parser for the hot path. |
| jwkThumbprint(jwk) | RFC 7638. |
| expressAgentCheck(options) | Express / Connect middleware. |
| withAgentCheck(handler, options) | Wrapper for any fetch handler. |
| generateAgentKey, signRequest, buildManifest | From /signing. |
AgentCheckResult
{
verified: boolean;
agentId: string;
ownerDomain: string;
reputationScore: number;
reputationBasis: "static_trust_tier" | "observed_history";
identity: AgentIdentity | null;
reason: DenyReason | null;
elapsedMicros: number;
cacheHit: boolean;
challengeResponse: () => Response;
}reputationScore is derived from locally verifiable facts only — directory tier, covered components, freshness. reputationBasis tells you which: it reads static_trust_tier until there is real aggregate history behind it. It is not an observed reputation, and the field says so rather than implying otherwise.
Performance
Measured with performance.now(), Node 24 on Apple Silicon, 3000 iterations:
| Path | p50 | p99 | |---|---|---| | Reject, no credentials | 3 µs | 24 µs | | Verify, warm cache | 23 µs | 101 µs | | Verify ECDSA P-256, cold | 91 µs | 151 µs |
The reject is the cheapest path in the system, not the most expensive: no cryptography, two header reads, return.
The comparison that matters isn't against zero — it's against the network round trip this architecture avoids:
| | latency | |---|---| | Local verification | 0.09 ms | | Intra-datacenter RTT | 0.5 – 2 ms | | Same-region RTT | 2 – 15 ms | | Cross-region RTT | 30 – 150 ms |
Body binding and replay defence
Signed writes must cover the body. For POST, PUT, PATCH and DELETE, content-digest (RFC 9530) is required among the covered components and checked against the body received. Without it, a signature covering only method, authority and path says nothing about the payload: any intermediary can rewrite the body and verification still passes.
signRequest computes the digest for you when given a body:
await signRequest(key, {
method: "POST",
url: "https://merchant.example/api/orders",
body: JSON.stringify({ sku: "HDX-900" }),
});Implementing it by hand? RFC 9530 uses standard base64 with padding, not base64url.
Nonces are opt-in, and single-use when present. Under the web-bot-auth profile a signature is legitimately reused across thousands of requests inside its validity window — that is what the verdict cache exists for. Emitting a nonce by default would make every signature single-use and break the primary use case. Declaring one is a statement of intent: this signature is for one request only.
import { agentCheck, MemoryNonceStore, kvNonceStore } from '@agentproofid/check';
await agentCheck(request, { nonceStore: new MemoryNonceStore() }); // one process
await agentCheck(request, { nonceStore: kvNonceStore(env.NONCES) }); // WorkersThe nonce is consumed after the cryptographic check, never before: consuming it first would let an attacker burn arbitrary nonces with forged requests and lock out the legitimate traffic carrying them.
MemoryNonceStore is correct only in a single process. Across several workers it gives the illusion of protection and none of the substance — a replay simply lands on the instance that hasn't seen the nonce. Use a Durable Object or Redis. kvNonceStore is a large improvement over nothing, but KV is eventually consistent and a replay hitting another colo within the propagation window can slip through.
Not done yet
- Signed directory responses. The spec allows directories to sign their own JWKS response; we fetch but don't verify that signature.
- Scheduled refresh.
KeyRing.refresh()exists but you must wire it to a cron trigger yourself. - Rate limiting per verified identity, shared across workers.
License
MIT
