From b39b2c7c4fe70e9037d9245c073a555ee55d0d18 Mon Sep 17 00:00:00 2001 From: Matt Schoch Date: Tue, 26 Mar 2024 17:53:13 -0400 Subject: [PATCH] Adding SIGN_MESSAGE action, signing EIP191 messages (#192) --- .../vault/src/vault/__test__/e2e/sign.spec.ts | 45 +++++++++-- .../__test__/unit/signing.service.spec.ts | 76 +++++++++++++++++-- .../src/vault/core/service/signing.service.ts | 29 ++++++- .../sign-message-request-data-dto.spec.ts | 57 ++++++++++++++ .../lib/dto/sign-message-request-data-dto.ts | 36 +++++++-- .../src/lib/type/action.type.ts | 6 +- 6 files changed, 228 insertions(+), 21 deletions(-) create mode 100644 packages/nestjs-shared/src/lib/dto/__test__/unit/sign-message-request-data-dto.spec.ts diff --git a/apps/vault/src/vault/__test__/e2e/sign.spec.ts b/apps/vault/src/vault/__test__/e2e/sign.spec.ts index 4df25fffa..1b2f4695d 100644 --- a/apps/vault/src/vault/__test__/e2e/sign.spec.ts +++ b/apps/vault/src/vault/__test__/e2e/sign.spec.ts @@ -1,4 +1,5 @@ import { EncryptionModuleOptionProvider } from '@narval/encryption-module' +import { Action } from '@narval/policy-engine-shared' import { JwsdHeader, Payload, @@ -17,6 +18,7 @@ import { Test, TestingModule } from '@nestjs/testing' import { ACCOUNT, UNSAFE_PRIVATE_KEY } from 'packages/policy-engine-shared/src/lib/dev.fixture' import request from 'supertest' import { v4 as uuid } from 'uuid' +import { verifyMessage } from 'viem' import { load } from '../../../main.config' import { REQUEST_HEADER_CLIENT_ID } from '../../../main.constant' import { KeyValueRepository } from '../../../shared/module/key-value/core/repository/key-value.repository' @@ -50,8 +52,8 @@ describe('Sign', () => { } const wallet: Wallet = { - id: 'eip155:eoa:0xc3bdcdb4f593aa5a5d81cd425f6fc3265d962157', - address: '0xc3bdcdb4F593AA5A5D81cD425f6Fc3265D962157', + id: 'eip155:eoa:0x2c4895215973CbBd778C32c456C074b99daF8Bf1', + address: '0x2c4895215973CbBd778C32c456C074b99daF8Bf1', privateKey: '0x7cfef3303797cbc7515d9ce22ffe849c701b0f2812f999b0847229c47951fca5' } @@ -59,7 +61,7 @@ describe('Sign', () => { action: 'signTransaction', nonce: 'random-nonce-111', transactionRequest: { - from: '0xc3bdcdb4F593AA5A5D81cD425f6Fc3265D962157', + from: '0x2c4895215973CbBd778C32c456C074b99daF8Bf1', to: '0x04B12F0863b83c7162429f0Ebb0DfdA20E1aA97B', chainId: 137, value: '0x5af3107a4000', @@ -70,7 +72,7 @@ describe('Sign', () => { maxFeePerGas: '291175227375', maxPriorityFeePerGas: '81000000000' }, - resourceId: 'eip155:eoa:0xc3bdcdb4f593aa5a5d81cd425f6fc3265d962157' + resourceId: 'eip155:eoa:0x2c4895215973CbBd778C32c456C074b99daF8Bf1' } const getAccessToken = async (request?: unknown, opts: object = {}) => { @@ -144,7 +146,7 @@ describe('Sign', () => { action: 'signTransaction', nonce: 'random-nonce-111', transactionRequest: { - from: '0xc3bdcdb4F593AA5A5D81cD425f6Fc3265D962157', + from: '0x2c4895215973CbBd778C32c456C074b99daF8Bf1', to: '04B12F0863b83c7162429f0Ebb0DfdA20E1aA97B', // INVALID chainId: 137, value: '0x5af3107a4000', @@ -155,7 +157,7 @@ describe('Sign', () => { maxFeePerGas: '291175227375', maxPriorityFeePerGas: '81000000000' }, - resourceId: 'eip155:eoa:0xc3bdcdb4f593aa5a5d81cd425f6fc3265d962157' + resourceId: 'eip155:eoa:0x2c4895215973CbBd778C32c456C074b99daF8Bf1' } } @@ -194,6 +196,37 @@ describe('Sign', () => { '0x02f875818982013d8512dbf9ea008543cb655fef82520c9404b12f0863b83c7162429f0ebb0dfda20e1aa97b865af3107a400080c080a00de78cbb96f83ef1b8d6be4d55b4046b2706c7d63ce0a815bae2b1ea4f891e6ba06f7648a9c9710b171d55e056c4abca268857f607a8a4a257d945fc44ace9f076' }) }) + + it('signs Message', async () => { + const messageRequest = { + action: Action.SIGN_MESSAGE, + nonce: 'random-nonce-111', + message: 'My ASCII message', + resourceId: 'eip155:eoa:0x2c4895215973CbBd778C32c456C074b99daF8Bf1' + } + + const accessToken = await getAccessToken(messageRequest) + const { status, body } = await request(app.getHttpServer()) + .post('/sign') + .set(REQUEST_HEADER_CLIENT_ID, clientId) + .set('authorization', `GNAP ${accessToken}`) + .send({ request: messageRequest }) + + const isVerified = await verifyMessage({ + address: wallet.address, + message: messageRequest.message, + signature: body.signature + }) + + expect(body).toEqual({ + signature: + '0x65071b7126abd24fe6b8fa396529e21d22448d23ff1a6c5a0e043a4f641cd11b2a21958127d1b91db4d991f8b33ad6b201637799a95eadbe3a7cf5cee26bd9521b' + }) + + expect(status).toEqual(HttpStatus.CREATED) + + expect(isVerified).toEqual(true) + }) }) describe('AuthorizationGuard', () => { diff --git a/apps/vault/src/vault/core/service/__test__/unit/signing.service.spec.ts b/apps/vault/src/vault/core/service/__test__/unit/signing.service.spec.ts index 8c0c9ab6f..c13b6e675 100644 --- a/apps/vault/src/vault/core/service/__test__/unit/signing.service.spec.ts +++ b/apps/vault/src/vault/core/service/__test__/unit/signing.service.spec.ts @@ -1,6 +1,14 @@ -import { Request } from '@narval/policy-engine-shared' +import { Action, Request } from '@narval/policy-engine-shared' import { Test } from '@nestjs/testing' -import { Hex, TransactionSerializable, hexToBigInt, parseTransaction, serializeTransaction } from 'viem' +import { + Hex, + TransactionSerializable, + hexToBigInt, + parseTransaction, + serializeTransaction, + toHex, + verifyMessage +} from 'viem' import { Wallet } from '../../../../../shared/type/domain.type' import { WalletRepository } from '../../../../persistence/repository/wallet.repository' import { SigningService } from '../../signing.service' @@ -9,8 +17,8 @@ describe('SigningService', () => { let signingService: SigningService const wallet: Wallet = { - id: 'eip155:eoa:0xc3bdcdb4f593aa5a5d81cd425f6fc3265d962157', - address: '0xc3bdcdb4F593AA5A5D81cD425f6Fc3265D962157', + id: 'eip155:eoa:0x2c4895215973CbBd778C32c456C074b99daF8Bf1', + address: '0x2c4895215973CbBd778C32c456C074b99daF8Bf1', privateKey: '0x7cfef3303797cbc7515d9ce22ffe849c701b0f2812f999b0847229c47951fca5' } @@ -37,9 +45,9 @@ describe('SigningService', () => { const request: Request = { action: 'signTransaction', nonce: 'random-nonce-111', - resourceId: 'eip155:eoa:0xc3bdcdb4f593aa5a5d81cd425f6fc3265d962157', + resourceId: 'eip155:eoa:0x2c4895215973CbBd778C32c456C074b99daF8Bf1', transactionRequest: { - from: '0xc3bdcdb4F593AA5A5D81cD425f6Fc3265D962157', + from: '0x2c4895215973CbBd778C32c456C074b99daF8Bf1', to: '0x04B12F0863b83c7162429f0Ebb0DfdA20E1aA97B', chainId: 137, value: '0x5af3107a4000', @@ -65,7 +73,7 @@ describe('SigningService', () => { // Just for testing formatting & stuff it('should serialize/deserialize', async () => { const txRequest: TransactionSerializable = { - // from: '0xc3bdcdb4F593AA5A5D81cD425f6Fc3265D962157', + // from: '0x2c4895215973CbBd778C32c456C074b99daF8Bf1', to: '0x04B12F0863b83c7162429f0Ebb0DfdA20E1aA97B'.toLowerCase() as Hex, chainId: 137, value: hexToBigInt('0x5af3107a4000'), @@ -77,5 +85,59 @@ describe('SigningService', () => { expect(deserialized).toEqual(txRequest) }) + + it('signs EIP191 Message string', async () => { + const tenantId = 'tenantId' + const messageRequest: Request = { + action: Action.SIGN_MESSAGE, + nonce: 'random-nonce-111', + message: 'My ASCII message', + resourceId: 'eip155:eoa:0x2c4895215973CbBd778C32c456C074b99daF8Bf1' + } + + const expectedSignature = + '0x65071b7126abd24fe6b8fa396529e21d22448d23ff1a6c5a0e043a4f641cd11b2a21958127d1b91db4d991f8b33ad6b201637799a95eadbe3a7cf5cee26bd9521b' + + // Call the sign method + const result = await signingService.sign(tenantId, messageRequest) + + const isVerified = await verifyMessage({ + address: wallet.address, + message: messageRequest.message, + signature: result + }) + + // Assert the result + expect(result).toEqual(expectedSignature) + expect(isVerified).toEqual(true) + }) + + it('signs EIP191 Message Hex', async () => { + const tenantId = 'tenantId' + const messageRequest: Request = { + action: Action.SIGN_MESSAGE, + nonce: 'random-nonce-111', + message: { + raw: toHex('My ASCII message') + }, + resourceId: 'eip155:eoa:0x2c4895215973CbBd778C32c456C074b99daF8Bf1' + } + + const expectedSignature = + '0x65071b7126abd24fe6b8fa396529e21d22448d23ff1a6c5a0e043a4f641cd11b2a21958127d1b91db4d991f8b33ad6b201637799a95eadbe3a7cf5cee26bd9521b' + + // Call the sign method + const result = await signingService.sign(tenantId, messageRequest) + + const isVerified = await verifyMessage({ + address: wallet.address, + message: messageRequest.message, + signature: result + }) + + // Assert the result + expect(result).toEqual(expectedSignature) + expect(isVerified).toEqual(true) + }) }) }) diff --git a/apps/vault/src/vault/core/service/signing.service.ts b/apps/vault/src/vault/core/service/signing.service.ts index 982eb09be..e41612670 100644 --- a/apps/vault/src/vault/core/service/signing.service.ts +++ b/apps/vault/src/vault/core/service/signing.service.ts @@ -1,4 +1,4 @@ -import { Action, Hex, Request, SignTransactionAction } from '@narval/policy-engine-shared' +import { Action, Hex, Request, SignMessageAction, SignTransactionAction } from '@narval/policy-engine-shared' import { HttpStatus, Injectable } from '@nestjs/common' import { TransactionRequest, @@ -18,9 +18,11 @@ import { WalletRepository } from '../../persistence/repository/wallet.repository export class SigningService { constructor(private walletRepository: WalletRepository) {} - async sign(tenantId: string, request: Request): Promise { + async sign(tenantId: string, request: Request): Promise { if (request.action === Action.SIGN_TRANSACTION) { return this.signTransaction(tenantId, request) + } else if (request.action === Action.SIGN_MESSAGE) { + return this.signMessage(tenantId, request) } throw new Error('Action not supported') @@ -78,4 +80,27 @@ export class SigningService { return signature } + + async signMessage(tenantId: string, action: SignMessageAction): Promise { + const { message, resourceId } = action + const wallet = await this.walletRepository.findById(tenantId, resourceId) + if (!wallet) { + throw new ApplicationException({ + message: 'Wallet not found', + suggestedHttpStatusCode: HttpStatus.BAD_REQUEST, + context: { clientId: tenantId, resourceId } + }) + } + + const account = privateKeyToAccount(wallet.privateKey) + + const client = createWalletClient({ + account, + chain: chains.mainnet, + transport: http('') // clear the RPC so we don't call any chain stuff here. + }) + + const signature = await client.signMessage({ message }) + return signature + } } diff --git a/packages/nestjs-shared/src/lib/dto/__test__/unit/sign-message-request-data-dto.spec.ts b/packages/nestjs-shared/src/lib/dto/__test__/unit/sign-message-request-data-dto.spec.ts new file mode 100644 index 000000000..9bb9c22d3 --- /dev/null +++ b/packages/nestjs-shared/src/lib/dto/__test__/unit/sign-message-request-data-dto.spec.ts @@ -0,0 +1,57 @@ +import { Action, toHex } from '@narval/policy-engine-shared' +import { Hex } from '@narval/signature' +import { validateSync } from 'class-validator' +import { SignMessageRequestDataDto } from '../../sign-message-request-data-dto' + +describe('SignMessageRequestDataDto', () => { + it('should validate a valid SignMessageRequestDataDto object', () => { + const dto = new SignMessageRequestDataDto() + dto.action = Action.SIGN_MESSAGE + dto.nonce = 'xxx' + dto.resourceId = 'eip155:eoa:0x2c4895215973CbBd778C32c456C074b99daF8Bf1' + dto.message = 'My ASCII message' + + const errors = validateSync(dto) + + expect(errors).toEqual([]) + expect(errors.length).toBe(0) + }) + + it('should not validate an invalid SignMessageRequestDataDto object', () => { + const dto = new SignMessageRequestDataDto() + dto.nonce = 'xxx' + // eslint-disable-next-line @typescript-eslint/no-explicit-any + dto.action = 'invalid-action' as any // Invalid action value + dto.resourceId = 'invalid-resource-id' // Invalid resourceId value + // eslint-disable-next-line @typescript-eslint/no-explicit-any + dto.message = 123 as any // Invalid message value + + const errors = validateSync(dto) + + expect(errors.length).toBeGreaterThan(0) + }) + + it('validates nested raw message', () => { + const dto = new SignMessageRequestDataDto() + dto.nonce = 'xxx' + dto.action = Action.SIGN_MESSAGE + dto.resourceId = 'eip155:eoa:0x2c4895215973CbBd778C32c456C074b99daF8Bf1' + dto.message = { raw: toHex('My ASCII message') } + + const errors = validateSync(dto) + + expect(errors.length).toBe(0) + }) + + it('validates nested raw message must be hex', () => { + const dto = new SignMessageRequestDataDto() + dto.nonce = 'xxx' + dto.action = Action.SIGN_MESSAGE + dto.resourceId = 'eip155:eoa:0x2c4895215973CbBd778C32c456C074b99daF8Bf1' + dto.message = { raw: 'My ASCII message' as Hex } + + const errors = validateSync(dto) + + expect(errors.length).toBe(1) + }) +}) diff --git a/packages/nestjs-shared/src/lib/dto/sign-message-request-data-dto.ts b/packages/nestjs-shared/src/lib/dto/sign-message-request-data-dto.ts index cd71bc7d3..719112e07 100644 --- a/packages/nestjs-shared/src/lib/dto/sign-message-request-data-dto.ts +++ b/packages/nestjs-shared/src/lib/dto/sign-message-request-data-dto.ts @@ -1,8 +1,35 @@ -import { Action } from '@narval/policy-engine-shared' +import { Action, isHexString } from '@narval/policy-engine-shared' +import { Hex } from '@narval/signature' import { ApiProperty } from '@nestjs/swagger' -import { IsDefined, IsIn, IsString } from 'class-validator' +import { IsDefined, IsIn, IsString, ValidationOptions, registerDecorator } from 'class-validator' import { BaseActionDto } from './' +// This is needed because class-validator cannot handle `string | RawMessage` when using @ValidateNested() +function IsStringOrHasRawProperty(validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + name: 'isStringOrHasRawProperty', + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + validator: { + validate: (value: unknown) => { + if (value === null || value === undefined) return false + if (typeof value === 'string') return true + if (typeof value === 'object' && 'raw' in value) { + return isHexString(value.raw) + } + return false + }, + defaultMessage: () => 'The message must be a string or an object with a raw property as a hex string' + } + }) + } +} + +class RawMessage { + raw: Hex +} export class SignMessageRequestDataDto extends BaseActionDto { @IsIn(Object.values(Action)) @IsDefined() @@ -17,8 +44,7 @@ export class SignMessageRequestDataDto extends BaseActionDto { @ApiProperty() resourceId: string - @IsString() - @IsDefined() @ApiProperty() - message: string // TODO: Is this string hex or raw? + @IsStringOrHasRawProperty() + message: string | RawMessage } diff --git a/packages/policy-engine-shared/src/lib/type/action.type.ts b/packages/policy-engine-shared/src/lib/type/action.type.ts index 6adcb3fee..5698bb7f6 100644 --- a/packages/policy-engine-shared/src/lib/type/action.type.ts +++ b/packages/policy-engine-shared/src/lib/type/action.type.ts @@ -1,3 +1,4 @@ +import { Hex } from 'viem' import { Address, JwtString, TransactionRequest } from './domain.type' import { AccountClassification, @@ -72,10 +73,13 @@ export type SignTransactionAction = BaseAction & { transactionRequest: TransactionRequest } +// Matching viem's SignableMessage options https://viem.sh/docs/actions/wallet/signMessage#message +export type SignableMessage = string | { raw: Hex } + export type SignMessageAction = BaseAction & { action: typeof Action.SIGN_MESSAGE resourceId: string - message: string + message: SignableMessage } export type SignTypedDataAction = BaseAction & {