From e507286f45d1d861aa40e9a98bade59accc8200a Mon Sep 17 00:00:00 2001 From: Jason Odoom Date: Thu, 4 Jun 2026 07:10:43 +0000 Subject: [PATCH] examples: INK contact endpoint design note and sketch --- examples/mcp-contact-endpoint/README.md | 102 ++++++++++++++++++ .../mcp-contact-endpoint/src/forward-email.ts | 63 +++++++++++ examples/mcp-contact-endpoint/src/inbound.ts | 102 ++++++++++++++++++ .../mcp-contact-endpoint/src/rate-limit.ts | 51 +++++++++ 4 files changed, 318 insertions(+) create mode 100644 examples/mcp-contact-endpoint/README.md create mode 100644 examples/mcp-contact-endpoint/src/forward-email.ts create mode 100644 examples/mcp-contact-endpoint/src/inbound.ts create mode 100644 examples/mcp-contact-endpoint/src/rate-limit.ts diff --git a/examples/mcp-contact-endpoint/README.md b/examples/mcp-contact-endpoint/README.md new file mode 100644 index 0000000..7db79f5 --- /dev/null +++ b/examples/mcp-contact-endpoint/README.md @@ -0,0 +1,102 @@ +# INK Contact Endpoint (design note + sketch) + +A publicly addressable INK receiver that forwards verified, signed envelopes to a +human inbox. It is the same shape as [`examples/reference-receiver/`](../reference-receiver/), +the only difference is the terminal action: instead of returning a JSON +acknowledgement, it emails the message to a monitored address (for example +`hello@example.com`). It is meant to sit alongside a service such as an MCP +server so other agents have a real INK address to reach a human. + +This directory is a **sketch plus design note**, not a wired, tested service. +The load-bearing decisions and the distinctive code are here; the shared +plumbing (`keys.ts`, `agent-card.ts`, `nonce-store.ts`, `did-web-resolver.ts`, +`audit-log.ts`) is identical to `reference-receiver` and should be lifted from +there verbatim. + +## The four decisions + +### 1. Publish a real DID and bind to it + +The endpoint has its own DID, for example `did:web:mcp.example.com`. Serve a +DID document at `/.well-known/did.json` carrying an `INKAgentEndpoint` service +entry that points at the Agent Card, and serve the card with the signing keys. + +This is not ceremony. The transport-auth signature base is + +``` +ink/\n\n\n\n\n +``` + +so `recipientDid` is part of what the sender signed. Accepting `to = "any"` +discards recipient binding and lets a signature minted for one endpoint be +replayed at another. The endpoint therefore **rejects any envelope whose signed +`recipientDid` is not its own DID**. `did:web` is the least-friction choice for +a service (no PLC registration). + +### 2. Reject encryption in v1 + +A public contact endpoint only ever handles first-contact intents +(`connection_request`, `intro_request`, `ask`), which are plaintext by design. +The only intents INK requires encryption for (`schedule_meeting`, +`context_share`, `multi_party_sync`) never reach a cold contact endpoint. So v1: + +- Publishes **no `keys.encryption`** set in its Agent Card, so a conformant + sender knows up front that encryption is unsupported. +- Rejects an `network.tulpa.encrypted` envelope or any non-first-contact intent + with a structured error. The capability signal in the card is the clean path; + the runtime reject is the backstop. + +### 3. Rate limit per IP and per DID + +Both, because each covers the other's blind spot: + +- **Per IP alone over-blocks.** Many legitimate agents share an egress IP + (Cloudflare Workers, model-provider infra), so one IP can carry hundreds of + honest DIDs. +- **Per DID alone is defeated trivially.** `did:key` is free to mint, so an + attacker rotates a fresh `did:key` per request and sails under any per-DID cap. + +Per-DID catches one identity abusing across many IPs; per-IP is the backstop +against DID rotation from a single source. Two refinements: gate +`connection_request` (the spam vector) harder than established senders, and +return **typed rejections with a `Retry-After` hint** rather than a bare 429, as +the [agent containment extension](../../specs/ink-agent-containment-and-governance-extension-spec.md) +specifies for handshake flood resistance. + +### 4. Forward email labels verified vs claimed + +The envelope `from` (agent DID) and its signature are verified. A human name or +email in the payload is self-asserted by the sender and unverified. The +forwarded email keeps these separate: + +- **Verified:** the agent DID, that the signature is valid, and the `provenance` + field (`human` / `agent_approved` / `agent_autonomous`) so the reader knows + whether a human approved the message. +- **Claimed:** the principal name and email from the payload, explicitly marked + unverified. +- The email's `Reply-To` is **never** set to the claimed address. It is + spoofable, and a one-click reply would send your response to an + attacker-chosen inbox. Respond by accepting the connection through INK until a + relationship is established. + +## Receiver checklist (the boring MUSTs) + +Inherited from the [compliance checklist](../../specs/ink-compliance-checklist.md); +do not skip them: + +- Verify **both** signatures: the body signature and the transport-auth header. +- Enforce replay protection: a `nonce` store plus the timestamp window (5 minutes + past, 30 seconds future). +- Accept `ink/0.1` and `ink/0.2`, and select the body-signature domain from the + signed `protocol` field. +- Bind `recipientDid` (decision 1). +- Apply the discovery SSRF floor when resolving a `did:web` sender's card. + +## Files + +| File | Status | +|------|--------| +| `src/inbound.ts` | Sketch. The verify-then-forward flow with the four decisions inline. | +| `src/forward-email.ts` | Sketch. Composes the forwarded email with verified-vs-claimed labeling. | +| `src/rate-limit.ts` | Sketch. The dual per-IP and per-DID limiter. | +| `keys.ts`, `agent-card.ts`, `nonce-store.ts`, `did-web-resolver.ts`, `audit-log.ts` | Lift from `../reference-receiver/` unchanged. | diff --git a/examples/mcp-contact-endpoint/src/forward-email.ts b/examples/mcp-contact-endpoint/src/forward-email.ts new file mode 100644 index 0000000..f30ae24 --- /dev/null +++ b/examples/mcp-contact-endpoint/src/forward-email.ts @@ -0,0 +1,63 @@ +/** + * Sketch: compose the forwarded email, keeping VERIFIED facts separate from + * CLAIMED ones (decision 4). The agent DID and the signature are verified; any + * human name or email in the payload is self-asserted and unverified. + * + * Illustrative, not a tested service. sendEmail is a thin wrapper over whatever + * transactional provider you use. + */ + +interface Envelope { + id: string; + from: string; // agent DID — verified + intent: string; + provenance?: string; // human | agent_approved | agent_autonomous + payload?: { + context?: string; + message?: string; + profileSnapshot?: { name?: string; headline?: string; email?: string }; + }; +} + +const INBOX = "hello@example.com"; + +export function buildForwardEmail(envelope: Envelope) { + const claimed = envelope.payload?.profileSnapshot ?? {}; + const message = envelope.payload?.context ?? envelope.payload?.message ?? ""; + + const text = [ + "An agent reached you over INK. The signature is verified; the human details", + "below are claimed by the sender and are NOT verified.", + "", + "VERIFIED", + ` Agent DID: ${envelope.from}`, + ` Provenance: ${envelope.provenance ?? "unspecified"}`, + ` Intent: ${envelope.intent}`, + "", + "CLAIMED (self-asserted by the sender, unverified)", + ` Name: ${claimed.name ?? "—"}`, + ` Headline: ${claimed.headline ?? "—"}`, + ` Email: ${claimed.email ?? "—"}`, + "", + "Message", + ` ${message}`, + "", + "To respond, accept the connection through INK rather than emailing the", + "claimed address directly. The claimed email is spoofable until a connection", + "is established.", + ].join("\n"); + + return { + to: INBOX, + // Deliberately NOT the claimed sender address — spoofable. Route replies + // back through the protocol or a controlled address. + replyTo: "noreply@example.com", + subject: `INK ${envelope.intent} from ${envelope.from}`, + text, + }; +} + +export async function sendEmail(env: { RESEND_API_KEY?: string }, msg: ReturnType): Promise { + // Wire to your transactional provider. Sketch only. + void env; void msg; +} diff --git a/examples/mcp-contact-endpoint/src/inbound.ts b/examples/mcp-contact-endpoint/src/inbound.ts new file mode 100644 index 0000000..4c060c0 --- /dev/null +++ b/examples/mcp-contact-endpoint/src/inbound.ts @@ -0,0 +1,102 @@ +/** + * Sketch: the verify-then-forward inbound flow for the INK contact endpoint. + * + * Mirrors examples/reference-receiver/src/inbound.ts. The only behavioural + * difference is the terminal action (forward to email instead of JSON ack) and + * the four design decisions called out below. Names like resolveSenderKey, + * verifyInkAuth and MessageEnvelopeSchema track the @adastracomputing/ink public + * surface; confirm exact signatures against the installed version before wiring. + * + * This is illustrative, not a tested service. + */ +import { + MessageEnvelopeSchema, + verifyInkAuth, + // resolveSenderKey: did:key decoded inline; did:web resolved behind the SSRF + // guard. Lift the implementation from ../reference-receiver/src. +} from "@adastracomputing/ink"; +import { checkRateLimits } from "./rate-limit.js"; +import { buildForwardEmail, sendEmail } from "./forward-email.js"; + +const OUR_DID = "did:web:mcp.example.com"; // decision 1 +const INBOUND_PATH = "/ink/v1/inbound"; +const ACCEPTED_INTENTS = new Set(["connection_request", "intro_request", "ask"]); + +function json(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); +} + +export async function handleInbound(req: Request, env: Env): Promise { + const ip = req.headers.get("CF-Connecting-IP") ?? "unknown"; + + // Pre-parse per-IP limit (cheap, before we trust anything). Typed rejection + // with a backoff hint, not a bare 429. + const ipGate = await checkRateLimits(env.RL, { ip, did: null, firstContact: true }); + if (!ipGate.ok) { + return new Response(JSON.stringify({ error: "rate_limited", scope: ipGate.scope, retryable: true }), + { status: 429, headers: { "content-type": "application/json", "Retry-After": String(ipGate.retryAfter) } }); + } + + // 1. Parse + schema-validate. MessageEnvelopeSchema accepts ink/0.1 and ink/0.2 + // and rejects any other protocol value. + const raw = await req.json().catch(() => null); + const parsed = MessageEnvelopeSchema.safeParse(raw); + if (!parsed.success) return json(400, { error: "invalid_envelope", details: parsed.error.issues }); + const envelope = parsed.data; + + // 2. Decision 1: recipient binding. recipientDid is inside the signed + // transport base, so an envelope not addressed to us is either misrouted or + // a replay attempt against a different endpoint. + if (envelope.to !== OUR_DID) return json(400, { error: "wrong_recipient" }); + + // 3. Decision 2: reject encryption. We advertise no encryption keys and only + // accept first-contact plaintext intents. + if ((envelope as { type?: string }).type === "network.tulpa.encrypted") { + return json(400, { error: "encryption_not_supported" }); + } + if (!ACCEPTED_INTENTS.has(envelope.intent)) { + return json(400, { error: "unsupported_intent", accepted: [...ACCEPTED_INTENTS] }); + } + + // 4. Resolve the sender's verification key (did:key inline, did:web SSRF-guarded). + const senderKey = await resolveSenderKey(envelope.from, env); + if (!senderKey) return json(400, { error: "unresolvable_sender" }); + + // 5. Verify BOTH signatures and replay. verifyInkAuth checks the transport + // header against the §3.3 base and the nonce/timestamp window; the body + // signature is verified under the domain keyed off the signed `protocol`. + const auth = await verifyInkAuth( + { + method: "POST", + path: INBOUND_PATH, + recipientDid: OUR_DID, + body: envelope, + authHeader: req.headers.get("Authorization"), + nonceStore: env.NONCES, + now: Date.now(), + }, + senderKey, + ); + if (!auth.valid) return json(401, { error: "signature_verification_failed", reason: auth.reason }); + + // 6. Now that the sender is authenticated, the per-DID limit (decision 3). + const didGate = await checkRateLimits(env.RL, { + ip, did: envelope.from, firstContact: envelope.intent === "connection_request", + }); + if (!didGate.ok) { + return new Response(JSON.stringify({ error: "rate_limited", scope: didGate.scope, retryable: true }), + { status: 429, headers: { "content-type": "application/json", "Retry-After": String(didGate.retryAfter) } }); + } + + // 7. Terminal action: forward to the human inbox with verified-vs-claimed + // labels (decision 4). + await sendEmail(env, buildForwardEmail(envelope)); + + return json(200, { ok: true, inReplyTo: envelope.id, receiverDid: OUR_DID, correlationId: envelope.correlationId }); +} + +// Wiring helpers (env shape, resolveSenderKey) live in index.ts / the lifted +// reference-receiver modules and are omitted from this sketch. +declare function resolveSenderKey(did: string, env: Env): Promise; +interface Env { RL: KVNamespace; NONCES: KVNamespace; [k: string]: unknown } +type KVNamespace = { get(k: string): Promise; put(k: string, v: string, o?: { expirationTtl?: number }): Promise }; diff --git a/examples/mcp-contact-endpoint/src/rate-limit.ts b/examples/mcp-contact-endpoint/src/rate-limit.ts new file mode 100644 index 0000000..9fae564 --- /dev/null +++ b/examples/mcp-contact-endpoint/src/rate-limit.ts @@ -0,0 +1,51 @@ +/** + * Sketch: dual rate limiter, per source IP and per sender DID (decision 3). + * + * Two independent fixed windows. did:key is free to mint, so the per-DID cap is + * weak on its own (an attacker rotates DIDs); per-IP is the backstop against + * that rotation, and per-DID catches one identity spread across many IPs. + * First-contact (connection_request) gets a tighter per-DID cap. + * + * Illustrative, not a tested service. Mirrors the KV/D1 shape of the nonce store. + */ + +type Store = { + get(k: string): Promise; + put(k: string, v: string, o?: { expirationTtl?: number }): Promise; +}; + +const WINDOW_SEC = 60; +const IP_CAP = 30; +const DID_CAP_FIRST_CONTACT = 5; +const DID_CAP_ESTABLISHED = 20; + +async function bump(store: Store, key: string, cap: number): Promise<{ ok: boolean; retryAfter: number }> { + // Approximate fixed-window counter. KV is not atomic, so a few requests may + // slip over the cap under contention — acceptable for a coarse limiter. + const used = parseInt((await store.get(key)) ?? "0", 10) || 0; + if (used >= cap) return { ok: false, retryAfter: WINDOW_SEC }; + await store.put(key, String(used + 1), { expirationTtl: WINDOW_SEC }); + return { ok: true, retryAfter: 0 }; +} + +async function hashIp(ip: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(ip)); + return [...new Uint8Array(digest)].slice(0, 8).map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +/** + * Pass `did: null` for the pre-parse per-IP check, then call again with the + * authenticated DID for the per-DID check. Returns the first scope that trips. + */ +export async function checkRateLimits( + store: Store, + { ip, did, firstContact }: { ip: string; did: string | null; firstContact: boolean }, +): Promise<{ ok: true } | { ok: false; scope: "ip" | "did"; retryAfter: number }> { + if (did === null) { + const r = await bump(store, `rl:ip:${await hashIp(ip)}`, IP_CAP); + return r.ok ? { ok: true } : { ok: false, scope: "ip", retryAfter: r.retryAfter }; + } + const cap = firstContact ? DID_CAP_FIRST_CONTACT : DID_CAP_ESTABLISHED; + const r = await bump(store, `rl:did:${did}`, cap); + return r.ok ? { ok: true } : { ok: false, scope: "did", retryAfter: r.retryAfter }; +}