Claude: changes for issue #91 #101
No reviewers
Labels
No labels
breaking-change
bug
documentation
duplicate
enhancement
good first issue
help wanted
performance
question
wontfix
No milestone
No project
No assignees
3 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Grandiras/ATProto.NET!101
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "claude/issue-91"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #91.
🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the task runs.
💬 Verdict: comment — notes only, nothing blocking.
This is a large, carefully engineered PR implementing the server-side (authority + repo host) half of the Spaces protocol. The parts I could review in the (truncated) diff are consistent with the security-conscious style established in the earlier client-side Spaces PR: DPoP proof verification follows RFC 9449 correctly (own-jwk verify, then thumbprint-to-cnf.jkt binding, ath/htm/htu/iat checks, single-use jti via a replay store keyed on (iss, jti, exp)), delegation-token audience is derived from the token's own sub rather than the request, space-credential signer resolution is anchored to the space URI rather than the credential's iss, and constant-time comparisons (CryptographicOperations.FixedTimeEquals) are used for the security-sensitive string comparisons. JWK algorithm is correctly pinned to the key's curve, and private-key material embedded in a proof's jwk is rejected. The
ISpaceReplayStorekeying on (issuer, jti, exp) is a reasonable design and the rationale is well documented.A few things worth flagging, none blocking based on what's visible:
DPoPProofValidator.NormalizeUrifalls back to returning the rawurlunchanged whenUri.TryCreatefails (non-absolutehtu). Since both sides of the comparison go through the same normalization, this isn't exploitable, but it does mean a malformedhtusilently skips scheme/host/port canonicalization rather than being rejected outright — worth confirming a request URI passed in asrequestUriis always a well-formed absolute URI upstream (built fromPublicBaseUrl+ path) so this fallback path is truly unreachable for the trusted side.SpaceServiceAuthVerifier.VerifyAsyncchecksexp(with clock skew) but doesn't appear to validateiat/freshness for service-auth tokens the way the DPoP proof does; that's consistent with the existing AT Proto service-auth convention elsewhere in the codebase, so likely fine, just noting the asymmetry.SpaceAuthorityEndpoints.cs), so I was not able to review the remaining endpoint handlers (repo host reads,getBlobaccess-check enforcement, write-notification delivery/inbound validation) or the new unit tests referenced in the CHANGELOG ("90 new unit tests"). Given the security-critical nature of this surface (credential exchange, blob access checks, replay handling), I'd recommend a follow-up pass — ideally via/code-review ultra— specifically over the untruncated remainder before merge, if that hasn't already happened.Nothing in the reviewed portion looks incorrect or unsafe; CHANGELOG.md was updated in the same commit as required by the repo's CLAUDE.md.
⏱ 0m 54s · 2 turns · tokens: 73.1k in / 4.6k out (+129.6k cache-read)
🤖 On it — follow along in the action run; the reply will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the task runs.
Pushed to
claude/issue-91asb86628f— the PR is updated.What I changed
1.
htuthat doesn't normalize is now rejected (review point 1). The reviewer was right that the fallback wasn't exploitable — both sides went through the same normalization, so a relativehtufailed the comparison anyway — but "not exploitable because the other side is also broken" is the wrong reason for a security check to hold.NormalizeUrinow returnsnullfor anything that isn't an absolute URL naming a host, and the two sides are treated differently on purpose:htuthat doesn't normalize is refused outright (NotAuthorized), because a value that skipped scheme/host/port canonicalization has been checked against nothing;ArgumentException, since that's this service's ownPublicBaseUrlbeing misconfigured (e.g. set without a scheme) — a fault, not a failed verification. To answer the question the review asked:BuildRequestUricomposes{PublicBaseUrl or request scheme+host}{path}, so the trusted side is absolute in a correct configuration, and it now fails loudly rather than silently degrading when it isn't.2. Single-use tokens are bounded in lifetime (review point 2). Looking into the
iat/freshness asymmetry turned up something more concrete than the asymmetry itself:expwas checked but never bounded, and the same was true of delegation tokens and client attestations. All three are minted to live 60 seconds —ServiceAuthGeneratoroutright refuses to mint beyond five minutes — but theexpon an inbound one is whatever its signer chose, and all three have theirjticonsumed byISpaceReplayStorebefore anything authorizes their issuer (theIsRepoHostAsynccheck onnotifyWriteruns afterVerifyAsyncreturns). So a self-signed token dated years ahead stayed replayable for years and pinned a replay-store entry for exactly as long, since the store evicts an entry only once the token it guards has expired. NewSpaceServerOptions.MaxSingleUseTokenLifetime(five minutes, configurable) caps all three, and service auth now also rejects aniatdated in the future.3. The truncated remainder (review point 3). I can't launch
/code-review ultra— it's user-triggered and billed — so I read the rest myself: the repo-host read endpoints,getBlob,notifyWrite,SpaceWriteNotifier, thesimplespaceadministration endpoints,SimpleSpaceAccessPolicy, and the new tests. Nothing else needed changing. ThegetBlobaccess check is real: the endpoint delegates toISpaceRepoHost.GetBlobAsync(space, repo, cid), whose contract documents that the reference check is the access check and that a CID-only lookup would leak any blob the account holds. The one thing I looked at hard and concluded is sound:NotifyWriteEndpointderives the expected service-auth audience from_options.ServiceDid ?? space.Authority, i.e. partly from the request whenServiceDidis unconfigured — but the space-state check that follows gates it to spaces this authority actually holds, whose authority is this service, so the audience can't be attacker-chosen in any case that reaches a write.Docs (
docs/spaces.md,docs/api-reference.md) andCHANGELOG.mdupdated in the same commit; the test count in the changelog entry went 90 → 102.Verification
dotnet buildclean with 0 warnings, anddotnet test tests/ATProtoNet.Tests/passes in both Debug and Release: 1613 passed, 0 failed, 5 skipped (12 new — relative/non-absolutehtu, non-absolute request URI,NormalizeUrinull cases, and a lifetime-ceiling test for each of the three single-use token kinds, plus a smallSpaceServiceAuthVerifiersuite that didn't exist before).No follow-up issues to file — everything I found in the unreviewed remainder is either fixed above or correct as written.
⏱ 8m 29s · 58 turns · tokens: 137.4k in / 30.6k out (+5550.7k cache-read)
📜 Full session transcript
@claude-bot Please fix the merge conflict
🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the task runs.
✅ Verdict: approve — no blocking issues found.
This re-review is on the follow-up commit
fix: reject unnormalizable htu and cap single-use token lifetimes, which lands on top of the previously-reviewedfeat: server-side space support in ATProtoNet.Servercommit. It directly addresses both points I raised in my prior pass:DPoPProofValidator.NormalizeUrino longer falls back to returning the rawurlstring when it fails to parse as an absolute URI — it now returnsnull, and both the trusted request-side URI and the proof'shtuare required to normalize successfully or the request is rejected (ArgumentExceptionfor a misconfiguredPublicBaseUrlon the trusted side,SpaceVerificationExceptionfor a badhtuon the caller side). This closes the theoretical gap where an unparseablehtuwould previously be compared verbatim.SpaceServerOptions.MaxSingleUseTokenLifetime(five minutes by default) is now enforced on the three single-use, signer-chosen-exptoken kinds — delegation tokens, client attestations, and service auth tokens — via the newIsWithinSingleUseWindowhelper. This bounds how long a captured/forged-but-signed token stays replayable and how long itsjtisits in the replay store, addressing the asymmetry I flagged around service-auth freshness (which now also rejects a future-datediat).Both changes are covered by new, well-targeted unit tests (relative/non-absolute
hturejection, oversized-lifetime rejection for all three token kinds, future-datediatrejection), andCHANGELOG.md/docs/spaces.md/docs/api-reference.mdwere updated in the same commit per the repo's CLAUDE.md requirement. The DI wiring inSpaceServerExtensionswas updated to passSpaceServerOptionsintoSpaceClientAttestationVerifier, which is consistent with the new check.I was not able to run
dotnet build/dotnet testin this session (git checkout/worktree operations required approval I didn't have), but the diff is a small, self-contained, low-risk change confined to validation-tightening logic with accompanying tests, and I reviewed the full diff (not truncated this time) plus the underlying full files where relevant. Nothing else in the change looks incorrect. One trivial nit for a future pass: the CHANGELOG's new DPoP bullet is missing a period between "...checked against nothing" and "A proof whose header leaks..." — not blocking.Both previously-raised concerns are resolved; approving.
⏱ 18m 17s · 15 turns · tokens: 96.9k in / 4.6k out (+1555.4k cache-read)
📜 Full session transcript
🤖 On it — follow along in the action run; the reply will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the task runs.
Merged
mainintoclaude/issue-91and pushed as9355435— the PR merges cleanly now (the branch fully containsorigin/mainat5bd0062).The conflicts
Two files, both purely additive —
mainand this branch had each appended to the same spot:CHANGELOG.md— both sides added entries under### Added. Kept both, ordered#93, #92, #91, #89to match the file's existing descending convention. Also took the reviewer's nit from the last pass while I was in the file: the missing period in the DPoP bullet.docs/index.md— this branch extended the Spaces line with the serving a space anchor;mainadded a Testing Against a Real Space Host line beneath it. Kept both.docs/spaces.mdauto-merged and I checked the result by hand rather than trusting it:main's three additions (the lexgen cross-reference, thePartial/NoRepoparagraph, the See-also link) all survived alongside this branch's new Serving a space section, with no duplicated heading.What the merge broke that the merge didn't fix
Textual conflicts weren't the whole story.
#98landed onmainwhile this branch was open and taught the core to read the legacyEcdsa...VerificationKey2019verification-method types alongsideMultikey, routingSpaceAuthority.GetSigningKeythrough the newDidDocument.GetVerificationKey. This branch's server-side key lookup inSpaceDidDocumentResolverExtensionsstill carried its own privateFindMultikey, which requiresType == "Multikey"— and that one is used on exactly one of the two paths: whenever a presented token names akid.So the merged tree compiled and every existing test passed, while the same DID document verified through the no-
kidpath and was refused through thekidpath. Neither branch is wrong on its own; the disagreement only exists once they're combined. Both paths now go throughGetVerificationKey.The second half of the same interaction: that lookup throws
FormatExceptionon malformed key material, where the old one returnednull. In a verifier that's an unhandled exception — a 500 — over a document fetched from the party being verified, i.e. something a remote party controls. It's now aSpaceVerificationExceptionreported under the caller's own error name (InvalidDelegationTokenon the delegation path, not a generic one), which is what the rest of the verification surface does.Three tests cover it: a delegation token from an account publishing a legacy
#atprotoentry, a credential naming a legacy#atproto_spacebykid(the path that doesn't go throughSpaceAuthority.GetSigningKey), and malformed key material surfacing as a failed verification. Changelog test count 102 → 105, with a sub-bullet on the change.Verification
dotnet buildclean, 0 warnings.dotnet test tests/ATProtoNet.Tests/in Release: 1657 passed, 0 failed, 5 skipped — that'smain's tests and this branch's running together for the first time. The integration-test project compiles too, though its space tests stay gated behindATPROTO_TEST_SPACESand didn't run here.No new issues to file.
⏱ 6m 28s · 49 turns · tokens: 73.1k in / 19.2k out (+3005.4k cache-read)
📜 Full session transcript
🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the task runs.
✅ Verdict: approve — no blocking issues found.
Re-review after a new commit landed:
9355435 Merge main into claude/issue-91. This is the only change since my prior approval ofb86628f(the fix commit) — no new feature commits.The merge itself is substantive and worth checking, not just a mechanical resync: bringing in main pulled in issue #98's work (teaching
DidDocument.GetVerificationKeyto read legacyEcdsa...VerificationKey2019entries alongsideMultikey), which would otherwise have silently regressed this PR's space verifiers. Before the merge,SpaceDidDocumentResolverExtensionshad its own privateFindMultikeyhelper used whenever a token named akid, so a legacy-key DID document would verify through the no-kidpath but be wrongly refused through thekidpath post-merge. The merge commit fixes this correctly: bothResolveAccountKeyAsyncandResolveAuthorityKeyAsyncnow route through the sharedDidDocument.GetVerificationKey, and I confirmed that method (insrc/ATProtoNet/Identity/PlcClient.cs) does read throughVerificationMethod.ToDidKey(), which is where #98's legacy-key support lives. The commit also correctly converts theFormatExceptionGetVerificationKeycan throw on malformed key material into aSpaceVerificationExceptionunder the caller's own error name, rather than letting it escape as an unhandled 500 — consistent with the pattern used elsewhere in this PR (e.g.CachingSpaceDidDocumentResolver.ResolveAsync).Three new unit tests were added covering exactly this interaction (legacy
#atprotodelegation-token signer, legacy#atproto_spacecredential signer resolved bykid, and malformed key material surfaced as a failed verification with "malformed" in the message) — I checked these exist intests/ATProtoNet.Tests/Server/Spaces/SpaceTokenVerifierTests.csandSpaceServerTestFixtures.csand match the stated intent.The two textual conflicts (CHANGELOG.md's
### Addedsection, docs/index.md's Spaces line) were resolved additively with both sides kept, and the new CHANGELOG bullet documenting this merge-time key-resolution fix was added in the same commit. As a bonus, the trivial missing-period nit I flagged in my previous pass ("...checked against nothing. A proof...") is now fixed in this same CHANGELOG text.I wasn't able to run
dotnet build/dotnet testin this session (worktree/build tooling required approval I didn't have), but the change is small, self-contained, well-reasoned, and backed by targeted tests. Both prior review passes' concerns remain resolved, and this merge commit introduces no new issues. Approving.⏱ 0m 55s · 15 turns · tokens: 91.2k in / 3.9k out (+1548.9k cache-read)
📜 Full session transcript