diff --git a/.github/workflows/signature.yml b/.github/workflows/signature.yml new file mode 100644 index 000000000..d57747d6c --- /dev/null +++ b/.github/workflows/signature.yml @@ -0,0 +1,64 @@ +name: '@narval/signature CI' + +on: + push: + paths: + - packages/signature/** + - .github/workflows/signature.yml + - jest.config.ts + - jest.preset.js + - .eslintrc.json + - .prettierrc + - package.json + - package-lock.json + +jobs: + build-and-test: + name: Build and test + + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@master + + - name: Install Node.js + uses: actions/setup-node@v3 + with: + node-version: '20.4.0' + + - name: Install dependencies + run: | + make install/ci + + - name: Code format + shell: bash + run: | + make signature/format/check + make signature/lint/check + + - name: Test types + shell: bash + run: | + make signature/test/type + + - name: Test upstream application types + shell: bash + run: | + make policy-engine/test/type + + - name: Test unit + shell: bash + run: | + make signature/test/unit + + - name: Send Slack notification on failure + if: failure() && github.ref == 'refs/heads/main' + uses: 8398a7/action-slack@v3 + with: + username: GitHub + author_name: '@narval/signature CI failed' + status: ${{ job.status }} + fields: message,commit,author + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} diff --git a/README.md b/README.md index 279fb27c6..05e008f26 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,11 @@ | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | [@app/armory](./apps/armory/README.md) | @app/armory CI status | | [@app/policy-engine](./apps/policy-engine/README.md) | @app/policy-engine CI status | -| [@narval/encryption-module](./packages/encryption-module/README.md) | N/A | +| [@narval/encryption](./packages/encryption/README.md) | @narval/encryption CI status | + | | [@narval/policy-engine-shared](./packages/policy-engine-shared/README.md) | @narval/policy-engine-shared CI status | -| [@narval/signature](./packages/signature/README.md) | N/A | +| [@narval/signature](./packages/signature/README.md) | @narval/signature CI status | + | | [@narval/transaction-request-intent](./packages/transaction-request-intent/README.md) | @narval/transaction-request-intent CI status | ## Getting started diff --git a/apps/armory/src/data-feed/core/service/historical-transfer-feed.service.ts b/apps/armory/src/data-feed/core/service/historical-transfer-feed.service.ts index 3d3813e1b..56e05a17d 100644 --- a/apps/armory/src/data-feed/core/service/historical-transfer-feed.service.ts +++ b/apps/armory/src/data-feed/core/service/historical-transfer-feed.service.ts @@ -1,5 +1,5 @@ import { Feed, HistoricalTransfer, JwtString } from '@narval/policy-engine-shared' -import { Payload, SigningAlg, hash, hexToBase64Url, privateKeyToJwk, signJwt } from '@narval/signature' +import { Payload, SigningAlg, hash, hexToBase64Url, secp256k1PrivateKeyToJwk, signJwt } from '@narval/signature' import { Injectable } from '@nestjs/common' import { ConfigService } from '@nestjs/config' import { mapValues, omit } from 'lodash/fp' @@ -37,7 +37,7 @@ export class HistoricalTransferFeedService implements DataFeed { } const now = Math.floor(Date.now() / 1000) - const jwk = privateKeyToJwk(this.getPrivateKey()) + const jwk = secp256k1PrivateKeyToJwk(this.getPrivateKey()) const payload: Payload = { data: hash(data), sub: account.address, 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/engine/app.controller.ts b/apps/policy-engine/src/engine/app.controller.ts index 449a55923..a338cb530 100644 --- a/apps/policy-engine/src/engine/app.controller.ts +++ b/apps/policy-engine/src/engine/app.controller.ts @@ -30,7 +30,12 @@ export class AppController { body }) - return this.evaluationService.evaluate(FIXTURE.ORGANIZATION.id, body) + const result = await this.evaluationService.evaluate(FIXTURE.ORGANIZATION.id, body) + + this.logger.log({ + message: 'Evaluation result', + body: result + }) } @Post('/evaluation-demo') diff --git a/apps/policy-engine/src/engine/core/service/signing.service.ts b/apps/policy-engine/src/engine/core/service/signing.service.ts index 8728ac5c5..518de268a 100644 --- a/apps/policy-engine/src/engine/core/service/signing.service.ts +++ b/apps/policy-engine/src/engine/core/service/signing.service.ts @@ -1,11 +1,13 @@ -import { JsonWebKey, toHex } from '@narval/policy-engine-shared' +import { toHex } from '@narval/policy-engine-shared' import { Alg, Payload, + PrivateKey, + PublicKey, SigningAlg, buildSignerEip191, buildSignerEs256k, - privateKeyToJwk, + secp256k1PrivateKeyToJwk, signJwt } from '@narval/signature' import { Injectable } from '@nestjs/common' @@ -17,8 +19,8 @@ type KeyGenerationOptions = { } type KeyGenerationResponse = { - publicKey: JsonWebKey - privateKey?: JsonWebKey + publicKey: PublicKey + privateKey?: PrivateKey } type SignOptions = { @@ -32,7 +34,7 @@ export class SigningService { async generateSigningKey(alg: Alg, options?: KeyGenerationOptions): Promise { if (alg === Alg.ES256K) { const privateKey = toHex(secp256k1.utils.randomPrivateKey()) - const privateJwk = privateKeyToJwk(privateKey, options?.keyId) + const privateJwk = secp256k1PrivateKeyToJwk(privateKey, options?.keyId) // Remove the privateKey from the public jwk const publicJwk = { @@ -49,21 +51,14 @@ export class SigningService { throw new Error('Unsupported algorithm') } - async sign(payload: Payload, jwk: JsonWebKey, opts: SignOptions = {}): Promise { + async sign(payload: Payload, jwk: PrivateKey, opts: SignOptions = {}): Promise { const alg: SigningAlg = opts.alg || jwk.alg if (alg === SigningAlg.ES256K) { - if (!jwk.d) { - throw new Error('Missing private key') - } const pk = jwk.d - const jwt = await signJwt(payload, jwk, opts, buildSignerEs256k(pk)) return jwt } else if (alg === SigningAlg.EIP191) { - if (!jwk.d) { - throw new Error('Missing private key') - } const pk = jwk.d const jwt = await signJwt(payload, jwk, opts, buildSignerEip191(pk)) diff --git a/apps/policy-engine/src/engine/core/service/tenant.service.ts b/apps/policy-engine/src/engine/core/service/tenant.service.ts index 7dcf38027..15a3bdf57 100644 --- a/apps/policy-engine/src/engine/core/service/tenant.service.ts +++ b/apps/policy-engine/src/engine/core/service/tenant.service.ts @@ -71,7 +71,7 @@ export class TenantService { this.tenantRepository.savePolicyStore(clientId, stores.policy) ]) - this.logger.log('Tenant data stores synced', { clientId, stores }) + this.logger.log('Tenant data stores synced', { clientId }) return true } diff --git a/apps/policy-engine/src/open-policy-agent/core/__test__/unit/open-policy-agent.engine.spec.ts b/apps/policy-engine/src/open-policy-agent/core/__test__/unit/open-policy-agent.engine.spec.ts index 50e758932..c12a12b9f 100644 --- a/apps/policy-engine/src/open-policy-agent/core/__test__/unit/open-policy-agent.engine.spec.ts +++ b/apps/policy-engine/src/open-policy-agent/core/__test__/unit/open-policy-agent.engine.spec.ts @@ -13,7 +13,7 @@ import { Then, toHex } from '@narval/policy-engine-shared' -import { SigningAlg, buildSignerEip191, hash, privateKeyToJwk, signJwt } from '@narval/signature' +import { SigningAlg, buildSignerEip191, hash, secp256k1PrivateKeyToJwk, signJwt } from '@narval/signature' import { Path, PathValue } from '@nestjs/config' import { Test, TestingModule } from '@nestjs/testing' import { Config, load } from '../../../../policy-engine.config' @@ -26,7 +26,7 @@ const ONE_ETH = toHex(BigInt('1000000000000000000')) const UNSAFE_ENGINE_PRIVATE_KEY = '0x7cfef3303797cbc7515d9ce22ffe849c701b0f2812f999b0847229c47951fca5' const getJwt = (option: { privateKey: Hex; request: Request; sub: string }): Promise => { - const jwk = privateKeyToJwk(option.privateKey) + const jwk = secp256k1PrivateKeyToJwk(option.privateKey) const signer = buildSignerEip191(option.privateKey) return signJwt( 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 8a985b33f..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 @@ -7,20 +7,20 @@ import { Entities, EvaluationRequest, EvaluationResponse, - JsonWebKey, JwtString, Policy } from '@narval/policy-engine-shared' import { Hex, Payload, + PrivateKey, + PublicKey, SigningAlg, base64UrlToHex, buildSignerEip191, decode, hash, - privateKeyToJwk, - publicKeyToJwk, + secp256k1PrivateKeyToJwk, signJwt, verifyJwt } from '@narval/signature' @@ -187,9 +187,10 @@ export class OpenPolicyAgentEngine implements Engine { }) } - const jwk = publicKeyToJwk(credential.pubKey as Hex) + const { key } = credential - const validJwt = await verifyJwt(signature, jwk) + console.log('### credential', credential) + const validJwt = await verifyJwt(signature, key) if (validJwt.payload.requestHash !== message) { throw new OpenPolicyAgentException({ @@ -304,8 +305,8 @@ export class OpenPolicyAgentEngine implements Engine { } private async sign(params: { principalCredential: CredentialEntity; message: string }): Promise { - const engineJwk: JsonWebKey = privateKeyToJwk(this.privateKey) - const principalJwk = publicKeyToJwk(params.principalCredential.pubKey as Hex) + 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/resource/open-policy-agent/rego/__test__/criteria/principal_test.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/principal_test.rego index d25bc974d..816adda3b 100644 --- a/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/principal_test.rego +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/principal_test.rego @@ -4,7 +4,7 @@ test_principal { user = principal with input as request with data.entities as entities - user == {"uid": "test-bob-uid", "role": "root"} + user == {"id": "test-bob-uid", "role": "root"} groups = principalGroups with input as request with data.entities as entities diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/main_test.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/main_test.rego index 8b5240749..8d6760677 100644 --- a/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/main_test.rego +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/main_test.rego @@ -115,23 +115,23 @@ request = { entities = { "users": { "test-bob-uid": { - "uid": "test-bob-uid", + "id": "test-bob-uid", "role": "root", }, "test-alice-uid": { - "uid": "test-alice-uid", + "id": "test-alice-uid", "role": "member", }, "test-bar-uid": { - "uid": "test-bar-uid", + "id": "test-bar-uid", "role": "admin", }, "test-foo-uid": { - "uid": "test-foo-uid", + "id": "test-foo-uid", "role": "admin", }, "0xaaa8ee1cbaa1856f4550c6fc24abb16c5c9b2a43": { - "uid": "0xaaa8ee1cbaa1856f4550c6fc24abb16c5c9b2a43", + "id": "0xaaa8ee1cbaa1856f4550c6fc24abb16c5c9b2a43", "role": "admin", }, }, diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/policies/e2e_test.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/policies/e2e_test.rego index 28d2e315b..771c15485 100644 --- a/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/policies/e2e_test.rego +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/policies/e2e_test.rego @@ -120,10 +120,10 @@ e2e_req = { e2e_entities = { "users": { - "u:root_user": {"uid": "u:root_user", "role": "root"}, - "matt@narval.xyz": {"uid": "matt@narval.xyz", "role": "admin"}, - "aa@narval.xyz": {"uid": "aa@narval.xyz", "role": "admin"}, - "bb@narval.xyz": {"uid": "bb@narval.xyz", "role": "admin"}, + "u:root_user": {"id": "u:root_user", "role": "root"}, + "matt@narval.xyz": {"id": "matt@narval.xyz", "role": "admin"}, + "aa@narval.xyz": {"id": "aa@narval.xyz", "role": "admin"}, + "bb@narval.xyz": {"id": "bb@narval.xyz", "role": "admin"}, }, "userGroups": { "ug:dev-group": {"uid": "ug:dev-group", "name": "Dev", "users": ["matt@narval.xyz"]}, diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/approval.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/approval.rego index 110e1a5a7..edf9fe031 100644 --- a/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/approval.rego +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/approval.rego @@ -30,7 +30,7 @@ getApprovalsCount(possibleApprovers) = result { checkApproval(approval) = result { approval.countPrincipal == true approval.approvalEntityType == "Narval::User" - possibleApprovers = {entity | entity = approval.entityIds[_]} | {principal.uid} + possibleApprovers = {entity | entity = approval.entityIds[_]} | {principal.id} result = getApprovalsCount(possibleApprovers) } @@ -39,7 +39,7 @@ checkApproval(approval) = result { approval.approvalEntityType == "Narval::User" possibleApprovers = {entity | entity = approval.entityIds[_] - entity != principal.uid + entity != principal.id } result = getApprovalsCount(possibleApprovers) } @@ -53,7 +53,7 @@ checkApproval(approval) = result { entity = approval.entityIds[_] users = userGroupsEntities[entity].users user = users[_] - } | {principal.uid} + } | {principal.id} result = getApprovalsCount(possibleApprovers) } @@ -65,7 +65,7 @@ checkApproval(approval) = result { entity = approval.entityIds[_] users = userGroupsEntities[entity].users user = users[_] - user != principal.uid + user != principal.id } result = getApprovalsCount(possibleApprovers) @@ -76,10 +76,10 @@ checkApproval(approval) = result { checkApproval(approval) = result { approval.countPrincipal == true approval.approvalEntityType == "Narval::UserRole" - possibleApprovers = {user.uid | + possibleApprovers = {user.id | user = usersEntities[_] user.role in approval.entityIds - } | {principal.uid} + } | {principal.id} result = getApprovalsCount(possibleApprovers) } @@ -87,10 +87,10 @@ checkApproval(approval) = result { checkApproval(approval) = result { approval.countPrincipal == false approval.approvalEntityType == "Narval::UserRole" - possibleApprovers = {user.uid | + possibleApprovers = {user.id | user = usersEntities[_] user.role in approval.entityIds - user.uid != principal.uid + user.id != principal.id } result = getApprovalsCount(possibleApprovers) diff --git a/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/principal.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/principal.rego index 8deba84d2..401d463c2 100644 --- a/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/principal.rego +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/principal.rego @@ -11,14 +11,14 @@ principalGroups = {group.uid | isPrincipalRootUser = principal.role == "root" -isPrincipalAssignedToWallet = principal.uid in resource.assignees +isPrincipalAssignedToWallet = principal.id in resource.assignees checkPrincipal { not isPrincipalRootUser isPrincipalAssignedToWallet } -checkPrincipalId(values) = principal.uid in values +checkPrincipalId(values) = principal.id in values checkPrincipalRole(values) = principal.role in values 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 5ab241ab3..069b34d03 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,5 +1,5 @@ import { ConfigService } from '@narval/config-module' -import { ArgumentsHost, HttpStatus } from '@nestjs/common' +import { ArgumentsHost, HttpStatus, Logger } from '@nestjs/common' import { HttpArgumentsHost } from '@nestjs/common/interfaces' import { Response } from 'express' import { mock } from 'jest-mock-extended' @@ -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/policy-engine/src/shared/testing/evaluation.testing.ts b/apps/policy-engine/src/shared/testing/evaluation.testing.ts index ffbd599e2..a0a268621 100644 --- a/apps/policy-engine/src/shared/testing/evaluation.testing.ts +++ b/apps/policy-engine/src/shared/testing/evaluation.testing.ts @@ -1,5 +1,6 @@ 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' export const ONE_ETH = BigInt('1000000000000000000') @@ -30,19 +31,19 @@ export const generateInboundEvaluationRequest = async (): Promise { + 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/package-lock.json b/package-lock.json index 9fc399290..0f7bc7013 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,7 +38,6 @@ "class-validator": "^0.14.1", "clsx": "^1.2.1", "date-fns": "^3.6.0", - "ethers": "^5.7.2", "handlebars": "^4.7.8", "jose": "^5.2.2", "lodash": "^4.17.21", @@ -6768,697 +6767,6 @@ "node": ">=14" } }, - "node_modules/@ethersproject/abi": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/abi/-/abi-5.7.0.tgz", - "integrity": "sha512-351ktp42TiRcYB3H1OP8yajPeAQstMW/yCFokj/AthP9bLHzQFPlOrxOcwYEDkUAICmOHljvN4K39OMTMUa9RA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "dependencies": { - "@ethersproject/address": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/constants": "^5.7.0", - "@ethersproject/hash": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@ethersproject/abstract-provider": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/abstract-provider/-/abstract-provider-5.7.0.tgz", - "integrity": "sha512-R41c9UkchKCpAqStMYUpdunjo3pkEvZC3FAwZn5S5MGbXoMQOHIdHItezTETxAO5bevtMApSyEhn9+CHcDsWBw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "dependencies": { - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/networks": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "@ethersproject/web": "^5.7.0" - } - }, - "node_modules/@ethersproject/abstract-signer": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/abstract-signer/-/abstract-signer-5.7.0.tgz", - "integrity": "sha512-a16V8bq1/Cz+TGCkE2OPMTOUDLS3grCpdjoJCYNnVBbdYEMSgKrU0+B90s8b6H+ByYTBZN7a3g76jdIJi7UfKQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "dependencies": { - "@ethersproject/abstract-provider": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0" - } - }, - "node_modules/@ethersproject/address": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/address/-/address-5.7.0.tgz", - "integrity": "sha512-9wYhYt7aghVGo758POM5nqcOMaE168Q6aRLJZwUmiqSrAungkG74gSSeKEIR7ukixesdRZGPgVqme6vmxs1fkA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "dependencies": { - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/rlp": "^5.7.0" - } - }, - "node_modules/@ethersproject/base64": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/base64/-/base64-5.7.0.tgz", - "integrity": "sha512-Dr8tcHt2mEbsZr/mwTPIQAf3Ai0Bks/7gTw9dSqk1mQvhW3XvRlmDJr/4n+wg1JmCl16NZue17CDh8xb/vZ0sQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "dependencies": { - "@ethersproject/bytes": "^5.7.0" - } - }, - "node_modules/@ethersproject/basex": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/basex/-/basex-5.7.0.tgz", - "integrity": "sha512-ywlh43GwZLv2Voc2gQVTKBoVQ1mti3d8HK5aMxsfu/nRDnMmNqaSJ3r3n85HBByT8OpoY96SXM1FogC533T4zw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/properties": "^5.7.0" - } - }, - "node_modules/@ethersproject/bignumber": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/bignumber/-/bignumber-5.7.0.tgz", - "integrity": "sha512-n1CAdIHRWjSucQO3MC1zPSVgV/6dy/fjL9pMrPP9peL+QxEg9wOsVqwD4+818B6LUEtaXzVHQiuivzRoxPxUGw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "bn.js": "^5.2.1" - } - }, - "node_modules/@ethersproject/bytes": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/bytes/-/bytes-5.7.0.tgz", - "integrity": "sha512-nsbxwgFXWh9NyYWo+U8atvmMsSdKJprTcICAkvbBffT75qDocbuggBU0SJiVK2MuTrp0q+xvLkTnGMPK1+uA9A==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "dependencies": { - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@ethersproject/constants": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/constants/-/constants-5.7.0.tgz", - "integrity": "sha512-DHI+y5dBNvkpYUMiRQyxRBYBefZkJfo70VUkUAsRjcPs47muV9evftfZ0PJVCXYbAiCgght0DtcF9srFQmIgWA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "dependencies": { - "@ethersproject/bignumber": "^5.7.0" - } - }, - "node_modules/@ethersproject/contracts": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/contracts/-/contracts-5.7.0.tgz", - "integrity": "sha512-5GJbzEU3X+d33CdfPhcyS+z8MzsTrBGk/sc+G+59+tPa9yFkl6HQ9D6L0QMgNTA9q8dT0XKxxkyp883XsQvbbg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "dependencies": { - "@ethersproject/abi": "^5.7.0", - "@ethersproject/abstract-provider": "^5.7.0", - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/address": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/constants": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/transactions": "^5.7.0" - } - }, - "node_modules/@ethersproject/hash": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/hash/-/hash-5.7.0.tgz", - "integrity": "sha512-qX5WrQfnah1EFnO5zJv1v46a8HW0+E5xuBBDTwMFZLuVTx0tbU2kkx15NqdjxecrLGatQN9FGQKpb1FKdHCt+g==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "dependencies": { - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/address": "^5.7.0", - "@ethersproject/base64": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@ethersproject/hdnode": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/hdnode/-/hdnode-5.7.0.tgz", - "integrity": "sha512-OmyYo9EENBPPf4ERhR7oj6uAtUAhYGqOnIS+jE5pTXvdKBS99ikzq1E7Iv0ZQZ5V36Lqx1qZLeak0Ra16qpeOg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "dependencies": { - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/basex": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/pbkdf2": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/sha2": "^5.7.0", - "@ethersproject/signing-key": "^5.7.0", - "@ethersproject/strings": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "@ethersproject/wordlists": "^5.7.0" - } - }, - "node_modules/@ethersproject/json-wallets": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/json-wallets/-/json-wallets-5.7.0.tgz", - "integrity": "sha512-8oee5Xgu6+RKgJTkvEMl2wDgSPSAQ9MB/3JYjFV9jlKvcYHUXZC+cQp0njgmxdHkYWn8s6/IqIZYm0YWCjO/0g==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "dependencies": { - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/address": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/hdnode": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/pbkdf2": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/random": "^5.7.0", - "@ethersproject/strings": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "aes-js": "3.0.0", - "scrypt-js": "3.0.1" - } - }, - "node_modules/@ethersproject/keccak256": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/keccak256/-/keccak256-5.7.0.tgz", - "integrity": "sha512-2UcPboeL/iW+pSg6vZ6ydF8tCnv3Iu/8tUmLLzWWGzxWKFFqOBQFLo6uLUv6BDrLgCDfN28RJ/wtByx+jZ4KBg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "js-sha3": "0.8.0" - } - }, - "node_modules/@ethersproject/logger": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/logger/-/logger-5.7.0.tgz", - "integrity": "sha512-0odtFdXu/XHtjQXJYA3u9G0G8btm0ND5Cu8M7i5vhEcE8/HmF4Lbdqanwyv4uQTr2tx6b7fQRmgLrsnpQlmnig==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ] - }, - "node_modules/@ethersproject/networks": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/@ethersproject/networks/-/networks-5.7.1.tgz", - "integrity": "sha512-n/MufjFYv3yFcUyfhnXotyDlNdFb7onmkSy8aQERi2PjNcnWQ66xXxa3XlS8nCcA8aJKJjIIMNJTC7tu80GwpQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "dependencies": { - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@ethersproject/pbkdf2": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/pbkdf2/-/pbkdf2-5.7.0.tgz", - "integrity": "sha512-oR/dBRZR6GTyaofd86DehG72hY6NpAjhabkhxgr3X2FpJtJuodEl2auADWBZfhDHgVCbu3/H/Ocq2uC6dpNjjw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/sha2": "^5.7.0" - } - }, - "node_modules/@ethersproject/properties": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/properties/-/properties-5.7.0.tgz", - "integrity": "sha512-J87jy8suntrAkIZtecpxEPxY//szqr1mlBaYlQ0r4RCaiD2hjheqF9s1LVE8vVuJCXisjIP+JgtK/Do54ej4Sw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "dependencies": { - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@ethersproject/providers": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/@ethersproject/providers/-/providers-5.7.2.tgz", - "integrity": "sha512-g34EWZ1WWAVgr4aptGlVBF8mhl3VWjv+8hoAnzStu8Ah22VHBsuGzP17eb6xDVRzw895G4W7vvx60lFFur/1Rg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "dependencies": { - "@ethersproject/abstract-provider": "^5.7.0", - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/address": "^5.7.0", - "@ethersproject/base64": "^5.7.0", - "@ethersproject/basex": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/constants": "^5.7.0", - "@ethersproject/hash": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/networks": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/random": "^5.7.0", - "@ethersproject/rlp": "^5.7.0", - "@ethersproject/sha2": "^5.7.0", - "@ethersproject/strings": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "@ethersproject/web": "^5.7.0", - "bech32": "1.1.4", - "ws": "7.4.6" - } - }, - "node_modules/@ethersproject/providers/node_modules/ws": { - "version": "7.4.6", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.6.tgz", - "integrity": "sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==", - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/@ethersproject/random": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/random/-/random-5.7.0.tgz", - "integrity": "sha512-19WjScqRA8IIeWclFme75VMXSBvi4e6InrUNuaR4s5pTF2qNhcGdCUwdxUVGtDDqC00sDLCO93jPQoDUH4HVmQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@ethersproject/rlp": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/rlp/-/rlp-5.7.0.tgz", - "integrity": "sha512-rBxzX2vK8mVF7b0Tol44t5Tb8gomOHkj5guL+HhzQ1yBh/ydjGnpw6at+X6Iw0Kp3OzzzkcKp8N9r0W4kYSs9w==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@ethersproject/sha2": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/sha2/-/sha2-5.7.0.tgz", - "integrity": "sha512-gKlH42riwb3KYp0reLsFTokByAKoJdgFCwI+CCiX/k+Jm2mbNs6oOaCjYQSlI1+XBVejwH2KrmCbMAT/GnRDQw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "hash.js": "1.1.7" - } - }, - "node_modules/@ethersproject/signing-key": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/signing-key/-/signing-key-5.7.0.tgz", - "integrity": "sha512-MZdy2nL3wO0u7gkB4nA/pEf8lu1TlFswPNmy8AiYkfKTdO6eXBJyUdmHO/ehm/htHw9K/qF8ujnTyUAD+Ry54Q==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "bn.js": "^5.2.1", - "elliptic": "6.5.4", - "hash.js": "1.1.7" - } - }, - "node_modules/@ethersproject/solidity": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/solidity/-/solidity-5.7.0.tgz", - "integrity": "sha512-HmabMd2Dt/raavyaGukF4XxizWKhKQ24DoLtdNbBmNKUOPqwjsKQSdV9GQtj9CBEea9DlzETlVER1gYeXXBGaA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "dependencies": { - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/sha2": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@ethersproject/strings": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/strings/-/strings-5.7.0.tgz", - "integrity": "sha512-/9nu+lj0YswRNSH0NXYqrh8775XNyEdUQAuf3f+SmOrnVewcJ5SBNAjF7lpgehKi4abvNNXyf+HX86czCdJ8Mg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/constants": "^5.7.0", - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@ethersproject/transactions": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/transactions/-/transactions-5.7.0.tgz", - "integrity": "sha512-kmcNicCp1lp8qanMTC3RIikGgoJ80ztTyvtsFvCYpSCfkjhD0jZ2LOrnbcuxuToLIUYYf+4XwD1rP+B/erDIhQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "dependencies": { - "@ethersproject/address": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/constants": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/rlp": "^5.7.0", - "@ethersproject/signing-key": "^5.7.0" - } - }, - "node_modules/@ethersproject/units": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/units/-/units-5.7.0.tgz", - "integrity": "sha512-pD3xLMy3SJu9kG5xDGI7+xhTEmGXlEqXU4OfNapmfnxLVY4EMSSRp7j1k7eezutBPH7RBN/7QPnwR7hzNlEFeg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "dependencies": { - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/constants": "^5.7.0", - "@ethersproject/logger": "^5.7.0" - } - }, - "node_modules/@ethersproject/wallet": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/wallet/-/wallet-5.7.0.tgz", - "integrity": "sha512-MhmXlJXEJFBFVKrDLB4ZdDzxcBxQ3rLyCkhNqVu3CDYvR97E+8r01UgrI+TI99Le+aYm/in/0vp86guJuM7FCA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "dependencies": { - "@ethersproject/abstract-provider": "^5.7.0", - "@ethersproject/abstract-signer": "^5.7.0", - "@ethersproject/address": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/hash": "^5.7.0", - "@ethersproject/hdnode": "^5.7.0", - "@ethersproject/json-wallets": "^5.7.0", - "@ethersproject/keccak256": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/random": "^5.7.0", - "@ethersproject/signing-key": "^5.7.0", - "@ethersproject/transactions": "^5.7.0", - "@ethersproject/wordlists": "^5.7.0" - } - }, - "node_modules/@ethersproject/web": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/@ethersproject/web/-/web-5.7.1.tgz", - "integrity": "sha512-Gueu8lSvyjBWL4cYsWsjh6MtMwM0+H4HvqFPZfB6dV8ctbP9zFAO73VG1cMWae0FLPCtz0peKPpZY8/ugJJX2w==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "dependencies": { - "@ethersproject/base64": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, - "node_modules/@ethersproject/wordlists": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@ethersproject/wordlists/-/wordlists-5.7.0.tgz", - "integrity": "sha512-S2TFNJNfHWVHNE6cNDjbVlZ6MgE17MIxMbMg2zv3wn+3XSJGosL1m9ZVv3GXCf/2ymSsQ+hRI5IzoMJTG6aoVA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "dependencies": { - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/hash": "^5.7.0", - "@ethersproject/logger": "^5.7.0", - "@ethersproject/properties": "^5.7.0", - "@ethersproject/strings": "^5.7.0" - } - }, "node_modules/@faker-js/faker": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.4.0.tgz", @@ -17910,11 +17218,6 @@ "node": ">= 10.0.0" } }, - "node_modules/aes-js": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.0.0.tgz", - "integrity": "sha512-H7wUZRn8WpTq9jocdxQ2c8x2sKo9ZVmzfRE13GiNJXfp7NcKYEdvl3vspKjXox6RIG2VtaRe4JFvxG4rqp2Zuw==" - }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -18900,11 +18203,6 @@ "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==" }, - "node_modules/bech32": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/bech32/-/bech32-1.1.4.tgz", - "integrity": "sha512-s0IrSOzLlbvX7yp4WBfPITzpAU8sqQcpsmwXDiKwrG4r491vwCO/XpejasRNl0piBMe/DvP4Tz0mIS/X1DPJBQ==" - }, "node_modules/big.js": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", @@ -24015,53 +23313,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/ethers": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/ethers/-/ethers-5.7.2.tgz", - "integrity": "sha512-wswUsmWo1aOK8rR7DIKiWSw9DbLWe6x98Jrn8wcTflTVvaXhAMaB5zGAXy0GYQEQp9iO1iSHWVyARQm11zUtyg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "dependencies": { - "@ethersproject/abi": "5.7.0", - "@ethersproject/abstract-provider": "5.7.0", - "@ethersproject/abstract-signer": "5.7.0", - "@ethersproject/address": "5.7.0", - "@ethersproject/base64": "5.7.0", - "@ethersproject/basex": "5.7.0", - "@ethersproject/bignumber": "5.7.0", - "@ethersproject/bytes": "5.7.0", - "@ethersproject/constants": "5.7.0", - "@ethersproject/contracts": "5.7.0", - "@ethersproject/hash": "5.7.0", - "@ethersproject/hdnode": "5.7.0", - "@ethersproject/json-wallets": "5.7.0", - "@ethersproject/keccak256": "5.7.0", - "@ethersproject/logger": "5.7.0", - "@ethersproject/networks": "5.7.1", - "@ethersproject/pbkdf2": "5.7.0", - "@ethersproject/properties": "5.7.0", - "@ethersproject/providers": "5.7.2", - "@ethersproject/random": "5.7.0", - "@ethersproject/rlp": "5.7.0", - "@ethersproject/sha2": "5.7.0", - "@ethersproject/signing-key": "5.7.0", - "@ethersproject/solidity": "5.7.0", - "@ethersproject/strings": "5.7.0", - "@ethersproject/transactions": "5.7.0", - "@ethersproject/units": "5.7.0", - "@ethersproject/wallet": "5.7.0", - "@ethersproject/web": "5.7.1", - "@ethersproject/wordlists": "5.7.0" - } - }, "node_modules/eval": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/eval/-/eval-0.1.8.tgz", @@ -28026,11 +27277,6 @@ "url": "https://github.com/sponsors/panva" } }, - "node_modules/js-sha3": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz", - "integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==" - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -36865,11 +36111,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/scrypt-js": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/scrypt-js/-/scrypt-js-3.0.1.tgz", - "integrity": "sha512-cdwTTnqPu0Hyvf5in5asVdZocVDTNRmR7XEcJuIzMjJeSHybHl7vpB66AzwTaIg6CLSbtjcxc8fqcySfnTkccA==" - }, "node_modules/search-insights": { "version": "2.13.0", "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.13.0.tgz", diff --git a/package.json b/package.json index 923e5e47a..dce5315b6 100644 --- a/package.json +++ b/package.json @@ -100,7 +100,6 @@ "class-validator": "^0.14.1", "clsx": "^1.2.1", "date-fns": "^3.6.0", - "ethers": "^5.7.2", "handlebars": "^4.7.8", "jose": "^5.2.2", "lodash": "^4.17.21", diff --git a/packages/policy-engine-shared/src/lib/dev.fixture.ts b/packages/policy-engine-shared/src/lib/dev.fixture.ts index 327122656..d0b42c5f6 100644 --- a/packages/policy-engine-shared/src/lib/dev.fixture.ts +++ b/packages/policy-engine-shared/src/lib/dev.fixture.ts @@ -1,4 +1,4 @@ -import { Alg, addressToKid } from '@narval/signature' +import { Secp256k1PublicKey, secp256k1PrivateKeyToJwk, secp256k1PublicKeySchema } from '@narval/signature' import { PrivateKeyAccount } from 'viem' import { privateKeyToAccount } from 'viem/accounts' import { Action } from './type/action.type' @@ -48,6 +48,14 @@ export const UNSAFE_PRIVATE_KEY: Record = { Dave: '0x82a0cf4f0fdfd42d93ff328b73bfdbc9c8b4f95f5aedfae82059753fc08a180f' } +export const PUBLIC_KEYS_JWK: Record = { + Root: secp256k1PublicKeySchema.parse(secp256k1PrivateKeyToJwk(UNSAFE_PRIVATE_KEY.Root)), + Alice: secp256k1PublicKeySchema.parse(secp256k1PrivateKeyToJwk(UNSAFE_PRIVATE_KEY.Alice)), + Bob: secp256k1PublicKeySchema.parse(secp256k1PrivateKeyToJwk(UNSAFE_PRIVATE_KEY.Bob)), + Carol: secp256k1PublicKeySchema.parse(secp256k1PrivateKeyToJwk(UNSAFE_PRIVATE_KEY.Carol)), + Dave: secp256k1PublicKeySchema.parse(secp256k1PrivateKeyToJwk(UNSAFE_PRIVATE_KEY.Dave)) +} + export const ACCOUNT: Record = { Root: privateKeyToAccount(UNSAFE_PRIVATE_KEY.Root), Alice: privateKeyToAccount(UNSAFE_PRIVATE_KEY.Alice), @@ -81,39 +89,29 @@ export const USER: Record = { export const CREDENTIAL: Record = { Root: { - id: addressToKid(ACCOUNT.Root.address), - pubKey: ACCOUNT.Root.publicKey, - address: ACCOUNT.Root.address, - alg: Alg.ES256K, - userId: USER.Root.id + id: PUBLIC_KEYS_JWK.Root.kid, + userId: USER.Root.id, + key: PUBLIC_KEYS_JWK.Root }, Alice: { - id: addressToKid(ACCOUNT.Alice.address), - pubKey: ACCOUNT.Alice.publicKey, - address: ACCOUNT.Alice.address, - alg: Alg.ES256K, - userId: USER.Alice.id + userId: USER.Alice.id, + id: PUBLIC_KEYS_JWK.Alice.kid, + key: PUBLIC_KEYS_JWK.Alice }, Bob: { - id: addressToKid(ACCOUNT.Bob.address), - pubKey: ACCOUNT.Bob.publicKey, - address: ACCOUNT.Bob.address, - alg: Alg.ES256K, - userId: USER.Bob.id + userId: USER.Bob.id, + id: PUBLIC_KEYS_JWK.Bob.kid, + key: PUBLIC_KEYS_JWK.Bob }, Carol: { - id: addressToKid(ACCOUNT.Carol.address), - pubKey: ACCOUNT.Carol.publicKey, - address: ACCOUNT.Carol.address, - alg: Alg.ES256K, - userId: USER.Carol.id + userId: USER.Carol.id, + id: PUBLIC_KEYS_JWK.Carol.kid, + key: PUBLIC_KEYS_JWK.Carol }, Dave: { - id: addressToKid(ACCOUNT.Dave.address), - pubKey: ACCOUNT.Dave.publicKey, - address: ACCOUNT.Dave.address, - alg: Alg.ES256K, - userId: USER.Dave.id + userId: USER.Dave.id, + id: PUBLIC_KEYS_JWK.Dave.kid, + key: PUBLIC_KEYS_JWK.Dave } } 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/schema/entity.schema.ts b/packages/policy-engine-shared/src/lib/schema/entity.schema.ts index c42967c60..ce1a20124 100644 --- a/packages/policy-engine-shared/src/lib/schema/entity.schema.ts +++ b/packages/policy-engine-shared/src/lib/schema/entity.schema.ts @@ -1,4 +1,4 @@ -import { Alg } from '@narval/signature' +import { publicKeySchema } from '@narval/signature' import { z } from 'zod' import { addressSchema } from './address.schema' @@ -23,10 +23,9 @@ export const accountClassificationSchema = z.nativeEnum({ export const credentialEntitySchema = z.object({ id: z.string(), - pubKey: z.string(), - address: z.string().optional(), - alg: z.nativeEnum(Alg), - userId: z.string() + userId: z.string(), + key: publicKeySchema + // TODO @ptroger: Should we be allowing a private key to be passed in entity data ? }) export const organizationEntitySchema = z.object({ 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/index.ts b/packages/signature/src/index.ts index a8dcd0d7b..17ae55436 100644 --- a/packages/signature/src/index.ts +++ b/packages/signature/src/index.ts @@ -1,5 +1,6 @@ export * from './lib/decode' export * from './lib/hash-request' +export * from './lib/schemas' export * from './lib/sign' export * from './lib/types' export * from './lib/utils' diff --git a/packages/signature/src/lib/__test__/unit/sign.spec.ts b/packages/signature/src/lib/__test__/unit/sign.spec.ts index aa5010cdb..9c9dd8037 100644 --- a/packages/signature/src/lib/__test__/unit/sign.spec.ts +++ b/packages/signature/src/lib/__test__/unit/sign.spec.ts @@ -5,14 +5,14 @@ import { createPublicKey } from 'node:crypto' import { toHex, verifyMessage } from 'viem' import { privateKeyToAccount, signMessage } from 'viem/accounts' import { buildSignerEip191, buildSignerEs256k, signJwt } from '../../sign' -import { Alg, JWK, Payload, SigningAlg } from '../../types' +import { Alg, Payload, SigningAlg } from '../../types' import { base64UrlToBytes, base64UrlToHex, - jwkToPrivateKey, - jwkToPublicKey, - privateKeyToJwk, - publicKeyToJwk + secp256k1PrivateKeyToHex, + secp256k1PrivateKeyToJwk, + secp256k1PublicKeyToHex, + secp256k1PublicKeyToJwk } from '../../utils' import { verifyJwt } from '../../verify' import { HEADER_PART, PAYLOAD_PART, PRIVATE_KEY_PEM } from './mock' @@ -38,14 +38,21 @@ describe('sign', () => { it('should sign build & sign es256 JWT correctly with a PEM', async () => { const key = await importPKCS8(PRIVATE_KEY_PEM, Alg.ES256) const jwk = await exportJWK(key) - const jwt = await signJwt(payload, { ...jwk, alg: Alg.ES256 } as JWK) + const jwt = await signJwt(payload, { + ...jwk, + alg: Alg.ES256, + crv: 'P-256', + kty: 'EC', + kid: 'somekid', + use: undefined + }) const verified = await jwtVerify(jwt, key) expect(verified.payload).toEqual(payload) }) it('should build & sign a EIP191 JWT', async () => { - const jwk = privateKeyToJwk(`0x${ENGINE_PRIVATE_KEY}`) + const jwk = secp256k1PrivateKeyToJwk(`0x${ENGINE_PRIVATE_KEY}`) const signer = buildSignerEip191(ENGINE_PRIVATE_KEY) const jwt = await signJwt(payload, jwk, { alg: SigningAlg.EIP191 }, signer) @@ -146,7 +153,7 @@ describe('sign', () => { const viemPubKey = privateKeyToAccount(`0x${ENGINE_PRIVATE_KEY}`).publicKey expect(toHex(publicKey)).toBe(viemPubKey) // Confirm that our key is in fact the same as what viem would give. - const jwk = privateKeyToJwk(`0x${ENGINE_PRIVATE_KEY}`) + const jwk = secp256k1PrivateKeyToJwk(`0x${ENGINE_PRIVATE_KEY}`) const k = await createPublicKey({ format: 'jwk', @@ -157,15 +164,15 @@ describe('sign', () => { }) it('should convert to and from jwk', async () => { - const jwk = privateKeyToJwk(`0x${ENGINE_PRIVATE_KEY}`) - const pk = jwkToPrivateKey(jwk) + const jwk = secp256k1PrivateKeyToJwk(`0x${ENGINE_PRIVATE_KEY}`) + const pk = secp256k1PrivateKeyToHex(jwk) expect(pk).toBe(`0x${ENGINE_PRIVATE_KEY}`) }) it('should convert to and from public jwk', async () => { const publicKey = secp256k1.getPublicKey(ENGINE_PRIVATE_KEY, false) - const jwk = publicKeyToJwk(toHex(publicKey)) - const pk = jwkToPublicKey(jwk) + const jwk = secp256k1PublicKeyToJwk(toHex(publicKey)) + const pk = secp256k1PublicKeyToHex(jwk) expect(pk).toBe(toHex(publicKey)) }) }) diff --git a/packages/signature/src/lib/__test__/unit/util.spec.ts b/packages/signature/src/lib/__test__/unit/util.spec.ts index dab91880e..e4ad7b2b8 100644 --- a/packages/signature/src/lib/__test__/unit/util.spec.ts +++ b/packages/signature/src/lib/__test__/unit/util.spec.ts @@ -1,4 +1,10 @@ +import { p256PrivateKeySchema, rsaPrivateKeySchema, secp256k1PrivateKeySchema } from '../../schemas' +import { buildSignerEip191, signJwt } from '../../sign' import { isHeader, isPayload } from '../../typeguards' +import { Alg, Secp256k1PrivateKey, SigningAlg } from '../../types' +import { generateJwk, secp256k1PrivateKeyToHex } from '../../utils' +import { validate } from '../../validate' +import { verifyJwt } from '../../verify' describe('isHeader', () => { it('returns true for a valid header object', () => { @@ -59,3 +65,38 @@ describe('isPayload', () => { expect(isPayload('string')).toBe(false) }) }) + +describe('generateKeys', () => { + it('generate a valid RSA key pair and return it as a JWK', async () => { + const key = await generateJwk(Alg.RS256) + expect(rsaPrivateKeySchema.safeParse(key).success).toBe(true) + }) + + it('generates a valid P-256 key pair and return it as a JWK', async () => { + const key = await generateJwk(Alg.ES256) + expect(p256PrivateKeySchema.safeParse(key).success).toBe(true) + }) + + it('generates a valid secp256k1 key pair and return it as a JWK', async () => { + const key = await generateJwk(Alg.ES256K) + expect(secp256k1PrivateKeySchema.safeParse(key).success).toBe(true) + }) + + it('can sign and verify with a generated secp256k1 key pair', async () => { + const key = await generateJwk(Alg.ES256K) + const message = 'test message' + const payload = { + requestHash: message + } + const validatedKey = validate( + secp256k1PrivateKeySchema, + key, + 'Invalid secp256k1 Private Key JWK' + ) + + const signer = buildSignerEip191(secp256k1PrivateKeyToHex(validatedKey)) + const signature = await signJwt(payload, key, { alg: SigningAlg.EIP191 }, signer) + const isValid = await verifyJwt(signature, key) + expect(isValid).not.toEqual(false) + }) +}) diff --git a/packages/signature/src/lib/__test__/unit/verify.spec.ts b/packages/signature/src/lib/__test__/unit/verify.spec.ts index b1502a345..b08f0e881 100644 --- a/packages/signature/src/lib/__test__/unit/verify.spec.ts +++ b/packages/signature/src/lib/__test__/unit/verify.spec.ts @@ -1,13 +1,13 @@ import { hash } from '../../hash-request' import { Payload } from '../../types' -import { privateKeyToJwk } from '../../utils' +import { secp256k1PrivateKeyToJwk } from '../../utils' import { verifyJwt } from '../../verify' describe('verify', () => { const ENGINE_PRIVATE_KEY = '7cfef3303797cbc7515d9ce22ffe849c701b0f2812f999b0847229c47951fca5' it('should verify a EIP191-signed JWT', async () => { - const jwk = privateKeyToJwk(`0x${ENGINE_PRIVATE_KEY}`) + const jwk = secp256k1PrivateKeyToJwk(`0x${ENGINE_PRIVATE_KEY}`) const header = { kid: '0x2c4895215973CbBd778C32c456C074b99daF8Bf1', diff --git a/packages/signature/src/lib/address.schema.ts b/packages/signature/src/lib/address.schema.ts new file mode 100644 index 000000000..57c0d165d --- /dev/null +++ b/packages/signature/src/lib/address.schema.ts @@ -0,0 +1,22 @@ +import { z } from 'zod' +import { isAddress } from './evm.util' + +/** + * Schema backward compatible with viem's Address type. + * + * @see https://viem.sh/docs/glossary/types#address + */ +export const addressSchema = z.custom<`0x${string}`>( + (value) => { + const parse = z.string().safeParse(value) + + if (parse.success) { + return isAddress(parse.data) + } + + return false + }, + { + message: 'value is an invalid Ethereum address' + } +) 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/evm.util.ts b/packages/signature/src/lib/evm.util.ts new file mode 100644 index 000000000..58dad15ce --- /dev/null +++ b/packages/signature/src/lib/evm.util.ts @@ -0,0 +1,47 @@ +// eslint-disable-next-line no-restricted-imports +import { InvalidAddressError, getAddress as viemGetAddress, isAddress as viemIsAddress } from 'viem' + +type Address = `0x${string}` + +/** + * Checks if a string is a valid Ethereum address without regard of its format. + * + * @param address - The string to be checked. + * @returns Returns true if the string is a valid Ethereum address, otherwise + * returns false. + */ +export const isAddress = (address: string): boolean => { + if (!/^(0x)?[0-9a-fA-F]{40}$/.test(address)) { + return false + } else if (/^(0x)?[0-9a-f]{40}$/.test(address) || /^(0x)?[0-9A-F]{40}$/.test(address)) { + return true + } else { + return viemIsAddress(address) + } +} + +/** + * Retrieves the Ethereum address from a given string representation without + * regard of its format. + * + * @param address - The string representation of the Ethereum address. + * @param options - Optional parameters for address retrieval. + * @param options.checksum - Specifies whether the retrieved address should be + * checksummed. + * @param options.chainId - The chain ID to be used for address retrieval. + * @returns The Ethereum address. + * @throws {InvalidAddressError} if the provided address is invalid. + */ +export const getAddress = (address: string, options?: { checksum?: boolean; chainId?: number }): Address => { + if (isAddress(address)) { + const validAddress = address as Address + + if (options?.checksum || options?.chainId) { + return viemGetAddress(validAddress, options.chainId) + } + + return validAddress + } + + throw new InvalidAddressError({ address }) +} diff --git a/packages/signature/src/lib/schemas.ts b/packages/signature/src/lib/schemas.ts new file mode 100644 index 000000000..eb23e5dcf --- /dev/null +++ b/packages/signature/src/lib/schemas.ts @@ -0,0 +1,89 @@ +import { z } from 'zod' +import { addressSchema } from './address.schema' +import { Alg, Curves, KeyTypes, Use } from './types' + +// Base JWK Schema +export const jwkBaseSchema = z.object({ + kty: z.nativeEnum(KeyTypes), + alg: z.nativeEnum(Alg), + use: z.nativeEnum(Use).optional(), + kid: z.string(), + addr: z.string().optional() +}) + +export const jwkEoaSchema = z.object({ + kty: z.literal(KeyTypes.EC), + crv: z.enum([Curves.SECP256K1]), + alg: z.literal(Alg.ES256K), + use: z.nativeEnum(Use).optional(), + kid: z.string(), + addr: addressSchema +}) + +// EC Base Schema +export const ecBaseSchema = jwkBaseSchema.extend({ + kty: z.literal(KeyTypes.EC), + crv: z.enum([Curves.SECP256K1, Curves.P256]), + x: z.string(), + y: z.string() +}) + +// RSA Base Schema +export const rsaBaseSchema = jwkBaseSchema.extend({ + kty: z.literal(KeyTypes.RSA), + alg: z.literal(Alg.RS256), + n: z.string(), + e: z.string() +}) + +// Specific Schemas for Public Keys +export const secp256k1PublicKeySchema = ecBaseSchema.extend({ + crv: z.literal(Curves.SECP256K1), + alg: z.literal(Alg.ES256K) +}) + +export const p256PublicKeySchema = ecBaseSchema.extend({ + crv: z.literal(Curves.P256), + alg: z.literal(Alg.ES256) +}) + +export const rsaPublicKeySchema = rsaBaseSchema + +// Specific Schemas for Private Keys +export const secp256k1PrivateKeySchema = secp256k1PublicKeySchema.extend({ + d: z.string() +}) + +export const p256PrivateKeySchema = p256PublicKeySchema.extend({ + d: z.string() +}) + +export const rsaPrivateKeySchema = rsaPublicKeySchema.extend({ + d: z.string() +}) + +export const publicKeySchema = z.union([ + secp256k1PublicKeySchema, + p256PublicKeySchema, + rsaPublicKeySchema, + jwkEoaSchema +]) + +export const privateKeySchema = z.union([secp256k1PrivateKeySchema, p256PrivateKeySchema, rsaPrivateKeySchema]) + +export const secp256k1KeySchema = z.union([secp256k1PublicKeySchema, secp256k1PrivateKeySchema]) + +const dynamicKeySchema = z.object({}).catchall(z.unknown()) + +export const jwkSchema = dynamicKeySchema.extend({ + 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 d9bb8e149..6692a70de 100644 --- a/packages/signature/src/lib/sign.ts +++ b/packages/signature/src/lib/sign.ts @@ -3,19 +3,38 @@ import { sha256 as sha256Hash } from '@noble/hashes/sha256' import { keccak_256 as keccak256 } from '@noble/hashes/sha3' import { SignJWT, base64url, importJWK } from 'jose' import { isHex, signatureToHex, toBytes, toHex } from 'viem' -import { EcdsaSignature, Header, Hex, JWK, Payload, SigningAlg } from './types' +import { hash } from './hash-request' +import { privateKeySchema } from './schemas' +import { EcdsaSignature, Header, Hex, Jwk, JwsdHeader, Payload, PrivateKey, SigningAlg } from './types' import { hexToBase64Url } from './utils' +import { validate } from './validate' + +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 +} -// WIP to replace `sign` export async function signJwt( payload: Payload, - jwk: JWK, + jwk: Jwk, opts: { alg?: SigningAlg } = {}, signer?: (payload: string) => Promise ): Promise { + const pk = validate(privateKeySchema, jwk, 'Invalid Private Key JWK') const header: Header = { - kid: jwk.kid, - alg: opts.alg || jwk.alg, // TODO: add separate type for `ES256k-KECCAK` + kid: pk.kid, + alg: opts.alg || pk.alg, // TODO: add separate type for `ES256k-KECCAK` typ: 'JWT' } diff --git a/packages/signature/src/lib/typeguards.ts b/packages/signature/src/lib/typeguards.ts index a64ba40a9..fe344902e 100644 --- a/packages/signature/src/lib/typeguards.ts +++ b/packages/signature/src/lib/typeguards.ts @@ -1,4 +1,5 @@ -import { Header, Payload, SigningAlg } from './types' +import { jwkEoaSchema, jwkSchema, secp256k1PublicKeySchema } from './schemas' +import { EoaPublicKey, Header, Jwk, Payload, Secp256k1PublicKey, SigningAlg } from './types' function isAlg(alg: unknown): alg is SigningAlg { return typeof alg === 'string' && Object.values(SigningAlg).includes(alg as SigningAlg) @@ -8,6 +9,14 @@ function isStringNonNull(kid: unknown): kid is string { return typeof kid === 'string' && kid.length > 0 } +export function isJwk(jwk: unknown): jwk is Jwk { + return jwkSchema.safeParse(jwk).success +} + +export const isSepc256k1PublicKeyJwk = (jwk: Jwk): jwk is Secp256k1PublicKey => + secp256k1PublicKeySchema.safeParse(jwk).success +export const isEoaPublicKeyJwk = (jwk: Jwk): jwk is EoaPublicKey => jwkEoaSchema.safeParse(jwk).success + export function isHeader(header: unknown): header is Header { return ( typeof header === 'object' && diff --git a/packages/signature/src/lib/types.ts b/packages/signature/src/lib/types.ts index 2e7c9fbc8..b86f47cbb 100644 --- a/packages/signature/src/lib/types.ts +++ b/packages/signature/src/lib/types.ts @@ -1,3 +1,17 @@ +import { z } from 'zod' +import { + jwkEoaSchema, + jwkSchema, + p256PrivateKeySchema, + p256PublicKeySchema, + privateKeySchema, + publicKeySchema, + rsaPrivateKeySchema, + secp256k1KeySchema, + secp256k1PrivateKeySchema, + secp256k1PublicKeySchema +} from './schemas' + export const KeyTypes = { EC: 'EC', RSA: 'RSA' @@ -36,19 +50,17 @@ export const Use = { export type Use = (typeof Use)[keyof typeof Use] -export type JWK = { - kty: 'EC' | 'RSA' - kid: string - alg: 'ES256K' | 'ES256' | 'RS256' - crv?: 'P-256' | 'secp256k1' | undefined - use?: 'sig' | 'enc' | undefined - n?: string | undefined - e?: string | undefined - x?: string | undefined - y?: string | undefined - d?: string | undefined - addr?: Hex | undefined -} +export type Secp256k1PrivateKey = z.infer +export type P256PrivateKey = z.infer +export type P256PublicKey = z.infer +export type RsaPrivateKey = z.infer +export type EoaPublicKey = z.infer +export type Secp256k1PublicKey = z.infer +export type Secp256k1KeySchema = z.infer +export type PublicKey = z.infer +export type PrivateKey = z.infer +export type Jwk = z.infer + export type Hex = `0x${string}` // DOMAIN /** @@ -68,6 +80,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. * @@ -78,7 +103,7 @@ export type Header = { * @param {string} sub - The subject of the JWT. * @param {string} [aud] - The audience of the JWT. * @param {string} [jti] - The JWT ID. - * @param {JWK} cnf - The client-bound key. + * @param {Jwk} cnf - The client-bound key. * */ export type Payload = { @@ -88,7 +113,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 } @@ -99,6 +124,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/utils.ts b/packages/signature/src/lib/utils.ts index b5955e023..a0a91be62 100644 --- a/packages/signature/src/lib/utils.ts +++ b/packages/signature/src/lib/utils.ts @@ -1,8 +1,26 @@ +import { p256 } from '@noble/curves/p256' import { secp256k1 } from '@noble/curves/secp256k1' import { sha256 as sha256Hash } from '@noble/hashes/sha256' +import { exportJWK, generateKeyPair } from 'jose' import { toHex } from 'viem' -import { getAddress, publicKeyToAddress } from 'viem/utils' -import { Alg, Curves, Hex, JWK, KeyTypes } from './types' +import { publicKeyToAddress } from 'viem/utils' +import { JwtError } from './error' +import { rsaPrivateKeySchema } from './schemas' +import { + Alg, + Curves, + Hex, + Jwk, + KeyTypes, + P256PrivateKey, + P256PublicKey, + RsaPrivateKey, + Secp256k1KeySchema, + Secp256k1PrivateKey, + Secp256k1PublicKey, + Use +} from './types' +import { validate } from './validate' export const algToJwk = ( alg: Alg @@ -39,7 +57,7 @@ export const addressToKid = (address: string): string => { } // ES256k -export const publicKeyToJwk = (publicKey: Hex, keyId?: string): JWK => { +export const secp256k1PublicKeyToJwk = (publicKey: Hex, keyId?: string): Secp256k1PublicKey => { // remove the 0x04 prefix -- 04 means it's an uncompressed ECDSA key, 02 or 03 means compressed -- these need to be removed in a JWK! const hexPubKey = publicKey.slice(4) const x = hexPubKey.slice(0, 64) @@ -55,40 +73,50 @@ export const publicKeyToJwk = (publicKey: Hex, keyId?: string): JWK => { } } +export const p256PublicKeyToJwk = (publicKey: Hex, keyId?: string): P256PublicKey => { + const hexPubKey = publicKey.slice(4) + const x = hexPubKey.slice(0, 64) + const y = hexPubKey.slice(64) + return { + kty: KeyTypes.EC, + crv: Curves.P256, + alg: Alg.ES256, + kid: keyId || publicKeyToAddress(publicKey), + x: hexToBase64Url(`0x${x}`), + y: hexToBase64Url(`0x${y}`) + } +} + // ES256k -export const privateKeyToJwk = (privateKey: Hex, keyId?: string): JWK => { +export const secp256k1PrivateKeyToJwk = (privateKey: Hex, keyId?: string): Secp256k1PrivateKey => { const publicKey = toHex(secp256k1.getPublicKey(privateKey.slice(2), false)) - const publicJwk = publicKeyToJwk(publicKey, keyId) + const publicJwk = secp256k1PublicKeyToJwk(publicKey, keyId) return { ...publicJwk, d: hexToBase64Url(privateKey) } } -// Eth EOA -export const addressToJwk = (address: string, keyId?: string): JWK => { +export const p256PrivateKeyToJwk = (privateKey: Hex, keyId?: string): P256PrivateKey => { + const publicKey = toHex(p256.getPublicKey(privateKey.slice(2), false)) + const publicJwk = p256PublicKeyToJwk(publicKey, keyId) return { - kty: KeyTypes.EC, - crv: Curves.SECP256K1, - alg: Alg.ES256K, - kid: keyId || addressToKid(address), - addr: getAddress(address) + ...publicJwk, + d: hexToBase64Url(privateKey) } } -export const jwkToPublicKey = (jwk: JWK): Hex => { - if (!jwk.x || !jwk.y) { - throw new Error('Invalid JWK; missing x or y') - } +export const p256PrivateKeyToHex = (jwk: P256PrivateKey): Hex => { + return base64UrlToHex(jwk.d) +} + +export const secp256k1PublicKeyToHex = (jwk: Secp256k1KeySchema): Hex => { const x = base64UrlToHex(jwk.x) const y = base64UrlToHex(jwk.y) return `0x04${x.slice(2)}${y.slice(2)}` } -export const jwkToPrivateKey = (jwk: JWK): Hex => { - if (!jwk.d) { - throw new Error('Invalid JWK; missing d') - } +export const secp256k1PrivateKeyToHex = (jwk: Secp256k1PrivateKey): Hex => { return base64UrlToHex(jwk.d) } @@ -114,3 +142,69 @@ export const base64UrlToHex = (base64Url: string): Hex => { export const base64UrlToBytes = (base64Url: string): Buffer => { return Buffer.from(base64UrlToBase64(base64Url), 'base64') } + +const rsaKeyToKid = (jwk: Jwk) => { + // Concatenate the 'n' and 'e' values, splitted by ':' + const dataToHash = `${jwk.n}:${jwk.e}` + + const binaryData = base64UrlToBytes(dataToHash) + const hash = sha256Hash(binaryData) + return toHex(hash) +} + +const generateRsaKeyPair = async ( + opts: { + keyId?: string + modulusLength?: number + use?: Use + } = { + modulusLength: 2048 + } +): Promise => { + const { privateKey } = await generateKeyPair(Alg.RS256, { + modulusLength: opts.modulusLength, + extractable: true + }) + + const partialJwk = await exportJWK(privateKey) + if (!partialJwk.n) { + throw new JwtError({ message: 'Invalid JWK; missing n', context: { partialJwk } }) + } + const jwk: Jwk = { + ...partialJwk, + alg: Alg.RS256, + kty: KeyTypes.RSA, + crv: undefined, + use: opts.use || undefined + } + jwk.kid = opts.keyId || rsaKeyToKid(jwk) + + const pk = validate(rsaPrivateKeySchema, jwk, 'Invalid RSA Private Key JWK') + return pk +} + +export const generateJwk = async ( + alg: Alg, + opts?: { + keyId?: string + modulusLength?: number + use?: Use + } +): Promise => { + switch (alg) { + case Alg.ES256K: { + const privateKeyK1 = toHex(secp256k1.utils.randomPrivateKey()) + return secp256k1PrivateKeyToJwk(privateKeyK1, opts?.keyId) + } + case Alg.ES256: { + const privateKeyP256 = toHex(p256.utils.randomPrivateKey()) + return p256PrivateKeyToJwk(privateKeyP256, opts?.keyId) + } + case Alg.RS256: { + const jwk = await generateRsaKeyPair(opts) + return jwk + } + default: + throw new Error(`Unsupported algorithm: ${alg}`) + } +} diff --git a/packages/signature/src/lib/validate.ts b/packages/signature/src/lib/validate.ts new file mode 100644 index 000000000..fb0735aa0 --- /dev/null +++ b/packages/signature/src/lib/validate.ts @@ -0,0 +1,16 @@ +import { ZodSchema } from 'zod' +import { JwtError } from './error' +import { Jwk } from './types' + +export function validate(schema: ZodSchema, jwk: Jwk, errorMessage: string = 'Validation failed') { + return (function validate(input: Jwk): T { + const result = schema.safeParse(input) + if (!result.success) { + throw new JwtError({ + message: errorMessage, + context: { errors: result.error.flatten().fieldErrors } + }) + } + return result.data + })(jwk) +} diff --git a/packages/signature/src/lib/verify.ts b/packages/signature/src/lib/verify.ts index c7a0a4abe..178d8ad10 100644 --- a/packages/signature/src/lib/verify.ts +++ b/packages/signature/src/lib/verify.ts @@ -1,11 +1,14 @@ 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 { Hex, JWK, Jwt, Payload, SigningAlg } from './types' -import { base64UrlToHex, jwkToPublicKey } from './utils' +import { isSepc256k1PublicKeyJwk } from './typeguards' +import { Alg, EoaPublicKey, Hex, Jwk, Jwsd, Jwt, Payload, PublicKey, Secp256k1PublicKey, SigningAlg } from './types' +import { base64UrlToHex, secp256k1PublicKeyToHex } from './utils' +import { validate } from './validate' const checkTokenExpiration = (payload: Payload): boolean => { const now = Math.floor(Date.now() / 1000) @@ -26,42 +29,60 @@ const verifyEip191WithRecovery = async (sig: Hex, hash: Uint8Array, address: Hex return true } -const verifyEip191WithPublicKey = async (sig: Hex, hash: Uint8Array, jwk: JWK): Promise => { - const pub = jwkToPublicKey(jwk) +const verifyEip191WithPublicKey = async (sig: Hex, hash: Uint8Array, jwk: PublicKey): Promise => { + if (isSepc256k1PublicKeyJwk(jwk)) { + const pub = secp256k1PublicKeyToHex(jwk) + // A eth sig has a `v` value of 27 or 28, so we need to remove that to get the signature + // And we remove the 0x prefix. So that means we slice the first and last 2 bytes, leaving the 128 character signature + const isValid = secp256k1.verify(sig.slice(2, 130), hash, pub.slice(2)) === true + if (!isValid) { + throw new Error('Invalid JWT signature') + } + return isValid + } + throw new JwtError({ + message: 'Validation error: unsupported algorithm', + context: { jwk } + }) +} - // A eth sig has a `v` value of 27 or 28, so we need to remove that to get the signature - // And we remove the 0x prefix. So that means we slice the first and last 2 bytes, leaving the 128 character signature - const isValid = secp256k1.verify(sig.slice(2, 130), hash, pub.slice(2)) === true - if (!isValid) { - throw new Error('Invalid JWT signature') +const verifySepc256k1 = async ( + sig: Hex, + hash: Uint8Array, + jwk: Secp256k1PublicKey | EoaPublicKey +): Promise => { + if (isSepc256k1PublicKeyJwk(jwk)) { + await verifyEip191WithPublicKey(sig, hash, jwk) + } else { + await verifyEip191WithRecovery(sig, hash, jwk.addr) } - return isValid + return true } -export const verifyEip191 = async (jwt: string, jwk: JWK): Promise => { +export const verifyEip191 = async (jwt: string, jwk: PublicKey): Promise => { const [headerStr, payloadStr, jwtSig] = jwt.split('.') const verificationMsg = [headerStr, payloadStr].join('.') const msg = eip191Hash(verificationMsg) const sig = base64UrlToHex(jwtSig) - // If we have an Address but no x & y, recover the address from the signature to verify - // Otherwise, verify directly against the public key from the x&y. - if (jwk.x && jwk.y) { - await verifyEip191WithPublicKey(sig, msg, jwk) - } else if (jwk.addr) { - await verifyEip191WithRecovery(sig, msg, jwk.addr) + if (jwk.alg === Alg.ES256K) { + await verifySepc256k1(sig, msg, jwk) } else { - throw new Error('Invalid JWK, no x & y or address') + throw new JwtError({ + message: 'Validation error: unsupported algorithm', + context: { jwk } + }) } return true } -export async function verifyJwt(jwt: string, jwk: JWK): Promise { +export async function verifyJwt(jwt: string, jwk: Jwk): Promise { const { header, payload, signature } = decode(jwt) + const key = validate(publicKeySchema, jwk, 'Invalid Public Key JWK') if (header.alg === SigningAlg.EIP191) { - await verifyEip191(jwt, jwk) + await verifyEip191(jwt, key) } else { // TODO: Implement other algs individually without jose const joseJwk = await importJWK(jwk) @@ -78,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 + } +}