Skip to content

Commit

Permalink
PermissionGrant Signing (#746)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
LiranCohen authored Aug 6, 2024
1 parent 51ac075 commit 1fee7a2
Show file tree
Hide file tree
Showing 14 changed files with 2,052 additions and 58 deletions.
8 changes: 8 additions & 0 deletions .changeset/olive-windows-give.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@web5/agent": patch
"@web5/identity-agent": patch
"@web5/proxy-agent": patch
"@web5/user-agent": patch
---

Apply logic to sign messages with grants, add utils for dealing with grants
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@
"pnpm": {
"overrides": {
"express@<4.19.2": ">=4.19.2",
"ws@<8.17.1": ">=8.17.1"
"ws@<8.17.1": ">=8.17.1",
"braces@<3.0.3": ">=3.0.3",
"fast-xml-parser@<4.4.1": ">=4.4.1",
"@75lb/deep-merge@<1.1.2": ">=1.1.2"
}
}
}
198 changes: 184 additions & 14 deletions packages/agent/src/dwn-api.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,45 @@
import type { Readable } from '@web5/common';
import type { DwnConfig, GenericMessage } from '@tbd54566975/dwn-sdk-js';

import {
Cid,
DataEncodedRecordsWriteMessage,
DataStoreLevel,
Dwn,
DwnConfig,
DwnMethodName,
EventLogLevel,
GenericMessage,
Message,
MessageStoreLevel,
PermissionGrant,
PermissionScope,
PermissionsProtocol,
RecordsWrite,
ResumableTaskStoreLevel
} from '@tbd54566975/dwn-sdk-js';

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

import type { Web5PlatformAgent } from './types/agent.js';
import type { DwnMessage, DwnMessageReply, DwnMessageWithData, DwnResponse, DwnSigner, MessageHandler, ProcessDwnRequest, SendDwnRequest } from './types/dwn.js';
import type {
DwnMessage,
DwnMessageInstance,
DwnMessageParams,
DwnMessageReply,
DwnMessageWithData,
DwnRecordsInterfaces,
DwnResponse,
DwnSigner,
MessageHandler,
ProcessDwnRequest,
SendDwnRequest
} from './types/dwn.js';

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

export type DwnMessageWithBlob<T extends DwnInterface> = {
message: DwnMessage[T];
Expand Down Expand Up @@ -39,6 +68,14 @@ export function isDwnMessage<T extends DwnInterface>(
return incomingMessageInterfaceName === messageType;
}

export function isRecordsType(messageType: DwnInterface): messageType is DwnRecordsInterfaces {
return messageType === DwnInterface.RecordsDelete ||
messageType === DwnInterface.RecordsQuery ||
messageType === DwnInterface.RecordsRead ||
messageType === DwnInterface.RecordsSubscribe ||
messageType === DwnInterface.RecordsWrite;
}

export class AgentDwnApi {
/**
* Holds the instance of a `Web5PlatformAgent` that represents the current execution context for
Expand Down Expand Up @@ -255,10 +292,15 @@ export class AgentDwnApi {
private async constructDwnMessage<T extends DwnInterface>({ request }: {
request: ProcessDwnRequest<T>
}): Promise<DwnMessageWithData<T>> {
// if the request has a granteeDid, ensure the messageParams include the proper grant parameters
if (request.granteeDid && !this.hasGrantParams(request.messageParams)) {
throw new Error('AgentDwnApi: Requested to sign with a permission but no grant messageParams were provided in the request');
}

const rawMessage = request.rawMessage;
let readableStream: Readable | undefined;

// TODO: Consider refactoring to move data transformations imposed by fetch() limitations to the HTTP transport-related methods.
// if the request is a RecordsWrite message, we need to handle the data stream and update the messageParams accordingly
if (isDwnRequest(request, DwnInterface.RecordsWrite)) {
const messageParams = request.messageParams;

Expand All @@ -285,23 +327,49 @@ export class AgentDwnApi {
}
}

// Determine the signer for the message.
const signer = await this.getSigner(request.author);

let dwnMessage: DwnMessageInstance[T];
const dwnMessageConstructor = dwnMessageConstructors[request.messageType];
const dwnMessage = rawMessage ? await dwnMessageConstructor.parse(rawMessage) : await dwnMessageConstructor.create({
// TODO: Implement alternative to type assertion.
...request.messageParams!,
signer
});

if (isRecordsWrite(dwnMessage) && request.signAsOwner) {
await dwnMessage.signAsOwner(signer);
// if there is no raw message provided, we need to create the dwn message
if (!rawMessage) {

// If we need to sign as an author delegate or with permissions we need to get the grantee's signer
// The messageParams should include either a permissionGrantId, or a delegatedGrant message
const signer = request.granteeDid ?
await this.getSigner(request.granteeDid) :
await this.getSigner(request.author);

dwnMessage = await dwnMessageConstructor.create({
// TODO: Implement alternative to type assertion.
...request.messageParams!,
signer
});

} else {
dwnMessage = await dwnMessageConstructor.parse(rawMessage);
if (isRecordsWrite(dwnMessage) && request.signAsOwner) {
// if we are signing as owner, we use the author's signer
const signer = await this.getSigner(request.author);
await dwnMessage.signAsOwner(signer);
} else if (request.granteeDid && isRecordsWrite(dwnMessage) && request.signAsOwnerDelegate) {
// if we are signing as owner delegate, we use the grantee's signer and the provided delegated grant
const signer = await this.getSigner(request.granteeDid);

//if we have reached here, the presence of the grant params has already been checked
const messageParams = request.messageParams as DwnMessageParams[DwnInterface.RecordsWrite];
await dwnMessage.signAsOwnerDelegate(signer, messageParams.delegatedGrant!);
}
}

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

private hasGrantParams<T extends DwnInterface>(params?: DwnMessageParams[T]): boolean {
return params !== undefined &&
(('permissionGrantId' in params && params.permissionGrantId !== undefined) ||
('delegatedGrant' in params && params.delegatedGrant !== undefined));
}

private async getSigner(author: string): Promise<DwnSigner> {
// If the author is the Agent's DID, use the Agent's signer.
if (author === this.agent.agentDid.uri) {
Expand Down Expand Up @@ -382,4 +450,106 @@ export class AgentDwnApi {

return dwnMessageWithBlob;
}

/**
* NOTE EVERYTHING BELOW THIS LINE IS TEMPORARY
* TODO: Create a `grants` API to handle creating permission requests, grants and revocations
* */

/**
* Performs a RecordsQuery for permission grants that match the given parameters.
*/
public async fetchGrants({ author, target, grantee, grantor }: {
/** author of the query message, defaults to grantee */
author?: string,
/** target of the query message, defaults to author */
target?: string,
grantor: string,
grantee: string
}): Promise<DataEncodedRecordsWriteMessage[]> {
// if no author is provided, use the grantee's DID
author ??= grantee;
// if no target is explicitly provided, use the author
target ??= author;

const { reply: grantsReply } = await this.processRequest({
author,
target,
messageType : DwnInterface.RecordsQuery,
messageParams : {
filter: {
author : grantor, // the author of the grant would be the grantor and the logical author of the message
recipient : grantee, // the recipient of the grant would be the grantee
...DwnPermissionsUtil.permissionsProtocolParams('grant')
}
}
});

if (grantsReply.status.code !== 200) {
throw new Error(`AgentDwnApi: Failed to fetch grants: ${grantsReply.status.detail}`);
}

return grantsReply.entries! as DataEncodedRecordsWriteMessage[];
};

/**
* Check whether a grant is revoked by reading the revocation record for a given grant recordId.
*/
public async isGrantRevoked(author:string, target: string, grantRecordId: string): Promise<boolean> {
const { reply: revocationReply } = await this.processRequest({
author,
target,
messageType : DwnInterface.RecordsRead,
messageParams : {
filter: {
parentId: grantRecordId,
...DwnPermissionsUtil.permissionsProtocolParams('revoke')
}
}
});

if (revocationReply.status.code === 404) {
// no revocation found, the grant is not revoked
return false;
} else if (revocationReply.status.code === 200) {
// a revocation was found, the grant is revoked
return true;
}

throw new Error(`AgentDwnApi: Failed to check if grant is revoked: ${revocationReply.status.detail}`);
}

public async createGrant({ grantedFrom, dateExpires, grantedTo, scope, delegated }:{
dateExpires: string,
grantedFrom: string,
grantedTo: string,
scope: PermissionScope,
delegated?: boolean
}): Promise<{
recordsWrite: RecordsWrite,
dataEncodedMessage: DataEncodedRecordsWriteMessage,
permissionGrantBytes: Uint8Array
}> {
return await PermissionsProtocol.createGrant({
signer: await this.getSigner(grantedFrom),
grantedTo,
dateExpires,
scope,
delegated
});
}

public async createRevocation({ grant, author }:{
author: string,
grant: PermissionGrant
}): Promise<{
recordsWrite: RecordsWrite,
dataEncodedMessage: DataEncodedRecordsWriteMessage,
permissionRevocationBytes: Uint8Array
}> {
return await PermissionsProtocol.createRevocation({
signer: await this.getSigner(author),
grant,
});
}
}
116 changes: 116 additions & 0 deletions packages/agent/src/dwn-permissions-util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { DataEncodedRecordsWriteMessage, MessagesPermissionScope, PermissionGrant, PermissionScope, PermissionsProtocol, ProtocolPermissionScope, RecordsPermissionScope } from '@tbd54566975/dwn-sdk-js';
import { DwnInterface } from './types/dwn.js';
import { isRecordsType } from './dwn-api.js';

export class DwnPermissionsUtil {

static permissionsProtocolParams(type: 'grant' | 'revoke' | 'request'): { protocol: string, protocolPath: string } {
const protocolPath = type === 'grant' ? PermissionsProtocol.grantPath :
type === 'revoke' ? PermissionsProtocol.revocationPath : PermissionsProtocol.requestPath;
return {
protocol: PermissionsProtocol.uri,
protocolPath,
};
}

/**
* Matches the appropriate grant from an array of grants based on the provided parameters.
*
* @param delegated if true, only delegated grants are turned, if false all grants are returned including delegated ones.
*/
static async matchGrantFromArray<T extends DwnInterface>(
grantor: string,
grantee: string,
messageParams: {
messageType: T,
protocol?: string,
protocolPath?: string,
contextId?: string,
},
grants: DataEncodedRecordsWriteMessage[],
delegated: boolean = false
): Promise<{ message: DataEncodedRecordsWriteMessage, grant: PermissionGrant } | undefined> {
for (const grant of grants) {
const grantData = await PermissionGrant.parse(grant);
// only delegated grants are returned
if (delegated === true && grantData.delegated !== true) {
continue;
}
const { messageType, protocol, protocolPath, contextId } = messageParams;

if (this.matchScopeFromGrant(grantor, grantee, messageType, grantData, protocol, protocolPath, contextId)) {
return { message: grant, grant: grantData };
}
}
}

private static matchScopeFromGrant<T extends DwnInterface>(
grantor: string,
grantee: string,
messageType: T,
grant: PermissionGrant,
protocol?: string,
protocolPath?: string,
contextId?: string
): boolean {
// Check if the grant matches the provided parameters
if (grant.grantee !== grantee || grant.grantor !== grantor) {
return false;
}

const scope = grant.scope;
const scopeMessageType = scope.interface + scope.method;
if (scopeMessageType === messageType) {
if (isRecordsType(messageType)) {
const recordScope = scope as RecordsPermissionScope;
if (!this.matchesProtocol(recordScope, protocol)) {
return false;
}

// If the grant scope is not restricted to a specific context or protocol path, it is unrestricted and can be used
if (this.isUnrestrictedProtocolScope(recordScope)) {
return true;
}

// protocolPath and contextId are mutually exclusive
// If the permission is scoped to a protocolPath and the permissionParams matches that path, this grant can be used
if (recordScope.protocolPath !== undefined && recordScope.protocolPath === protocolPath) {
return true;
}

// If the permission is scoped to a contextId and the permissionParams starts with that contextId, this grant can be used
if (recordScope.contextId !== undefined && contextId?.startsWith(recordScope.contextId)) {
return true;
}
} else {
const messagesScope = scope as MessagesPermissionScope | ProtocolPermissionScope;
if (this.protocolScopeUnrestricted(messagesScope)) {
return true;
}

if (!this.matchesProtocol(messagesScope, protocol)) {
return false;
}

return this.isUnrestrictedProtocolScope(messagesScope);
}
}

return false;
}

private static matchesProtocol(scope: PermissionScope & { protocol?: string }, protocol?: string): boolean {
return scope.protocol !== undefined && scope.protocol === protocol;
}

/**
* Checks if the scope is restricted to a specific protocol
*/
private static protocolScopeUnrestricted(scope: PermissionScope & { protocol?: string }): boolean {
return scope.protocol === undefined;
}

private static isUnrestrictedProtocolScope(scope: PermissionScope & { contextId?: string, protocolPath?: string }): boolean {
return scope.contextId === undefined && scope.protocolPath === undefined;
}
}
1 change: 1 addition & 0 deletions packages/agent/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export * from './bearer-identity.js';
export * from './crypto-api.js';
export * from './did-api.js';
export * from './dwn-api.js';
export * from './dwn-permissions-util.js';
export * from './dwn-registrar.js';
export * from './hd-identity-vault.js';
export * from './identity-api.js';
Expand Down
Loading

0 comments on commit 1fee7a2

Please sign in to comment.