Accepting Foreign Senders
A foreign sender is an INK agent whose DID method is not native to your service. If your platform issues did:plc: identifiers, then did:web: and did:key: senders are foreign. If you issue did:web: identifiers under your own host, senders on every other host are foreign.
This guide is for anyone implementing INK in a way that lets foreign agents reach their users. It separates two different kinds of requirements:
- Protocol requirements (MUST). The minimum cryptographic and replay-protection checks every conforming INK receiver applies to every inbound envelope. These are not optional and are the same whether the sender is native or foreign. They come from the authentication, replay-protection, and key rotation specs.
- Deployment policy (SHOULD). Operational choices a service makes to control which foreign senders reach which users, how aggressively to throttle them, and how to make those decisions visible. None of these are required by the protocol, but a public-internet deployment without most of them will get abused.
A viewable reference implementation lives in the INK repo at examples/foreign-sender-receiver/. See the Reference implementations section below for the full breakdown.
Protocol requirements
These apply to every inbound envelope, foreign or native. If you skip any of them you are not running INK.
INK envelope verification (MUST)
- Verify the envelope signature against the sender’s keys per the authentication spec.
- Enforce the freshness window (5 minutes past, 30 seconds future).
- Enforce single-use of the envelope nonce via a NonceStore scoped at least per (sender, recipient).
- Confirm the envelope’s
tofield matches the receiving DID. - Validate canonicalization per JCS before computing the signing base.
Any of these failing is a hard reject. Do not log the envelope payload on reject; reason codes are enough for audit.
Sender key authority (MUST)
The sender’s signing keys come from their published identity record, never from the envelope itself. The exact record depends on the DID method:
did:key:— the public key is encoded in the DID. No network fetch is required; decoding the multibase suffix yields the only valid signing key.did:web:(and similar document-published methods) — fetch the DID document from the well-known URL. The document’sverificationMethodentries carry the signing keys.did:tulpa:/did:plc:and other Agent-Card-publishing methods — fetch the Agent Card and use itskeys.signingset per the key-rotation authority rule.
For any sender whose keys come from a published record (anything except did:key:):
- The published key set is canonical. The first key in the published set that verifies wins; the receiver records which key was used.
- If no entry verifies, reject. Do not fall through to any locally-cached key, bootstrap key, or agent-ID-derived key once a published record has been observed for that sender.
For Agent Card senders specifically, the key-rotation authority rule adds finer semantics: iterate active then retired, never iterate revoked, and retired keys may verify only within their validFrom and validUntil window. Plain DID-document methods (e.g. did:web:) typically expose only an active set of verificationMethod entries and do not carry rotation metadata; the receiver should treat every entry in the document as active and re-fetch on miss.
Sender identity binding (MUST)
The published record you fetch must bind cleanly to the resolution context:
- For a DID document fetch, the document’s
idfield must equal the DID under resolution; reject any document whoseidmismatches. - For an Agent Card fetch, the card’s
ownerDid(when present) must equal the DID under resolution. The card’sagentIdis a separate identifier (the agent’s own routable id, not the human DID) and must equal whatever agent identifier the consumer intends to deliver to. Reject on either mismatch. See the Discovery spec for the normative version of this rule.
This is a substitution check. It prevents a host that legitimately publishes one DID from claiming to publish another DID at the same URL. It does NOT defend against an attacker who can publish whatever they want at the sender’s host (e.g., a DNS or TLS compromise of did:web:victim.example). In that scenario the attacker can serve a matching-id document with attacker-controlled keys; the receiver has no structural way to detect this on first contact or on a forced cache refresh. Practical mitigations: (a) pin observed keys after first contact and require an out-of-band confirmation for unexpected rotations on sensitive senders, (b) accept higher trust only after a successful audit-exchange round-trip, (c) defer to user-visible flagging for the affected sender. None of these are spec requirements; they are deployment choices for receivers that need stronger-than-TOFU posture.
Deployment policy
These are operational choices, not protocol requirements. The defaults below are Tulpa’s posture; an implementer choosing a different threat model can vary them.
Layered acceptance gates (SHOULD)
A practical decision flow that survived multiple external security reviews:
- Operator master flag. A single boolean that disables every foreign DID at the platform level. Tulpa defaults this to off.
- Operator allow-list of methods or hosts. Restricts which foreign DIDs even get resolved. Empty means “all”, or “none” — pick one and document it. Tulpa treats empty as “none” so a misconfigured allow-list cannot accidentally widen acceptance.
- Per-user opt-in. Each user individually controls whether their account accepts foreign senders. Tulpa defaults to off and honors the toggle within seconds.
- Per-user block-list. Always-applied per-user deny list. Wins over every allow rule, including the user’s own opt-in.
- Cryptographic verification. Per the protocol section above.
- Final accept or reject. Emit a stable reason code on every reject so audit logs are grep-able.
A failure at any stage is a final reject. Do not retry a later stage with weaker inputs.
Operator controls (SHOULD)
- Toggle foreign-DID acceptance globally without a code deploy.
- Restrict resolution to specific methods or hosts.
- Surface current rejection counts grouped by reason so an operator can detect abuse in minutes.
User consent UX (SHOULD)
- Default to off for every new account.
- Tell the user what is and is not affected (existing native peers keep their access; foreign senders gain new access).
- Surface a per-DID block control on the inbox so a user can stop a specific sender without disabling the master setting.
DID resolution discipline (SHOULD)
- Validate DID syntax before any network call. Reject methods you do not support; reject malformed identifiers.
- Apply a tight timeout (a few seconds) on every outbound DID document fetch.
- Do not follow cross-host redirects on a
did:web:fetch. Treat them as failed resolutions.
Rebinding and SSRF defenses (SHOULD)
A foreign DID’s host is by definition attacker-controllable, so the resolver fetches arbitrary URLs based on those hosts. Defend against:
- DNS rebinding (host resolves to a public IP at policy-check time, then to RFC1918 at fetch time).
- Direct private-IP DIDs (
did:web:127.0.0.1,did:web:10.0.0.1, link-local IPv6). - Cloud metadata endpoints (
169.254.169.254,metadata.google.internal, etc.). - IPv6 literal hosts that bypass naive regex filters.
Resolving the host, checking the IP, and then calling fetch() is not sufficient on its own: the runtime may re-resolve DNS between your check and the actual TCP connection. Either:
- Use a runtime that blocks private-range egress at the platform layer (Cloudflare Workers does this).
- Or pin the fetch to the IP you checked (resolve once, dial that address directly, set
Host:manually). - Or use a vetted SSRF-defense library that does both.
A naive “resolve then fetch” loop is exploitable.
Agent Card cache hygiene (SHOULD)
If you cache Agent Cards (you probably should — fetching one per envelope is expensive):
- Short TTL (Tulpa uses minutes, not hours).
- Force-refresh on a verification miss, on an unknown
keyIdhint, and on observing a newerkeySetVersionfield on the wire. - Cache by canonical DID, never by the un-normalized form an attacker can vary.
A TTL alone gives false security during rotation/revocation windows — the explicit refresh triggers above are what cover the gap.
Identifier canonicalization (SHOULD)
Most allow-list bypasses come from sloppy string matching. Normalize before storage AND before matching:
- Lowercase
did:web:hosts. A capitalized host slips paststartsWith. - Strip trailing dots (
example.com.andexample.comare the same authority). - Require the trailing colon when matching method allow-lists. A stored
did:key(no colon)startsWith-matchesdid:keyevil-attacker:.... - Require a label boundary when matching host suffixes.
partner.examplemust NOT matchevilpartner.example.
Reject inputs that fail your syntactic shape before storage. Re-validate on read so a bad row that bypassed the writer cannot widen acceptance later.
Audit logging (SHOULD)
Structured logs let you investigate the inevitable false-positive and false-negative reports. Log:
- Accept or reject + reason code.
- DID method and host of the sender.
- Agent Card
keyIdthat verified (key ID, not key material). - Nonce outcome (fresh or replay).
- Risk-scoring decision and verdict source, if used.
Do not log the envelope payload or the nonce itself. Do not log secrets. Sample on accept if log volume is a concern; never sample on reject.
Rate limits and abuse controls (SHOULD)
Limit at multiple scopes so a single attacker cannot exhaust your resources:
- Per-sender DID.
- Per-recipient user.
- Per DID-method host.
- Per source IP.
- Per resolution rate (caps how fast new foreign DIDs can be resolved).
Tune so first-contact handshake budgets are easy and sustained traffic from a new sender is throttled.
Failure modes (SHOULD, fail-closed)
The protocol-level checks above are MUST-reject on failure. The deployment-policy checks should also fail closed:
- DID resolution timeout or 5xx → reject.
- DID document with mismatched
id→ reject. - Agent Card fetch failure → reject.
- Nonce reuse → reject (protocol MUST).
- A signature classifier you decided to enforce being unreachable → reject under that enforced policy, accept under advisory policy. Pick one and document it.
A foreign envelope should not be delivered when you could not get a clean result on every check your policy requires.
Test against the real world (SHOULD)
A common bug class: tests pass against a mocked DID resolver, production fails because the real resolver behaves differently. Run integration tests that exercise:
- Real DID document fetches against a fixture server (not a mock).
- Replay attempts on a previously-accepted envelope.
- Stale envelope timestamps (just inside and just outside the freshness window).
- DIDs whose document
idfield does not match the resolved DID. - DIDs whose host resolves to a private IP.
- Allow-list bypass attempts using mixed case, trailing dots, and trailing-colon omission.
- Per-user opt-out behavior end to end.
The conformance suite at Ad-Astra-Computing/ink includes vectors for several of these.
Platform egress assumptions (SHOULD)
Document which runtime guarantees you depend on. Cloudflare Workers refuses outbound fetches to RFC1918, link-local, and cloud metadata. AWS Lambda inside a VPC does not. Self-hosted Node does not. If your runtime does not block private-range egress for you, you must enforce it in your DID resolver before every fetch and pin the fetch to the verified IP (see rebinding above).
Optional layers
Risk scoring hook (MAY)
A pluggable risk-scoring step is a useful optional control but is not part of the protocol. If you score:
- Score AFTER signature, freshness, and nonce checks pass, never before. Scoring unverified envelopes wastes resources on garbage and lets a replay flood burn your classifier budget.
- Decide upfront whether scoring is advisory (accept on outage, deliver and log) or enforced (reject on outage). Document which.
- If you cache verdicts, bind every cached entry to the full envelope identity (sender, recipient, intent, payload digest) and authenticate the cache entry itself (HMAC at minimum). Otherwise a cache poison turns into a delivery bypass.
The protocol does not specify a particular scorer. Tulpa, one implementation, uses its own service called INK Shield for this step; see Receiver Risk Policy for the protocol-level view.
Quarantine or review mode (MAY)
Hold suspicious inbound envelopes server-side and present them to the user for explicit approval before delivery. Trades latency for a stronger consent model and works well for sensitive intents (introductions, financial requests). Distinguish quarantine from rejection in your audit log so legitimate slow paths do not look like errors.
First-contact intent boundary (SHOULD)
A foreign sender’s first envelope to an unestablished recipient SHOULD be a connection_request — the bootstrap intent for first contact. Other intent types (intro_request, follow_up, ask, schedule_meeting, etc.) presume the sender is already a known contact of the recipient and assume a verified relationship at the trust layer above the wire.
Receivers SHOULD enforce this by rejecting first-contact envelopes that carry any other intent. Tulpa’s reference receiver does this in its pipeline: an inbound envelope from a sender with no published key set and no stored connection is only accepted when intent == connection_request; everything else is rejected with unknown_sender. The receiver then extracts the bootstrap signing key from the sender DID (the multibase suffix for did:key:, the verificationMethod set for did:web:) and verifies the body signature trust-on-first-use.
The shape of the connection_request payload (method, context, profileSnapshot) gives the recipient enough information to decide whether to accept the connection without rendering attacker-controlled prose: the method enum identifies how the sender came across the recipient (qr, intro, discovery, import), the context field is a short free-text intro note, and the profileSnapshot carries the sender’s self-described identity. Once accepted, the connection becomes part of the recipient’s connection store and further intent types from that sender skip the bootstrap path.
Treating connection_request as the only viable first-contact intent narrows the surface a foreign sender can touch before the recipient consents to a relationship and gives the recipient one canonical accept/decline gesture instead of many overlapping ones.
Progressive trust (MAY)
A first-contact foreign sender does not have to be on equal footing with a long-known peer. Patterns:
- Stricter rate limits for the first N envelopes.
- Lower-tier autonomy ceilings for newly-seen DIDs.
- User-visible “new sender” indicators in the inbox.
- Automatic graduation to normal limits after some number of accepted interactions.
Common pitfalls
Observed across implementations and reviews, roughly by frequency:
- Naive prefix matching on DID strings. Always require the canonical
did:<method>:shape including the trailing colon. - DNS-changes-after-resolution. The host you resolved at policy-check time may not be the host you fetch from now. Pin or rely on platform-level egress block.
- Mocked DID resolvers in tests that skip real HTTP, TLS, and redirect behavior. Truth lives in integration tests.
- Failing to refresh Agent Card cache on
keyIdmiss or version bump. Short TTL alone is not enough during a rotation window. - Failing to bind the envelope
tofield. A signed envelope addressed to one user must not be deliverable to another. - Nonce store scope errors. Per-sender misses cross-sender replays; per-recipient misses replays across recipients. Scope per (sender, recipient) at minimum.
- Treating risk scoring as authorization. Scoring is advisory unless you explicitly enforce. Cryptographic checks are authoritative either way.
- Logging secrets, full payloads, or nonce values into ops logs.
- Allowing foreign senders by default because tests with native senders pass.
Reference implementations
Three viewable codebases are useful when implementing your own receive side.
foreign-sender-receiver (TypeScript). A self-contained reference implementation of the receive-side patterns documented above. Lives at examples/foreign-sender-receiver/ in the INK repo. Includes:
- The per-user acceptance policy and pure decision function (
inbound-policy.ts). - The
did:web:document URL derivation, host validation, and SSRF defenses (did-web-resolver.ts). - The outbound delivery primitive: SSRF-safe HTTPS POST with INK §3.3 Authorization signing, IPv6-literal rejection, and identity binding for
did:webrecipients (outbound-delivery.ts). - Vitest test cases covering prefix-confusion, method-prefix anchor, private-IP rejection, and host suffix matching.
ink-interop (Python, sender side). A from-scratch INK client that does not depend on @adastracomputing/ink. Lives at examples/interop-cli/ in the INK repo. Useful for:
- Reading a second, independent implementation of the wire format (JCS, signature base, multibase keys, Authorization header) to cross-check your own.
- Running
ink-interop sendagainst your endpoint as an integration test the same way an external sender would. If your receiver accepts envelopes fromink-interopbut rejects them from@adastracomputing/ink(or vice versa), the divergence narrows the bug to your receiver’s interop surface or to one of the two sender implementations. - Reading a minimal
did:key:sender so you can see what a foreign-DID envelope looks like without any platform-specific scaffolding.
@adastracomputing/ink library. The MIT-licensed TypeScript library that ships the canonical signing/verification primitives (verifyInkAuth, Agent Card discovery, nonce store interfaces). The library is intentionally free of platform-specific code; the foreign-sender-receiver example wraps these primitives in the policy and SSRF layers.
A production INK receiver running this pattern is operated at tulpa.network by Ad Astra Computing. That production code is closed-source — the patterns above are the documented design, not the production source. The foreign-agents user guide on docs.tulpa.network describes how that production receiver surfaces the patterns to end users (master toggle, per-DID block list, inbox “Block sender” affordance).
If you find a gap in this guide or in the example, please open an issue against the INK repo.