fix(spaces): owner-signed enroll, enrollment-only default binding, declaration validation#40
Merged
flo-bit merged 4 commits intoMay 9, 2026
Conversation
The recordHost.enroll endpoint accepted either an owner-signed call OR a self-attested authority call. The latter let any DID claim to be the authority for any space's URI and rebind that space's authority. Drop the self-attesting-authority branch. Require the caller to be the space owner (sa.issuer === parts.ownerDid). The owner is still free to designate any authority via body.authority — only the legitimacy of the caller changes. Updates the existing test that codified the old behavior to assert the new rejection, and adds a positive test for the canonical owner-driven split-deployment enrollment flow.
The default in-process credential verifier composed [Enrollment, Local], so a never-enrolled space still resolved its authority via LocalBindingResolver — collapsing the host-side consent layer the enrollment table is meant to provide. Default to enrollment-only. createLocalBindingResolver remains exported for explicit single-tenant wiring via options.credentialVerifier.
The PDS binding resolver trusted body.value.authority on any record
returned for the space's URI, with only a startsWith('did:') sniff. A
malformed or attacker-shaped record at the same NSID could rebind
authority — record content alone, no signature verification.
Validate before trusting:
- $type must equal the URI's type (the space-type lexicon embeds the
declaration fields per tools.atmo.space.declaration's contract)
- createdAt must be a string
- authority must match a strict did:plc | did:web regex
Fail closed on any deviation. Adds three negative tests covering each
deviation path.
Two trust assumptions in the binding layer cannot be closed at the Contrail layer alone: - DID-doc service-entry rebind: deployments using createDidDocBindingResolver inherit PLC's rotation-key authorization model. Any rotation key on the owner's account can rewrite the #atproto_space_authority service entry. - PDS app password held for managed communities: community.provision and community.adopt hold unscoped app passwords that can write any record on the user-owned PDS, including the binding declaration read by createPdsBindingResolver. Both are constraints of their respective binding sources and onboarding flows, not bugs. Document each so operators picking a binding strategy can size them against their own threat model, with pointers to the canonical issues for the upstream/protocol-level work that would close them: flo-bit#38 (DID-doc), flo-bit#39 (PDS app password).
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.
Three concrete fixes against
feat/another-big-refactor(HEADc9425dc), plus documentation for two trust assumptions whose actual fixes live outside Contrail. Each commit is independent and has its own test coverage.Commit 1 —
fix(record-host): require owner-signed enrollmentThe
recordHost.enrollhandler accepted either an owner-signed call OR a self-attested authority call (thecallerIsAuthoritybranch inroutes.ts). The latter let any DID call enroll claimingauthority: did:plc:evefor someone else's space URI and rebind that space's authority — credentials minted by Eve would then verify, since the host's binding now points at her.Drop the self-attesting branch. Require
sa.issuer === parts.ownerDid. The owner remains free to designate any authority viabody.authority; only the legitimacy of the caller changes.The existing test at
tests/spaces-enrollment.test.ts:372-401codified the broken behavior — replaced with a negative assertion (the authority service alone cannot enroll Alice's space) and a positive test for the canonical owner-driven split-deployment flow.Commit 2 —
fix(spaces): drop LocalBindingResolver from default compositeThe default in-process credential verifier composed
[Enrollment, Local], so a never-enrolled space still resolved its authority viaLocalBindingResolver— collapsing the host-side consent layer the enrollment table is meant to provide. A space could be created without enrollment and still accept credentials from the configured authority.Default to
Enrollmentonly.createLocalBindingResolverremains exported for explicit single-tenant wiring viaoptions.credentialVerifier. Existing split-deployment tests already cover the no-fallback behavior.Commit 3 —
fix(spaces): schema-validate PDS-record declarationscreatePdsBindingResolvertrustedbody.value.authorityon any record returned for the space's URI, with only astartsWith('did:')sniff. A malformed or attacker-shaped record at the same NSID could rebind authority based on record content alone — no signature verification on the binding fields.Validate before trusting:
$typemust equal the URI's type (the space-type lexicon embeds the declaration fields pertools.atmo.space.declaration's contract)createdAtmust be a stringauthoritymust match a strictdid:(plc|web)regexFail closed on any deviation. Three negative tests added covering each path.
Commit 4 —
docs: trust-assumption sectionsTwo assumptions in the binding layer cannot be closed at the Contrail layer alone:
createDidDocBindingResolverinherit PLC's rotation-key authorization model. Any rotation key on the owner's account can rewrite the#atproto_space_authorityservice entry — there's no per-entry signature constraint at the PLC layer. Tracked as DID-doc service-entry rebind: any rotation key can silently reassign a space's authority #38.community.provisionandcommunity.adoptnecessarily hold an unscoped ATProto app password on the user-owned PDS. That password can write any record, including the binding declarationcreatePdsBindingResolverreads. This is an intentional consequence of the managed-community model, but the docs hadn't named it. Tracked as Document trust assumption: community.provision and community.adopt leave Contrail holding full-PDS-write authority #39.Both are documented as constraints (in
docs/05-auth.mdanddocs/10-deployment-shapes.md) so operators picking a binding strategy or onboarding flow can size them against their own threat model. Each section names the alternative binding source (local enrollment) and points at the upstream issue tracking the fix.Test plan
pnpm --filter @atmo-dev/contrail run test— 317 passing, 2 skipped. (7 pre-existing failures intests/search.test.tsunchanged by this PR.)pnpm --filter './packages/*' run buildclean.docs/05-auth.mdmatches how you'd describe these to operators.Out of scope
Other findings from the same review (different
audsemantics, URI ownership freeze, one-authority-per-owner enforcement, record-host discovery, reads-conditioning, enrollment lifecycle gaps, multi-organizer co-edit) are deferred — they need either spec discussion, lexicon coordination, or larger feature work, and don't fit the "fix the holes in this PR" shape.