Skip to content

Commit 1fee7a2

Browse files
authored
PermissionGrant Signing (#746)
This is the first in a series of PRs that enable users to use permission grants via the web5 agent. In a subsequent PR these features will be used within `@web5/api` to fetch and cache the grants, and use them accordingly. The same will happen for the `sync manager`. Each higher-level abstraction that calls `processMessage` will manage it's own fetching/caching and using of the grants themselves. Within this PR: - a `granteeDid` optional property has been added to `ProcessDwnRequest` representing the DID that is signing the message and has been granted a permission. - a `signAsOwnerDelegate` optional property has been added to `ProcessDwnRequest` showing intent for signing as an owner using a delegate grant. Some temporary helper methods have been added to the `dwn` api that will eventually move to it's own `permissions` api in a subsequent PR. These include: - `fetchGrants` - `isGrantRevoked` - `createGrant` - `createRevocation` A utility method `matchGrantFromArray` has been added to the general utilities, this may be moved to a static method within the `permissions` api in a subsequent PR.
1 parent 51ac075 commit 1fee7a2

14 files changed

Lines changed: 2052 additions & 58 deletions

.changeset/olive-windows-give.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@web5/agent": patch
3+
"@web5/identity-agent": patch
4+
"@web5/proxy-agent": patch
5+
"@web5/user-agent": patch
6+
---
7+
8+
Apply logic to sign messages with grants, add utils for dealing with grants

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,10 @@
3939
"pnpm": {
4040
"overrides": {
4141
"express@<4.19.2": ">=4.19.2",
42-
"ws@<8.17.1": ">=8.17.1"
42+
"ws@<8.17.1": ">=8.17.1",
43+
"braces@<3.0.3": ">=3.0.3",
44+
"fast-xml-parser@<4.4.1": ">=4.4.1",
45+
"@75lb/deep-merge@<1.1.2": ">=1.1.2"
4346
}
4447
}
4548
}

packages/agent/src/dwn-api.ts

Lines changed: 184 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,45 @@
11
import type { Readable } from '@web5/common';
2-
import type { DwnConfig, GenericMessage } from '@tbd54566975/dwn-sdk-js';
2+
3+
import {
4+
Cid,
5+
DataEncodedRecordsWriteMessage,
6+
DataStoreLevel,
7+
Dwn,
8+
DwnConfig,
9+
DwnMethodName,
10+
EventLogLevel,
11+
GenericMessage,
12+
Message,
13+
MessageStoreLevel,
14+
PermissionGrant,
15+
PermissionScope,
16+
PermissionsProtocol,
17+
RecordsWrite,
18+
ResumableTaskStoreLevel
19+
} from '@tbd54566975/dwn-sdk-js';
320

421
import { NodeStream } from '@web5/common';
522
import { utils as cryptoUtils } from '@web5/crypto';
623
import { DidDht, DidJwk, DidResolverCacheLevel, UniversalResolver } from '@web5/dids';
7-
import { Cid, DataStoreLevel, Dwn, DwnMethodName, EventLogLevel, Message, MessageStoreLevel, ResumableTaskStoreLevel } from '@tbd54566975/dwn-sdk-js';
824

925
import type { Web5PlatformAgent } from './types/agent.js';
10-
import type { DwnMessage, DwnMessageReply, DwnMessageWithData, DwnResponse, DwnSigner, MessageHandler, ProcessDwnRequest, SendDwnRequest } from './types/dwn.js';
26+
import type {
27+
DwnMessage,
28+
DwnMessageInstance,
29+
DwnMessageParams,
30+
DwnMessageReply,
31+
DwnMessageWithData,
32+
DwnRecordsInterfaces,
33+
DwnResponse,
34+
DwnSigner,
35+
MessageHandler,
36+
ProcessDwnRequest,
37+
SendDwnRequest
38+
} from './types/dwn.js';
1139

1240
import { DwnInterface, dwnMessageConstructors } from './types/dwn.js';
1341
import { blobToIsomorphicNodeReadable, getDwnServiceEndpointUrls, isRecordsWrite, webReadableToIsomorphicNodeReadable } from './utils.js';
42+
import { DwnPermissionsUtil } from './dwn-permissions-util.js';
1443

1544
export type DwnMessageWithBlob<T extends DwnInterface> = {
1645
message: DwnMessage[T];
@@ -39,6 +68,14 @@ export function isDwnMessage<T extends DwnInterface>(
3968
return incomingMessageInterfaceName === messageType;
4069
}
4170

71+
export function isRecordsType(messageType: DwnInterface): messageType is DwnRecordsInterfaces {
72+
return messageType === DwnInterface.RecordsDelete ||
73+
messageType === DwnInterface.RecordsQuery ||
74+
messageType === DwnInterface.RecordsRead ||
75+
messageType === DwnInterface.RecordsSubscribe ||
76+
messageType === DwnInterface.RecordsWrite;
77+
}
78+
4279
export class AgentDwnApi {
4380
/**
4481
* Holds the instance of a `Web5PlatformAgent` that represents the current execution context for
@@ -255,10 +292,15 @@ export class AgentDwnApi {
255292
private async constructDwnMessage<T extends DwnInterface>({ request }: {
256293
request: ProcessDwnRequest<T>
257294
}): Promise<DwnMessageWithData<T>> {
295+
// if the request has a granteeDid, ensure the messageParams include the proper grant parameters
296+
if (request.granteeDid && !this.hasGrantParams(request.messageParams)) {
297+
throw new Error('AgentDwnApi: Requested to sign with a permission but no grant messageParams were provided in the request');
298+
}
299+
258300
const rawMessage = request.rawMessage;
259301
let readableStream: Readable | undefined;
260-
261302
// TODO: Consider refactoring to move data transformations imposed by fetch() limitations to the HTTP transport-related methods.
303+
// if the request is a RecordsWrite message, we need to handle the data stream and update the messageParams accordingly
262304
if (isDwnRequest(request, DwnInterface.RecordsWrite)) {
263305
const messageParams = request.messageParams;
264306

@@ -285,23 +327,49 @@ export class AgentDwnApi {
285327
}
286328
}
287329

288-
// Determine the signer for the message.
289-
const signer = await this.getSigner(request.author);
290-
330+
let dwnMessage: DwnMessageInstance[T];
291331
const dwnMessageConstructor = dwnMessageConstructors[request.messageType];
292-
const dwnMessage = rawMessage ? await dwnMessageConstructor.parse(rawMessage) : await dwnMessageConstructor.create({
293-
// TODO: Implement alternative to type assertion.
294-
...request.messageParams!,
295-
signer
296-
});
297332

298-
if (isRecordsWrite(dwnMessage) && request.signAsOwner) {
299-
await dwnMessage.signAsOwner(signer);
333+
// if there is no raw message provided, we need to create the dwn message
334+
if (!rawMessage) {
335+
336+
// If we need to sign as an author delegate or with permissions we need to get the grantee's signer
337+
// The messageParams should include either a permissionGrantId, or a delegatedGrant message
338+
const signer = request.granteeDid ?
339+
await this.getSigner(request.granteeDid) :
340+
await this.getSigner(request.author);
341+
342+
dwnMessage = await dwnMessageConstructor.create({
343+
// TODO: Implement alternative to type assertion.
344+
...request.messageParams!,
345+
signer
346+
});
347+
348+
} else {
349+
dwnMessage = await dwnMessageConstructor.parse(rawMessage);
350+
if (isRecordsWrite(dwnMessage) && request.signAsOwner) {
351+
// if we are signing as owner, we use the author's signer
352+
const signer = await this.getSigner(request.author);
353+
await dwnMessage.signAsOwner(signer);
354+
} else if (request.granteeDid && isRecordsWrite(dwnMessage) && request.signAsOwnerDelegate) {
355+
// if we are signing as owner delegate, we use the grantee's signer and the provided delegated grant
356+
const signer = await this.getSigner(request.granteeDid);
357+
358+
//if we have reached here, the presence of the grant params has already been checked
359+
const messageParams = request.messageParams as DwnMessageParams[DwnInterface.RecordsWrite];
360+
await dwnMessage.signAsOwnerDelegate(signer, messageParams.delegatedGrant!);
361+
}
300362
}
301363

302364
return { message: dwnMessage.message as DwnMessage[T], dataStream: readableStream };
303365
}
304366

367+
private hasGrantParams<T extends DwnInterface>(params?: DwnMessageParams[T]): boolean {
368+
return params !== undefined &&
369+
(('permissionGrantId' in params && params.permissionGrantId !== undefined) ||
370+
('delegatedGrant' in params && params.delegatedGrant !== undefined));
371+
}
372+
305373
private async getSigner(author: string): Promise<DwnSigner> {
306374
// If the author is the Agent's DID, use the Agent's signer.
307375
if (author === this.agent.agentDid.uri) {
@@ -382,4 +450,106 @@ export class AgentDwnApi {
382450

383451
return dwnMessageWithBlob;
384452
}
453+
454+
/**
455+
* NOTE EVERYTHING BELOW THIS LINE IS TEMPORARY
456+
* TODO: Create a `grants` API to handle creating permission requests, grants and revocations
457+
* */
458+
459+
/**
460+
* Performs a RecordsQuery for permission grants that match the given parameters.
461+
*/
462+
public async fetchGrants({ author, target, grantee, grantor }: {
463+
/** author of the query message, defaults to grantee */
464+
author?: string,
465+
/** target of the query message, defaults to author */
466+
target?: string,
467+
grantor: string,
468+
grantee: string
469+
}): Promise<DataEncodedRecordsWriteMessage[]> {
470+
// if no author is provided, use the grantee's DID
471+
author ??= grantee;
472+
// if no target is explicitly provided, use the author
473+
target ??= author;
474+
475+
const { reply: grantsReply } = await this.processRequest({
476+
author,
477+
target,
478+
messageType : DwnInterface.RecordsQuery,
479+
messageParams : {
480+
filter: {
481+
author : grantor, // the author of the grant would be the grantor and the logical author of the message
482+
recipient : grantee, // the recipient of the grant would be the grantee
483+
...DwnPermissionsUtil.permissionsProtocolParams('grant')
484+
}
485+
}
486+
});
487+
488+
if (grantsReply.status.code !== 200) {
489+
throw new Error(`AgentDwnApi: Failed to fetch grants: ${grantsReply.status.detail}`);
490+
}
491+
492+
return grantsReply.entries! as DataEncodedRecordsWriteMessage[];
493+
};
494+
495+
/**
496+
* Check whether a grant is revoked by reading the revocation record for a given grant recordId.
497+
*/
498+
public async isGrantRevoked(author:string, target: string, grantRecordId: string): Promise<boolean> {
499+
const { reply: revocationReply } = await this.processRequest({
500+
author,
501+
target,
502+
messageType : DwnInterface.RecordsRead,
503+
messageParams : {
504+
filter: {
505+
parentId: grantRecordId,
506+
...DwnPermissionsUtil.permissionsProtocolParams('revoke')
507+
}
508+
}
509+
});
510+
511+
if (revocationReply.status.code === 404) {
512+
// no revocation found, the grant is not revoked
513+
return false;
514+
} else if (revocationReply.status.code === 200) {
515+
// a revocation was found, the grant is revoked
516+
return true;
517+
}
518+
519+
throw new Error(`AgentDwnApi: Failed to check if grant is revoked: ${revocationReply.status.detail}`);
520+
}
521+
522+
public async createGrant({ grantedFrom, dateExpires, grantedTo, scope, delegated }:{
523+
dateExpires: string,
524+
grantedFrom: string,
525+
grantedTo: string,
526+
scope: PermissionScope,
527+
delegated?: boolean
528+
}): Promise<{
529+
recordsWrite: RecordsWrite,
530+
dataEncodedMessage: DataEncodedRecordsWriteMessage,
531+
permissionGrantBytes: Uint8Array
532+
}> {
533+
return await PermissionsProtocol.createGrant({
534+
signer: await this.getSigner(grantedFrom),
535+
grantedTo,
536+
dateExpires,
537+
scope,
538+
delegated
539+
});
540+
}
541+
542+
public async createRevocation({ grant, author }:{
543+
author: string,
544+
grant: PermissionGrant
545+
}): Promise<{
546+
recordsWrite: RecordsWrite,
547+
dataEncodedMessage: DataEncodedRecordsWriteMessage,
548+
permissionRevocationBytes: Uint8Array
549+
}> {
550+
return await PermissionsProtocol.createRevocation({
551+
signer: await this.getSigner(author),
552+
grant,
553+
});
554+
}
385555
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { DataEncodedRecordsWriteMessage, MessagesPermissionScope, PermissionGrant, PermissionScope, PermissionsProtocol, ProtocolPermissionScope, RecordsPermissionScope } from '@tbd54566975/dwn-sdk-js';
2+
import { DwnInterface } from './types/dwn.js';
3+
import { isRecordsType } from './dwn-api.js';
4+
5+
export class DwnPermissionsUtil {
6+
7+
static permissionsProtocolParams(type: 'grant' | 'revoke' | 'request'): { protocol: string, protocolPath: string } {
8+
const protocolPath = type === 'grant' ? PermissionsProtocol.grantPath :
9+
type === 'revoke' ? PermissionsProtocol.revocationPath : PermissionsProtocol.requestPath;
10+
return {
11+
protocol: PermissionsProtocol.uri,
12+
protocolPath,
13+
};
14+
}
15+
16+
/**
17+
* Matches the appropriate grant from an array of grants based on the provided parameters.
18+
*
19+
* @param delegated if true, only delegated grants are turned, if false all grants are returned including delegated ones.
20+
*/
21+
static async matchGrantFromArray<T extends DwnInterface>(
22+
grantor: string,
23+
grantee: string,
24+
messageParams: {
25+
messageType: T,
26+
protocol?: string,
27+
protocolPath?: string,
28+
contextId?: string,
29+
},
30+
grants: DataEncodedRecordsWriteMessage[],
31+
delegated: boolean = false
32+
): Promise<{ message: DataEncodedRecordsWriteMessage, grant: PermissionGrant } | undefined> {
33+
for (const grant of grants) {
34+
const grantData = await PermissionGrant.parse(grant);
35+
// only delegated grants are returned
36+
if (delegated === true && grantData.delegated !== true) {
37+
continue;
38+
}
39+
const { messageType, protocol, protocolPath, contextId } = messageParams;
40+
41+
if (this.matchScopeFromGrant(grantor, grantee, messageType, grantData, protocol, protocolPath, contextId)) {
42+
return { message: grant, grant: grantData };
43+
}
44+
}
45+
}
46+
47+
private static matchScopeFromGrant<T extends DwnInterface>(
48+
grantor: string,
49+
grantee: string,
50+
messageType: T,
51+
grant: PermissionGrant,
52+
protocol?: string,
53+
protocolPath?: string,
54+
contextId?: string
55+
): boolean {
56+
// Check if the grant matches the provided parameters
57+
if (grant.grantee !== grantee || grant.grantor !== grantor) {
58+
return false;
59+
}
60+
61+
const scope = grant.scope;
62+
const scopeMessageType = scope.interface + scope.method;
63+
if (scopeMessageType === messageType) {
64+
if (isRecordsType(messageType)) {
65+
const recordScope = scope as RecordsPermissionScope;
66+
if (!this.matchesProtocol(recordScope, protocol)) {
67+
return false;
68+
}
69+
70+
// If the grant scope is not restricted to a specific context or protocol path, it is unrestricted and can be used
71+
if (this.isUnrestrictedProtocolScope(recordScope)) {
72+
return true;
73+
}
74+
75+
// protocolPath and contextId are mutually exclusive
76+
// If the permission is scoped to a protocolPath and the permissionParams matches that path, this grant can be used
77+
if (recordScope.protocolPath !== undefined && recordScope.protocolPath === protocolPath) {
78+
return true;
79+
}
80+
81+
// If the permission is scoped to a contextId and the permissionParams starts with that contextId, this grant can be used
82+
if (recordScope.contextId !== undefined && contextId?.startsWith(recordScope.contextId)) {
83+
return true;
84+
}
85+
} else {
86+
const messagesScope = scope as MessagesPermissionScope | ProtocolPermissionScope;
87+
if (this.protocolScopeUnrestricted(messagesScope)) {
88+
return true;
89+
}
90+
91+
if (!this.matchesProtocol(messagesScope, protocol)) {
92+
return false;
93+
}
94+
95+
return this.isUnrestrictedProtocolScope(messagesScope);
96+
}
97+
}
98+
99+
return false;
100+
}
101+
102+
private static matchesProtocol(scope: PermissionScope & { protocol?: string }, protocol?: string): boolean {
103+
return scope.protocol !== undefined && scope.protocol === protocol;
104+
}
105+
106+
/**
107+
* Checks if the scope is restricted to a specific protocol
108+
*/
109+
private static protocolScopeUnrestricted(scope: PermissionScope & { protocol?: string }): boolean {
110+
return scope.protocol === undefined;
111+
}
112+
113+
private static isUnrestrictedProtocolScope(scope: PermissionScope & { contextId?: string, protocolPath?: string }): boolean {
114+
return scope.contextId === undefined && scope.protocolPath === undefined;
115+
}
116+
}

packages/agent/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export * from './bearer-identity.js';
1010
export * from './crypto-api.js';
1111
export * from './did-api.js';
1212
export * from './dwn-api.js';
13+
export * from './dwn-permissions-util.js';
1314
export * from './dwn-registrar.js';
1415
export * from './hd-identity-vault.js';
1516
export * from './identity-api.js';

0 commit comments

Comments
 (0)