UNPKG

@dwn-protocol/id-sdk

Version:

SDK for accessing the features and capabilities

613 lines (513 loc) 20 kB
import type { JwkKeyPair, PrivateKeyJwk, PublicKeyJwk, IDCrypto } from '../crypto/index.js'; import type { IonDocumentModel, IonPublicKeyModel, JwkEd25519, JwkEs256k } from '@decentralized-identity/ion-sdk'; import { Convert, universalTypeOf } from '../common/index.js'; import IonProofOfWork from '@decentralized-identity/ion-pow-sdk'; import { EcdsaAlgorithm, EdDsaAlgorithm, Jose } from '../crypto/index.js'; import { IonDid, IonPublicKeyPurpose, IonRequest } from '@decentralized-identity/ion-sdk'; import type { DidDocument, DidKeySetVerificationMethodKey, DidMethod, DidResolutionOptions, DidResolutionResult, DidService, DwnServiceEndpoint, PortableDid } from './types.js'; import { getServices, isDwnServiceEndpoint, parseDid } from './utils.js'; export type DidIonAnchorOptions = { challengeEnabled?: boolean; challengeEndpoint?: string; operationsEndpoint?: string; keySet: DidIonKeySet; services: DidService[]; } export type DidIonCreateOptions = { anchor?: boolean; keyAlgorithm?: typeof SupportedCryptoAlgorithms[number]; keySet?: DidIonKeySet; services?: DidService[]; } export type DidIonKeySet = { recoveryKey?: JwkKeyPair; updateKey?: JwkKeyPair; verificationMethodKeys?: DidKeySetVerificationMethodKey[]; } enum OperationType { Create = 'create', Update = 'update', Deactivate = 'deactivate', Recover = 'recover' } /** * Data model representing a public key in the DID Document. */ export interface IonCreateRequestModel { type: OperationType; suffixData: { deltaHash: string; recoveryCommitment: string; }; delta: { updateCommitment: string; patches: { action: string; document: IonDocumentModel; }[]; } } const SupportedCryptoAlgorithms = [ 'Ed25519', 'secp256k1' ] as const; const VerificationRelationshipToIonPublicKeyPurpose = { assertionMethod : IonPublicKeyPurpose.AssertionMethod, authentication : IonPublicKeyPurpose.Authentication, capabilityDelegation : IonPublicKeyPurpose.CapabilityDelegation, capabilityInvocation : IonPublicKeyPurpose.CapabilityInvocation, keyAgreement : IonPublicKeyPurpose.KeyAgreement }; export class DidIonMethod implements DidMethod { /** * Name of the DID method */ public static methodName = 'ion'; public static async anchor(options: { services: DidService[], keySet: DidIonKeySet, challengeEnabled?: boolean, challengeEndpoint?: string, operationsEndpoint?: string }): Promise<DidResolutionResult | undefined> { const { challengeEnabled = true, challengeEndpoint = 'https://beta.ion.msidentity.com/api/v1.0/proof-of-work-challenge', keySet, services, operationsEndpoint = 'https://beta.ion.msidentity.com/api/v1.0/operations' } = options; // Create ION Document. const ionDocument = await DidIonMethod.createIonDocument({ keySet: keySet, services }); const createRequest = await DidIonMethod.getIonCreateRequest({ ionDocument, recoveryPublicKeyJwk : keySet.recoveryKey.publicKeyJwk, updatePublicKeyJwk : keySet.updateKey.publicKeyJwk }); let resolutionResult: DidResolutionResult; if (challengeEnabled) { const response = await IonProofOfWork.submitIonRequest( challengeEndpoint, operationsEndpoint, JSON.stringify(createRequest) ); if (response !== undefined && universalTypeOf(response) === 'String') { resolutionResult = JSON.parse(response); } } else { const response = await fetch(operationsEndpoint, { method : 'POST', mode : 'cors', body : JSON.stringify(createRequest), headers : { 'Content-Type': 'application/json' } }); if (response.ok) { resolutionResult = await response.json(); } } return resolutionResult; } public static async create(options?: DidIonCreateOptions): Promise<PortableDid> { let { anchor, keyAlgorithm, keySet, services } = options ?? { }; // Begin constructing a PortableDid. const did: Partial<PortableDid> = {}; // If any member of the key set is missing, generate the keys. did.keySet = await DidIonMethod.generateKeySet({ keyAlgorithm, keySet }); // Generate Long Form DID URI. did.did = await DidIonMethod.getLongFormDid({ keySet: did.keySet, services }); // Get short form DID. did.canonicalId = await DidIonMethod.getShortFormDid({ didUrl: did.did }); let didResolutionResult: DidResolutionResult | undefined; if (anchor) { // Attempt to anchor the DID. didResolutionResult = await DidIonMethod.anchor({ keySet: did.keySet, services }); } else { // If anchoring was not requested, then resolve the long form DID. didResolutionResult = await DidIonMethod.resolve({ didUrl: did.did }); } // Store the DID Document. did.document = didResolutionResult.didDocument; return did as PortableDid; } public static async decodeLongFormDid(options: { didUrl: string }): Promise<IonCreateRequestModel> { const { didUrl } = options; const parsedDid = parseDid({ didUrl }); if (!parsedDid) { throw new Error(`DidIonMethod: Unable to parse DID: ${didUrl}`); } const decodedLongFormDid = Convert.base64Url( parsedDid.id.split(':').pop() ).toObject() as Pick<IonCreateRequestModel, 'delta' | 'suffixData'>; const createRequest: IonCreateRequestModel = { ...decodedLongFormDid, type: OperationType.Create }; return createRequest; } /** * Generates two key pairs used for authorization and encryption purposes * when interfacing with DWNs. The IDs of these keys are referenced in the * service object that includes the dwnUrls provided. */ public static async generateDwnOptions(options: { encryptionKeyId?: string, serviceEndpointNodes: string[], serviceId?: string, signingKeyAlgorithm?: typeof SupportedCryptoAlgorithms[number] signingKeyId?: string, }): Promise<DidIonCreateOptions> { const { signingKeyAlgorithm = 'Ed25519', // Generate Ed25519 key pairs, by default. serviceId = '#dwn', // Use default ID value, unless overridden. signingKeyId = '#dwn-sig', // Use default key ID value, unless overridden. encryptionKeyId = '#dwn-enc', // Use default key ID value, unless overridden. serviceEndpointNodes } = options; const signingKeyPair = await DidIonMethod.generateJwkKeyPair({ keyAlgorithm : signingKeyAlgorithm, keyId : signingKeyId }); /** Currently, `id` has only implemented support for record * encryption using the `ECIES-ES256K` crypto algorithm. Until the * DWN SDK supports ECIES with EdDSA, the encryption key pair must * use secp256k1. */ const encryptionKeyPair = await DidIonMethod.generateJwkKeyPair({ keyAlgorithm : 'secp256k1', keyId : encryptionKeyId }); const keySet: DidIonKeySet = { verificationMethodKeys: [ { ...signingKeyPair, relationships: ['authentication'] }, { ...encryptionKeyPair, relationships: ['keyAgreement'] } ] }; const serviceEndpoint: DwnServiceEndpoint = { encryptionKeys : [encryptionKeyId], nodes : serviceEndpointNodes, signingKeys : [signingKeyId] }; const services: DidService[] = [{ id : serviceId, serviceEndpoint, type : 'DecentralizedWebNode', }]; return { keySet, services }; } public static async generateJwkKeyPair(options: { keyAlgorithm: typeof SupportedCryptoAlgorithms[number], keyId?: string }): Promise<JwkKeyPair> { const { keyAlgorithm, keyId } = options; let cryptoKeyPair: IDCrypto.CryptoKeyPair; switch (keyAlgorithm) { case 'Ed25519': { cryptoKeyPair = await new EdDsaAlgorithm().generateKey({ algorithm : { name: 'EdDSA', namedCurve: 'Ed25519' }, extractable : true, keyUsages : ['sign', 'verify'] }); break; } case 'secp256k1': { cryptoKeyPair = await new EcdsaAlgorithm().generateKey({ algorithm : { name: 'ECDSA', namedCurve: 'secp256k1' }, extractable : true, keyUsages : ['sign', 'verify'] }); break; } default: { throw new Error(`Unsupported crypto algorithm: '${keyAlgorithm}'`); } } // Convert the CryptoKeyPair to JwkKeyPair. const jwkKeyPair = await Jose.cryptoKeyToJwkPair({ keyPair: cryptoKeyPair }); // Set kid values. if (keyId) { jwkKeyPair.privateKeyJwk.kid = keyId; jwkKeyPair.publicKeyJwk.kid = keyId; } else { // If a key ID is not specified, generate RFC 7638 JWK thumbprint. const jwkThumbprint = await Jose.jwkThumbprint({ key: jwkKeyPair.publicKeyJwk }); jwkKeyPair.privateKeyJwk.kid = jwkThumbprint; jwkKeyPair.publicKeyJwk.kid = jwkThumbprint; } return jwkKeyPair; } public static async generateKeySet(options?: { keyAlgorithm?: typeof SupportedCryptoAlgorithms[number], keySet?: DidIonKeySet }): Promise<DidIonKeySet> { // Generate Ed25519 authentication key pair, by default. let { keyAlgorithm = 'Ed25519', keySet = {} } = options ?? {}; // If keySet lacks verification method keys, generate one. if (keySet.verificationMethodKeys === undefined) { const authenticationkeyPair = await DidIonMethod.generateJwkKeyPair({ keyAlgorithm, keyId: 'dwn-sig' }); keySet.verificationMethodKeys = [{ ...authenticationkeyPair, relationships: ['authentication', 'assertionMethod'] }]; } // If keySet lacks recovery key, generate one. if (keySet.recoveryKey === undefined) { // Note: ION/Sidetree only supports secp256k1 recovery keys. keySet.recoveryKey = await DidIonMethod.generateJwkKeyPair({ keyAlgorithm : 'secp256k1', keyId : 'ion-recovery-1' }); } // If keySet lacks update key, generate one. if (keySet.updateKey === undefined) { // Note: ION/Sidetree only supports secp256k1 update keys. keySet.updateKey = await DidIonMethod.generateJwkKeyPair({ keyAlgorithm : 'secp256k1', keyId : 'ion-update-1' }); } // Generate RFC 7638 JWK thumbprints if `kid` is missing from any key. for (const key of [...keySet.verificationMethodKeys, keySet.recoveryKey, keySet.updateKey]) { if ('publicKeyJwk' in key) key.publicKeyJwk.kid ??= await Jose.jwkThumbprint({ key: key.publicKeyJwk }); if ('privateKeyJwk' in key) key.privateKeyJwk.kid ??= await Jose.jwkThumbprint({ key: key.privateKeyJwk }); } return keySet; } /** * Given the W3C DID Document of a `did:ion` DID, return the identifier of * the verification method key that will be used for signing messages and * credentials, by default. * * @param document = DID Document to get the default signing key from. * @returns Verification method identifier for the default signing key. */ public static async getDefaultSigningKey(options: { didDocument: DidDocument }): Promise<string | undefined> { const { didDocument } = options; if (!didDocument.id) { throw new Error(`DidIonMethod: DID document is missing 'id' property`); } /** If the DID document contains a DWN service endpoint in the expected * format, return the first entry in the `signingKeys` array. */ const [dwnService] = getServices({ didDocument, type: 'DecentralizedWebNode' }); if (isDwnServiceEndpoint(dwnService?.serviceEndpoint)) { const [verificationMethodId] = dwnService.serviceEndpoint.signingKeys; const did = didDocument.id; const signingKeyId = `${did}${verificationMethodId}`; return signingKeyId; } /** Otherwise, fallback to a naive approach of returning the first key ID * in the `authentication` verification relationships array. */ if (didDocument.authentication && Array.isArray(didDocument.authentication) && didDocument.authentication.length > 0 && typeof didDocument.authentication[0] === 'string') { const [verificationMethodId] = didDocument.authentication; const did = didDocument.id; const signingKeyId = `${did}${verificationMethodId}`; return signingKeyId; } } public static async getLongFormDid(options: { services: DidService[], keySet: DidIonKeySet }): Promise<string> { const { services = [], keySet } = options; // Create ION Document. const ionDocument = await DidIonMethod.createIonDocument({ keySet: keySet, services }); // Filter JWK to only those properties expected by ION/Sidetree. const recoveryKey = DidIonMethod.jwkToIonJwk({ key: keySet.recoveryKey.publicKeyJwk }) as JwkEs256k; const updateKey = DidIonMethod.jwkToIonJwk({ key: keySet.updateKey.publicKeyJwk }) as JwkEs256k; // Create an ION DID create request operation. const did = await IonDid.createLongFormDid({ document: ionDocument, recoveryKey, updateKey }); return did; } public static async getShortFormDid(options: { didUrl: string }): Promise<string> { const { didUrl } = options; const parsedDid = parseDid({ didUrl }); if (!parsedDid) { throw new Error(`DidIonMethod: Unable to parse DID: ${didUrl}`); } const shortFormDid = parsedDid.did.split(':', 3).join(':'); return shortFormDid; } public static async resolve(options: { didUrl: string, resolutionOptions?: DidResolutionOptions }): Promise<DidResolutionResult> { // TODO: add resolutionOptions as defined in https://www.w3.org/TR/did-core/#did-resolution const { didUrl, resolutionOptions = {} } = options; const parsedDid = parseDid({ didUrl }); if (!parsedDid) { return { '@context' : 'https://w3id.org/did-resolution/v1', didDocument : undefined, didDocumentMetadata : {}, didResolutionMetadata : { contentType : 'application/did+json', error : 'invalidDid', errorMessage : `Cannot parse DID: ${didUrl}` } }; } if (parsedDid.method !== 'ion') { return { '@context' : 'https://w3id.org/did-resolution/v1', didDocument : undefined, didDocumentMetadata : {}, didResolutionMetadata : { contentType : 'application/did+json', error : 'methodNotSupported', errorMessage : `Method not supported: ${parsedDid.method}` } }; } const { resolutionEndpoint = 'https://discover.did.msidentity.com/1.0/identifiers/' } = resolutionOptions; const normalizeUrl = (url: string): string => url.endsWith('/') ? url : url + '/'; const resolutionUrl = `${normalizeUrl(resolutionEndpoint)}${parsedDid.did}`; const response = await fetch(resolutionUrl); let resolutionResult: DidResolutionResult | object; try { resolutionResult = await response.json(); } catch (error) { resolutionResult = {}; } if (response.ok) { return resolutionResult as DidResolutionResult; } // Response was not "OK" (HTTP 4xx-5xx status code) // Return result if it contains DID resolution metadata. if ('didResolutionMetadata' in resolutionResult) { return resolutionResult; } // Set default error code and message. let error = 'internalError'; let errorMessage = `DID resolver responded with HTTP status code: ${response.status}`; /** The Microsoft resolution endpoint does not return a valid DidResolutionResult * when an ION DID is "not found" so normalization is needed. */ if ('error' in resolutionResult && typeof resolutionResult.error === 'object' && 'code' in resolutionResult.error && typeof resolutionResult.error.code === 'string' && 'message' in resolutionResult.error && typeof resolutionResult.error.message === 'string') { error = resolutionResult.error.code.includes('not_found') ? 'notFound' : error; errorMessage = resolutionResult.error.message ?? errorMessage; } return { '@context' : 'https://w3id.org/did-resolution/v1', didDocument : undefined, didDocumentMetadata : {}, didResolutionMetadata : { contentType: 'application/did+json', error, errorMessage } }; } public static async createIonDocument(options: { keySet: DidIonKeySet, services?: DidService[] }): Promise<IonDocumentModel> { const { services = [], keySet } = options; /** * STEP 1: Convert key set verification method keys to ION SDK format. */ const ionPublicKeys: IonPublicKeyModel[] = []; for (const key of keySet.verificationMethodKeys) { // Map W3C DID verification relationship names to ION public key purposes. const ionPurposes: IonPublicKeyPurpose[] = []; for (const relationship of key.relationships) { ionPurposes.push( VerificationRelationshipToIonPublicKeyPurpose[relationship] ); } /** During certain ION operations, JWK validation will throw an error * if key IDs provided as input are prefixed with `#`. ION operation * outputs and DID document resolution always include the `#` prefix * for key IDs resulting in a confusing mismatch between inputs and * outputs. To improve the developer experience, this inconsistency * is addressed by normalizing input key IDs before being passed * to ION SDK methods. */ const publicKeyId = (key.publicKeyJwk.kid.startsWith('#')) ? key.publicKeyJwk.kid.substring(1) : key.publicKeyJwk.kid; // Convert public key JWK to ION format. const publicKey: IonPublicKeyModel = { id : publicKeyId, publicKeyJwk : DidIonMethod.jwkToIonJwk({ key: key.publicKeyJwk }), purposes : ionPurposes, type : 'JsonWebKey2020' }; ionPublicKeys.push(publicKey); } /** * STEP 2: Convert service entries, if any, to ION SDK format. */ const ionServices = services.map(service => ({ ...service, id: service.id.startsWith('#') ? service.id.substring(1) : service.id })); /** * STEP 3: Format as ION document. */ const ionDocumentModel: IonDocumentModel = { publicKeys : ionPublicKeys, services : ionServices }; return ionDocumentModel; } public static async getIonCreateRequest(options: { ionDocument: IonDocumentModel, recoveryPublicKeyJwk: PublicKeyJwk, updatePublicKeyJwk: PublicKeyJwk }): Promise<IonCreateRequestModel> { const { ionDocument, recoveryPublicKeyJwk, updatePublicKeyJwk } = options; // Create an ION DID create request operation. const createRequest = await IonRequest.createCreateRequest({ document : ionDocument, recoveryKey : DidIonMethod.jwkToIonJwk({ key: recoveryPublicKeyJwk }) as JwkEs256k, updateKey : DidIonMethod.jwkToIonJwk({ key: updatePublicKeyJwk }) as JwkEs256k }); return createRequest; } private static jwkToIonJwk({ key }: { key: PrivateKeyJwk | PublicKeyJwk }): JwkEd25519 | JwkEs256k { let ionJwk: Partial<JwkEd25519 | JwkEs256k> = { }; if ('crv' in key) { ionJwk.crv = key.crv; ionJwk.kty = key.kty; ionJwk.x = key.x; if ('d' in key) ionJwk.d = key.d; if ('y' in key && key.y) { // secp256k1 JWK. return { ...ionJwk, y: key.y} as JwkEs256k; } // Ed25519 JWK. return { ...ionJwk } as JwkEd25519; } throw new Error(`jwkToIonJwk: Unsupported key algorithm.`); } }