From 9bebe3fc7393db0a9b1d06655337a6e8b4a9eaf9 Mon Sep 17 00:00:00 2001 From: Jack Tanner <25777536+theblockstalk@users.noreply.github.com> Date: Wed, 3 May 2023 20:18:18 +0200 Subject: [PATCH] feat: add support for ConditionalProof2022 verificationMethods (#272) --- package.json | 8 +- src/ConditionalAlgorithm.ts | 157 +++++++ src/JWT.ts | 188 ++++++-- src/__tests__/ConditionalAlgorithm.test.ts | 406 ++++++++++++++++++ .../ConditionalAlgorithmResolverHelper.ts | 62 +++ src/__tests__/JWT.test.ts | 2 +- src/index.ts | 2 + yarn.lock | 68 ++- 8 files changed, 864 insertions(+), 29 deletions(-) create mode 100644 src/ConditionalAlgorithm.ts create mode 100644 src/__tests__/ConditionalAlgorithm.test.ts create mode 100644 src/__tests__/ConditionalAlgorithmResolverHelper.ts diff --git a/package.json b/package.json index 30cb7376..6e5b492a 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,9 @@ "contributors": [ "Mircea Nistor ", "Oliver Terbu ", - "Joel Thorstensson " + "Joel Thorstensson ", + "Jack Tanner ", + "Rebal Alhaqash " ], "repository": { "type": "git", @@ -66,8 +68,10 @@ "@babel/preset-env": "7.20.2", "@babel/preset-typescript": "7.21.0", "@ethersproject/address": "5.7.0", + "@greymass/eosio": "^0.6.9", "@semantic-release/changelog": "6.0.3", "@semantic-release/git": "10.0.1", + "@tonomy/antelope-did": "^0.1.5", "@types/jest": "28.1.8", "@types/jsonwebtoken": "^8.5.9", "@types/jwk-to-pem": "^2.0.1", @@ -105,4 +109,4 @@ "eslintIgnore": [ "*.test.ts" ] -} +} \ No newline at end of file diff --git a/src/ConditionalAlgorithm.ts b/src/ConditionalAlgorithm.ts new file mode 100644 index 00000000..f12b6e68 --- /dev/null +++ b/src/ConditionalAlgorithm.ts @@ -0,0 +1,157 @@ +import type { VerificationMethod } from 'did-resolver' +import { EcdsaSignature } from './util' +import { JWT_ERROR } from './Errors' +import { JWTDecoded, JWTVerifyOptions, resolveAuthenticator, verifyJWT, verifyJWTDecoded } from './JWT' + +export type Signer = (data: string | Uint8Array) => Promise +export type SignerAlgorithm = (payload: string, signer: Signer) => Promise + +export const CONDITIONAL_PROOF_2022 = 'ConditionalProof2022' + +export async function verifyProof( + jwt: string, + { header, payload, signature, data }: JWTDecoded, + authenticator: VerificationMethod, + options: JWTVerifyOptions +): Promise { + if (authenticator.type === CONDITIONAL_PROOF_2022) { + return await verifyConditionalProof(jwt, { payload, header, signature, data }, authenticator, options) + } else { + return await verifyJWTDecoded({ header, payload, data, signature }, [authenticator]) + } +} + +export async function verifyConditionalProof( + jwt: string, + { header, payload, signature, data }: JWTDecoded, + authenticator: VerificationMethod, + options: JWTVerifyOptions +): Promise { + // Validate the condition according to it's condition property + if (authenticator.conditionWeightedThreshold) { + return await verifyConditionWeightedThreshold(jwt, { header, payload, data, signature }, authenticator, options) + } else if (authenticator.conditionDelegated) { + return await verifyConditionDelegated(jwt, { header, payload, data, signature }, authenticator, options) + } + // TODO other conditions + + throw new Error( + `${JWT_ERROR.INVALID_JWT}: conditional proof type did not find condition for authenticator ${authenticator.id}.` + ) +} + +async function verifyConditionWeightedThreshold( + jwt: string, + { header, payload, data, signature }: JWTDecoded, + authenticator: VerificationMethod, + options: JWTVerifyOptions +): Promise { + if (!authenticator.conditionWeightedThreshold || !authenticator.threshold) { + throw new Error('Expected conditionWeightedThreshold and threshold') + } + + const issuers: string[] = [] + const threshold = authenticator.threshold + let weightCount = 0 + + for (const weightedCondition of authenticator.conditionWeightedThreshold) { + const currentCondition = weightedCondition.condition + let foundSigner: VerificationMethod | undefined + + try { + if (currentCondition.type === CONDITIONAL_PROOF_2022) { + if (!options.didAuthenticator) { + throw new Error('Expected didAuthenticator') + } + + const newOptions = { + ...options, + ...{ + didAuthenticator: { + didResolutionResult: options.didAuthenticator?.didResolutionResult, + authenticators: [currentCondition], + issuer: currentCondition.id, + }, + }, + } + const { verified } = await verifyJWT(jwt, newOptions as JWTVerifyOptions) + if (verified) { + foundSigner = currentCondition + } + } else { + foundSigner = await verifyJWTDecoded({ header, payload, data, signature }, currentCondition) + } + } catch (e) { + if (!(e as Error).message.startsWith(JWT_ERROR.INVALID_SIGNATURE)) throw e + } + + if (foundSigner && !issuers.includes(foundSigner.id)) { + issuers.push(foundSigner.id) + weightCount += weightedCondition.weight + + if (weightCount >= threshold) { + return authenticator + } + } + } + throw new Error(`${JWT_ERROR.INVALID_SIGNATURE}: condition for authenticator ${authenticator.id} is not met.`) +} + +async function verifyConditionDelegated( + jwt: string, + { header, payload, data, signature }: JWTDecoded, + authenticator: VerificationMethod, + options: JWTVerifyOptions +): Promise { + if (!authenticator.conditionDelegated) { + throw new Error('Expected conditionDelegated') + } + if (!options.resolver) { + throw new Error('Expected resolver') + } + + let foundSigner: VerificationMethod | undefined + + const issuer = authenticator.conditionDelegated + const didAuthenticator = await resolveAuthenticator(options.resolver, header.alg, issuer, options.proofPurpose) + const didResolutionResult = didAuthenticator.didResolutionResult + + if (!didResolutionResult?.didDocument) { + throw new Error(`${JWT_ERROR.RESOLVER_ERROR}: Could not resolve delegated DID ${issuer}.`) + } + + const delegatedAuthenticator = didAuthenticator.authenticators.find((authenticator) => authenticator.id === issuer) + if (!delegatedAuthenticator) { + throw new Error( + `${JWT_ERROR.NO_SUITABLE_KEYS}: Could not find delegated authenticator ${issuer} in it's DID Document` + ) + } + + if (delegatedAuthenticator.type === CONDITIONAL_PROOF_2022) { + const { verified } = await verifyJWT(jwt, { + ...options, + ...{ + didAuthenticator: { + didResolutionResult, + authenticators: [delegatedAuthenticator], + issuer: delegatedAuthenticator.id, + }, + }, + }) + if (verified) { + foundSigner = delegatedAuthenticator + } + } else { + try { + foundSigner = await verifyJWTDecoded({ header, payload, data, signature }, delegatedAuthenticator) + } catch (e) { + if (!(e as Error).message.startsWith('invalid_signature:')) throw e + } + } + + if (foundSigner) { + return authenticator + } + + throw new Error(`${JWT_ERROR.INVALID_SIGNATURE}: condition for authenticator ${authenticator.id} is not met.`) +} diff --git a/src/JWT.ts b/src/JWT.ts index 2c6ed6a7..51ee7471 100644 --- a/src/JWT.ts +++ b/src/JWT.ts @@ -1,9 +1,10 @@ import canonicalizeData from 'canonicalize' -import type { DIDDocument, DIDResolutionResult, Resolvable, VerificationMethod } from 'did-resolver' +import { DIDDocument, DIDResolutionResult, parse, ParsedDID, Resolvable, VerificationMethod } from 'did-resolver' import SignerAlg from './SignerAlgorithm' import { decodeBase64url, EcdsaSignature, encodeBase64url } from './util' import VerifierAlgorithm from './VerifierAlgorithm' import { JWT_ERROR } from './Errors' +import { verifyProof } from './ConditionalAlgorithm' export type Signer = (data: string | Uint8Array) => Promise export type SignerAlgorithm = (payload: string, signer: Signer) => Promise @@ -36,6 +37,7 @@ export interface JWTVerifyOptions { /** See https://www.w3.org/TR/did-spec-registries/#verification-relationships */ proofPurpose?: ProofPurposeTypes policies?: JWTVerifyPolicies + didAuthenticator?: DIDAuthenticator } /** @@ -166,6 +168,10 @@ export const SUPPORTED_PUBLIC_KEY_TYPES: PublicKeyTypes = { * not an ethereumAddress */ 'EcdsaPublicKeySecp256k1', + /** + * TODO - support R1 key aswell + * 'ConditionalProof2022', + */ 'JsonWebKey2020', ], 'ES256K-R': [ @@ -189,6 +195,7 @@ export const SUPPORTED_PUBLIC_KEY_TYPES: PublicKeyTypes = { * not an ethereumAddress */ 'EcdsaPublicKeySecp256k1', + 'ConditionalProof2022', 'JsonWebKey2020', ], Ed25519: [ @@ -233,8 +240,6 @@ function decodeJWS(jws: string): JWSDecoded { throw new Error('invalid_argument: Incorrect format JWS') } -/** @module did-jwt/JWT */ - /** * Decodes a JWT and returns an object representing the payload * @@ -242,13 +247,22 @@ function decodeJWS(jws: string): JWSDecoded { * decodeJWT('eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NksifQ.eyJpYXQiOjE1...') * * @param {String} jwt a JSON Web Token to verify + * @param {Object} [recurse] whether to recurse into the payload to decode any nested JWTs * @return {Object} a JS object representing the decoded JWT */ -export function decodeJWT(jwt: string): JWTDecoded { +export function decodeJWT(jwt: string, recurse = true): JWTDecoded { if (!jwt) throw new Error('invalid_argument: no JWT passed into decodeJWT') try { const jws = decodeJWS(jwt) const decodedJwt: JWTDecoded = Object.assign(jws, { payload: JSON.parse(decodeBase64url(jws.payload)) }) + const iss = decodedJwt.payload.iss + + if (decodedJwt.header.cty === 'JWT' && recurse) { + const innerDecodedJwt = decodeJWT(decodedJwt.payload.jwt) + + if (innerDecodedJwt.payload.iss !== iss) throw new Error(`${JWT_ERROR.INVALID_JWT}: multiple issuers`) + return innerDecodedJwt + } return decodedJwt } catch (e) { throw new Error('invalid_argument: Incorrect format JWT') @@ -281,6 +295,9 @@ export async function createJWS( const jwtSigner: SignerAlgorithm = SignerAlg(header.alg) const signature: string = await jwtSigner(signingInput, signer) + + // JWS Compact Serialization + // https://www.rfc-editor.org/rfc/rfc7515#section-7.1 return [signingInput, signature].join('.') } @@ -326,10 +343,94 @@ export async function createJWT( } } const fullPayload = { ...timestamps, ...payload, iss: issuer } - return createJWS(fullPayload, signer, header, { canonicalize }) + return createJWS(fullPayload, signer, header, { canonicalize }) as Promise } -function verifyJWSDecoded( +/** + * Creates a multi-signature signed JWT given multiple issuers and their corresponding signers, and a payload for which the signature is + * over. + * + * @example + * const signer = ES256KSigner(process.env.PRIVATE_KEY) + * createJWT({address: '5A8bRWU3F7j3REx3vkJ...', signer}, {key1: 'value', key2: ..., ... }).then(jwt => { + * ... + * }) + * + * @param {Object} payload payload object + * @param {Object} [options] an unsigned credential object + * @param {boolean} options.expiresIn optional flag to denote the expiration time + * @param {boolean} options.canonicalize optional flag to canonicalize header and payload before signing + * @param {Object[]} issuers array of the issuers, their signers and algorithms + * @param {string} issuers[].issuer The DID of the issuer (signer) of JWT + * @param {Signer} issuers[].signer a `Signer` function, Please see `ES256KSigner` or `EdDSASigner` + * @param {String} issuers[].alg [DEPRECATED] The JWT signing algorithm to use. Supports: + * [ES256K, ES256K-R, Ed25519, EdDSA], Defaults to: ES256K. Please use `header.alg` to specify the algorithm + * @return {Promise} a promise which resolves with a signed JSON Web Token or + * rejects with an error + */ +export async function createMultisignatureJWT( + payload: Partial, + { expiresIn, canonicalize }: Partial, + issuers: { issuer: string; signer: Signer; alg: string }[] +): Promise { + if (issuers.length === 0) throw new Error('invalid_argument: must provide one or more issuers') + + let payloadResult: Partial = payload + + let jwt = '' + for (let i = 0; i < issuers.length; i++) { + const issuer = issuers[i] + + const header: Partial = { + typ: 'JWT', + alg: issuer.alg, + } + + // Create nested JWT + // See Point 5 of https://www.rfc-editor.org/rfc/rfc7519#section-7.1 + // After the first JWT is created (the first JWS), the next JWT is created by inputting the previous JWT as the payload + if (i !== 0) { + header.cty = 'JWT' + } + + jwt = await createJWT(payloadResult, { ...issuer, canonicalize, expiresIn }, header) + + payloadResult = { jwt } + } + return jwt +} + +export function verifyJWTDecoded( + { header, payload, data, signature }: JWTDecoded, + pubKeys: VerificationMethod | VerificationMethod[] +): VerificationMethod { + if (!Array.isArray(pubKeys)) pubKeys = [pubKeys] + + const iss = payload.iss + let recurse = true + do { + if (iss !== payload.iss) throw new Error(`${JWT_ERROR.INVALID_JWT}: multiple issuers`) + + try { + const result = VerifierAlgorithm(header.alg)(data, signature, pubKeys) + + return result + } catch (e) { + if (!(e as Error).message.startsWith(JWT_ERROR.INVALID_SIGNATURE)) throw e + } + + // TODO probably best to create copy objects than replace reference objects + if (header.cty !== 'JWT') { + recurse = false + } else { + ;({ payload, header, signature, data } = decodeJWT(payload.jwt, false)) + } + } while (recurse) + + throw new Error(`${JWT_ERROR.INVALID_SIGNATURE}: no matching public key found`) +} + +export function verifyJWSDecoded( { header, data, signature }: JWSDecoded, pubKeys: VerificationMethod | VerificationMethod[] ): VerificationMethod { @@ -392,61 +493,99 @@ export async function verifyJWT( skewTime: undefined, proofPurpose: undefined, policies: {}, + didAuthenticator: undefined, } ): Promise { if (!options.resolver) throw new Error('missing_resolver: No DID resolver has been configured') - const { payload, header, signature, data }: JWTDecoded = decodeJWT(jwt) + const { payload, header, signature, data }: JWTDecoded = decodeJWT(jwt, false) const proofPurpose: ProofPurposeTypes | undefined = Object.prototype.hasOwnProperty.call(options, 'auth') ? options.auth ? 'authentication' : undefined : options.proofPurpose - let did + let didUrl: string | undefined if (!payload.iss && !payload.client_id) { throw new Error(`${JWT_ERROR.INVALID_JWT}: JWT iss or client_id are required`) } - if (payload.iss === SELF_ISSUED_V2 || payload.iss === SELF_ISSUED_V2_VC_INTEROP) { + if (options.didAuthenticator) { + didUrl = options.didAuthenticator.issuer + } else if (payload.iss === SELF_ISSUED_V2 || payload.iss === SELF_ISSUED_V2_VC_INTEROP) { if (!payload.sub) { throw new Error(`${JWT_ERROR.INVALID_JWT}: JWT sub is required`) } if (typeof payload.sub_jwk === 'undefined') { - did = payload.sub + didUrl = payload.sub } else { - did = (header.kid || '').split('#')[0] + didUrl = (header.kid || '').split('#')[0] } } else if (payload.iss === SELF_ISSUED_V0_1) { if (!payload.did) { throw new Error(`${JWT_ERROR.INVALID_JWT}: JWT did is required`) } - did = payload.did + didUrl = payload.did } else if (!payload.iss && payload.scope === 'openid' && payload.redirect_uri) { // SIOP Request payload // https://identity.foundation/jwt-vc-presentation-profile/#self-issued-op-request-object if (!payload.client_id) { throw new Error(`${JWT_ERROR.INVALID_JWT}: JWT client_id is required`) } - did = payload.client_id + didUrl = payload.client_id } else { - did = payload.iss + didUrl = payload.iss } - if (!did) { + if (!didUrl) { throw new Error(`${JWT_ERROR.INVALID_JWT}: No DID has been found in the JWT`) } - const { didResolutionResult, authenticators, issuer }: DIDAuthenticator = await resolveAuthenticator( - options.resolver, - header.alg, - did, - proofPurpose - ) - const signer: VerificationMethod = await verifyJWSDecoded({ header, data, signature } as JWSDecoded, authenticators) - const now: number = typeof options.policies?.now === 'number' ? options.policies.now : Math.floor(Date.now() / 1000) - const skewTime = typeof options.skewTime !== 'undefined' && options.skewTime >= 0 ? options.skewTime : NBF_SKEW + let authenticators: VerificationMethod[] + let issuer: string + let didResolutionResult: DIDResolutionResult + if (options.didAuthenticator) { + ;({ didResolutionResult, authenticators, issuer } = options.didAuthenticator) + } else { + ;({ didResolutionResult, authenticators, issuer } = await resolveAuthenticator( + options.resolver, + header.alg, + didUrl, + proofPurpose + )) + // Add to options object for recursive reference + options.didAuthenticator = { didResolutionResult, authenticators, issuer } + } + + const { did } = parse(didUrl) as ParsedDID + + let signer: VerificationMethod | null = null + + if (did !== didUrl) { + const authenticator = authenticators.find((auth) => auth.id === didUrl) + if (!authenticator) { + throw new Error(`${JWT_ERROR.INVALID_JWT}: No authenticator found for did URL ${didUrl}`) + } + + signer = await verifyProof(jwt, { payload, header, signature, data }, authenticator, options) + } else { + let i = 0 + while (!signer && i < authenticators.length) { + const authenticator = authenticators[i] + try { + signer = await verifyProof(jwt, { payload, header, signature, data }, authenticator, options) + } catch (e) { + if (!(e as Error).message.includes(JWT_ERROR.INVALID_SIGNATURE) || i === authenticators.length - 1) throw e + } + + i++ + } + } + if (signer) { + const now: number = typeof options.policies?.now === 'number' ? options.policies.now : Math.floor(Date.now() / 1000) + const skewTime = typeof options.skewTime !== 'undefined' && options.skewTime >= 0 ? options.skewTime : NBF_SKEW + const nowSkewed = now + skewTime if (options.policies?.nbf !== false && payload.nbf) { if (payload.nbf > nowSkewed) { @@ -471,6 +610,7 @@ export async function verifyJWT( throw new Error(`${JWT_ERROR.INVALID_AUDIENCE}: JWT audience does not match your DID or callback url`) } } + return { verified: true, payload, didResolutionResult, issuer, signer, jwt, policies: options.policies } } throw new Error( diff --git a/src/__tests__/ConditionalAlgorithm.test.ts b/src/__tests__/ConditionalAlgorithm.test.ts new file mode 100644 index 00000000..3983d034 --- /dev/null +++ b/src/__tests__/ConditionalAlgorithm.test.ts @@ -0,0 +1,406 @@ +import MockDate from 'mockdate' +import { + createMultisignatureJWT, + verifyJWT, +} from '../JWT' + +// add declarations for ES256 Tests +import { createResolver, createSigner } from './ConditionalAlgorithmResolverHelper' +import { PrivateKey } from '@greymass/eosio' +import { JWT_ERROR } from '../Errors' + +const NOW = 1485321133 +MockDate.set(NOW * 1000 + 123) + +const account = 'jack' +const network = 'eos' +const did = `did:antelope:${network}:${account}` + +export const privateKeys = [ + '5JAJ7BfYKdRnrSQCsdcBqrCcBVQQSuQ77fuRAJ5fcbQ3UDhuLLZ', + '5JcKy5rAp4rTnE9za1CNm5xBG4DnJ2T29cYpz87kRVRdQqv1K8x', + '5JibpxxNpkqdejc38KD9xZF3fKHtTUonKCAYiZbwNPsgoKbw6FQ', + '5KFVyCULqHQ82PCvCpCa4XGzTs8SGrRnts8t9LQMsv7hiCp7oBq', + '5JQ8U8WS2isz3fiCXcFyZyiPfUXB4PXwDUK5tvWiqLAmUyqaXpe', +]; + +const publicKeys = privateKeys.map((privKey) => { + return PrivateKey.from(privKey).toPublic().toString() +}) + +describe('createMultisignatureJWT()', () => { + describe('ConditionalProof - multisignature', () => { + + it('creates a valid signed JWT that satisfies 1 of 1 signature', async () => { + expect.assertions(2) + + const issuers = [{ + issuer: did, + signer: createSigner(privateKeys[0]), + alg: 'ES256K-R' + }] + + const jwt = await createMultisignatureJWT({ requested: ['name', 'phone']}, {}, issuers) + + // resolves to a DID Document with 1 verification method that requires 1 of 1 signatures + const resolver = createResolver({ + threshold: 1, + keys: [publicKeys[0]].map((key) => { return { key, weight: 1}}), + accounts: [] + }) + + const verified = await verifyJWT(jwt, { resolver }) + expect(verified.verified).toBe(true) + expect(verified.payload.requested).toEqual(['name', 'phone']) + }) + + it('creates a valid signed JWT that satisfies 2 of 2 signature', async () => { + expect.assertions(1) + + const issuers = [{ + issuer: did, + signer: createSigner(privateKeys[0]), + alg: 'ES256K-R' + }, { + issuer: did, + signer: createSigner(privateKeys[1]), + alg: 'ES256K-R' + }] + + const jwt = await createMultisignatureJWT({ requested: ['name', 'phone']}, {}, issuers) + + // resolves to a DID Document with 1 verification method that requires 2 of 2 signatures + const resolver = createResolver({ + threshold: 2, + keys: [publicKeys[0], publicKeys[1]].map((key) => { return { key, weight: 1}}), + accounts: [] + }) + + const verified = await verifyJWT(jwt, { resolver }) + expect(verified.verified).toBe(true) + }) + + it('creates a valid signed JWT that satisfies 2 of 3 signature', async () => { + expect.assertions(1) + + const issuers = [{ + issuer: did, + signer: createSigner(privateKeys[0]), + alg: 'ES256K-R' + }, { + issuer: did, + signer: createSigner(privateKeys[1]), + alg: 'ES256K-R' + }] + + const jwt = await createMultisignatureJWT({ requested: ['name', 'phone']}, {}, issuers) + + // resolves to a DID Document with 1 verification method that requires 2 of 3 signatures + const resolver = createResolver({ + threshold: 2, + keys: [publicKeys[0], publicKeys[1], publicKeys[2]].map((key) => { return { key, weight: 1}}), + accounts: [] + }) + + const verified = await verifyJWT(jwt, { resolver }) + expect(verified.verified).toBe(true) + }) + + it('creates a valid signed JWT with only one signature that fails to satisfy 2 of 3 signature', async () => { + expect.assertions(1) + + const issuers = [{ + issuer: did, + signer: createSigner(privateKeys[0]), + alg: 'ES256K-R' + }] + + const jwt = await createMultisignatureJWT({ requested: ['name', 'phone']}, {}, issuers) + + // resolves to a DID Document with 1 verification method that requires 2 of 3 signatures + const resolver = createResolver({ + threshold: 2, + keys: [publicKeys[0], publicKeys[1], publicKeys[2]].map((key) => { return { key, weight: 1}}), + accounts: [] + }) + + await expect(verifyJWT(jwt, { resolver })).rejects.toThrow(JWT_ERROR.INVALID_SIGNATURE) + }) + }) + + describe('ConditionalProof - delegated signatures', () => { + + it('creates a valid signed JWT that satisfies 1 delegation', async () => { + expect.assertions(2) + + const issuers = [{ + issuer: did + '#permission1', + signer: createSigner(privateKeys[0]), + alg: 'ES256K-R' + }] + + const jwt = await createMultisignatureJWT({ requested: ['name', 'phone']}, {}, issuers) + + // resolves to a DID Document with 2 verification methods + // - one that requires 1 of 1 signatures + // - one that delegates to the first + const resolver = createResolver([{ + threshold: 1, + keys: [{ + key: publicKeys[0], + weight: 1 + }], + accounts: [] + }, { + threshold: 1, + keys: [], + accounts: [{ + permission: { + permission: 'permission0', + actor: account, + }, + weight: 1 + }] + }]) + + const verified = await verifyJWT(jwt, { resolver }) + expect(verified.verified).toBe(true) + expect(verified.signer.id).toBe(did + '#permission1') + }) + + it('creates a valid signed JWT that fails satisfies 1 delegation when the signing key is incorrect', async () => { + expect.assertions(1) + + const issuers = [{ + issuer: did, + signer: createSigner(privateKeys[1]), + alg: 'ES256K-R' + }] + + const jwt = await createMultisignatureJWT({ requested: ['name', 'phone']}, {}, issuers) + + // resolves to a DID Document with 2 verification methods + // - one that requires 1 of 1 signatures + // - one that delegates to the first + const resolver = createResolver([{ + threshold: 1, + keys: [{ + key: publicKeys[0], + weight: 1 + }], + accounts: [] + }, { + threshold: 1, + keys: [], + accounts: [{ + permission: { + permission: 'permission0', + actor: account, + }, + weight: 1 + }] + }]) + + await expect(verifyJWT(jwt, { resolver })).rejects.toThrow(JWT_ERROR.INVALID_SIGNATURE) + }) + }) + + describe('ConditionalProof - combination key and delegated signatures', () => { + + it('creates a valid signed JWT that satisfies 3 threshold and 2 keys and 2 delegated signature check', async () => { + expect.assertions(2) + + const issuers = [{ + issuer: did + '#permission0', + signer: createSigner(privateKeys[0]), + alg: 'ES256K-R' + }, { + issuer: did + '#permission0', + signer: createSigner(privateKeys[1]), + alg: 'ES256K-R' + }, { + issuer: did + '#permission0', + signer: createSigner(privateKeys[2]), + alg: 'ES256K-R' + }] + + const jwt = await createMultisignatureJWT({ requested: ['name', 'phone']}, {}, issuers) + + // resolves to a DID Document with 3 verification methods + // - one of 3 threshold with 2 signature (privateKey[0] and privateKey[1]) and 2 delegations to the 2nd and 3rd verification method + // - one with 1 signature requirement of privateKey[2] + // - one with 1 signature requirement of privateKey[3] + const resolver = createResolver([{ + threshold: 3, + keys: [{ + key: publicKeys[0], + weight: 1 + }, { + key: publicKeys[1], + weight: 1 + }], + accounts: [{ + permission: { + permission: 'permission1', + actor: account, + }, + weight: 1 + }, { + permission: { + permission: 'permission2', + actor: account, + }, + weight: 1 + }] + }, { + threshold: 1, + keys: [{ + key: publicKeys[2], + weight: 1 + }], + accounts: [] + }, { + threshold: 1, + keys: [{ + key: publicKeys[3], + weight: 1 + }], + accounts: [] + }]) + + const verified = await verifyJWT(jwt, { resolver }) + expect(verified.verified).toBe(true) + expect(verified.signer.id).toBe(did + '#permission0') + }) + + it('creates a valid signed JWT that fails satisfies 3 threshold and 2 keys and 2 delegated signature check, with a bad key', async () => { + expect.assertions(1) + + const issuers = [{ + issuer: did + '#permission0', + signer: createSigner(privateKeys[4]), + alg: 'ES256K-R' + }, { + issuer: did + '#permission0', + signer: createSigner(privateKeys[1]), + alg: 'ES256K-R' + }, { + issuer: did + '#permission0', + signer: createSigner(privateKeys[2]), + alg: 'ES256K-R' + }] + + const jwt = await createMultisignatureJWT({ requested: ['name', 'phone']}, {}, issuers) + + // resolves to a DID Document with 3 verification methods + // - one of 3 threshold with 2 signature (privateKey[0] and privateKey[1]) and 2 delegations to the 2nd and 3rd verification method + // - one with 1 signature requirement of privateKey[2] + // - one with 1 signature requirement of privateKey[3] + const resolver = createResolver([{ + threshold: 3, + keys: [{ + key: publicKeys[0], + weight: 1 + }, { + key: publicKeys[1], + weight: 1 + }], + accounts: [{ + permission: { + permission: 'permission1', + actor: account, + }, + weight: 1 + }, { + permission: { + permission: 'permission2', + actor: account, + }, + weight: 1 + }] + }, { + threshold: 1, + keys: [{ + key: publicKeys[2], + weight: 1 + }], + accounts: [] + }, { + threshold: 1, + keys: [{ + key: publicKeys[3], + weight: 1 + }], + accounts: [] + }]) + + await expect(verifyJWT(jwt, { resolver })).rejects.toThrow(JWT_ERROR.INVALID_SIGNATURE) + }) + + + it('creates a valid signed JWT that fails satisfies 3 threshold and 2 keys and 2 delegated signature check, with a bad delegate', async () => { + expect.assertions(1) + + const issuers = [{ + issuer: did + '#permission0', + signer: createSigner(privateKeys[0]), + alg: 'ES256K-R' + }, { + issuer: did + '#permission0', + signer: createSigner(privateKeys[1]), + alg: 'ES256K-R' + }, { + issuer: did + '#permission0', + signer: createSigner(privateKeys[4]), + alg: 'ES256K-R' + }] + + const jwt = await createMultisignatureJWT({ requested: ['name', 'phone']}, {}, issuers) + + // resolves to a DID Document with 3 verification methods + // - one of 3 threshold with 2 signature (privateKey[0] and privateKey[1]) and 2 delegations to the 2nd and 3rd verification method + // - one with 1 signature requirement of privateKey[2] + // - one with 1 signature requirement of privateKey[3] + const resolver = createResolver([{ + threshold: 3, + keys: [{ + key: publicKeys[0], + weight: 1 + }, { + key: publicKeys[1], + weight: 1 + }], + accounts: [{ + permission: { + permission: 'permission1', + actor: account, + }, + weight: 1 + }, { + permission: { + permission: 'permission2', + actor: account, + }, + weight: 1 + }] + }, { + threshold: 1, + keys: [{ + key: publicKeys[2], + weight: 1 + }], + accounts: [] + }, { + threshold: 1, + keys: [{ + key: publicKeys[3], + weight: 1 + }], + accounts: [] + }]) + + await expect(verifyJWT(jwt, { resolver })).rejects.toThrow(JWT_ERROR.INVALID_SIGNATURE) + }) + }) + +}) diff --git a/src/__tests__/ConditionalAlgorithmResolverHelper.ts b/src/__tests__/ConditionalAlgorithmResolverHelper.ts new file mode 100644 index 00000000..7ee0da83 --- /dev/null +++ b/src/__tests__/ConditionalAlgorithmResolverHelper.ts @@ -0,0 +1,62 @@ +import { createDIDDocument, antelopeChainRegistry, checkDID } from '@tonomy/antelope-did' +import { parse } from 'did-resolver' +import { Signer } from '../JWT' +import { PrivateKey, KeyType } from '@greymass/eosio' +import { ES256KSigner } from '../signers/ES256KSigner' + +type AntelopePermission = { + threshold: number + keys: { + key: string + weight: number + }[] + accounts: { + permission: { + permission: string + actor: string + } + weight: number + }[] +} + +export function createResolver(required_auth: AntelopePermission | AntelopePermission[]) { + return { + resolve: async (did: string) => { + const parsed = parse(did) + if (!parsed) throw new Error('could not parse did') + const methodId = checkDID(parsed, antelopeChainRegistry) + if (!methodId) throw new Error('invalid did') + + let auth: AntelopePermission[] + if (!Array.isArray(required_auth)) { + auth = [required_auth] + } else { + auth = required_auth + } + const mockAccountResponse = { + permissions: auth.map((permission, index) => { + return { + perm_name: 'permission' + index, + parent: 'owner', + required_auth: permission, + } + }), + } + const didDoc = createDIDDocument(methodId, parsed.did, mockAccountResponse) + + return { + didResolutionMetadata: {}, + didDocument: didDoc, + didDocumentMetadata: {}, + } + }, + } +} + +export function createSigner(privKey: string): Signer { + const privateKey = PrivateKey.from(privKey) + if (privateKey.type !== KeyType.K1) { + throw new Error('Unsupported key type') + } + return ES256KSigner(privateKey.data.array, true) +} diff --git a/src/__tests__/JWT.test.ts b/src/__tests__/JWT.test.ts index 5b789a45..1adb1765 100644 --- a/src/__tests__/JWT.test.ts +++ b/src/__tests__/JWT.test.ts @@ -712,7 +712,7 @@ describe('verifyJWT() for ES256K', () => { 'eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NksifQ.eyJpYXQiOjE0ODUzMjExMzMsImlzcyI6ImRpZDpldGhyOjB4MjBjNzY5ZWM5YzA5OTZiYTc3MzdhNDgyNmMyYWFmZjAwYjFiMjA0MCIsInJlcXVlc3RlZCI6WyJuYW1lIiwicGhvbmUiXX0.TTpuw77fUbd_AY3GJcCumd6F6hxnkskMDJYNpJlI2DQi5MKKudXya9NlyM9e8-KFgTLe-WnXgq9EjWLvjpdiXA' it('rejects a JWT with bad signature', async () => { expect.assertions(1) - await expect(verifyJWT(badJwt, { resolver })).rejects.toThrowError(/Signature invalid for JWT/) + await expect(verifyJWT(badJwt, { resolver })).rejects.toThrowError(/invalid_signature: no matching public key found/) }) }) diff --git a/src/index.ts b/src/index.ts index 74bbc7ac..6f0aac6d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { EdDSASigner } from './signers/EdDSASigner' import { createJWS, createJWT, + createMultisignatureJWT, decodeJWT, JWTHeader, JWTPayload, @@ -42,6 +43,7 @@ export { EdDSASigner, verifyJWT, createJWT, + createMultisignatureJWT, decodeJWT, verifyJWS, createJWS, diff --git a/yarn.lock b/yarn.lock index 50fd2082..1fd79be5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1593,6 +1593,17 @@ resolved "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz" integrity sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw== +"@greymass/eosio@^0.6.9": + version "0.6.9" + resolved "https://registry.yarnpkg.com/@greymass/eosio/-/eosio-0.6.9.tgz#e5475dfb6f1507da6ec2f9f0065e19684e8d4b70" + integrity sha512-Xd6X3sesStFQiw3+6+8tD1IauW3WX25GQnHa/zhUqIE1cB23bjzLqn89JDTcyQYKJ4R24L1rERqko/u6p7RizA== + dependencies: + bn.js "^4.11.9" + brorand "^1.1.0" + elliptic "^6.5.4" + hash.js "^1.0.0" + tslib "^2.0.3" + "@humanwhocodes/config-array@^0.11.8": version "0.11.8" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.8.tgz#03595ac2075a4dc0f191cc2131de14fbd7d410b9" @@ -2465,6 +2476,29 @@ magic-string "^0.25.0" string.prototype.matchall "^4.0.6" +"@tonomy/antelope-did-resolver@^0.1.9": + version "0.1.9" + resolved "https://registry.yarnpkg.com/@tonomy/antelope-did-resolver/-/antelope-did-resolver-0.1.9.tgz#51f10746bbe90ba15eab0d6986a946020a92dcc2" + integrity sha512-oli1hMVweBkPEtbkK58i4oH7a25+tLtuOapXF0bV7qhvL7SWIQ0PjBEJpFtBUYs1oZ39fCRsu8ioVgwoOt+HQQ== + dependencies: + base64url "^3.0.1" + eosjs "^21.0.4" + +"@tonomy/antelope-did@^0.1.5": + version "0.1.5" + resolved "https://registry.yarnpkg.com/@tonomy/antelope-did/-/antelope-did-0.1.5.tgz#dcfd14b79ea4e236d28278603889fd3b1ef10884" + integrity sha512-CzWCx0sdXenIZthywHMydZVnmuRK2V5rwPo9YheLBxD7hm0OA85sDflW4ADmiFu8HaWjk0JgjS6jwS7Xsr56rg== + dependencies: + "@tonomy/antelope-did-resolver" "^0.1.9" + "@tonomy/did-resolver" "^4.0.4" + eosjs "^22.0.0" + node-fetch "^2.6.1" + +"@tonomy/did-resolver@^4.0.4": + version "4.0.4" + resolved "https://registry.yarnpkg.com/@tonomy/did-resolver/-/did-resolver-4.0.4.tgz#4a0c5bc68e4498e992bca62914ec5d763eaacf82" + integrity sha512-7sgPKUENMsGfq2TaAo4/hwthy45pvVnZep79o6BpmPK5xSwzPctglITkgfM7OY6hyS4le4wQC0LYI7mrGShD2A== + "@tootallnate/once@1": version "1.1.2" resolved "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz" @@ -3325,6 +3359,11 @@ binary-extensions@^2.2.0: resolved "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz" integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== +bn.js@5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.2.0.tgz#358860674396c6997771a9d051fcc1b57d4ae002" + integrity sha512-D7iWRBvnZE8ecXiLj/9wbxH7Tk79fAh8IHaTNq1RWRixsS02W+5qS+iE9yq6RYl0asXx5tw0bLhmT5pIfbSquw== + bn.js@^4.0.0, bn.js@^4.11.9: version "4.12.0" resolved "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz" @@ -4165,7 +4204,7 @@ electron-to-chromium@^1.4.284: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.286.tgz#0e039de59135f44ab9a8ec9025e53a9135eba11f" integrity sha512-Vp3CVhmYpgf4iXNKAucoQUDcCrBQX3XLBtwgFqP9BUXuucgvAV9zWp1kYU7LL9j4++s9O+12cb3wMtN4SJy6UQ== -elliptic@^6.5.4: +elliptic@6.5.4, elliptic@^6.5.4: version "6.5.4" resolved "https://registry.npmjs.org/elliptic/-/elliptic-6.5.4.tgz" integrity sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ== @@ -4238,6 +4277,26 @@ envinfo@^7.7.3: resolved "https://registry.npmjs.org/envinfo/-/envinfo-7.8.1.tgz" integrity sha512-/o+BXHmB7ocbHEAs6F2EnG0ogybVVUdkRunTT2glZU9XAaGmhqskrvKwqXuDfNjEO0LZKWdejEEpnq8aM0tOaw== +eosjs@^21.0.4: + version "21.0.4" + resolved "https://registry.yarnpkg.com/eosjs/-/eosjs-21.0.4.tgz#67794a9fbc3c7659de19a7926c2c13cce4515571" + integrity sha512-XbuIoidplA1hHIejy7VQ+hmBfC6T28kYFaQMsn6G1DMTg1CFwUzxwzUvZg/dGNPuf7hgPxOpaQvAUsdidTgGhQ== + dependencies: + bn.js "5.2.0" + elliptic "6.5.4" + hash.js "1.1.7" + pako "2.0.3" + +eosjs@^22.0.0: + version "22.1.0" + resolved "https://registry.yarnpkg.com/eosjs/-/eosjs-22.1.0.tgz#7ac40e2f1f959fab70539c30ac8ae46c9038aa3c" + integrity sha512-Ka8KO7akC3RxNdSg/3dkGWuUWUQESTzSUzQljBdVP16UG548vmQoBqSGnZdnjlZyfcab8VOu2iEt+JjyfYc5+A== + dependencies: + bn.js "5.2.0" + elliptic "6.5.4" + hash.js "1.1.7" + pako "2.0.3" + err-code@^2.0.2: version "2.0.3" resolved "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz" @@ -4977,7 +5036,7 @@ has@^1.0.3: dependencies: function-bind "^1.1.1" -hash.js@^1.0.0, hash.js@^1.0.3: +hash.js@1.1.7, hash.js@^1.0.0, hash.js@^1.0.3: version "1.1.7" resolved "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz" integrity sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA== @@ -7217,6 +7276,11 @@ pacote@^13.0.3, pacote@^13.6.1, pacote@^13.6.2: ssri "^9.0.0" tar "^6.1.11" +pako@2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/pako/-/pako-2.0.3.tgz#cdf475e31b678565251406de9e759196a0ea7a43" + integrity sha512-WjR1hOeg+kki3ZIOjaf4b5WVcay1jaliKSYiEaB1XzwhMQZJxRdQRv0V31EKBYlxb4T7SK3hjfc/jxyU64BoSw== + parent-module@^1.0.0: version "1.0.1" resolved "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz"