From 44942de56a24faee2bb7dc6d32781de96b6b47ea Mon Sep 17 00:00:00 2001 From: Moe Jangda Date: Wed, 6 Dec 2023 20:12:05 -0600 Subject: [PATCH] add jwt verification and move separate jwt functionality --- packages/credentials/src/compact-jwt.ts | 224 ++++++++++++++++++ .../credentials/src/verifiable-credential.ts | 159 ++----------- packages/dids/src/did-resolver.ts | 37 +++ packages/dids/src/types.ts | 7 +- packages/dids/src/utils.ts | 11 +- 5 files changed, 293 insertions(+), 145 deletions(-) create mode 100644 packages/credentials/src/compact-jwt.ts diff --git a/packages/credentials/src/compact-jwt.ts b/packages/credentials/src/compact-jwt.ts new file mode 100644 index 000000000..c4bf49c5e --- /dev/null +++ b/packages/credentials/src/compact-jwt.ts @@ -0,0 +1,224 @@ +import type { CryptoAlgorithm, JwkParamsEcPrivate, JwkParamsOkpPrivate, PublicKeyJwk, Web5Crypto } from '@web5/crypto'; +import type { PortableDid } from '@web5/dids'; + +import { PrivateKeyJwk, EdDsaAlgorithm, EcdsaAlgorithm } from '@web5/crypto'; +import { DidDhtMethod, DidIonMethod, DidKeyMethod, DidResolver, utils } from '@web5/dids'; +import { JwtHeader, JwtPayload } from 'jwt-decode'; +import { Convert } from '@web5/common'; + +/** + * Parameters for creating a JWT. + */ +export type CreateJwtParams = { + signerDid: PortableDid + payload: JwtPayload & Record +} + +/** + * Parameters for verifying a JWT. + */ +export type VerifyJwtParams = { + compactJwt: string +} + +/** + * Parameters for decoding a JWT. + */ +export type DecodeJwtParams = { + compactJwt: string +} + +/** + * Represents a verified JWT with signer information, header, and payload. + */ +export type VerifiedJwt = { + signerDid: string + header: JwtHeader + payload: JwtPayload & Record +} + +/** + * Represents a signer with a specific cryptographic algorithm and options. + * @template T - The type of cryptographic options. + */ +type Signer = { + signer: CryptoAlgorithm, + options?: T | undefined + alg: string + crv: string +} + +const secp256k1Signer: Signer = { + signer : new EcdsaAlgorithm(), + options : { name: 'ES256K'}, + alg : 'ES256K', + crv : 'secp256k1' +}; + +const ed25519Signer: Signer = { + signer : new EdDsaAlgorithm(), + options : { name: 'EdDSA' }, + alg : 'EdDSA', + crv : 'Ed25519' +}; + +/** + * Class for handling Compact JSON Web Tokens (JWTs). + * This class provides methods to create, verify, and decode JWTs using various cryptographic algorithms. + */ +export class CompactJwt { + /** supported cryptographic algorithms. keys are `${alg}:${crv}`. */ + static algorithms: { [alg: string]: Signer } = { + 'ES256K:' : secp256k1Signer, + 'ES256K:secp256k1' : secp256k1Signer, + ':secp256k1' : secp256k1Signer, + 'EdDSA:Ed25519' : ed25519Signer + }; + + /** + * DID Resolver instance for resolving decentralized identifiers. + */ + static didResolver: DidResolver = new DidResolver({ didResolvers: [DidIonMethod, DidKeyMethod, DidDhtMethod] }); + + /** + * Creates a JWT. + * @param params - Parameters for JWT creation including signer DID and payload. + * @returns The compact JWT as a string. + * @example + * ``` + * const jwt = await CompactJwt.create({ signerDid: myDid, payload: myPayload }); + * ``` + */ + static async create(params: CreateJwtParams) { + const { signerDid, payload } = params; + const privateKeyJwk = signerDid.keySet.verificationMethodKeys![0].privateKeyJwk! as JwkParamsEcPrivate | JwkParamsOkpPrivate; + + const header: JwtHeader = { + typ : 'JWT', + alg : privateKeyJwk.alg, + kid : signerDid.document.verificationMethod![0].id + }; + + const base64UrlEncodedHeader = Convert.object(header).toBase64Url(); + const base64UrlEncodedPayload = Convert.object(payload).toBase64Url(); + + const toSign = `${base64UrlEncodedHeader}.${base64UrlEncodedPayload}`; + const toSignBytes = Convert.string(toSign).toUint8Array(); + + const algorithmId = `${header.alg}:${privateKeyJwk['crv'] || ''}`; + if (!(algorithmId in CompactJwt.algorithms)) { + throw new Error(`Signing failed: ${algorithmId} not supported`); + } + + const { signer, options } = CompactJwt.algorithms[algorithmId]; + + const signatureBytes = await signer.sign({ key: privateKeyJwk as PrivateKeyJwk, data: toSignBytes, algorithm: options! }); + const base64UrlEncodedSignature = Convert.uint8Array(signatureBytes).toBase64Url(); + + return `${toSign}.${base64UrlEncodedSignature}`; + } + + /** + * Verifies a JWT. + * @param params - Parameters for JWT verification + * @returns Verified JWT information including signer DID, header, and payload. + * @example + * ``` + * const verifiedJwt = await CompactJwt.verify({ compactJwt: myJwt }); + * ``` + */ + static async verify(params: VerifyJwtParams) { + const { decoded: decodedJwt, encoded: encodedJwt } = CompactJwt.decode({ compactJwt: params.compactJwt }); + // TODO: should really be looking for verificationMethod with authentication verification relationship + const verificationMethod = await CompactJwt.didResolver.deference({ didUrl: decodedJwt.header.kid! }); + if (!utils.isVerificationMethod(verificationMethod)) { // ensure that appropriate verification method was found + throw new Error('Verification failed: Expected kid in JWT header to dereference a DID Document Verification Method'); + } + + // will be used to verify signature + const publicKeyJwk = verificationMethod.publicKeyJwk as JwkParamsEcPrivate | JwkParamsOkpPrivate; + if (!publicKeyJwk) { // ensure that Verification Method includes public key as a JWK. + throw new Error('Verification failed: Expected kid in JWT header to dereference to a DID Document Verification Method with publicKeyJwk'); + } + + const signedData = `${encodedJwt.header}.${encodedJwt.payload}`; + const signedDataBytes = Convert.string(signedData).toUint8Array(); + + const signatureBytes = Convert.base64Url(encodedJwt.signature).toUint8Array(); + + const algorithmId = `${decodedJwt.header.alg}:${publicKeyJwk['crv'] || ''}`; + if (!(algorithmId in CompactJwt.algorithms)) { + throw new Error(`Verification failed: ${algorithmId} not supported`); + } + + const { signer, options } = CompactJwt.algorithms[algorithmId]; + + const isLegit = await signer.verify({ + algorithm : options!, + key : publicKeyJwk as PublicKeyJwk, + data : signedDataBytes, + signature : signatureBytes + }); + + if (!isLegit) { + throw new Error('Signature verification failed: Integrity mismatch'); + } + + return { + signerDid : verificationMethod.controller, + header : encodedJwt.header, + payload : encodedJwt.payload, + }; + } + + /** + * Decodes a JWT without verifying its signature. + * @param params - Parameters for JWT decoding, including the JWT string. + * @returns Decoded JWT parts, including header and payload. + * @example + * const decodedJwt = CompactJwt.decode({ compactJwt: myJwt }); + */ + static decode(params: DecodeJwtParams) { + const splitJwt = params.compactJwt.split('.'); + if (splitJwt.length !== 3) { + throw new Error(`Verification failed: Malformed JWT. expected 3 parts. got ${splitJwt.length}`); + } + + const [base64urlEncodedJwtHeader, base64urlEncodedJwtPayload, base64urlEncodedSignature] = splitJwt; + let jwtHeader: JwtHeader; + let jwtPayload: JwtPayload; + + try { + jwtHeader = Convert.base64Url(base64urlEncodedJwtHeader).toObject(); + } catch(e) { + throw new Error('Verification failed: Malformed JWT. Invalid base64url encoding for JWT header'); + } + + if (!jwtHeader.typ || jwtHeader.typ !== 'JWT') { + throw new Error('Verification failed: Expected JWT header to contain typ property set to JWT'); + } + + if (!jwtHeader.alg || !jwtHeader.kid) { // ensure that JWT header has required properties + throw new Error('Verification failed: Expected JWT header to contain alg and kid'); + } + + // TODO: validate optional payload fields: https://datatracker.ietf.org/doc/html/rfc7519#section-4.1 + try { + jwtPayload = Convert.base64Url(base64urlEncodedJwtPayload).toObject(); + } catch(e) { + throw new Error('Verification failed: Malformed JWT. Invalid base64url encoding for JWT payload'); + } + + return { + decoded: { + header : jwtHeader, + payload : jwtPayload as JwtPayload & Record, + }, + encoded: { + header : base64urlEncodedJwtHeader, + payload : base64urlEncodedJwtPayload, + signature : base64urlEncodedSignature + } + }; + } +} \ No newline at end of file diff --git a/packages/credentials/src/verifiable-credential.ts b/packages/credentials/src/verifiable-credential.ts index 32c04605a..2293f810c 100644 --- a/packages/credentials/src/verifiable-credential.ts +++ b/packages/credentials/src/verifiable-credential.ts @@ -1,19 +1,10 @@ -import type { Resolvable, DIDResolutionResult} from 'did-resolver'; -import type { CryptoAlgorithm, JwkParamsEcPrivate, JwkParamsOkpPrivate, Web5Crypto } from '@web5/crypto'; -import type { JwtHeader } from 'jwt-decode'; -import type { - ICredential, - ICredentialSubject, - JwtDecodedVerifiableCredential } from '@sphereon/ssi-types'; +import type { ICredential, ICredentialSubject} from '@sphereon/ssi-types'; -import { v4 as uuidv4 } from 'uuid'; import { getCurrentXmlSchema112Timestamp } from './utils.js'; -import { Convert } from '@web5/common'; -import { verifyJWT } from 'did-jwt'; -import { DidDhtMethod, DidIonMethod, DidKeyMethod, DidResolver } from '@web5/dids'; +import { v4 as uuidv4 } from 'uuid'; import { SsiValidator } from './validators.js'; import { PortableDid } from '@web5/dids'; -import { PrivateKeyJwk, EdDsaAlgorithm, EcdsaAlgorithm } from '@web5/crypto'; +import { CompactJwt } from './compact-jwt.js'; export const DEFAULT_CONTEXT = 'https://www.w3.org/2018/credentials/v1'; export const DEFAULT_VC_TYPE = 'VerifiableCredential'; @@ -25,6 +16,7 @@ export const DEFAULT_VC_TYPE = 'VerifiableCredential'; */ export type VcDataModel = ICredential; + /** * Options for creating a verifiable credential. * @param type Optional. The type of the credential, can be a string or an array of strings. @@ -52,63 +44,8 @@ export type VerifiableCredentialSignOptions = { did: PortableDid; }; -/** - * Options for `createJwt` - * @param issuerDid - The DID to sign with. - * @param subjectDid - The subject of the credential. - * @param payload - The payload to be signed. - */ -export type CreateJwtOptions = { - issuerDid: PortableDid, - subjectDid: string, - payload: any, -} - type CredentialSubject = ICredentialSubject; -type JwtHeaderParams = { - alg: string; - typ: 'JWT' - kid: string; -}; - -type DecodedVcJwt = { - header: JwtHeaderParams - payload: JwtDecodedVerifiableCredential, - signature: string -} - -type Signer = { - signer: CryptoAlgorithm, - options?: T | undefined - alg: string - crv: string -} - -const secp256k1Signer: Signer = { - signer : new EcdsaAlgorithm(), - options : { name: 'ES256K'}, - alg : 'ES256K', - crv : 'secp256k1' -}; - -const ed25519Signer: Signer = { - signer : new EdDsaAlgorithm(), - options : { name: 'EdDSA' }, - alg : 'EdDSA', - crv : 'Ed25519' -}; - -const didResolver = new DidResolver({ didResolvers: [DidIonMethod, DidKeyMethod, DidDhtMethod] }); - -class TbdResolver implements Resolvable { - async resolve(didUrl: string): Promise { - return await didResolver.resolve(didUrl) as DIDResolutionResult; - } -} - -const tbdResolver = new TbdResolver(); - /** * `VerifiableCredential` represents a digitally verifiable credential according to the * [W3C Verifiable Credentials Data Model](https://www.w3.org/TR/vc-data-model/). @@ -122,14 +59,6 @@ const tbdResolver = new TbdResolver(); export class VerifiableCredential { constructor(public vcDataModel: VcDataModel) {} - /** supported cryptographic algorithms. keys are `${alg}:${crv}`. */ - static algorithms: { [alg: string]: Signer } = { - 'ES256K:' : secp256k1Signer, - 'ES256K:secp256k1' : secp256k1Signer, - ':secp256k1' : secp256k1Signer, - 'EdDSA:Ed25519' : ed25519Signer - }; - get type(): string { return this.vcDataModel.type[this.vcDataModel.type.length - 1]; } @@ -159,7 +88,15 @@ export class VerifiableCredential { * ``` */ public async sign(vcSignOptions: VerifiableCredentialSignOptions): Promise { - const vcJwt: string = await createJwt({issuerDid: vcSignOptions.did, subjectDid: this.subject, payload: { vc: this.vcDataModel }}); + const vcJwt: string = await CompactJwt.create({ + signerDid : vcSignOptions.did, + payload : { + vc : this.vcDataModel, + iss : this.issuer, + sub : this.subject, + } + }); + return vcJwt; } @@ -250,20 +187,7 @@ export class VerifiableCredential { * ``` */ public static async verify(vcJwt: string): Promise { - const jwt = decode(vcJwt); // Parse and validate JWT - - // Ensure the presence of critical header elements `alg` and `kid` - if (!jwt.header.alg || !jwt.header.kid) { - throw new Error('Signature verification failed: Expected JWS header to contain alg and kid'); - } - - const verificationResponse = await verifyJWT(vcJwt, { - resolver: tbdResolver - }); - - if (!verificationResponse.verified) { - throw new Error('VC JWT could not be verified. Reason: ' + JSON.stringify(verificationResponse)); - } + await CompactJwt.verify({ compactJwt: vcJwt }); } /** @@ -278,8 +202,8 @@ export class VerifiableCredential { * ``` */ public static parseJwt(vcJwt: string): VerifiableCredential { - const decodedVcJwt: DecodedVcJwt = decode(vcJwt); - const vcDataModel: VcDataModel = decodedVcJwt.payload.vc; + const parsedJwt = CompactJwt.decode({ compactJwt: vcJwt }); + const vcDataModel: VcDataModel = parsedJwt.decoded.payload['vc']; if(!vcDataModel) { throw Error('Jwt payload missing vc property'); @@ -301,55 +225,4 @@ function validatePayload(vc: VcDataModel): void { SsiValidator.validateCredentialSubject(vc.credentialSubject); if (vc.issuanceDate) SsiValidator.validateTimestamp(vc.issuanceDate); if (vc.expirationDate) SsiValidator.validateTimestamp(vc.expirationDate); -} - -/** - * Decodes a VC JWT into its constituent parts: header, payload, and signature. - * - * @param jwt - The JWT string to decode. - * @returns An object containing the decoded header, payload, and signature. - */ -function decode(jwt: string): DecodedVcJwt { - const [encodedHeader, encodedPayload, encodedSignature] = jwt.split('.'); - - if (!encodedHeader || !encodedPayload || !encodedSignature) { - throw Error('Not a valid jwt'); - } - - return { - header : Convert.base64Url(encodedHeader).toObject() as JwtHeaderParams, - payload : Convert.base64Url(encodedPayload).toObject() as JwtDecodedVerifiableCredential, - signature : encodedSignature - }; -} - -async function createJwt(createJwtOptions: CreateJwtOptions) { - const { issuerDid, subjectDid, payload } = createJwtOptions; - const privateKeyJwk = issuerDid.keySet.verificationMethodKeys![0].privateKeyJwk! as JwkParamsEcPrivate | JwkParamsOkpPrivate; - - const header: JwtHeader = { typ: 'JWT', alg: privateKeyJwk.alg, kid: issuerDid.document.verificationMethod![0].id }; - const base64UrlEncodedHeader = Convert.object(header).toBase64Url(); - - const jwtPayload = { - iss : issuerDid.did, - sub : subjectDid, - ...payload, - }; - - const base64UrlEncodedPayload = Convert.object(jwtPayload).toBase64Url(); - - const toSign = `${base64UrlEncodedHeader}.${base64UrlEncodedPayload}`; - const toSignBytes = Convert.string(toSign).toUint8Array(); - - const algorithmId = `${header.alg}:${privateKeyJwk['crv'] || ''}`; - if (!(algorithmId in VerifiableCredential.algorithms)) { - throw new Error(`Signing failed: ${algorithmId} not supported`); - } - - const { signer, options } = VerifiableCredential.algorithms[algorithmId]; - - const signatureBytes = await signer.sign({ key: privateKeyJwk as PrivateKeyJwk, data: toSignBytes, algorithm: options! }); - const base64UrlEncodedSignature = Convert.uint8Array(signatureBytes).toBase64Url(); - - return `${toSign}.${base64UrlEncodedSignature}`; } \ No newline at end of file diff --git a/packages/dids/src/did-resolver.ts b/packages/dids/src/did-resolver.ts index cdd49d505..7315de0d1 100644 --- a/packages/dids/src/did-resolver.ts +++ b/packages/dids/src/did-resolver.ts @@ -3,6 +3,7 @@ import type { DidMethodResolver, DidResolutionResult, DidResolutionOptions, + DidResource } from './types.js'; import { parseDid } from './utils.js'; @@ -13,6 +14,10 @@ export type DidResolverOptions = { cache?: DidResolverCache; } +export type DeferenceParams = { + didUrl: string +} + /** * The `DidResolver` class is responsible for resolving DIDs to DID documents. * It uses method resolvers to resolve DIDs of different methods and a cache @@ -99,9 +104,41 @@ export class DidResolver { didUrl: parsedDid.did, resolutionOptions }); + await this.cache.set(parsedDid.did, resolutionResult); return resolutionResult; } } + + async deference(params: DeferenceParams): Promise { + const { didUrl } = params; + const { didDocument } = await this.resolve(didUrl); + + const parsedDid = parseDid(params); + + // return the entire DID Document if no fragment is present on the did url + if (!parsedDid.fragment) { + return didDocument; + } + + const { service, verificationMethod } = didDocument; + + // create a set of possible id matches. the DID spec allows for an id to be the entire did#fragment or just #fragment. + // See: https://www.w3.org/TR/did-core/#relative-did-urls + // using a set for fast string comparison. DIDs can be lonnng. + const idSet = new Set([didUrl, parsedDid.fragment, `#${parsedDid.fragment}`]); + + for (let vm of verificationMethod) { + if (idSet.has(vm.id)) { + return vm; + } + } + + for (let svc of service) { + if (idSet.has(svc.id)) { + return svc; + } + } + } } \ No newline at end of file diff --git a/packages/dids/src/types.ts b/packages/dids/src/types.ts index b1e95b462..9d569d153 100644 --- a/packages/dids/src/types.ts +++ b/packages/dids/src/types.ts @@ -90,6 +90,11 @@ export interface DidMethodOperator { getDefaultSigningKey(options: { didDocument: DidDocument }): Promise; } +/** + * A DID Resource is either a DID Document, a DID Verification method or a DID Service + */ +export type DidResource = DidDocument | VerificationMethod | DidService + /** * Services are used in DID documents to express ways of communicating with the DID subject or associated entities. * A service can be any type of service the DID subject wants to advertise. @@ -275,4 +280,4 @@ export type VerificationRelationship = * subject to invoke a cryptographic capability, such as the authorization * to update the DID Document. */ - | 'capabilityInvocation'; + | 'capabilityInvocation'; \ No newline at end of file diff --git a/packages/dids/src/utils.ts b/packages/dids/src/utils.ts index 39bd6e5b9..d4ed0881f 100644 --- a/packages/dids/src/utils.ts +++ b/packages/dids/src/utils.ts @@ -1,7 +1,7 @@ import type { PublicKeyJwk } from '@web5/crypto'; import { parse, type ParsedDID } from 'did-resolver'; -import type { DidDocument, DidService, DidServiceEndpoint, DwnServiceEndpoint } from './types.js'; +import type { DidDocument, DidResource, VerificationMethod, DidService, DidServiceEndpoint, DwnServiceEndpoint } from './types.js'; export interface ParsedDid { did: string @@ -121,4 +121,13 @@ export function parseDid({ didUrl }: { didUrl: string }): ParsedDid | undefined const parsedDid: ParsedDid = parse(didUrl); return parsedDid; +} + +/** + * type guard for {@link VerificationMethod} + * @param didResource - the resource to check + * @returns true if the didResource is a `VerificationMethod` + */ +export function isVerificationMethod(didResource: DidResource): didResource is VerificationMethod { + return didResource && 'id' in didResource && 'type' in didResource && 'controller' in didResource; } \ No newline at end of file