diff --git a/apps/devtool/src/app/components/EditorComponent.tsx b/apps/devtool/src/app/components/EditorComponent.tsx index 44b737d31..ea443e299 100644 --- a/apps/devtool/src/app/components/EditorComponent.tsx +++ b/apps/devtool/src/app/components/EditorComponent.tsx @@ -1,7 +1,7 @@ 'use client' import Editor from '@monaco-editor/react' -import { JWK, Payload, SigningAlg, hash, hexToBase64Url, signJwt } from '@narval/signature' +import { Jwk, Payload, SigningAlg, hash, hexToBase64Url, signJwt } from '@narval/signature' import { getAccount, signMessage } from '@wagmi/core' import axios from 'axios' import Image from 'next/image' @@ -46,8 +46,8 @@ const EditorComponent = () => { const address = getAccount(config).address if (!address) throw new Error('No address connected') - // Need real JWK - const jwk: JWK = { + // Need real Jwk + const jwk: Jwk = { kty: 'EC', crv: 'secp256k1', alg: SigningAlg.ES256K, diff --git a/apps/policy-engine/src/open-policy-agent/core/open-policy-agent.engine.ts b/apps/policy-engine/src/open-policy-agent/core/open-policy-agent.engine.ts index 647cdc5be..97e0f1863 100644 --- a/apps/policy-engine/src/open-policy-agent/core/open-policy-agent.engine.ts +++ b/apps/policy-engine/src/open-policy-agent/core/open-policy-agent.engine.ts @@ -12,8 +12,9 @@ import { } from '@narval/policy-engine-shared' import { Hex, - Jwk, Payload, + PrivateKey, + PublicKey, SigningAlg, base64UrlToHex, buildSignerEip191, @@ -304,8 +305,8 @@ export class OpenPolicyAgentEngine implements Engine { } private async sign(params: { principalCredential: CredentialEntity; message: string }): Promise { - const engineJwk: Jwk = secp256k1PrivateKeyToJwk(this.privateKey) - const principalJwk: Jwk = params.principalCredential.key + const engineJwk: PrivateKey = secp256k1PrivateKeyToJwk(this.privateKey) + const principalJwk: PublicKey = params.principalCredential.key const payload: Payload = { requestHash: params.message, diff --git a/apps/policy-engine/src/shared/filter/__test__/unit/application-exception.filter.spec.ts b/apps/policy-engine/src/shared/filter/__test__/unit/application-exception.filter.spec.ts index f440e4e79..e27884571 100644 --- a/apps/policy-engine/src/shared/filter/__test__/unit/application-exception.filter.spec.ts +++ b/apps/policy-engine/src/shared/filter/__test__/unit/application-exception.filter.spec.ts @@ -1,4 +1,4 @@ -import { ArgumentsHost, HttpStatus } from '@nestjs/common' +import { ArgumentsHost, HttpStatus, Logger } from '@nestjs/common' import { HttpArgumentsHost } from '@nestjs/common/interfaces' import { ConfigService } from '@nestjs/config' import { Response } from 'express' @@ -46,6 +46,8 @@ describe(ApplicationExceptionFilter.name, () => { }) describe('catch', () => { + Logger.overrideLogger([]) + describe('when environment is production', () => { it('responds with exception status and short message', () => { const filter = new ApplicationExceptionFilter(buildConfigServiceMock(Env.PRODUCTION)) diff --git a/apps/vault/src/shared/filter/__test__/unit/application-exception.filter.spec.ts b/apps/vault/src/shared/filter/__test__/unit/application-exception.filter.spec.ts new file mode 100644 index 000000000..8f47ab56c --- /dev/null +++ b/apps/vault/src/shared/filter/__test__/unit/application-exception.filter.spec.ts @@ -0,0 +1,82 @@ +import { ArgumentsHost, HttpStatus } from '@nestjs/common' +import { HttpArgumentsHost } from '@nestjs/common/interfaces' +import { ConfigService } from '@nestjs/config' +import { Response } from 'express' +import { mock } from 'jest-mock-extended' +import { Config, Env } from '../../../../main.config' +import { ApplicationException } from '../../../../shared/exception/application.exception' +import { ApplicationExceptionFilter } from '../../../../shared/filter/application-exception.filter' + +describe(ApplicationExceptionFilter.name, () => { + const exception = new ApplicationException({ + message: 'Test application exception filter', + suggestedHttpStatusCode: HttpStatus.INTERNAL_SERVER_ERROR, + context: { + additional: 'information', + to: 'debug' + } + }) + + const buildArgumentsHostMock = (): [ArgumentsHost, jest.Mock, jest.Mock] => { + const jsonMock = jest.fn() + const statusMock = jest.fn().mockReturnValue( + mock({ + json: jsonMock + }) + ) + + const host = mock({ + switchToHttp: jest.fn().mockReturnValue( + mock({ + getResponse: jest.fn().mockReturnValue( + mock({ + status: statusMock + }) + ) + }) + ) + }) + + return [host, statusMock, jsonMock] + } + + const buildConfigServiceMock = (env: Env) => + mock>({ + get: jest.fn().mockReturnValue(env) + }) + + describe('catch', () => { + describe('when environment is production', () => { + it('responds with exception status and short message', () => { + const filter = new ApplicationExceptionFilter(buildConfigServiceMock(Env.PRODUCTION)) + const [host, statusMock, jsonMock] = buildArgumentsHostMock() + + filter.catch(exception, host) + + expect(statusMock).toHaveBeenCalledWith(exception.getStatus()) + expect(jsonMock).toHaveBeenCalledWith({ + statusCode: exception.getStatus(), + message: exception.message, + context: exception.context + }) + }) + }) + + describe('when environment is not production', () => { + it('responds with exception status and complete message', () => { + const filter = new ApplicationExceptionFilter(buildConfigServiceMock(Env.DEVELOPMENT)) + const [host, statusMock, jsonMock] = buildArgumentsHostMock() + + filter.catch(exception, host) + + expect(statusMock).toHaveBeenCalledWith(exception.getStatus()) + expect(jsonMock).toHaveBeenCalledWith({ + statusCode: exception.getStatus(), + message: exception.message, + context: exception.context, + stack: exception.stack + }) + }) + }) + }) +}) diff --git a/apps/vault/src/shared/filter/application-exception.filter.ts b/apps/vault/src/shared/filter/application-exception.filter.ts new file mode 100644 index 000000000..4fc0ea45b --- /dev/null +++ b/apps/vault/src/shared/filter/application-exception.filter.ts @@ -0,0 +1,52 @@ +import { ArgumentsHost, Catch, ExceptionFilter, LogLevel, Logger } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import { Response } from 'express' +import { Config, Env } from '../../main.config' +import { ApplicationException } from '../../shared/exception/application.exception' + +@Catch(ApplicationException) +export class ApplicationExceptionFilter implements ExceptionFilter { + private logger = new Logger(ApplicationExceptionFilter.name) + + constructor(private configService: ConfigService) {} + + catch(exception: ApplicationException, host: ArgumentsHost) { + const ctx = host.switchToHttp() + const response = ctx.getResponse() + const status = exception.getStatus() + const isProduction = this.configService.get('env') === Env.PRODUCTION + + this.log(exception) + + response.status(status).json( + isProduction + ? { + statusCode: status, + message: exception.message, + context: exception.context + } + : { + statusCode: status, + message: exception.message, + context: exception.context, + stack: exception.stack, + ...(exception.origin && { origin: exception.origin }) + } + ) + } + + // TODO (@wcalderipe, 16/01/24): Unit test the logging logic. For that, we + // must inject the logger in the constructor via dependency injection. + private log(exception: ApplicationException) { + const level: LogLevel = exception.getStatus() >= 500 ? 'error' : 'warn' + + if (this.logger[level]) { + this.logger[level](exception.message, { + status: exception.getStatus(), + context: exception.context, + stacktrace: exception.stack, + origin: exception.origin + }) + } + } +} diff --git a/apps/vault/src/shared/filter/zod-exception.filter.ts b/apps/vault/src/shared/filter/zod-exception.filter.ts new file mode 100644 index 000000000..aa9aff7f4 --- /dev/null +++ b/apps/vault/src/shared/filter/zod-exception.filter.ts @@ -0,0 +1,39 @@ +import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus, Logger } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import { Response } from 'express' +import { ZodError } from 'zod' +import { Config, Env } from '../../main.config' + +@Catch(ZodError) +export class ZodExceptionFilter implements ExceptionFilter { + private logger = new Logger(ZodExceptionFilter.name) + + constructor(private configService: ConfigService) {} + + catch(exception: ZodError, host: ArgumentsHost) { + const ctx = host.switchToHttp() + const response = ctx.getResponse() + const status = HttpStatus.UNPROCESSABLE_ENTITY + const isProduction = this.configService.get('env') === Env.PRODUCTION + + // Log as error level because Zod issues should be handled by the caller. + this.logger.error('Uncaught ZodError', { + exception + }) + + response.status(status).json( + isProduction + ? { + statusCode: status, + message: 'Internal validation error', + context: exception.errors + } + : { + statusCode: status, + message: 'Internal validation error', + context: exception.errors, + stacktrace: exception.stack + } + ) + } +} diff --git a/apps/vault/src/shared/guard/authorization.guard.ts b/apps/vault/src/shared/guard/authorization.guard.ts new file mode 100644 index 000000000..0af22d185 --- /dev/null +++ b/apps/vault/src/shared/guard/authorization.guard.ts @@ -0,0 +1,117 @@ +import { PublicKey, hash, hexToBase64Url, verifyJwsd, verifyJwt } from '@narval/signature' +import { CanActivate, ExecutionContext, HttpStatus, Injectable } from '@nestjs/common' +import { z } from 'zod' +import { REQUEST_HEADER_CLIENT_ID } from '../../main.constant' +import { TenantService } from '../../tenant/core/service/tenant.service' +import { ApplicationException } from '../exception/application.exception' + +const AuthorizationHeaderSchema = z.object({ + authorization: z.string() +}) + +@Injectable() +export class AuthorizationGuard implements CanActivate { + constructor(private tenantService: TenantService) {} + + async canActivate(context: ExecutionContext): Promise { + const req = context.switchToHttp().getRequest() + const clientId = req.headers[REQUEST_HEADER_CLIENT_ID] + const headers = AuthorizationHeaderSchema.parse(req.headers) + // Expect the header in the format "GNAP " + const accessToken: string | undefined = headers.authorization.split('GNAP ')[1] + + if (!accessToken) { + throw new ApplicationException({ + message: `Missing or invalid Access Token in Authorization header`, + suggestedHttpStatusCode: HttpStatus.UNAUTHORIZED + }) + } + + if (!clientId) { + throw new ApplicationException({ + message: `Missing or invalid ${REQUEST_HEADER_CLIENT_ID} header`, + suggestedHttpStatusCode: HttpStatus.UNAUTHORIZED + }) + } + + const tenant = await this.tenantService.findByClientId(clientId) + if (!tenant?.engineJwk) { + throw new ApplicationException({ + message: 'No engine key configured', + suggestedHttpStatusCode: HttpStatus.UNAUTHORIZED, + context: { + clientId + } + }) + } + const isAuthorized = await this.validateToken(context, accessToken, tenant?.engineJwk) + + return isAuthorized + } + + async validateToken(context: ExecutionContext, token: string, tenantJwk: PublicKey): Promise { + // 1. Validate the JWT has a valid signature for the expected tenant key + const { payload } = await verifyJwt(token, tenantJwk) + // console.log('Validated', { header, payload }) + + // 2. Validate the TX Request sent is the same as the one in the JWT + const req = context.switchToHttp().getRequest() + const request = req.body.request + const verificationMsg = hash(request) + const requestMatches = payload.requestHash === verificationMsg + if (!requestMatches) { + throw new ApplicationException({ + message: `Request payload does not match the authorized request`, + suggestedHttpStatusCode: HttpStatus.FORBIDDEN + }) + } + + // 3. Validate that the JWT has all the corerct properties, claims, etc. + // This belongs in the signature lib, but needs to accept a options obj like jose does. + // We probs have to roll our own simply so we can support EIP191 + // const v = await jwtVerify(token, await importJWK(tenantJwk)) + // console.log('JWT Verified', v) + + // We want to also check the client key in cnf so we can optionally do bound requests + if (payload.cnf) { + const boundKey = payload.cnf + const jwsdHeader = req.headers['detached-jws'] + if (!jwsdHeader) { + throw new ApplicationException({ + message: `Missing detached-jws header`, + suggestedHttpStatusCode: HttpStatus.FORBIDDEN + }) + } + + const parts = jwsdHeader.split('.') + const deepCopyBody = JSON.parse(JSON.stringify(req.body)) + // This is the GNAP spec; base64URL the sha256 of the whole request body (not just our tx body.request part) + const jwsdPayload = hexToBase64Url(`0x${hash(deepCopyBody)}`) + // Replace the payload part; this lets the JWT be compacted with `header..signature` to be shorter. + parts[1] = jwsdPayload + const jwsdToVerify = parts.join('.') + // Will throw if not valid + try { + const decodedJwsd = await verifyJwsd(jwsdToVerify, boundKey) + // Verify the ATH matches our accessToken + const tokenHash = hexToBase64Url(`0x${hash(token)}`) + if (decodedJwsd.header.ath !== tokenHash) { + throw new ApplicationException({ + message: `Request ath does not match the access token`, + suggestedHttpStatusCode: HttpStatus.FORBIDDEN + }) + } + } catch (err) { + throw new ApplicationException({ + message: err.message, + suggestedHttpStatusCode: HttpStatus.FORBIDDEN + }) + } + // TODO: verify the request URI & such in the jwsd header + } + + // Then we sign. + + return true + } +} diff --git a/apps/vault/src/shared/schema/tenant.schema.ts b/apps/vault/src/shared/schema/tenant.schema.ts index c9e1098a2..00c641913 100644 --- a/apps/vault/src/shared/schema/tenant.schema.ts +++ b/apps/vault/src/shared/schema/tenant.schema.ts @@ -1,8 +1,10 @@ +import { publicKeySchema } from '@narval/signature' import { z } from 'zod' export const tenantSchema = z.object({ clientId: z.string(), clientSecret: z.string(), + engineJwk: publicKeySchema.optional(), createdAt: z.coerce.date(), updatedAt: z.coerce.date() }) diff --git a/apps/vault/src/tenant/__test__/e2e/tenant.spec.ts b/apps/vault/src/tenant/__test__/e2e/tenant.spec.ts index d455d40b0..545ecf166 100644 --- a/apps/vault/src/tenant/__test__/e2e/tenant.spec.ts +++ b/apps/vault/src/tenant/__test__/e2e/tenant.spec.ts @@ -95,6 +95,38 @@ describe('Tenant', () => { expect(status).toEqual(HttpStatus.CREATED) }) + it('creates a new tenant with Engine JWK', async () => { + const newPayload: CreateTenantDto = { + clientId: 'tenant-2', + engineJwk: { + kty: 'EC', + crv: 'secp256k1', + alg: 'ES256K', + kid: '0x73d3ed0e92ac09a45d9538980214abb1a36c4943d64ffa53a407683ddf567fc9', + x: 'sxT67JN5KJVnWYyy7xhFNUOk4buvPLrbElHBinuFwmY', + y: 'CzC7IHlsDg9wz-Gqhtc78eC0IEX75upMgrvmS3U6Ad4' + } + } + const { status, body } = await request(app.getHttpServer()) + .post('/tenants') + .set(REQUEST_HEADER_API_KEY, adminApiKey) + .send(newPayload) + const actualTenant = await tenantRepository.findByClientId('tenant-2') + + expect(body).toMatchObject({ + clientId: newPayload.clientId, + clientSecret: expect.any(String), + createdAt: expect.any(String), + updatedAt: expect.any(String) + }) + expect(body).toEqual({ + ...actualTenant, + createdAt: actualTenant?.createdAt.toISOString(), + updatedAt: actualTenant?.updatedAt.toISOString() + }) + expect(status).toEqual(HttpStatus.CREATED) + }) + it('responds with an error when clientId already exist', async () => { await request(app.getHttpServer()).post('/tenants').set(REQUEST_HEADER_API_KEY, adminApiKey).send(payload) @@ -103,10 +135,8 @@ describe('Tenant', () => { .set(REQUEST_HEADER_API_KEY, adminApiKey) .send(payload) - expect(body).toEqual({ - message: 'Tenant already exist', - statusCode: HttpStatus.BAD_REQUEST - }) + expect(body.statusCode).toEqual(HttpStatus.BAD_REQUEST) + expect(body.message).toEqual('Tenant already exist') expect(status).toEqual(HttpStatus.BAD_REQUEST) }) diff --git a/apps/vault/src/tenant/http/rest/controller/tenant.controller.ts b/apps/vault/src/tenant/http/rest/controller/tenant.controller.ts index a5e223884..203662771 100644 --- a/apps/vault/src/tenant/http/rest/controller/tenant.controller.ts +++ b/apps/vault/src/tenant/http/rest/controller/tenant.controller.ts @@ -1,3 +1,4 @@ +import { publicKeySchema } from '@narval/signature' import { Body, Controller, Post, UseGuards } from '@nestjs/common' import { randomBytes } from 'crypto' import { v4 as uuid } from 'uuid' @@ -14,9 +15,11 @@ export class TenantController { async create(@Body() body: CreateTenantDto) { const now = new Date() + const engineJwk = body.engineJwk ? publicKeySchema.parse(body.engineJwk) : undefined // Validate the JWK, instead of in DTO const tenant = await this.tenantService.onboard({ clientId: body.clientId || uuid(), clientSecret: randomBytes(42).toString('hex'), + engineJwk, createdAt: now, updatedAt: now }) diff --git a/apps/vault/src/tenant/http/rest/dto/create-tenant.dto.ts b/apps/vault/src/tenant/http/rest/dto/create-tenant.dto.ts index d545788f1..6a6448411 100644 --- a/apps/vault/src/tenant/http/rest/dto/create-tenant.dto.ts +++ b/apps/vault/src/tenant/http/rest/dto/create-tenant.dto.ts @@ -1,8 +1,12 @@ +import { Jwk } from '@narval/signature' import { ApiPropertyOptional } from '@nestjs/swagger' -import { IsString } from 'class-validator' +import { IsOptional, IsString } from 'class-validator' export class CreateTenantDto { @IsString() @ApiPropertyOptional() clientId?: string + + @IsOptional() + engineJwk?: Jwk } diff --git a/apps/vault/src/tenant/tenant.module.ts b/apps/vault/src/tenant/tenant.module.ts index 92e1002d5..b532b6372 100644 --- a/apps/vault/src/tenant/tenant.module.ts +++ b/apps/vault/src/tenant/tenant.module.ts @@ -1,5 +1,5 @@ import { HttpModule } from '@nestjs/axios' -import { Module, OnApplicationBootstrap, ValidationPipe } from '@nestjs/common' +import { Module, OnApplicationBootstrap, ValidationPipe, forwardRef } from '@nestjs/common' import { APP_PIPE } from '@nestjs/core' import { AdminApiKeyGuard } from '../shared/guard/admin-api-key.guard' import { KeyValueModule } from '../shared/module/key-value/key-value.module' @@ -11,7 +11,7 @@ import { TenantRepository } from './persistence/repository/tenant.repository' @Module({ // NOTE: The AdminApiKeyGuard is the only reason we need the VaultModule. - imports: [HttpModule, KeyValueModule, VaultModule], + imports: [HttpModule, KeyValueModule, forwardRef(() => VaultModule)], controllers: [TenantController], providers: [ AdminApiKeyGuard, diff --git a/apps/vault/src/vault/__test__/e2e/sign.spec.ts b/apps/vault/src/vault/__test__/e2e/sign.spec.ts index 983d125af..4df25fffa 100644 --- a/apps/vault/src/vault/__test__/e2e/sign.spec.ts +++ b/apps/vault/src/vault/__test__/e2e/sign.spec.ts @@ -1,11 +1,24 @@ import { EncryptionModuleOptionProvider } from '@narval/encryption-module' +import { + JwsdHeader, + Payload, + SigningAlg, + buildSignerEip191, + hash, + hexToBase64Url, + secp256k1PrivateKeyToJwk, + secp256k1PublicKeyToJwk, + signJwsd, + signJwt +} from '@narval/signature' import { HttpStatus, INestApplication } from '@nestjs/common' import { ConfigModule } from '@nestjs/config' 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 { load } from '../../../main.config' -import { REQUEST_HEADER_API_KEY, REQUEST_HEADER_CLIENT_ID } from '../../../main.constant' +import { REQUEST_HEADER_CLIENT_ID } from '../../../main.constant' import { KeyValueRepository } from '../../../shared/module/key-value/core/repository/key-value.repository' import { InMemoryKeyValueRepository } from '../../../shared/module/key-value/persistence/repository/in-memory-key-value.repository' import { getTestRawAesKeyring } from '../../../shared/testing/encryption.testing' @@ -20,9 +33,18 @@ describe('Sign', () => { const adminApiKey = 'test-admin-api-key' const clientId = uuid() + + const PRIVATE_KEY = '0x7cfef3303797cbc7515d9ce22ffe849c701b0f2812f999b0847229c47951fca5' + // Engine key used to sign the approval request + const enginePrivateJwk = secp256k1PrivateKeyToJwk(PRIVATE_KEY) + // Engine public key registered w/ the Vault Tenant + // eslint-disable-next-line + const { d, ...tenantPublicJWK } = enginePrivateJwk + const tenant: Tenant = { clientId, clientSecret: adminApiKey, + engineJwk: tenantPublicJWK, createdAt: new Date(), updatedAt: new Date() } @@ -33,6 +55,39 @@ describe('Sign', () => { privateKey: '0x7cfef3303797cbc7515d9ce22ffe849c701b0f2812f999b0847229c47951fca5' } + const defaultRequest = { + action: 'signTransaction', + nonce: 'random-nonce-111', + transactionRequest: { + from: '0xc3bdcdb4F593AA5A5D81cD425f6Fc3265D962157', + to: '0x04B12F0863b83c7162429f0Ebb0DfdA20E1aA97B', + chainId: 137, + value: '0x5af3107a4000', + data: '0x', + nonce: 317, + type: '2', + gas: '21004', + maxFeePerGas: '291175227375', + maxPriorityFeePerGas: '81000000000' + }, + resourceId: 'eip155:eoa:0xc3bdcdb4f593aa5a5d81cd425f6fc3265d962157' + } + + const getAccessToken = async (request?: unknown, opts: object = {}) => { + const payload: Payload = { + requestHash: hash(request || defaultRequest), + sub: 'test-root-user-uid', + iss: 'https://armory.narval.xyz', + iat: Math.floor(Date.now() / 1000), + ...opts + } + + const signer = buildSignerEip191(PRIVATE_KEY) + const jwt = await signJwt(payload, enginePrivateJwk, { alg: SigningAlg.EIP191 }, signer) + + return jwt + } + beforeAll(async () => { module = await Test.createTestingModule({ imports: [ @@ -60,7 +115,7 @@ describe('Sign', () => { }) .compile() - app = module.createNestApplication() + app = module.createNestApplication({ logger: false }) await app.init() }) @@ -75,11 +130,10 @@ describe('Sign', () => { it('has client secret guard', async () => { const { status } = await request(app.getHttpServer()) .post('/sign') - .set(REQUEST_HEADER_API_KEY, adminApiKey) // .set(REQUEST_HEADER_CLIENT_ID, clientId) NO CLIENT SECRET .send({}) - expect(status).toEqual(HttpStatus.UNAUTHORIZED) + expect(status).toEqual(HttpStatus.UNPROCESSABLE_ENTITY) }) it('validates nested txn data', async () => { @@ -105,10 +159,12 @@ describe('Sign', () => { } } + const accessToken = await getAccessToken(payload.request) + const { status, body } = await request(app.getHttpServer()) .post('/sign') - .set(REQUEST_HEADER_API_KEY, adminApiKey) .set(REQUEST_HEADER_CLIENT_ID, clientId) + .set('authorization', `GNAP ${accessToken}`) .send(payload) expect(status).toEqual(HttpStatus.BAD_REQUEST) @@ -121,31 +177,15 @@ describe('Sign', () => { }) it('signs', async () => { - const payload = { - request: { - action: 'signTransaction', - nonce: 'random-nonce-111', - transactionRequest: { - from: '0xc3bdcdb4F593AA5A5D81cD425f6Fc3265D962157', - to: '0x04B12F0863b83c7162429f0Ebb0DfdA20E1aA97B', - chainId: 137, - value: '0x5af3107a4000', - data: '0x', - nonce: 317, - type: '2', - gas: '21004', - maxFeePerGas: '291175227375', - maxPriorityFeePerGas: '81000000000' - }, - resourceId: 'eip155:eoa:0xc3bdcdb4f593aa5a5d81cd425f6fc3265d962157' - } - } + const bodyPayload = { request: defaultRequest } + + const accessToken = await getAccessToken() const { status, body } = await request(app.getHttpServer()) .post('/sign') - .set(REQUEST_HEADER_API_KEY, adminApiKey) .set(REQUEST_HEADER_CLIENT_ID, clientId) - .send(payload) + .set('authorization', `GNAP ${accessToken}`) + .send(bodyPayload) expect(status).toEqual(HttpStatus.CREATED) @@ -155,4 +195,121 @@ describe('Sign', () => { }) }) }) + + describe('AuthorizationGuard', () => { + it('returns error when request does not match authorized request', async () => { + const bodyPayload = { + request: { + ...defaultRequest, + nonce: defaultRequest.nonce + 'x' // CHANGE THE NONCE SO IT DOES NOT MATCH ACCESS TOKEN + } + } + + const accessToken = await getAccessToken(defaultRequest) + + const { status, body } = await request(app.getHttpServer()) + .post('/sign') + .set(REQUEST_HEADER_CLIENT_ID, clientId) + .set('authorization', `GNAP ${accessToken}`) + .send(bodyPayload) + + expect(status).toEqual(HttpStatus.FORBIDDEN) + expect(body.statusCode).toEqual(HttpStatus.FORBIDDEN) + expect(body.message).toEqual('Request payload does not match the authorized request') + }) + + describe('jwsd', () => { + it('returns error when auth is client-bound but no jwsd header', async () => { + const bodyPayload = { request: defaultRequest } + + const clientJwk = secp256k1PublicKeyToJwk(ACCOUNT.Alice.publicKey) + const accessToken = await getAccessToken(defaultRequest, { cnf: clientJwk }) + + const { status, body } = await request(app.getHttpServer()) + .post('/sign') + .set(REQUEST_HEADER_CLIENT_ID, clientId) + .set('authorization', `GNAP ${accessToken}`) + .send(bodyPayload) + + expect(status).toEqual(HttpStatus.FORBIDDEN) + expect(body.statusCode).toEqual(HttpStatus.FORBIDDEN) + expect(body.message).toEqual(`Missing detached-jws header`) + }) + + it('verifies jwsd header in a client-bound request', async () => { + const now = Math.floor(Date.now() / 1000) + const bodyPayload = { request: defaultRequest } + + const clientJwk = secp256k1PublicKeyToJwk(ACCOUNT.Alice.publicKey) + const accessToken = await getAccessToken(defaultRequest, { cnf: clientJwk }) + + const jwsdSigner = buildSignerEip191(UNSAFE_PRIVATE_KEY.Alice) + const jwsdHeader: JwsdHeader = { + alg: SigningAlg.EIP191, + kid: clientJwk.kid, + typ: 'gnap-binding-jwsd', + htm: 'POST', + uri: 'https://armory.narval.xyz/sign', + created: now, + ath: hexToBase64Url(`0x${hash(accessToken)}`) + } + const jwsd = await signJwsd(bodyPayload, jwsdHeader, jwsdSigner).then((jws) => { + // Strip out the middle part for size + const parts = jws.split('.') + parts[1] = '' + return parts.join('.') + }) + + const { status, body } = await request(app.getHttpServer()) + .post('/sign') + .set(REQUEST_HEADER_CLIENT_ID, clientId) + .set('authorization', `GNAP ${accessToken}`) + .set('detached-jws', jwsd) + .send(bodyPayload) + + expect(body.message).toBeUndefined() // no message on this response; we're asserting it so we get a nice message on why this failed if it does fail. + expect(status).toEqual(HttpStatus.CREATED) + }) + + it('returns error when auth is client-bound to a different key', async () => { + const now = Math.floor(Date.now() / 1000) + const bodyPayload = { request: defaultRequest } + + const clientJwk = secp256k1PublicKeyToJwk(ACCOUNT.Alice.publicKey) + const boundClientJwk = secp256k1PublicKeyToJwk(ACCOUNT.Bob.publicKey) + // We bind BOB to the access token, but ALICe is the one signing the request, so she has + // a valid access token but it's not bound to her. + const accessToken = await getAccessToken(defaultRequest, { cnf: boundClientJwk }) + + const jwsdSigner = buildSignerEip191(UNSAFE_PRIVATE_KEY.Alice) + const jwsdHeader: JwsdHeader = { + alg: SigningAlg.EIP191, + kid: clientJwk.kid, + typ: 'gnap-binding-jwsd', + htm: 'POST', + uri: 'https://armory.narval.xyz/sign', + created: now, + ath: '' + } + + const jwsd = await signJwsd(bodyPayload, jwsdHeader, jwsdSigner).then((jws) => { + // Strip out the middle part for size + const parts = jws.split('.') + parts[1] = '' + return parts.join('.') + }) + + const { status, body } = await request(app.getHttpServer()) + .post('/sign') + .set(REQUEST_HEADER_CLIENT_ID, clientId) + .set('authorization', `GNAP ${accessToken}`) + .set('detached-jws', jwsd) + .send(bodyPayload) + + expect(status).toEqual(HttpStatus.FORBIDDEN) + expect(body.statusCode).toEqual(HttpStatus.FORBIDDEN) + expect(body.message).toEqual('Invalid JWT signature') + }) + }) + }) }) diff --git a/apps/vault/src/vault/http/rest/controller/sign.controller.ts b/apps/vault/src/vault/http/rest/controller/sign.controller.ts index 28429622a..c6b462b94 100644 --- a/apps/vault/src/vault/http/rest/controller/sign.controller.ts +++ b/apps/vault/src/vault/http/rest/controller/sign.controller.ts @@ -1,12 +1,12 @@ import { Request } from '@narval/policy-engine-shared' import { Body, Controller, Post, UseGuards } from '@nestjs/common' import { ClientId } from '../../../../shared/decorator/client-id.decorator' -import { ClientSecretGuard } from '../../../../shared/guard/client-secret.guard' +import { AuthorizationGuard } from '../../../../shared/guard/authorization.guard' import { SigningService } from '../../../core/service/signing.service' import { SignRequestDto } from '../dto/sign-request.dto' @Controller('/sign') -@UseGuards(ClientSecretGuard) +@UseGuards(AuthorizationGuard) export class SignController { constructor(private signingService: SigningService) {} diff --git a/apps/vault/src/vault/persistence/repository/mock_data.ts b/apps/vault/src/vault/persistence/repository/mock_data.ts index b8b2aecce..74a260d7e 100644 --- a/apps/vault/src/vault/persistence/repository/mock_data.ts +++ b/apps/vault/src/vault/persistence/repository/mock_data.ts @@ -1,5 +1,5 @@ import { Action, EvaluationRequest, FIXTURE, Request, TransactionRequest } from '@narval/policy-engine-shared' -import { Payload, SigningAlg, buildSignerEip191, hash, privateKeyToJwk, signJwt } from '@narval/signature' +import { Payload, SigningAlg, buildSignerEip191, hash, secp256k1PrivateKeyToJwk, signJwt } from '@narval/signature' import { UNSAFE_PRIVATE_KEY } from 'packages/policy-engine-shared/src/lib/dev.fixture' import { toHex } from 'viem' @@ -31,19 +31,19 @@ export const generateInboundRequest = async (): Promise => { // const aliceSignature = await FIXTURE.ACCOUNT.Alice.signMessage({ message }) const aliceSignature = await signJwt( payload, - privateKeyToJwk(UNSAFE_PRIVATE_KEY.Alice), + secp256k1PrivateKeyToJwk(UNSAFE_PRIVATE_KEY.Alice), { alg: SigningAlg.EIP191 }, buildSignerEip191(UNSAFE_PRIVATE_KEY.Alice) ) const bobSignature = await signJwt( payload, - privateKeyToJwk(UNSAFE_PRIVATE_KEY.Bob), + secp256k1PrivateKeyToJwk(UNSAFE_PRIVATE_KEY.Bob), { alg: SigningAlg.EIP191 }, buildSignerEip191(UNSAFE_PRIVATE_KEY.Bob) ) const carolSignature = await signJwt( payload, - privateKeyToJwk(UNSAFE_PRIVATE_KEY.Carol), + secp256k1PrivateKeyToJwk(UNSAFE_PRIVATE_KEY.Carol), { alg: SigningAlg.EIP191 }, buildSignerEip191(UNSAFE_PRIVATE_KEY.Carol) ) diff --git a/apps/vault/src/vault/vault.module.ts b/apps/vault/src/vault/vault.module.ts index 7affa09f5..36b653950 100644 --- a/apps/vault/src/vault/vault.module.ts +++ b/apps/vault/src/vault/vault.module.ts @@ -2,9 +2,11 @@ import { EncryptionModule } from '@narval/encryption-module' import { HttpModule } from '@nestjs/axios' import { Module, ValidationPipe, forwardRef } from '@nestjs/common' import { ConfigModule, ConfigService } from '@nestjs/config' -import { APP_PIPE } from '@nestjs/core' +import { APP_FILTER, APP_PIPE } from '@nestjs/core' import { load } from '../main.config' import { EncryptionModuleOptionFactory } from '../shared/factory/encryption-module-option.factory' +import { ApplicationExceptionFilter } from '../shared/filter/application-exception.filter' +import { ZodExceptionFilter } from '../shared/filter/zod-exception.filter' import { ClientSecretGuard } from '../shared/guard/client-secret.guard' import { KeyValueModule } from '../shared/module/key-value/key-value.module' import { TenantModule } from '../tenant/tenant.module' @@ -50,6 +52,14 @@ import { VaultService } from './vault.service' new ValidationPipe({ transform: true }) + }, + { + provide: APP_FILTER, + useClass: ApplicationExceptionFilter + }, + { + provide: APP_FILTER, + useClass: ZodExceptionFilter } ], exports: [AppService, ProvisionService] diff --git a/packages/policy-engine-shared/src/lib/schema/data-store.schema.ts b/packages/policy-engine-shared/src/lib/schema/data-store.schema.ts index 420d5d428..cb77a8dd9 100644 --- a/packages/policy-engine-shared/src/lib/schema/data-store.schema.ts +++ b/packages/policy-engine-shared/src/lib/schema/data-store.schema.ts @@ -1,24 +1,12 @@ +import { jwkSchema } from '@narval/signature' import { z } from 'zod' import { entitiesSchema } from './entity.schema' import { policySchema } from './policy.schema' -export const jsonWebKeySchema = z.object({ - kty: z.enum(['EC', 'RSA']).describe('Key Type (e.g. RSA or EC'), - crv: z.enum(['P-256', 'secp256k1']).optional().describe('Curve name'), - kid: z.string().describe('Unique key ID'), - alg: z.enum(['ES256K', 'ES256', 'RS256']).describe('Algorithm'), - use: z.enum(['sig', 'enc']).optional().describe('Public Key Use'), - n: z.string().optional().describe('(RSA) Key modulus'), - e: z.string().optional().describe('(RSA) Key exponent'), - x: z.string().optional().describe('(EC) X Coordinate'), - y: z.string().optional().describe('(EC) Y Coordinate'), - d: z.string().optional().describe('(EC) Private Key') -}) - export const dataStoreConfigurationSchema = z.object({ dataUrl: z.string().min(1), signatureUrl: z.string().min(1), - keys: z.array(jsonWebKeySchema) + keys: z.array(jwkSchema) }) export const dataStoreSchema = z.object({ @@ -40,7 +28,7 @@ export const entitySignatureSchema = z.object({ export const entityJsonWebKeysSchema = z.object({ entity: z.object({ - keys: z.array(jsonWebKeySchema) + keys: z.array(jwkSchema) }) }) @@ -63,7 +51,7 @@ export const policySignatureSchema = z.object({ export const policyJsonWebKeysSchema = z.object({ policy: z.object({ - keys: z.array(jsonWebKeySchema) + keys: z.array(jwkSchema) }) }) diff --git a/packages/policy-engine-shared/src/lib/type/data-store.type.ts b/packages/policy-engine-shared/src/lib/type/data-store.type.ts index 8d37fe2e3..e12b7daf0 100644 --- a/packages/policy-engine-shared/src/lib/type/data-store.type.ts +++ b/packages/policy-engine-shared/src/lib/type/data-store.type.ts @@ -5,15 +5,12 @@ import { entityJsonWebKeysSchema, entitySignatureSchema, entityStoreSchema, - jsonWebKeySchema, policyDataSchema, policyJsonWebKeysSchema, policySignatureSchema, policyStoreSchema } from '../schema/data-store.schema' -export type JsonWebKey = z.infer - export type DataStoreConfiguration = z.infer export type EntityData = z.infer diff --git a/packages/signature/src/lib/decode.ts b/packages/signature/src/lib/decode.ts index 8a74b68f9..6432a066f 100644 --- a/packages/signature/src/lib/decode.ts +++ b/packages/signature/src/lib/decode.ts @@ -1,6 +1,6 @@ import { JwtError } from './error' import { isHeader } from './typeguards' -import { Jwt } from './types' +import { Jwsd, Jwt } from './types' import { base64UrlToBytes } from './utils' /** @@ -19,16 +19,49 @@ export function decode(rawToken: string): Jwt { } const [headerStr, payloadStr, jwtSig] = parts const header = JSON.parse(base64UrlToBytes(headerStr).toString('utf-8')) + if (!isHeader(header)) { + throw new JwtError({ message: 'Invalid header', context: { rawToken, header } }) + } + const payload = JSON.parse(base64UrlToBytes(payloadStr).toString('utf-8')) // TODO: Switch these to zod parsers // if (!isPayload(payload)) { // throw new JwtError({ message: 'Invalid payload', context: { rawToken, payload } }) // } + + return { + header, + payload, + signature: jwtSig + } + } catch (error) { + throw new JwtError({ message: 'Malformed token', context: { rawToken, error } }) + } +} + +/** + * Decodes a JWT without verifying its signature. + * + * @param {string} rawToken - The JWT to decode. + * @returns {Jwsd} A promise that resolves with the decoded payload. + * @throws {Error} If the payload does not match the expected structure. + * @throws {Error} If the header does not match the expected structure. + */ +export function decodeJwsd(rawToken: string): Jwsd { + try { + const parts = rawToken.split('.') + if (parts.length !== 3) { + throw new Error('Invalid JWT: The token must have three parts') + } + const [headerStr, payloadStr, jwtSig] = parts + const header = JSON.parse(base64UrlToBytes(headerStr).toString('utf-8')) if (!isHeader(header)) { throw new JwtError({ message: 'Invalid header', context: { rawToken, header } }) } + const payload = base64UrlToBytes(payloadStr).toString('utf-8') // Should be a sha256hash + return { header, payload, diff --git a/packages/signature/src/lib/schemas.ts b/packages/signature/src/lib/schemas.ts index 6e0b5206d..eb23e5dcf 100644 --- a/packages/signature/src/lib/schemas.ts +++ b/packages/signature/src/lib/schemas.ts @@ -76,14 +76,14 @@ export const secp256k1KeySchema = z.union([secp256k1PublicKeySchema, secp256k1Pr const dynamicKeySchema = z.object({}).catchall(z.unknown()) export const jwkSchema = dynamicKeySchema.extend({ - kty: z.nativeEnum(KeyTypes).optional(), - crv: z.nativeEnum(Curves).optional(), - alg: z.nativeEnum(Alg).optional(), - use: z.nativeEnum(Use).optional(), - kid: z.string().optional(), - x: z.string().optional(), - y: z.string().optional(), - n: z.string().optional(), - e: z.string().optional(), - d: z.string().optional() + kty: z.nativeEnum(KeyTypes).optional().describe('Key Type (e.g. RSA or EC'), + crv: z.nativeEnum(Curves).optional().describe('Curve name'), + alg: z.nativeEnum(Alg).optional().describe('Algorithm'), + use: z.nativeEnum(Use).optional().describe('Public Key Use'), + kid: z.string().optional().describe('Unique key ID'), + n: z.string().optional().describe('(RSA) Key modulus'), + e: z.string().optional().describe('(RSA) Key exponent'), + x: z.string().optional().describe('(EC) X Coordinate'), + y: z.string().optional().describe('(EC) Y Coordinate'), + d: z.string().optional().describe('(EC) Private Key') }) diff --git a/packages/signature/src/lib/sign.ts b/packages/signature/src/lib/sign.ts index ab7ba6f72..6bfbc9158 100644 --- a/packages/signature/src/lib/sign.ts +++ b/packages/signature/src/lib/sign.ts @@ -4,11 +4,27 @@ import { keccak_256 as keccak256 } from '@noble/hashes/sha3' import { SignJWT, base64url, importJWK } from 'jose' import { isHex, signatureToHex, toBytes, toHex } from 'viem' import { privateKeySchema } from './schemas' -import { EcdsaSignature, Header, Hex, Jwk, Payload, PrivateKey, SigningAlg } from './types' +import { EcdsaSignature, Header, Hex, Jwk, JwsdHeader, Payload, PrivateKey, SigningAlg } from './types' +import { hash } from './hash-request' import { hexToBase64Url } from './utils' import { validate } from './validate' -// WIP to replace `sign` +export async function signJwsd( + rawBody: string | object, + header: JwsdHeader, + signer: (payload: string) => Promise +): Promise { + const encodedHeader = base64url.encode(JSON.stringify(header)) + const encodedPayload = hexToBase64Url(`0x${hash(rawBody)}`) + + const messageToSign = `${encodedHeader}.${encodedPayload}` + + const signature = await signer(messageToSign) + + const completeJWT = `${messageToSign}.${signature}` + return completeJWT +} + export async function signJwt( payload: Payload, jwk: Jwk, diff --git a/packages/signature/src/lib/types.ts b/packages/signature/src/lib/types.ts index d5ac704e2..f15319928 100644 --- a/packages/signature/src/lib/types.ts +++ b/packages/signature/src/lib/types.ts @@ -74,6 +74,19 @@ export type Header = { ath?: string | undefined // The hash of the access token. The value MUST be the result of Base64url encoding (with no padding) the SHA-256 digest of the ASCII encoding of the associated access token's value. } +// https://www.ietf.org/archive/id/draft-ietf-gnap-core-protocol-19.html#name-detached-jws +// For GNAP JWSD header, the fields are required. +// `ath` is also required IF it's a bound-request, otherwise it's optional +export type JwsdHeader = { + alg: SigningAlg + kid: string // Key ID to identify the signing key + typ: 'gnap-binding-jwsd' // see https://www.ietf.org/archive/id/draft-ietf-gnap-core-protocol-19.html#name-detached-jws + htm: string // HTTP Method + uri: string // The HTTP URI used for this request. This value MUST be an absolute URI, including all path and query components and no fragment component. + created: number // The time the request was created. + ath?: string | undefined // The hash of the access token. The value MUST be the result of Base64url encoding (with no padding) the SHA-256 digest of the ASCII encoding of the associated access token's value. +} + /** * Defines the payload of JWT. * @@ -94,7 +107,7 @@ export type Payload = { iss?: string aud?: string jti?: string - cnf?: Jwk // The client-bound key + cnf?: PublicKey // The client-bound key requestHash?: string data?: string // hash of any data } @@ -105,6 +118,12 @@ export type Jwt = { signature: string } +export type Jwsd = { + header: Header + payload: string + signature: string +} + /** * Defines the input required to generate a JWT signature for a request. * diff --git a/packages/signature/src/lib/verify.ts b/packages/signature/src/lib/verify.ts index c7f87930c..e191ee37b 100644 --- a/packages/signature/src/lib/verify.ts +++ b/packages/signature/src/lib/verify.ts @@ -1,12 +1,12 @@ import { secp256k1 } from '@noble/curves/secp256k1' import { importJWK, jwtVerify } from 'jose' import { isAddressEqual, recoverAddress } from 'viem' -import { decode } from './decode' +import { decode, decodeJwsd } from './decode' import { JwtError } from './error' import { publicKeySchema } from './schemas' import { eip191Hash } from './sign' import { isSepc256k1PublicKeyJwk } from './typeguards' -import { Alg, EoaPublicKey, Hex, Jwk, Jwt, Payload, PublicKey, Secp256k1PublicKey, SigningAlg } from './types' +import { Alg, EoaPublicKey, Hex, Jwk, Jwsd, Jwt, Payload, PublicKey, Secp256k1PublicKey, SigningAlg } from './types' import { base64UrlToHex, secp256k1PublicKeyToHex } from './utils' import { validate } from './validate' @@ -99,3 +99,21 @@ export async function verifyJwt(jwt: string, jwk: Jwk): Promise { signature } } + +export async function verifyJwsd(jws: string, jwk: PublicKey): Promise { + const { header, payload, signature } = decodeJwsd(jws) + + if (header.alg === SigningAlg.EIP191) { + await verifyEip191(jws, jwk) + } else { + // TODO: Implement other algs individually without jose + const joseJwk = await importJWK(jwk) + await jwtVerify(jws, joseJwk) + } + + return { + header, + payload, + signature + } +}