Skip to content

feat(adapter-api): let a client state the owner DID it expects to find - #157

Merged
cuibonobo merged 1 commit into
mainfrom
claude/issues-138-140-plan-k4n2d4
Aug 11, 2026
Merged

feat(adapter-api): let a client state the owner DID it expects to find#157
cuibonobo merged 1 commit into
mainfrom
claude/issues-138-140-plan-k4n2d4

Conversation

@cuibonobo

@cuibonobo cuibonobo commented Aug 11, 2026

Copy link
Copy Markdown
Member

Closes #140.

Summary

§Identity's pitch is "claims are signatures; anyone can verify without asking anyone" — but a client's belief about whose stack it is talking to rests on an unsigned JSON field. APIAdapter reads entityId from GET /.well-known/stack and caches it as ownerEntityId for the session, and nothing challenges the server to prove a binding to that DID. Structurally nothing can: the server deliberately never holds the owner's key. A hostile or misdirected server can present itself as anyone's stack, and a client will write private data into it.

That is inherent to the hosted topology and probably acceptable at intimates scale. What was wrong is that the spec was silent about it while being scrupulous about naming exactly this kind of asymmetry everywhere else. Two changes, no new cryptography.

The spec says it. A new Wire format § Identity is trusted on transport states that discovery identity rests on TLS and the URL you chose rather than on a signature, and defers proving a server↔owner binding alongside key rotation — which is the same problem from the other side, since both need an identity that outlives a single key. §Identity's opening claim gains the one clause that keeps it true, and the deferred-key-rotation section points back.

A client can state what it expects. expectedOwner?: EntityId on APIAdapterOpenOptions fails open() with APIAdapterOwnerMismatchError when discovery reports anything else. A client following a Group's stackUrl, or reconnecting to a stack it has used before, already holds that expectation and had no way to say so. The error carries both DIDs for a caller that wants to report them.

Three details worth stating, since each is a decision rather than a default:

  • Absence is a mismatch. Discovery carrying no entityId is refused rather than passed — it is certainly not the DID you asked for.
  • The comparison is exact string equality. No DID method is normalized, because core resolves none of them; normalizing would mean inventing method-specific rules for methods this library deliberately knows nothing about.
  • The check runs after version negotiation. A differing protocol major means discovery's fields may not mean what this client reads them as, so entityId isn't worth comparing yet. Pinned as a test rather than left to reading order.

What the option is not

Two limits are in the spec rather than left for someone to discover:

  • It narrows misdirection to a server that already knows which DID it should be claiming. It does not make discovery identity a proof, and no client-side check can — the proof would have to come from the server, which holds nothing to make it with.
  • It runs on a response that has already been fetched, so a configured static token reached that server before the check could refuse it. expectedOwner guards what a client goes on to write, not what it already sent to a URL it chose to contact. Dropping the token from the discovery request isn't the fix — discovery may legitimately require auth, which is why open() already handles a 401 there.

The second limit tightens under #138: a client with a DID credential must run this check before signing a challenge, or a hostile server can relay — fetch a challenge from your real server, hand you that nonce, and redeem your signature as a token. That ordering, and the domain separation that closes the relay independently, belong to that issue rather than this one.

Spec

Observable behavior changes at open(). Sections updated:

  • Wire format § Identity is trusted on transport — new. Why entityId is unsigned and why the server cannot prove it; the deferral, and what it shares with key rotation; expectedOwner, exact comparison, absence as mismatch; and the two limits above
  • Identity — the self-certification pitch names the one claim in the served topology that falls outside it, pointing at the section above
  • Identity § Deferred: key rotation — proving a server↔owner binding is deferred alongside rotation, needing the same thing: an identity a server can speak for without holding the owner's key

Conformance fixtures are N/A — this is a client-side check with no wire surface of its own.

Verification

pnpm run format:check && pnpm run lint && pnpm test && pnpm run build && pnpm run typecheck

All five green. 144 tests in adapter-api, 1068 across 8 packages.

Checked by hand:

  • Every new refusal was re-run against the source reverted and fails without the change — the mismatch, the absent entityId, the case-differing DID, and the "no further request is sent" pin. The two no-regression cases (a matching expectedOwner opens; an omitted one opens against any owner) pass either way, which is what makes them no-regression cases
  • The ordering pin was verified by moving the check ahead of version negotiation rather than by removing it: with the owner check absent the test passes trivially, since only a version error can throw. Moved ahead, it fails. So it pins the order rather than merely the outcome
  • Both new spec anchors resolve to headings that exist, checked mechanically, along with the inbound link from the code comment

Notes for reviewers

Deliberate, and worth disagreeing with if you do:

  • Absence of entityId is a mismatch, not a pass. The alternative reading — "the server didn't say, so don't refuse" — treats a server that declines to identify itself as more trustworthy than one that identifies itself wrongly.
  • No normalization of any kind. did:key is base58 and case-carrying, so lowercasing would make distinct keys compare equal. Other methods have their own rules, and core resolves none of them, so any normalization here would be a guess on behalf of a method this library doesn't implement.
  • Ordered after version negotiation, not before. The opposite order is defensible — "refuse the wrong server before parsing anything else" — but a major mismatch means this client may be misreading the very field the owner check reads.
  • A plain APIAdapterError subtype, not a core error class. The issue left this open. It is a client-side connection-time refusal with no wire representation and no server involvement, which is what every other APIAdapter*Error is; putting it in the core taxonomy would imply a wire code and status it will never have.

Follow-up: #138 is next on this branch. Its handshake makes the ordering constraint above load-bearing rather than advisory, and the domain-separated signing payload closes the relay path that expectedOwner alone only narrows.

Discovery's `entityId` is an unsigned JSON field, and the server cannot
prove any binding to it — it deliberately never holds the owner's key. So
a client's belief about whose stack it is talking to rests on TLS and the
URL it was given, while every other claim in the system is a signature.
The spec was silent on that asymmetry despite naming this kind of gap
everywhere else, so it now says it: inherent to the hosted topology, and
deferred alongside key rotation, which is the same problem approached
from the other side.

A client following a Group's `stackUrl` already knows which DID it
expects to find there and had no way to say so. `expectedOwner` compares
it against discovery and fails `open()` with
`APIAdapterOwnerMismatchError`, carrying both DIDs for a caller that
wants to report them. Absence of `entityId` is a mismatch rather than a
pass, and the comparison is exact — no DID method is normalized, since
core resolves none of them.

The check runs after version negotiation: a differing protocol major
means discovery's fields may not mean what this client reads them as, so
`entityId` isn't worth comparing yet.

Two limits are stated in the spec rather than implied. The option narrows
misdirection to a server that already knows which DID it should be
claiming; it does not make discovery identity a proof, and no client-side
check can. And it runs on a response already fetched, so a configured
static `token` reached that server before the check could refuse it — it
guards what a client goes on to write, not what it already sent.

Refs #140.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KawUMrf9qvEm8zxetRxJqr
@cuibonobo
cuibonobo merged commit c65232b into main Aug 11, 2026
5 checks passed
@cuibonobo
cuibonobo deleted the claude/issues-138-140-plan-k4n2d4 branch August 11, 2026 18:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Discovery entityId is trusted-on-transport: acknowledge in spec, add optional expectedOwner check to APIAdapter

2 participants