Skip to content

fix(spaces): owner-signed enroll, enrollment-only default binding, declaration validation#40

Merged
flo-bit merged 4 commits into
flo-bit:feat/another-big-refactorfrom
tompscanlan:tompscanlan/pr30-trio-fixes
May 9, 2026
Merged

fix(spaces): owner-signed enroll, enrollment-only default binding, declaration validation#40
flo-bit merged 4 commits into
flo-bit:feat/another-big-refactorfrom
tompscanlan:tompscanlan/pr30-trio-fixes

Conversation

@tompscanlan
Copy link
Copy Markdown
Collaborator

Three concrete fixes against feat/another-big-refactor (HEAD c9425dc), 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 enrollment

The recordHost.enroll handler accepted either an owner-signed call OR a self-attested authority call (the callerIsAuthority branch in routes.ts). The latter let any DID call enroll claiming authority: did:plc:eve for 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 via body.authority; only the legitimacy of the caller changes.

The existing test at tests/spaces-enrollment.test.ts:372-401 codified 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 composite

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. A space could be created without enrollment and still accept credentials from the configured authority.

Default to Enrollment only. createLocalBindingResolver remains exported for explicit single-tenant wiring via options.credentialVerifier. Existing split-deployment tests already cover the no-fallback behavior.

Commit 3 — fix(spaces): schema-validate PDS-record declarations

createPdsBindingResolver 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 based on record content alone — no signature verification on the binding fields.

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|web) regex

Fail closed on any deviation. Three negative tests added covering each path.

Commit 4 — docs: trust-assumption sections

Two assumptions in the binding layer cannot be closed at the Contrail layer alone:

Both are documented as constraints (in docs/05-auth.md and docs/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 in tests/search.test.ts unchanged by this PR.)
  • Built all packages: pnpm --filter './packages/*' run build clean.
  • Reviewer sanity-check the strict-DID regex in commit 3 against any expected-but-rejected DID forms in your test corpus.
  • Reviewer confirm the trust-assumption framing in docs/05-auth.md matches how you'd describe these to operators.

Out of scope

Other findings from the same review (different aud semantics, 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.

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).
@flo-bit flo-bit merged commit 180d97f into flo-bit:feat/another-big-refactor May 9, 2026
1 check passed
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.

2 participants