Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions examples/mcp-contact-endpoint/README.md
Original file line number Diff line number Diff line change
@@ -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/<version>\n<METHOD>\n<PATH>\n<recipientDid>\n<JCS(body)>\n<timestamp>
```

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. |
63 changes: 63 additions & 0 deletions examples/mcp-contact-endpoint/src/forward-email.ts
Original file line number Diff line number Diff line change
@@ -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<typeof buildForwardEmail>): Promise<void> {
// Wire to your transactional provider. Sketch only.
void env; void msg;
}
102 changes: 102 additions & 0 deletions examples/mcp-contact-endpoint/src/inbound.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
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<Uint8Array | null>;
interface Env { RL: KVNamespace; NONCES: KVNamespace; [k: string]: unknown }
type KVNamespace = { get(k: string): Promise<string | null>; put(k: string, v: string, o?: { expirationTtl?: number }): Promise<void> };
51 changes: 51 additions & 0 deletions examples/mcp-contact-endpoint/src/rate-limit.ts
Original file line number Diff line number Diff line change
@@ -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<string | null>;
put(k: string, v: string, o?: { expirationTtl?: number }): Promise<void>;
};

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<string> {
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 };
}
Loading