Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs/05-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,28 @@ No auth needed for:

Public requests skip all verification middleware — no JWT parsing, no DID-doc fetch. Fast path.

## Trust assumptions

Two trust assumptions in the binding layer cannot be closed at the Contrail layer alone. They are listed here so operators picking a binding strategy or onboarding flow can size them against their own threat model.

### DID-doc binding path

The record host can resolve a space's authority from three sources (see [Spaces § Discovery](./06-spaces.md#discovery--binding-resolution)):

- **Local enrollment** — the host's own `record_host_enrollments` table, written via `recordHost.enroll`. Owner-signed; the host has full control over what's stored.
- **PDS record** — read from the owner's PDS at the space URI.
- **DID-doc service entry** — read from `service[id="#atproto_space_authority"]` on the owner's DID doc.

The DID-doc path inherits PLC's authorization model: any rotation key on the owner's account can submit an update op rewriting the service entry. There is no per-entry signature or "this entry can only be edited by key X" constraint at the PLC layer.

If the integrity of the space-authority binding needs to exceed what any one of the owner's rotation keys can already do, configure the host to resolve via local enrollment instead, or wait for an upstream signed-binding mechanism. Tracked upstream as [flo-bit/contrail#38](https://github.com/flo-bit/contrail/issues/38).

### Contrail-held PDS app password

Deployments running `community.provision` or `community.adopt` necessarily hold an ATProto app password for the user-owned PDS account so Contrail can write on the community's behalf. ATProto app passwords are unscoped at the PDS layer — they can write any record to the repo, including the `tools.atmo.space.declaration` record (or its embedded equivalent on a space-type record) that drives binding decisions in `createPdsBindingResolver`.

This is an intentional consequence of the managed-community model, not a bug. Operators should treat stored app passwords with the same care as rotation keys, and reach for scoped app passwords (when ATProto adds them) or signed binding records to tighten the model further. Tracked upstream as [flo-bit/contrail#39](https://github.com/flo-bit/contrail/issues/39).

## How the pieces fit

A typical flow for a third-party app acting as a user in a space:
Expand Down
9 changes: 9 additions & 0 deletions docs/10-deployment-shapes.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,15 @@ A deployment can act as the authority for spaces it owns *and* a record host for

When in doubt, all-in-one. Splitting is for when you have a real operational reason to separate the two — different teams running them, different latency profiles, different scaling targets, different governance.

## Known trust assumptions

Two assumptions in the binding layer cannot be closed at the Contrail layer alone, and which one applies depends on the shape you pick:

- Deployments wiring `createDidDocBindingResolver` inherit PLC's rotation-key authorization model for the `#atproto_space_authority` service entry. Tracked as [flo-bit/contrail#38](https://github.com/flo-bit/contrail/issues/38).
- Deployments running `community.provision` or `community.adopt` hold an unscoped ATProto app password for each provisioned PDS account. Tracked as [flo-bit/contrail#39](https://github.com/flo-bit/contrail/issues/39).

See [Auth § Trust assumptions](./05-auth.md#trust-assumptions) for the constraints in detail and what binding source to prefer when those assumptions don't fit your threat model.

## What's not here

- **Authority migration** — moving a space's authority from DID A to DID B. The architecture supports it (re-enroll on the host with the new authority binding) but no helper API yet.
Expand Down
11 changes: 1 addition & 10 deletions packages/contrail-appview/src/core/spaces/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,7 @@ import { HostedAdapter } from "./adapter";
import {
buildVerifier,
createBindingCredentialVerifier,
createCompositeBindingResolver,
createEnrollmentBindingResolver,
createLocalBindingResolver,
createLocalKeyResolver,
createServiceAuthMiddleware,
} from "@atmo-dev/contrail-base";
Expand Down Expand Up @@ -71,18 +69,11 @@ export function registerSpacesRoutes(
);

if (spacesConfig.recordHost) {
// Default in-process verifier: enrollment is the canonical binding
// source; Local-binding is a fallback for spaces created but not yet
// enrolled. Caller overrides via `options.credentialVerifier` to
// accept external authorities.
const credentialVerifier =
options.credentialVerifier ??
(authorityConfig.signing
? createBindingCredentialVerifier({
bindings: createCompositeBindingResolver([
createEnrollmentBindingResolver({ recordHost: adapter }),
createLocalBindingResolver({ authorityDid: authorityConfig.serviceDid }),
]),
bindings: createEnrollmentBindingResolver({ recordHost: adapter }),
keys: createLocalKeyResolver({
authorityDid: authorityConfig.serviceDid,
publicKey: authorityConfig.signing.publicKey,
Expand Down
12 changes: 9 additions & 3 deletions packages/contrail-base/src/spaces/binding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,16 @@ export function createPdsBindingResolver(args: {
}
if (!res.ok) return null;
const body = (await res.json().catch(() => null)) as
| { value?: { authority?: unknown } }
| { value?: { $type?: unknown; authority?: unknown; createdAt?: unknown } }
| null;
const authority = body?.value?.authority;
return typeof authority === "string" && authority.startsWith("did:") ? authority : null;
const value = body?.value;
if (!value) return null;
if (value.$type !== parts.type) return null;
if (typeof value.createdAt !== "string") return null;
const authority = value.authority;
if (typeof authority !== "string") return null;
if (!/^did:(plc|web):[a-zA-Z0-9._:%-]+(#[a-zA-Z0-9._-]+)?$/.test(authority)) return null;
return authority;
},
};
}
Expand Down
6 changes: 2 additions & 4 deletions packages/contrail-record-host/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,9 @@ export function registerRecordHostRoutes(
if (!parts) {
return c.json({ error: "InvalidRequest", reason: "malformed-uri" }, 400);
}
const callerIsOwner = sa.issuer === parts.ownerDid;
const callerIsAuthority = sa.issuer === body.authority;
if (!callerIsOwner && !callerIsAuthority) {
if (sa.issuer !== parts.ownerDid) {
return c.json(
{ error: "Forbidden", reason: "not-owner-or-authority" },
{ error: "Forbidden", reason: "not-owner" },
403
);
}
Expand Down
66 changes: 66 additions & 0 deletions packages/contrail/tests/spaces-binding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,72 @@ describe("BindingResolver — PDS record", () => {
});
expect(await r.resolveAuthority(SPACE_URI)).toBeNull();
});

it("returns null when the record's $type doesn't match the URI's type", async () => {
const fetch = mockFetch(
new Map([
[
"https://pds.test/xrpc/com.atproto.repo.getRecord",
{
value: {
$type: "com.attacker.fake.type",
authority: "did:web:authority.example",
createdAt: "2026-04-30T00:00:00Z",
},
},
],
])
);
const r = createPdsBindingResolver({
resolver: mockResolver({ pdsEndpoint: "https://pds.test" }),
fetch,
});
expect(await r.resolveAuthority(SPACE_URI)).toBeNull();
});

it("returns null when createdAt is missing or non-string", async () => {
const fetch = mockFetch(
new Map([
[
"https://pds.test/xrpc/com.atproto.repo.getRecord",
{
value: {
$type: "com.example.event.space",
authority: "did:web:authority.example",
// createdAt deliberately omitted
},
},
],
])
);
const r = createPdsBindingResolver({
resolver: mockResolver({ pdsEndpoint: "https://pds.test" }),
fetch,
});
expect(await r.resolveAuthority(SPACE_URI)).toBeNull();
});

it("returns null when authority isn't a well-formed DID", async () => {
const fetch = mockFetch(
new Map([
[
"https://pds.test/xrpc/com.atproto.repo.getRecord",
{
value: {
$type: "com.example.event.space",
authority: "did:fake!!://garbage",
createdAt: "2026-04-30T00:00:00Z",
},
},
],
])
);
const r = createPdsBindingResolver({
resolver: mockResolver({ pdsEndpoint: "https://pds.test" }),
fetch,
});
expect(await r.resolveAuthority(SPACE_URI)).toBeNull();
});
});

describe("BindingResolver — DID-doc service entry", () => {
Expand Down
46 changes: 39 additions & 7 deletions packages/contrail/tests/spaces-enrollment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ describe("auto-enrollment via createSpace", () => {
expect(((await reenroll.json()) as any).ok).toBe(true);
});

it("non-owner / non-authority callers cannot enroll", async () => {
it("non-owner callers cannot enroll", async () => {
const app = await makeApp();
const create = await call(app, "POST", "/xrpc/test.enroll.space.createSpace", ALICE, {});
const uri = ((await create.json()) as any).space.uri;
Expand All @@ -137,7 +137,7 @@ describe("auto-enrollment via createSpace", () => {
authority: SERVICE_DID,
});
expect(res.status).toBe(403);
expect((await res.json()).reason).toBe("not-owner-or-authority");
expect((await res.json()).reason).toBe("not-owner");
});
});

Expand Down Expand Up @@ -369,10 +369,10 @@ describe("split deployment — authority and record host on separate apps", () =
expect((await list.json()).reason).toBe("not-enrolled");
});

it("the declared authority can enroll a space without the owner's involvement", async () => {
// Scenario: the authority service ('Contrail' in our naming) is a
// separate DID and acts on behalf of an owner. The authority can enroll
// because phase 5 accepts either owner-or-authority.
it("a third party cannot enroll a space by claiming to be the authority", async () => {
// Regression: the enroll handler used to accept either owner-signed
// OR authority-self-attested calls. That let any DID claim "I am the
// authority for ats://<victim>/..." and rebind the space.
const authorityDb = createSqliteDatabase(":memory:");
const hostDb = createSqliteDatabase(":memory:");
const cfg: ContrailConfig = {
Expand All @@ -392,11 +392,43 @@ describe("split deployment — authority and record host on separate apps", () =
const create = await call(authorityApp, "POST", "/xrpc/test.split.space.createSpace", ALICE, {});
const uri = ((await create.json()) as any).space.uri;

// Authority service identifies itself via the JWT issuer matching its DID.
// SERVICE_DID self-attests as the authority for Alice's space.
// Must be rejected — only the owner can enroll.
const enroll = await call(hostApp, "POST", "/xrpc/test.split.recordHost.enroll", SERVICE_DID, {
spaceUri: uri,
authority: SERVICE_DID,
});
expect(enroll.status).toBe(403);
expect((await enroll.json()).reason).toBe("not-owner");
});

it("the owner can enroll their space designating a separate authority", async () => {
// Positive: the owner-signed path lets Alice point her space at the
// configured authority service (the canonical split-deployment flow).
const authorityDb = createSqliteDatabase(":memory:");
const hostDb = createSqliteDatabase(":memory:");
const cfg: ContrailConfig = {
namespace: "test.split",
collections: { message: { collection: "app.event.message" } },
spaces: {
authority: { type: "tools.atmo.event.space", serviceDid: SERVICE_DID, signing: SIGNING },
recordHost: {},
},
};
const resolved = resolveConfig(cfg);
await initSchema(authorityDb, resolved);
await initSchema(hostDb, resolved);

const authorityApp = buildAuthorityApp(authorityDb);
const hostApp = buildRecordHostApp(hostDb);
const create = await call(authorityApp, "POST", "/xrpc/test.split.space.createSpace", ALICE, {});
const uri = ((await create.json()) as any).space.uri;

const enroll = await call(hostApp, "POST", "/xrpc/test.split.recordHost.enroll", ALICE, {
spaceUri: uri,
authority: SERVICE_DID,
});
expect(enroll.status).toBe(200);
expect(((await enroll.json()) as any).ok).toBe(true);
});
});
Loading