feat(adapter-api): let a client state the owner DID it expects to find - #157
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
APIAdapterreadsentityIdfromGET /.well-known/stackand caches it asownerEntityIdfor 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?: EntityIdonAPIAdapterOpenOptionsfailsopen()withAPIAdapterOwnerMismatchErrorwhen discovery reports anything else. A client following a Group'sstackUrl, 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:
entityIdis refused rather than passed — it is certainly not the DID you asked for.entityIdisn'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:
tokenreached that server before the check could refuse it.expectedOwnerguards 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 whyopen()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:entityIdis 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 aboveConformance fixtures are N/A — this is a client-side check with no wire surface of its own.
Verification
All five green. 144 tests in
adapter-api, 1068 across 8 packages.Checked by hand:
entityId, the case-differing DID, and the "no further request is sent" pin. The two no-regression cases (a matchingexpectedOwneropens; an omitted one opens against any owner) pass either way, which is what makes them no-regression casesNotes for reviewers
Deliberate, and worth disagreeing with if you do:
entityIdis 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.did:keyis 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.APIAdapterErrorsubtype, 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 otherAPIAdapter*Erroris; putting it in the core taxonomy would imply a wirecodeand 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
expectedOwneralone only narrows.