diff --git a/src/core/grant-authorization.ts b/src/core/grant-authorization.ts index 79366141c..0357f1b28 100644 --- a/src/core/grant-authorization.ts +++ b/src/core/grant-authorization.ts @@ -1,5 +1,4 @@ import type { GenericMessage } from '../types/message-types.js'; -import type { MessageInterface } from '../types/message-interface.js'; import type { MessageStore } from '../types/message-store.js'; import type { PermissionsGrantMessage } from '../types/permissions-types.js'; @@ -12,20 +11,18 @@ export class GrantAuthorization { /** * Performs PermissionsGrant-based authorization against the given message * Does not validate grant `conditions` or `scope` beyond `interface` and `method` - * @throws {Error} if authorization fails + * @throws {DwnError} if authorization fails */ public static async authorizeGenericMessage( tenant: string, - incomingMessage: MessageInterface, + incomingMessage: GenericMessage, author: string, - permissionsGrantId: string, + permissionsGrantMessage: PermissionsGrantMessage, messageStore: MessageStore, - ): Promise { - - const incomingMessageDescriptor = incomingMessage.message.descriptor; + ): Promise { - // Fetch grant - const permissionsGrantMessage = await GrantAuthorization.fetchGrant(tenant, messageStore, permissionsGrantId); + const incomingMessageDescriptor = incomingMessage.descriptor; + const permissionsGrantId = await Message.getCid(permissionsGrantMessage); GrantAuthorization.verifyGrantedToAndGrantedFor(author, tenant, permissionsGrantMessage); @@ -45,8 +42,6 @@ export class GrantAuthorization { permissionsGrantMessage, permissionsGrantId ); - - return permissionsGrantMessage; } /** @@ -54,7 +49,7 @@ export class GrantAuthorization { * @returns the PermissionsGrantMessage with CID `permissionsGrantId` if message exists * @throws {Error} if PermissionsGrantMessage with CID `permissionsGrantId` does not exist */ - private static async fetchGrant( + public static async fetchGrant( tenant: string, messageStore: MessageStore, permissionsGrantId: string, @@ -101,7 +96,7 @@ export class GrantAuthorization { * Verify that the incoming message is within the allowed time frame of the grant, * and the grant has not been revoked. * @param permissionsGrantId Purely being passed as an optimization. Technically can be computed from `permissionsGrantMessage`. - * @throws {Error} if incomingMessage has timestamp for a time in which the grant is not active. + * @throws {DwnError} if incomingMessage has timestamp for a time in which the grant is not active. */ private static async verifyGrantActive( tenant: string, @@ -144,7 +139,8 @@ export class GrantAuthorization { /** * Verify that the `interface` and `method` grant scopes match the incoming message - * @throws {Error} if the `interface` and `method` of the incoming message do not match the scope of the PermissionsGrant + * @param permissionsGrantId Purely being passed for logging purposes. + * @throws {DwnError} if the `interface` and `method` of the incoming message do not match the scope of the PermissionsGrant */ private static async verifyGrantScopeInterfaceAndMethod( dwnInterface: string, diff --git a/src/core/records-grant-authorization.ts b/src/core/records-grant-authorization.ts index f099a8f16..2d7e991bf 100644 --- a/src/core/records-grant-authorization.ts +++ b/src/core/records-grant-authorization.ts @@ -2,7 +2,7 @@ import type { MessageStore } from '../types/message-store.js'; import type { PermissionsGrantMessage } from '../types/permissions-types.js'; import type { RecordsPermissionScope } from '../types/permissions-grant-descriptor.js'; import type { RecordsRead } from '../interfaces/records-read.js'; -import type { RecordsWrite } from '../interfaces/records-write.js'; +import type { RecordsWriteMessage } from '../types/records-types.js'; import { GrantAuthorization } from './grant-authorization.js'; import { PermissionsConditionPublication } from '../types/permissions-grant-descriptor.js'; @@ -14,15 +14,16 @@ export class RecordsGrantAuthorization { */ public static async authorizeWrite( tenant: string, - incomingMessage: RecordsWrite, + incomingMessage: RecordsWriteMessage, author: string, + permissionsGrantMessage: PermissionsGrantMessage, messageStore: MessageStore, ): Promise { - const permissionsGrantMessage = await GrantAuthorization.authorizeGenericMessage( + await GrantAuthorization.authorizeGenericMessage( tenant, incomingMessage, author, - incomingMessage.signaturePayload!.permissionsGrantId!, + permissionsGrantMessage, messageStore ); @@ -37,19 +38,20 @@ export class RecordsGrantAuthorization { public static async authorizeRead( tenant: string, incomingMessage: RecordsRead, - newestRecordsWrite: RecordsWrite, + newestRecordsWriteMessage: RecordsWriteMessage, author: string, + permissionsGrantMessage: PermissionsGrantMessage, messageStore: MessageStore, ): Promise { - const permissionsGrantMessage = await GrantAuthorization.authorizeGenericMessage( + await GrantAuthorization.authorizeGenericMessage( tenant, - incomingMessage, + incomingMessage.message, author, - incomingMessage.signaturePayload!.permissionsGrantId!, + permissionsGrantMessage, messageStore ); - RecordsGrantAuthorization.verifyScope(newestRecordsWrite, permissionsGrantMessage); + RecordsGrantAuthorization.verifyScope(newestRecordsWriteMessage, permissionsGrantMessage); } /** @@ -57,7 +59,7 @@ export class RecordsGrantAuthorization { * then this is the incoming RecordsWrite. Otherwise, it is the newest existing RecordsWrite. */ private static verifyScope( - recordsWrite: RecordsWrite, + recordsWriteMessage: RecordsWriteMessage, permissionsGrantMessage: PermissionsGrantMessage, ): void { const grantScope = permissionsGrantMessage.descriptor.scope as RecordsPermissionScope; @@ -65,11 +67,11 @@ export class RecordsGrantAuthorization { if (RecordsGrantAuthorization.isUnrestrictedScope(grantScope)) { // scope has no restrictions beyond interface and method. Message is authorized to access any record. return; - } else if (recordsWrite.message.descriptor.protocol !== undefined) { + } else if (recordsWriteMessage.descriptor.protocol !== undefined) { // authorization of protocol records must have grants that explicitly include the protocol - RecordsGrantAuthorization.authorizeProtocolRecord(recordsWrite, grantScope); + RecordsGrantAuthorization.authorizeProtocolRecord(recordsWriteMessage, grantScope); } else { - RecordsGrantAuthorization.authorizeFlatRecord(recordsWrite, grantScope); + RecordsGrantAuthorization.authorizeFlatRecord(recordsWriteMessage, grantScope); } } @@ -77,7 +79,7 @@ export class RecordsGrantAuthorization { * Authorizes a grant scope for a protocol record */ private static authorizeProtocolRecord( - recordsWrite: RecordsWrite, + recordsWriteMessage: RecordsWriteMessage, grantScope: RecordsPermissionScope ): void { // Protocol records must have grants specifying the protocol @@ -89,7 +91,7 @@ export class RecordsGrantAuthorization { } // The record's protocol must match the protocol specified in the record - if (grantScope.protocol !== recordsWrite.message.descriptor.protocol) { + if (grantScope.protocol !== recordsWriteMessage.descriptor.protocol) { throw new DwnError( DwnErrorCode.RecordsGrantAuthorizationScopeProtocolMismatch, `Grant scope specifies different protocol than what appears in the record` @@ -97,7 +99,7 @@ export class RecordsGrantAuthorization { } // If grant specifies either contextId, check that record is that context - if (grantScope.contextId !== undefined && grantScope.contextId !== recordsWrite.message.contextId) { + if (grantScope.contextId !== undefined && grantScope.contextId !== recordsWriteMessage.contextId) { throw new DwnError( DwnErrorCode.RecordsGrantAuthorizationScopeContextIdMismatch, `Grant scope specifies different contextId than what appears in the record` @@ -105,7 +107,7 @@ export class RecordsGrantAuthorization { } // If grant specifies protocolPath, check that record is at that protocolPath - if (grantScope.protocolPath !== undefined && grantScope.protocolPath !== recordsWrite.message.descriptor.protocolPath) { + if (grantScope.protocolPath !== undefined && grantScope.protocolPath !== recordsWriteMessage.descriptor.protocolPath) { throw new DwnError( DwnErrorCode.RecordsGrantAuthorizationScopeProtocolPathMismatch, `Grant scope specifies different protocolPath than what appears in the record` @@ -117,11 +119,11 @@ export class RecordsGrantAuthorization { * Authorizes a grant scope for a non-protocol record */ private static authorizeFlatRecord( - recordsWrite: RecordsWrite, + recordsWriteMessage: RecordsWriteMessage, grantScope: RecordsPermissionScope ): void { if (grantScope.schema !== undefined) { - if (grantScope.schema !== recordsWrite.message.descriptor.schema) { + if (grantScope.schema !== recordsWriteMessage.descriptor.schema) { throw new DwnError( DwnErrorCode.RecordsGrantAuthorizationScopeSchema, `Record does not have schema in PermissionsGrant scope with schema '${grantScope.schema}'` @@ -134,11 +136,11 @@ export class RecordsGrantAuthorization { * Verifies grant `conditions`. * Currently the only condition is `published` which only applies to RecordsWrites */ - private static verifyConditions(incomingMessage: RecordsWrite, permissionsGrantMessage: PermissionsGrantMessage): void { + private static verifyConditions(recordsWriteMessage: RecordsWriteMessage, permissionsGrantMessage: PermissionsGrantMessage): void { const conditions = permissionsGrantMessage.descriptor.conditions; // If conditions require publication, RecordsWrite must have `published` === true - if (conditions?.publication === PermissionsConditionPublication.Required && !incomingMessage.message.descriptor.published) { + if (conditions?.publication === PermissionsConditionPublication.Required && !recordsWriteMessage.descriptor.published) { throw new DwnError( DwnErrorCode.RecordsGrantAuthorizationConditionPublicationRequired, 'PermissionsGrant requires message to be published' @@ -146,7 +148,7 @@ export class RecordsGrantAuthorization { } // if conditions prohibit publication, RecordsWrite must have published === false or undefined - if (conditions?.publication === PermissionsConditionPublication.Prohibited && incomingMessage.message.descriptor.published) { + if (conditions?.publication === PermissionsConditionPublication.Prohibited && recordsWriteMessage.descriptor.published) { throw new DwnError( DwnErrorCode.RecordsGrantAuthorizationConditionPublicationProhibited, 'PermissionsGrant prohibits message from being published' diff --git a/src/handlers/records-read.ts b/src/handlers/records-read.ts index 56a6f379c..ee1e9b003 100644 --- a/src/handlers/records-read.ts +++ b/src/handlers/records-read.ts @@ -9,6 +9,7 @@ import { authenticate } from '../core/auth.js'; import { DataStream } from '../utils/data-stream.js'; import { DwnInterfaceName } from '../enums/dwn-interface-method.js'; import { Encoder } from '../utils/encoder.js'; +import { GrantAuthorization } from '../core/grant-authorization.js'; import { Message } from '../core/message.js'; import { messageReplyFromError } from '../core/message-reply.js'; import { ProtocolAuthorization } from '../core/protocol-authorization.js'; @@ -113,7 +114,10 @@ export class RecordsReadHandler implements MethodHandler { // The recipient of a message may always read it return; } else if (recordsRead.author !== undefined && recordsRead.signaturePayload!.permissionsGrantId !== undefined) { - await RecordsGrantAuthorization.authorizeRead(tenant, recordsRead, newestRecordsWrite, recordsRead.author, messageStore); + const permissionsGrantMessage = await GrantAuthorization.fetchGrant(tenant, messageStore, recordsRead.signaturePayload!.permissionsGrantId); + await RecordsGrantAuthorization.authorizeRead( + tenant, recordsRead, newestRecordsWrite.message, recordsRead.author, permissionsGrantMessage, messageStore + ); } else if (descriptor.protocol !== undefined) { await ProtocolAuthorization.authorizeRead(tenant, recordsRead, newestRecordsWrite, messageStore); } else { diff --git a/src/handlers/records-write.ts b/src/handlers/records-write.ts index 6682ad40a..be6cc6a0f 100644 --- a/src/handlers/records-write.ts +++ b/src/handlers/records-write.ts @@ -11,6 +11,7 @@ import { Cid } from '../utils/cid.js'; import { DataStream } from '../utils/data-stream.js'; import { DwnConstant } from '../core/dwn-constant.js'; import { Encoder } from '../utils/encoder.js'; +import { GrantAuthorization } from '../core/grant-authorization.js'; import { Message } from '../core/message.js'; import { messageReplyFromError } from '../core/message-reply.js'; import { ProtocolAuthorization } from '../core/protocol-authorization.js'; @@ -260,6 +261,10 @@ export class RecordsWriteHandler implements MethodHandler { ); } + if (recordsWrite.isSignedByDelegatee) { + await recordsWrite.authorizeDelegatee(messageStore); + } + if (recordsWrite.owner !== undefined) { // if incoming message is a write retained by this tenant, we by-design always allow // NOTE: the "owner === tenant" check is already done earlier in this method @@ -268,7 +273,8 @@ export class RecordsWriteHandler implements MethodHandler { // if author is the same as the target tenant, we can directly grant access return; } else if (recordsWrite.author !== undefined && recordsWrite.signaturePayload!.permissionsGrantId !== undefined) { - await RecordsGrantAuthorization.authorizeWrite(tenant, recordsWrite, recordsWrite.author, messageStore); + const permissionsGrantMessage = await GrantAuthorization.fetchGrant(tenant, messageStore, recordsWrite.signaturePayload!.permissionsGrantId); + await RecordsGrantAuthorization.authorizeWrite(tenant, recordsWrite.message, recordsWrite.author, permissionsGrantMessage, messageStore); } else if (recordsWrite.message.descriptor.protocol !== undefined) { await ProtocolAuthorization.authorizeWrite(tenant, recordsWrite, messageStore); } else { diff --git a/src/interfaces/protocols-query.ts b/src/interfaces/protocols-query.ts index ff337667d..546279467 100644 --- a/src/interfaces/protocols-query.ts +++ b/src/interfaces/protocols-query.ts @@ -81,11 +81,12 @@ export class ProtocolsQuery extends AbstractMessage { if (this.author === tenant) { return; } else if (this.author !== undefined && this.signaturePayload!.permissionsGrantId) { + const permissionsGrantMessage = await GrantAuthorization.fetchGrant(tenant, messageStore, this.signaturePayload!.permissionsGrantId); await GrantAuthorization.authorizeGenericMessage( tenant, - this, + this.message, this.author, - this.signaturePayload!.permissionsGrantId, + permissionsGrantMessage, messageStore ); } else { diff --git a/src/interfaces/records-write.ts b/src/interfaces/records-write.ts index bc7a28164..ea08bb026 100644 --- a/src/interfaces/records-write.ts +++ b/src/interfaces/records-write.ts @@ -24,6 +24,7 @@ import { Jws } from '../utils/jws.js'; import { KeyDerivationScheme } from '../utils/hd-key.js'; import { Message } from '../core/message.js'; import { Records } from '../utils/records.js'; +import { RecordsGrantAuthorization } from '../core/records-grant-authorization.js'; import { removeUndefinedProperties } from '../utils/object.js'; import { Secp256k1 } from '../utils/secp256k1.js'; import { Time } from '../utils/time.js'; @@ -182,6 +183,21 @@ export class RecordsWrite implements MessageInterface { return this._ownerSignaturePayload; } + /** + * If this message is signed by a delegated entity. + */ + public get isSignedByDelegatee(): boolean { + return this._message.authorization?.authorDelegatedGrant !== undefined; + } + + /** + * Gets the signer of this message. + * This is not to be confused with the logical author of the message. + */ + public get signer(): string | undefined { + return Message.getSigner(this._message); + } + readonly attesters: string[]; private constructor(message: InternalRecordsWriteMessage) { @@ -696,6 +712,13 @@ export class RecordsWrite implements MessageInterface { return indexes; } + public async authorizeDelegatee(messageStore: MessageStore): Promise { + const grantedTo = this.signer!; + const grantedFor = this.author!; + const delegatedGrant = this.message.authorization.authorDelegatedGrant!; + await RecordsGrantAuthorization.authorizeWrite(grantedFor, this.message, grantedTo, delegatedGrant, messageStore); + } + /** * Checks if the given message is the initial entry of a record. diff --git a/src/utils/records.ts b/src/utils/records.ts index 09453f173..24a1b1c42 100644 --- a/src/utils/records.ts +++ b/src/utils/records.ts @@ -312,7 +312,8 @@ export class Records { // when delegated grant exists, the grantee (grantedTo) must be the same as the signer of the message if (authorDelegatedGrantDefined) { - const grantedTo = message.authorization!.authorDelegatedGrant!.descriptor.grantedTo; + const delegatedGrant = message.authorization!.authorDelegatedGrant!; + const grantedTo = delegatedGrant.descriptor.grantedTo; const signer = Message.getSigner(message); if (grantedTo !== signer) { throw new DwnError( diff --git a/tests/interfaces/records-write.spec.ts b/tests/interfaces/records-write.spec.ts index f891d064e..518dd1674 100644 --- a/tests/interfaces/records-write.spec.ts +++ b/tests/interfaces/records-write.spec.ts @@ -357,6 +357,21 @@ describe('RecordsWrite', () => { }); }); + describe('isSignedByDelegatee()', () => { + it('should return false if the given RecordsWrite is not signed at all', async () => { + const data = new TextEncoder().encode('any data'); + const recordsWrite = await RecordsWrite.create({ + protocol : 'unused', + protocolPath : 'unused', + schema : 'unused', + dataFormat : 'unused', + data + }); + + const isSignedByDelegatee = recordsWrite.isSignedByDelegatee; + expect(isSignedByDelegatee).to.be.false; + }); + }); describe('isInitialWrite()', () => { it('should return false if given message is not a RecordsWrite', async () => { diff --git a/tests/scenarios/delegated-grant.spec.ts b/tests/scenarios/delegated-grant.spec.ts index 7d24c1d67..cfc1a4d16 100644 --- a/tests/scenarios/delegated-grant.spec.ts +++ b/tests/scenarios/delegated-grant.spec.ts @@ -57,7 +57,7 @@ export function testDelegatedGrantScenarios(): void { await dwn.close(); }); - it('should only allow entity invoking a valid delegated grant to write', async () => { + it('should only allow correct entity invoking a delegated grant to write', async () => { // scenario: // 1. Alice creates a delegated grant for Device X and Device Y, // 2. Device X and Y can both use their grants to write a message to Bob's DWN as Alice @@ -180,7 +180,7 @@ export function testDelegatedGrantScenarios(): void { expect(carolWriteReply.status.detail).to.contain(DwnErrorCode.RecordsValidateIntegrityGrantedToAndSignerMismatch); }); - it('should only allow entity invoking a valid delegated grant to read or query', async () => { + it('should only allow correct entity invoking a delegated grant to read or query', async () => { // scenario: // 1. Alice creates a delegated grant for device X, // 2. Bob starts a chat thread with Alice on his DWN @@ -332,7 +332,7 @@ export function testDelegatedGrantScenarios(): void { expect(recordsQueryByCarolReply.status.detail).to.contain(DwnErrorCode.RecordsValidateIntegrityGrantedToAndSignerMismatch); }); - it('should only allow entity invoking a valid delegated grant to delete', async () => { + it('should only allow entity invoking a delegated grant to delete', async () => { // scenario: // 1. Bob installs the chat protocol on his DWN and makes Alice an admin // 2. Bob starts a chat thread with Carol on his DWN @@ -464,7 +464,60 @@ export function testDelegatedGrantScenarios(): void { xit('should not allow entity using a non-delegated grant as a delegated grant to invoke delete', async () => { }); - xit('should evaluate scoping correctly when invoking a delegated grant to write', async () => { + it('should fail if delegated grant has a mismatching protocol scope - write', async () => { + // scenario: + // 1. Alice creates a delegated grant for device X to act as her for a protocol that is NOT email protocol + // 2. Bob has email protocol configured for his DWN that allows anyone to write an email to him + // 3. Device X attempts to use the delegated grant to write an email to Bob as Alice + // 4. Bob's DWN should reject Device X's message + const alice = await DidKeyResolver.generate(); + const deviceX = await DidKeyResolver.generate(); + const bob = await DidKeyResolver.generate(); + + // 1. Alice creates a delegated grant for device X to act as her for a protocol that is NOT email protocol + const scope: PermissionScope = { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + protocol : 'random-protocol' + }; + + const deviceXGrant = await PermissionsGrant.create({ + delegated : true, // this is a delegated grant + dateExpires : Time.createOffsetTimestamp({ seconds: 100 }), + description : 'Allow to write to some random protocol', + grantedBy : alice.did, + grantedTo : deviceX.did, + grantedFor : alice.did, + scope : scope, + signer : Jws.createSigner(alice) + }); + + // 2. Bob has email protocol configured for his DWN that allows anyone to write an email to him + const protocolDefinition = emailProtocolDefinition; + const protocol = protocolDefinition.protocol; + const protocolsConfig = await TestDataGenerator.generateProtocolsConfigure({ + author: bob, + protocolDefinition + }); + const protocolConfigureReply = await dwn.processMessage(bob.did, protocolsConfig.message); + expect(protocolConfigureReply.status.code).to.equal(202); + + // 3. Device X attempts to use the delegated grant to write an email to Bob as Alice + const deviceXData = new TextEncoder().encode('message from device X'); + const deviceXDataStream = DataStream.fromBytes(deviceXData); + const messageByDeviceX = await RecordsWrite.create({ + signer : Jws.createSigner(deviceX), + delegatedGrant : deviceXGrant.asDelegatedGrant(), + protocol, + protocolPath : 'email', // this comes from `types` in protocol definition + schema : protocolDefinition.types.email.schema, + dataFormat : protocolDefinition.types.email.dataFormats[0], + data : deviceXData + }); + + const deviceXWriteReply = await dwn.processMessage(bob.did, messageByDeviceX.message, deviceXDataStream); + expect(deviceXWriteReply.status.code).to.equal(401); + expect(deviceXWriteReply.status.detail).to.contain(DwnErrorCode.RecordsGrantAuthorizationScopeProtocolMismatch); }); xit('should evaluate scoping correctly when invoking a delegated grant to read', async () => { @@ -484,5 +537,8 @@ export function testDelegatedGrantScenarios(): void { xit('should fail if presented with a delegated grant with mismatching grant ID in the payload of the message signature', async () => { }); + + xit('should fail if presented with a revoked delegated grant', async () => { + }); }); }