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
11 changes: 11 additions & 0 deletions .changeset/bright-remotes-verify.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@enbox/dwn-sdk-js": patch
"@enbox/agent": patch
"@enbox/api": patch
---

Authenticate protocol configurations used for remote encryption-policy resolution and record artifacts returned through app-facing remote query, read, and initial subscription snapshot calls, bind record results to the original request filter, and verify inline or streamed record bytes against their signed CID and size. Remote protocol definitions used for encryption policy must now be signed directly by the target DID. Anonymous subscriptions now use the current transport request shape, and lazy read-only records reject data from a different record version.

These checks authenticate returned artifacts; they do not prove result completeness or freshness because DWN query replies do not yet carry a tenant-authenticated state commitment. `RecordsCount` replies carry no signed artifacts, so their aggregate values remain assertions by the remote DWN. Initial `RecordsSubscribe` snapshots are verified, but subsequent live events remain outside this response-verification boundary.

Streamed reads are authenticated at successful end-of-stream, so callers can observe chunks before the final CID check completes. Integrity-sensitive consumers that cannot tolerate an unauthenticated prefix must buffer the stream through successful completion before using its bytes.
4 changes: 3 additions & 1 deletion .changeset/remove-social-protocols.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@
"@enbox/protocols": patch
---

Remove `SocialGraphProtocol`, `StatusProtocol`, and `ListsProtocol` from the shipped catalog together with their definitions, data types, codecs, and JSON Schemas. Profile no longer composes with Social Graph and no longer exposes `PrivateNoteData` or the friend-gated `privateNote` path. No compatibility aliases are retained.
Remove `SocialGraphProtocol`, `StatusProtocol`, and `ListsProtocol` from the shipped catalog together with their definitions, data types, codecs, and JSON Schemas. The removed Social Graph definition allowed anyone to create a `friend` role record, while current role invocation accepts a matching role recipient without independently binding the role to a trusted issuer. A caller could therefore self-assign friend authority and reach friend-gated data in protocols composed with that role. Profile no longer composes with Social Graph and no longer exposes `PrivateNoteData` or the friend-gated `privateNote` path.

This removal does not change engine role verification, uninstall definitions, revoke existing role records, or migrate stored data. Applications that installed these protocols should stop treating their role-gated records as confidential, install a corrected protocol under a new URI, migrate and re-encrypt sensitive records, and retire the old records. No replacement or compatibility aliases are provided.
48 changes: 25 additions & 23 deletions packages/agent/src/anonymous-dwn-api.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { DidUrlDereferencer } from '@enbox/dids';
import type { DidResolver, DidUrlDereferencer } from '@enbox/dids';
import type { DwnRpcRequest, EnboxRpc } from '@enbox/dwn-clients';

import type {
DateSort,
GenericMessage,
Pagination,
ProtocolsQueryFilter,
ProtocolsQueryReply,
Expand All @@ -13,18 +15,18 @@ import type {
RecordsSubscribeReply,
SubscriptionListener,
} from '@enbox/dwn-sdk-js';
import type { DwnRpcRequest, EnboxRpc } from '@enbox/dwn-clients';

import { ProtocolsQuery, RecordsCount, RecordsQuery, RecordsRead, RecordsSubscribe } from '@enbox/dwn-sdk-js';

import { getDwnServiceEndpointUrls } from './utils.js';
import { verifyRemoteDwnResponse } from './remote-dwn-response.js';
import { getDwnServiceEndpointUrls, resolveDwnSubscriptionUrl } from './utils.js';

/**
* Parameters for constructing an {@link AnonymousDwnApi}.
*/
export type AnonymousDwnApiParams = {
/** A DID URL dereferencer for resolving target DID service endpoints. */
didResolver: DidUrlDereferencer;
/** Resolver used for target DWN discovery and returned-message authentication. */
didResolver: DidResolver & DidUrlDereferencer;
/** An RPC client for sending messages to remote DWNs. */
rpcClient: EnboxRpc;
};
Expand Down Expand Up @@ -98,7 +100,7 @@ export type AnonymousProtocolsQueryParams = {
* ```
*/
export class AnonymousDwnApi {
private readonly _didResolver: DidUrlDereferencer;
private readonly _didResolver: DidResolver & DidUrlDereferencer;
private readonly _rpcClient: EnboxRpc;

constructor({ didResolver, rpcClient }: AnonymousDwnApiParams) {
Expand Down Expand Up @@ -219,40 +221,40 @@ export class AnonymousDwnApi {
*/
private async sendRequest<TReply>(
target: string,
message: unknown,
message: GenericMessage,
data?: Blob,
subscriptionHandler?: SubscriptionListener,
): Promise<TReply> {
const dwnEndpointUrls = await getDwnServiceEndpointUrls(target, this._didResolver);
const errorMessages: { url: string; message: string }[] = [];

for (let dwnUrl of dwnEndpointUrls) {
for (const endpointUrl of dwnEndpointUrls) {
try {
// For subscriptions, upgrade to WebSocket transport if available.
if (subscriptionHandler !== undefined) {
const serverInfo = await this._rpcClient.getServerInfo(dwnUrl);
if (!serverInfo.webSocketSupport) {
errorMessages.push({ url: dwnUrl, message: 'WebSocket support is not enabled on the server.' });
continue;
}

const parsedUrl = new URL(dwnUrl);
parsedUrl.protocol = parsedUrl.protocol === 'http:' ? 'ws:' : 'wss:';
dwnUrl = parsedUrl.toString();
}
const dwnUrl = subscriptionHandler === undefined
? endpointUrl
: await resolveDwnSubscriptionUrl(endpointUrl, this._rpcClient);

const reply = await this._rpcClient.sendDwnRequest({
dwnUrl,
targetDid : target,
targetDid : target,
message,
data,
subscriptionHandler : subscriptionHandler,
subscription : subscriptionHandler === undefined ? undefined : {
handler: subscriptionHandler,
},
} as DwnRpcRequest);

await verifyRemoteDwnResponse({
didResolver : this._didResolver,
message,
reply,
targetDid : target,
});

return reply as TReply;
} catch (error: unknown) {
errorMessages.push({
url : dwnUrl,
url : endpointUrl,
message : (error instanceof Error) ? error.message : 'Unknown error',
});
}
Expand Down
66 changes: 26 additions & 40 deletions packages/agent/src/dwn-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,10 @@ import type {

import { DwnDiscoveryFile } from './dwn-discovery-file.js';
import { PermissionGrantNotFoundError } from './permissions-api.js';
import { verifyRemoteDwnResponse } from './remote-dwn-response.js';
import { DEFAULT_LOCAL_DWN_STRATEGY, LocalDwnDiscovery } from './local-dwn.js';
import { DwnInterface, dwnMessageConstructors } from './types/dwn.js';
import { getDwnServiceEndpointUrls, isRecordsWrite } from './utils.js';
import { getDwnServiceEndpointUrls, isRecordsWrite, resolveDwnSubscriptionUrl } from './utils.js';

// Re-export DWN type guards from the agent API surface.
export { isDwnMessage, isDwnRequest, isMessagesPermissionScope, isRecordPermissionScope, isRecordsType } from './dwn-type-guards.js';
Expand Down Expand Up @@ -689,6 +690,8 @@ export class AgentDwnApi {
message,
data : dataStream,
subscriptionHandler,
// The configured local DWN already verifies artifacts on admission; this check is for untrusted remote replies.
verifyResponse : false,
resubscribeFactory : subscriptionHandler === undefined
? undefined
: this.createResubscribeFactory(request, 'local'),
Expand Down Expand Up @@ -917,6 +920,8 @@ export class AgentDwnApi {
dwnEndpointUrls : [this._localDwnEndpoint!],
message : message as DwnMessage[DwnInterface],
data : options?.dataStream,
// The configured local DWN already verifies artifacts on admission; this check is for untrusted remote replies.
verifyResponse : false,
});
}

Expand Down Expand Up @@ -1006,12 +1011,13 @@ export class AgentDwnApi {

// Send the RPC request to the target DID's DWN service endpoint using the Agent's RPC client.
const reply = await this.sendDwnRpcRequest({
targetDid: request.target,
targetDid : request.target,
dwnEndpointUrls,
message,
data,
subscriptionHandler,
resubscribeFactory,
verifyResponse : true,
});
this.invalidateAcceptedProtocolDefinition(request.target, message, reply.status.code);

Expand All @@ -1038,43 +1044,16 @@ export class AgentDwnApi {
};
}

/**
* Resolves the URL to send a subscription request to: upgrades `dwnUrl` to a
* WebSocket transport when the server supports it (unsecured `ws` for `http`,
* secured `wss` for `https`), or records an error and returns `undefined` to
* signal that this endpoint should be skipped.
*/
private async resolveSubscriptionDwnUrl(
dwnUrl: string,
errorMessages: { url: string, message: string }[],
): Promise<string | undefined> {
// we get the server info to check if the server supports WebSocket for subscription requests
const serverInfo = await this.agent.rpc.getServerInfo(dwnUrl);
if (!serverInfo.webSocketSupport) {
// If the server does not support WebSocket, add an error message and continue to the next URL.
errorMessages.push({
url : dwnUrl,
message : 'WebSocket support is not enabled on the server.'
});
return undefined;
}

// If the server supports WebSocket, replace the subscription URL with a socket transport.
// For `http` we use the unsecured `ws` protocol, and for `https` we use the secured `wss` protocol.
const parsedUrl = new URL(dwnUrl);
parsedUrl.protocol = parsedUrl.protocol === 'http:' ? 'ws:' : 'wss:';
return parsedUrl.toString();
}

private async sendDwnRpcRequest<T extends DwnInterface>({
targetDid, dwnEndpointUrls, message, data, subscriptionHandler, resubscribeFactory
targetDid, dwnEndpointUrls, message, data, subscriptionHandler, resubscribeFactory, verifyResponse
}: {
targetDid: string;
dwnEndpointUrls: string[];
message: DwnMessage[T];
data?: DwnRpcData;
subscriptionHandler?: MessageHandler[T];
resubscribeFactory?: ResubscribeFactory;
verifyResponse: boolean;
}
): Promise<DwnMessageReply[T]> {
const errorMessages: { url: string, message: string }[] = [];
Expand All @@ -1084,15 +1063,11 @@ export class AgentDwnApi {
}

// Try sending to author's publicly addressable DWNs until the first request succeeds.
for (let dwnUrl of dwnEndpointUrls) {
for (const endpointUrl of dwnEndpointUrls) {
try {
if (subscriptionHandler !== undefined) {
const subscriptionDwnUrl = await this.resolveSubscriptionDwnUrl(dwnUrl, errorMessages);
if (subscriptionDwnUrl === undefined) {
continue;
}
dwnUrl = subscriptionDwnUrl;
}
const dwnUrl = subscriptionHandler === undefined
? endpointUrl
: await resolveDwnSubscriptionUrl(endpointUrl, this.agent.rpc);

const dwnReply = await this.agent.rpc.sendDwnRequest({
dwnUrl,
Expand All @@ -1105,10 +1080,19 @@ export class AgentDwnApi {
} : undefined,
});

if (verifyResponse) {
await verifyRemoteDwnResponse({
didResolver : this.agent.did,
message,
reply : dwnReply,
targetDid,
});
}

return dwnReply;
} catch (error: any) {
errorMessages.push({
url : dwnUrl,
url : endpointUrl,
message : (error instanceof Error) ? error.message : 'Unknown error',
});
}
Expand Down Expand Up @@ -2562,6 +2546,8 @@ export class AgentDwnApi {
targetDid : author,
dwnEndpointUrls : [this._localDwnEndpoint!],
message : messagesRead.message,
// The configured local DWN already verifies artifacts on admission; this check is for untrusted remote replies.
verifyResponse : false,
});
}

Expand Down
5 changes: 4 additions & 1 deletion packages/agent/src/dwn-protocol-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ type SendDwnRpcRequestFn = <T extends DwnInterface>(params: {
message: DwnMessage[T];
data?: Blob;
subscriptionHandler?: MessageHandler[T];
verifyResponse: boolean;
}) => Promise<DwnMessageReply[T]>;

/** Minimal DWN interface needed for local `processMessage` calls. */
Expand Down Expand Up @@ -107,7 +108,8 @@ export async function getProtocolDefinition(
* Fetches a protocol definition from a **remote** DWN.
*
* Uses an unsigned `ProtocolsQuery` since public protocols can be queried
* anonymously.
* anonymously. The returned configuration must carry a valid signature made
* directly by the target DID before its definition can enter the cache.
*
* @param targetDid - The remote DWN owner
* @param protocolUri - The protocol URI to look up
Expand Down Expand Up @@ -138,6 +140,7 @@ export async function fetchRemoteProtocolDefinition(
targetDid,
dwnEndpointUrls : await getDwnEndpointUrls(targetDid),
message : protocolsQuery.message,
verifyResponse : true,
}) as ProtocolsQueryReply;

if (reply.status.code !== 200 || !reply.entries?.length) {
Expand Down
Loading
Loading