fix(spaces)!: protocol hardening, performance and a smaller server surface #172
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
4 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Grandiras/ATProto.NET!172
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "issue-133-spaces"
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 #133.
Brings Spaces in line with proposal 0016's September clarifications and the reference implementation (bluesky-social/atproto #5187 / #5496,
packages/spaceandpds/src/auth-verifier.ts), adds per-account signing for space service auth, makes the hot read path cheap, and shrinks the public server surface. Includes the two scope additions from the issue comments.Protocol hardening
SpaceAuthority.GetSigningKey/GetHostEndpointuse the strictTryGetVerificationKey/TryGetServiceEndpointlookups from #120: an entry that is absent falls back to#atproto/#atproto_pds; one that is published but unusable throwsFormatException(unknown key type, no or undecodable key material; a#atproto_space_hostnot of typeAtprotoSpaceHostor without an absolute https endpoint).SpaceCredentialProvider.ResolveHostAsyncreports it asSpaceCredentialException,SpaceServiceAuthVerifier.IsRepoHostAsyncas a refusal, andSpaceWriteNotifieras an undelivered notification.kid(G15). Decision: a missingkidis refused, as the reference'sresolveSpaceKeyrefuses it (every conforming issuer,SpaceTokens.Createincluded, sends one). Delegation tokens:#atprotoonly (proposal: "MUST be #atproto"; the reference also tolerates#atproto_space— see Flagged). Credentials:#atproto_spaceor#atproto, with or without#, never DID-qualified; the named entry is resolved with no fallback (SpaceDidResolution.RequireKeyId/ResolveKeyAsync). The old no-kid →#atproto_space-then-#atprotopath is gone from the server.SpaceServerOptions.CredentialKeyIddocs now say an authority with a dedicated key must set it.DPoPProofValidatorpinsES256(the reference'sSIGNING_ALG) and refuses anathon the credential exchange (the reference's "must be omitted when obtaining a credential"). Anonceclaim is ignored (0016: no server nonces).SpaceRepoCar.Verify(G17). Index keys must split on exactly one/into a valid NSID and record key — checked inDecodeIndexfor every entry, so index-only CARs are covered; index CIDs must be DAG-CBOR; each record block must be a DAG-CBOR map (major type 5, definite length,DagCborDecoder.TryValidate).FromLexiconrefuses a wildcard incollectionswith a message naming it;namehas no length limit (the doc line was already fixed by #169).SimpleSpaceUserPolicy/SimpleSpaceAppAccessare[AtProtoUnion]open unions withUnknownSimpleSpaceUserPolicy/UnknownSimpleSpaceAppAccess;SpaceWriteOpis[AtProtoUnion(Closed = true)](the Lexicon markswritesclosed). Object models (SpaceView,SpaceRepoView,SpaceRecordView,SpaceRepoOpEntry,SpaceWriteOpResult,SimpleSpaceMember, the policies,SignedSpaceCommit) derive fromLexObject.createSpace/updateSpaceanswerUnsupportedPolicy/UnsupportedAppAccessfor an unknown variant. TheOpenUnionTestsguard no longer exempts Spaces.LexiconTypeRegistry.RegisterUnionVariantthrowsArgumentExceptionfor a closed[AtProtoUnion]base, and the converter no longer consults the registry for a closed union. Tests inLexiconTypeRegistryTestsandSpaceUnionTests.docs/spaces.mdsayssync.getBlobanswersBlobNotFoundfor a space-only blob.SpaceSyncerverified commits withSpaceAuthority.GetSigningKey, i.e. a#atproto_spacekey when the author published one; commits are signed with the repo key, so it now uses#atproto(the integration fixture's helper too).Per-account signing (issue comment 1)
ISpaceAccountSigner.GetSignerAsync(Did)→ServiceAuthGenerator?(owned by the signer).SpaceWriteNotifiersignsNotifyWriteAsyncas the writer,ForwardWriteAsyncandNotifySpaceDeletedAsyncas the space authority;SimpleSpaceManagingAppClientsignscheckUserAccessas the authority. Fallback to the serviceServiceAuthGeneratorwhen no signer is registered, it returnsnull, it throws (logged), or it returns a generator for another DID. Skipped when the account already is the service DID. DI passessp.GetService<ISpaceAccountSigner>().SpaceAccountSigningTests([RequiresSpacesFact]) registers a did:plc in the test network's PLC with a key the test holds, then has an SDKSpaceWriteNotifiernotify the reference authority: signed as the writer it joins the writer set; signed as the host service DID it is refused. Not run (no spaces-alpha network here); it compiles.Performance
SpaceCredentialVerifierkeys verified credentials bySHA-256(jwt)(the same hash is the expectedath, computed once) untilmin(exp, now + DidCache.StaleAfter); bounded bySpaceServerOptions.VerifiedCredentialCacheCapacity(10,000; 0 disables; when full of live entries a newcomer is verified per request rather than evicting). Per request it still checks the requested space,exp, and the whole DPoP proof including itsjti. A mismatched space is refused before any key resolution.JsonWebKeyVerifierimports a JWK once (the import validates the point, so an off-curve Y cannot pass as the same-X key), keeps it by thumbprint as a did:key, and verifies through the existing pooledDidKeyCacheJWS path (high-S accepted as before).client_idinSpaceClientAttestationVerifier(ClientMetadataCacheLifetime, 5 min); a kid miss or failed signature refetches at most every 30 s per client.SpaceCredentialProvider: per-spaceSemaphoreSlimgate with a lock-free hit path (single-flight per space; a forced renewal replaces only the credential it saw); replaced credentials are kept for live readers and disposed once expired. The caching identity resolver was already in place (#120).SpaceSyncer.RecoverAsyncpre-sizes fromContent-Length(capped at 32 MiB so a host cannot force a large allocation) and verifies fromMemoryStream.GetBuffer()without theToArray()copy.RecordWriteAsync,RegisterNotifyAsync,PutMemberAsync,MarkDeletedAsynctryExecuteUpdateAsyncfirst and fall back to the insert path;UpdateSpaceAsync,DeleteSpaceAsync,RemoveMemberAsync,UnregisterNotifyAsyncare singleExecuteUpdate/ExecuteDeletestatements. The writer-set revision guard moved into SQL (TIDs are[2-7a-z], which every collation orders bytewise).InMemoryJtiReplayStoreandEfCoreJtiReplayStore<T>(backgroundTask.Run,internal LastSweepfor tests).Measured with a Stopwatch harness (
SpaceCredentialVerifier.VerifyAsyncon the same credential with a fresh DPoP proof per call, cached DID document, 20,000 ops after 2,000 warm-up, Release, one thread; before = main atef506f1, run back to back):What remains per request is the DPoP proof's own P-256 signature check and its JSON parsing.
Smaller surface
SpaceRepoEndpointBase<T>,SimpleSpaceEndpointBaseand the query-parameter types areinternal, with primary constructors and no DI null checks; aRequireLiveSpaceAsynchelper replaces the repeated state checks.ISpaceServiceAuthVerifier(one implementation, no fakes). KeptISimpleSpaceManagingAppClientandISpaceCallerResolver: both are stubbed in tests (test-seam value), per the issue's criterion.DPoPProofValidator, the four verifiers,SpaceRequestAuthenticator,SimpleSpaceAccessPolicy) areTryAddSingleton<T>(); the verifiers take the space resolver via[FromKeyedServices(DidResolverKey)], so a registeredTimeProvidernow reaches them (test added).VerifiedDelegationToken.Token,VerifiedSpaceCredential.Token/AuthorityDid,VerifiedClientAttestation.Token,SpaceCredentialRequestAuth.Space,DPoPProof.AccessTokenHash/Nonce, publicSpaceRequestAuthenticator.BuildRequestUri,InMemorySpaceAuthorityStore.MarkDeleted, andSpaceCredentialIssuer'sownsKey/IDisposable. (CachingSpaceDidDocumentResolver.Invalidatewas already gone;VerifiedServiceAuth.Audienceis now used by #166's claims and stays.)InMemorySpaceStoreWarningtrimmed to the replay store (as the issue says; the simplespace member-list notice is gone too — see Flagged).SpacePaging.After(in-memory) andSpacePaging.Page(all four stores).LengthLimitedStreamwas already replaced byReadBoundedAsync, with size-cap tests, in #151 — nothing left to do.Breaking changes (migration)
*Parametersinternal → register throughAddSpaceAuthority/AddSpaceRepoHost/AddSimpleSpace; implement your ownIXrpcEndpointfor custom routes.ISpaceServiceAuthVerifierremoved → useSpaceServiceAuthVerifier.auth.Delegation.Space,credential.Space.Authority,SpaceServerOptions.BuildRequestUri; deletion via the simplespace store orEfCoreSpaceAuthorityStore.MarkDeletedAsync.SpaceCredentialVerifier(resolver, validator, options?, timeProvider?);SpaceWriteNotifier(…, accountSigner?, logger?);SimpleSpaceManagingAppClient(…, accountSigner?, logger?);SpaceCredentialIssuer(key, options)→ passtimeProvider:/logger:by name; dispose the issuer's key yourself.LexObject(binary break) → add anUnknown…arm to policy switches.RegisterUnionVarianton a closed union throws → remove the registration.kid, ES256K or exchange-athDPoP proofs, malformed#atproto_space(_host)entries, invalid repo CARs are now refused → an authority signing with#atproto_spacesetsCredentialKeyId = "#atproto_space".ExecuteUpdate/ExecuteDelete(not EF InMemory) → test on SQLite in-memory (now under Breaking changes).SpaceServiceAuthVerifier.IsRepoHostAsyncremoved;notifyWritemust be signed by the writer.Verification
dotnet build(solution,--no-incremental): 0 warnings, 0 errors.a5f2489: 4887 passed, 0 failed, 5 skipped (4892); the Spaces and replay-store subset ran 5 more times with no failures. The original commit had 4678 passed (4683). New/extended:SpaceAuthorityTests(malformed entries throw, absent entries fall back),SpaceTokenKeyIdTests(kid allow-list, no fallback, missing kid),DPoPProofValidatorTests(ES256K on a real secp256k1 key refused, exchangeathrefused, off-curve JWK Y refused, cached import still verifies each signature),SpaceCredentialCacheTests(one resolution for repeated presentations; replayed proof, foreign key, wrong space and expiry still refused; re-verification pastStaleAfteragainst a rotated key; cache off / full / failed-credential cases),SpaceRepoCarTests(two-slash, empty and missing-slash paths, index-only CAR, non-map record blocks incl. indefinite-length, raw-codec CID),SpaceScopeTests(wildcard refusal, 200-char name),SpaceUnionTests+LexiconTypeRegistryTests(unknown policy round-trips byte-for-byte, extension data, closedSpaceWriteOp, closed-union registration rejected),SpaceWriteNotifierTests/SimpleSpaceManagingAppClientTests(iss per call, fallbacks, token verifies against the account key),SpaceCredentialMintTests(single-flight per space, no cross-space blocking, superseded retention/disposal),SpaceCredentialProviderTests(malformed space host throws; published one preferred),SpaceSyncerTests(commit verified against#atprotowhen#atproto_spaceis published; capped pre-size),SpaceClientKeyCacheTests,SpaceServerRegistrationTests(TimeProvider reaches verifiers; signer reaches the notifier),JtiReplayStoreTests(consumption does not wait for the sweep),SimpleSpaceEndpointTests(unknown variant →UnsupportedPolicy/UnsupportedAppAccess).SpaceAccountSigningTests) compile but were not run here.Review fixes
Rebased onto
origin/maina5f2489(#171); the only conflicts were in CHANGELOG, where both sides were kept and every bullet stayed in its section.0a00095became2cc2dce. Everyfixcommit's regression test was checked to fail with that commit'ssrc/change reverted.6f12df9(items 1, 6, 8, 10a) —notifyWriterequiresiss == repo, the reference rule;IsRepoHostAsyncis removed. Item 1 has both options:AddSpaceAuthority(signingKey, serviceAuthKey?), andISpaceAccountSigneris also consulted for the service's own DID. A dedicated#atproto_spacecredential key with neither fails at startup.InMemorySpaceAuthorityStore.MarkDeletedis restored. A malformed managing-app space-host entry is refused rather than returning 500.3f5cedf(items 2, 3) — Item 2: LRU eviction with a per-authority quota of capacity/4 (a foreign authority minting 50 credentials holds 2 of 8 slots), rather than caching only after the endpoint confirms. Item 3: every cache hit re-checks the key against the DID document currently served (with a reference-equality fast path), so a cached credential stops verifying exactly when a fresh one would. Benchmark unchanged, about 60 µs per op.e6cb40b(item 4) — client metadata is fetched at most once per 30 s, on success or failure; failures are cached; one shared fetch per client, which no single waiter's cancellation stops.14b9e6c(item 7) — one shared mint task per space, so all waiters get the same credential or the same failure; minted under a provider-level token; store, retire and dispose share one lock, andDisposecancels a mint in flight.10b178e(item 5) — reads the stored revision, compares it ordinally in C#, and advances withExecuteUpdate … WHERE Rev == <read>. Equality is collation-safe; ordering is not. A missing row takes the insert path, and the loop gives up withDbUpdateConcurrencyExceptionafter 5 attempts. The test puts SQLite'sRevcolumn under aREVERSEDcollation. Cost: one primary-key read plus oneUPDATE(was oneUPDATE).624758c(item 9) — a strict record validator,Spaces/SpaceRecordCbor.cs:CborReaderin Canonical mode, plus only tag 42 over0x00…, text keys only, and no floats orundefined. It lives inSpaces/, soRepo/DagCborDecoder(#134) is untouched.SpaceSyncer.MaxRepoSize(256 MiB) boundsgetRepoboth by the declaredContent-Lengthand during the read.75638a2(item 11) — the EF InMemory incompatibility is moved to Breaking changes, with the SQLite in-memory migration.fbc8437(item 12) — both replay-store sweep tests awaitLastSweepbefore the replay check, and include an expired row to prove the sweep ran. The other item-12 gaps are covered in the commits above; theIsRepoHostAsyncgap is moot because that method is removed.5034dc0(item 10b) — no behaviour change. The referenceunregisterNotifyremoves any service once the credential checks pass (verified at787a730fand on the alpha branch), and a credential carries no subscriber identity to match against. Documented on the endpoint and indocs/spaces.md: the only impact is latency (the sweep plus a re-register).Flagged / deferred
kid: follows the proposal (#atprotoonly), one notch stricter than the reference (which also accepts#atproto_space); no known issuer sends that.InMemorySpaceStoreWarningno longer mentions in-memorysimplespacemember lists (trimmed per the issue); the store's own docs still say so.— resolved in the review fixes (item 1).AddSpaceAuthority's fallbackServiceAuthGeneratorsigns with the credential keySpaceCredentialProvider.ResolveHostAsyncresolves repo hosts through#atproto_space_hosttoo (pre-existing), so a malformed space-host entry on a writer's document now fails that read rather than falling back.🤖 Generated with Claude Code
🤖 Reviewing this PR — the review will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the review runs.
⏳ Held on the OpenCode Go window — the 5-hour window is at 0% (the bot holds at 95%).
This continues automatically after the window resets (2026-09-26T21:41:10.767Z) — nothing to re-trigger.
@claude /review — security-relevant Spaces hardening (permissioned data, proposal 0016). The diff will be truncated, so please read these in full from the branch:
src/ATProtoNet.Server/Spaces/SpaceCredentialVerifier.cs(the new credential cache keyed by SHA-256(jwt): the per-request exp/subject/jti checks must still run on a cache hit, and cache lifetime ≤ min(exp, DID-cache TTL)),SpaceDidResolution.cs(a malformed entry is an error, an absent one falls back),JsonWebKeyVerifier.cs+SpaceClientAttestationVerifier.cs(client-key/JWKS caching per client_id, DPoP ES256 only, no ath on the credential exchange),SpaceServiceAuthVerifier.cs,ISpaceAccountSigner.csand its use inSpaceWriteNotifier/SimpleSpaceAccessPolicy,src/ATProtoNet/Spaces/SpaceCredentialProvider.cs(per-space single-flight, disposal of superseded credentials),SpaceRepoCarverification, the EF stores' update-first upserts, and theLexiconTypeRegistry/AtProtoUnionConverterclosed-union change. One point to judge in particular: an authority that signs credentials with a dedicated#atproto_spacekey signs its fallback service auth with that same key under the default#atprotokid. Is that a bug that should be refused or warned about at startup? Focus on cache-induced auth bypasses (revocation, key rotation, a token reused across subjects), token and kid confusion, and DoS bounds on the caches.🤖 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 is a large, carefully-documented hardening/perf pass on the Spaces implementation, and the visible portion of the diff (the PR diff was truncated, so some referenced files —
SpaceDidResolution,SpacePaging,SpaceRepoCar,LexiconTypeRegistry, the open-union plumbing — weren't shown and couldn't be fully verified) is consistent, internally coherent, and matches the PR description closely.What I checked closely and found sound:
DPoPProofValidator: ES256 pinning happens before signature verification; the exchange-proofathprohibition is enforced both ways (missing when expected, and rejected when present but not expected). TheValidateAsync→ValidateWithHashAsyncsplit correctly hoistsDPoP.AccessTokenHashcomputation for the credential-cache case.SpaceCredentialVerifier: the cache-hit path still re-checks the requested space, re-validates expiry against the live clock, and still runs the full DPoP proof/jticheck every request — matching the stated "only the signature check is skipped" design. The cache TTL is correctly capped atmin(exp, now + StaleAfter), so a rotated authority key stops verifying cached credentials no later than fresh ones. The capacity-full behavior (verify-per-request rather than evict) matches the docs.JsonWebKeyVerifier: algorithm-vs-curve pinning happens before any cache lookup or import, so an unusable key never pollutes the cache; the thumbprint (which coversx/y) makes cache-key collisions cryptographically implausible, and the off-curve-Y protection is preserved because the ECDSA import (which validates the point) happens before the did:key is cached.SpaceClientAttestationVerifier: the cached-keys → refusal → forced refetch (rate-limited to 30s) → recheck flow correctly avoids both stale-key lockout and refetch-per-forged-attestation.ISpaceAccountSigner/SpaceAccountSigning.ChooseAsync: fallback-to-service-key behavior on null signer, wrong-DID signer, exceptions, and same-DID short-circuit all match the description.RecordWriteAsyncvia the same<=/>=boundary check duplicated between the SQLWHEREand the fallbackMutateAsyncpath).One thing worth a sanity check post-merge rather than blocking:
RecordWriteAsync'sExecuteUpdateAsyncguard usesstring.Compare(e.Rev, revValue) <= 0inside the LINQ predicate. Two-argumentstring.Compare/CompareToagainst a constant is a documented, provider-supported EF Core translation pattern on relational providers (confirmed via search), so this should translate rather than throw or fall back to client evaluation — but it's exactly the kind of thing where provider/collation quirks (Postgres vs SQL Server vs SQLite) bite in practice, so it's worth confirming the EF integration tests actually exercise this against a real relational provider (not just that they compile) if they haven't already run in CI.No correctness or security issues found in the reviewed portion.
⏱ 2m 46s · 4 turns · tokens: 90.3k in / 15.1k out (+349.0k cache-read)
📜 Full session transcript
🤖 Resolving the merge conflicts — the reply will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the fix runs.
⏳ Held on the OpenCode Go window — the 5-hour window is at 0% (the bot holds at 95%).
This continues automatically after the window resets (2026-09-26T21:57:27.503Z) — nothing to re-trigger.
0a000952b95034dc04de@claude /review — re-review please. The branch was rebased onto main (after #171, CHANGELOG-only conflicts). Nine new commits (6f12df9..5034dc0) on top of the approved patch fix the adversarial review's findings; see "Review fixes" in the description. Please read those commits in full (
git diff 2cc2dce..5034dc0), especially:serviceAuthKey,ISpaceAccountSigneralso consulted for the service's own DID, and the startup failure;notifyWriterequiringiss == repo, withIsRepoHostAsyncremoved;SpaceCredentialProviderfailure sharing and dispose races;ExecuteUpdate … WHERE Rev == read, with retries);SpaceRecordCborstrict validation and the boundedgetRepo.🤖 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.
🤖 Reviewing this PR — the review will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the review runs.
⏳ Held on the OpenCode Go window — the 5-hour window is at 0% (the bot holds at 95%).
This continues automatically after the window resets (2026-09-27T01:47:27.404Z) — nothing to re-trigger.
✅ Verdict: approve — no blocking issues found.
Re-review after the "Review fixes" commits. The one non-blocking note from my last pass — whether
RecordWriteAsync's revision comparison would translate/behave correctly across relational providers — is now fully resolved:EfCoreSpaceAuthorityStore.RecordWriteAsyncreads the currentRevfirst, compares it ordinally in C#, and advances with a plainExecuteUpdate(... WHERE Rev == <read>), retrying up to 5 times and throwingDbUpdateConcurrencyExceptionif it can't win. No SQL-side string comparison remains, so provider/collation quirks are no longer a concern.I checked out the PR branch directly (the diff in the description was truncated) and read the full, current versions of the files most central to this pass's security claims:
JsonWebKeyVerifier: algorithm/curve pinning happens before the thumbprint cache lookup; the cache key is the RFC 7638 thumbprint (DPoP.Thumbprint, same computationDPoPProofValidatoruses), so an off-curve-Y key can never collide with a valid same-X key in the cache — the thumbprint coversx/yand a collision would require breaking SHA-256. TheImportvalidation (which catches the off-curve point) still runs before anything is cached.SpaceCredentialVerifier: cache-hit path still re-checks the requested space, re-validates the authority's current key against the cachedDidKey/Document, and always re-runs the full DPoP proof/jticheck. LRU + per-authority quota eviction (Remember/RemoveLocked) is bookkept consistently.SpaceClientAttestationVerifier: cached-key → refusal → rate-limited (30s) forced refetch → recheck flow is implemented as described, with shared in-flight fetches viaLazy<Task<...>>keyed per client.SpaceAuthority.GetSigningKey/GetHostEndpoint: correctly distinguishAbsent(falls back to#atproto/#atproto_pds) fromMalformed(throwsFormatException), built onDidDocument.TryGetVerificationKey/TryGetServiceEndpoint, which already reportMalformedfor a wrong service type or non-absolute/wrong-scheme endpoint.SpaceRepoCar: index-key parsing now requires exactly one/(ParsePath), index CIDs are required to be DAG-CBOR-coded, andSpaceRecordCbor.TryValidateRecord(new) enforces canonical DAG-CBOR (definite lengths, text-only keys, no floats, tag 42-only over a0x00-prefixed byte string) viaCborReaderinCanonicalmode.LexiconTypeRegistry.RegisterUnionVariantnow throws for a closed union base, andAtProtoUnionConverter<TBase>no longer consults the registry whenshape.Closed— matches the described G22/closed-union-registration behavior exactly.NotifyWriteEndpoint: now requirescaller.Issuer == repo(theIsRepoHostAsync/document-based check is gone), matching the reference authority's rule and the item 1/6/8/10a fix description.SpaceCredentialProvider: single-flight mint per space viaLazy<Task<...>>, double-check-after-await to skip a redundant mint, and superseded-credential retention/disposal under one lock all behave as documented;Disposecancels an in-flight mint and disposes a credential that completes anyway.Everything I inspected is internally consistent, matches the (very detailed) PR description, and no new correctness or security issues turned up. Nothing here blocks merging.
⏱ 3m 32s · 23 turns · tokens: 137.3k in / 12.4k out (+2793.6k cache-read)