diff --git a/packages/credentials/tests/verifiable-credential.spec.ts b/packages/credentials/tests/verifiable-credential.spec.ts index b5b2abfaa..702809590 100644 --- a/packages/credentials/tests/verifiable-credential.spec.ts +++ b/packages/credentials/tests/verifiable-credential.spec.ts @@ -8,7 +8,7 @@ import { Jwt } from '../src/jwt.js'; import { VerifiableCredential } from '../src/verifiable-credential.js'; import CredentialsVerifyTestVector from '../../../test-vectors/credentials/verify.json' assert { type: 'json' }; -describe('Verifiable Credential Tests', () => { +describe('VerifiableCredentialTests', () => { let issuerDid: PortableDid; class StreetCredibility { diff --git a/packages/dids/src/did-web.ts b/packages/dids/src/did-web.ts new file mode 100644 index 000000000..33968d1bc --- /dev/null +++ b/packages/dids/src/did-web.ts @@ -0,0 +1,365 @@ +import type { PrivateKeyJwk, PublicKeyJwk, Web5Crypto } from '@web5/crypto'; + +import { + Jose, + EcdsaAlgorithm, + EdDsaAlgorithm, +} from '@web5/crypto'; + +import type { + DidMethod, + DidDocument, + PortableDid, + DidResolutionResult, + DidResolutionOptions, + DidKeySetVerificationMethodKey, +} from './types.js'; + +import { getVerificationMethodTypes, parseDid } from './utils.js'; +import { DidKeyKeySet, DidVerificationMethodType } from './did-key.js'; + +const WELL_KNOWN = '/.well-known'; +const DID_JSON = '/did.json'; + +const SupportedCryptoAlgorithms = [ + 'Ed25519', + 'secp256k1' +] as const; + +const VERIFICATION_METHOD_TYPES: Record = { + 'Ed25519VerificationKey2020' : 'https://w3id.org/security/suites/ed25519-2020/v1', + 'JsonWebKey2020' : 'https://w3id.org/security/suites/jws-2020/v1', + 'X25519KeyAgreementKey2020' : 'https://w3id.org/security/suites/x25519-2020/v1', +} as const; + +export type DidWebCreateDocumentOptions = { + did: string; + keySet: DidWebKeySet; + defaultContext?: string; + publicKeyFormat?: DidVerificationMethodType; +} + +export type DidWebKeySet = { + verificationMethodKeys?: DidKeySetVerificationMethodKey[]; +} + +export type DidWebCreateOptions = { + didWebId: string; + keyAlgorithm?: typeof SupportedCryptoAlgorithms[number]; + keySet?: DidKeyKeySet; + publicKeyFormat?: DidVerificationMethodType; +} + +export class DidWebMethod implements DidMethod { + + /** + * Name of the DID method + */ + public static methodName = 'web'; + + /** + * DID method specific identifier + */ + public static kid = '#key-0'; + + /** + * Creates a DID Web identifier and associated key set. + * + * @param options - Configuration for creating a DID Web identifier. + * @param options.didWebId - The DID identifier to create. + * @param options.keyAlgorithm - Optional. The key algorithm to use for the key set. + * @param options.keySet - Optional. The key set to use for the DID. + * @param options.publicKeyFormat - Optional. The format of the public key. + * @returns A Promise that resolves to a `PortableDid`, containing the DID identifier, DID Document, and associated key set. + */ + public static async create(options: DidWebCreateOptions): Promise { + let { + didWebId, + keyAlgorithm, + keySet, + publicKeyFormat = 'JsonWebKey2020' + } = options ?? { }; + + // Validate the DID identifier + if (!DidWebMethod.validateIdentifier({ did: didWebId })) { + throw new Error(`invalidDid: Invalid DID format for did:web: ${didWebId}`); + } + + // If keySet not given, generate a default key set. + if (keySet === undefined) { + keySet = await DidWebMethod.generateKeySet({ keyAlgorithm }); + } + + const portableDid: Partial = {}; + + portableDid.did = didWebId; + + portableDid.document = await DidWebMethod.createDocument({ + did: portableDid.did, + publicKeyFormat, + keySet, + }); + + portableDid.keySet = keySet; + + return portableDid as PortableDid; + } + + + public static async generateKeySet(options?: { + keyAlgorithm?: typeof SupportedCryptoAlgorithms[number] + }): Promise { + // Generate Ed25519 keys, by default. + const { keyAlgorithm = 'Ed25519' } = options ?? {}; + + let keyPair: Web5Crypto.CryptoKeyPair; + + switch (keyAlgorithm) { + case 'Ed25519': { + keyPair = await new EdDsaAlgorithm().generateKey({ + algorithm : { name: 'EdDSA', namedCurve: 'Ed25519' }, + extractable : true, + keyUsages : ['sign', 'verify'] + }); + break; + } + + case 'secp256k1': { + keyPair = await new EcdsaAlgorithm().generateKey({ + algorithm : { name: 'ECDSA', namedCurve: 'secp256k1' }, + extractable : true, + keyUsages : ['sign', 'verify'] + }); + break; + } + + default: { + throw new Error(`Unsupported crypto algorithm: '${keyAlgorithm}'`); + } + } + + const publicKeyJwk = await Jose.cryptoKeyToJwk({ key: keyPair.publicKey }) as PublicKeyJwk; + const privateKeyJwk = await Jose.cryptoKeyToJwk({ key: keyPair.privateKey }) as PrivateKeyJwk; + + const keySet: DidKeyKeySet = { + verificationMethodKeys: [{ + publicKeyJwk, + privateKeyJwk, + relationships: ['authentication'] + }] + }; + + return keySet; + } + + /** + * Expands a did:web identifier to a DID Document. + * + * Reference: https://w3c-ccg.github.io/did-method-web/ + * + * @param options + * @returns - A DID dodcument. + */ + public static async createDocument(options: DidWebCreateDocumentOptions): Promise { + const { + defaultContext = 'https://www.w3.org/ns/did/v1', + did, + keySet, + publicKeyFormat = 'JsonWebKey2020' + } = options; + + const document: Partial = {}; + + if (!DidWebMethod.validateIdentifier({did})) { + throw new Error(`invalidDid: Invalid DID format for did:web: ${did}`); + } + + document.id = did; + + document.verificationMethod = [{ + id : `${did}${DidWebMethod.kid}`, + type : publicKeyFormat, + controller : did, + publicKeyJwk : keySet.verificationMethodKeys[0].publicKeyJwk + }]; + + document.authentication = [`${did}${DidWebMethod.kid}`]; + document.assertionMethod = [`${did}${DidWebMethod.kid}`]; + document.capabilityInvocation = [`${did}${DidWebMethod.kid}`]; + document.capabilityDelegation = [`${did}${DidWebMethod.kid}`]; + + const contextArray = [defaultContext]; + + // For every object in every verification relationship listed in document, + // add a string value to the contextArray based on the object type value, + // if it doesn't already exist, according to the following table: + // {@link https://w3c-ccg.github.io/did-method-key/#context-creation-algorithm | Context Type URL} + const verificationMethodTypes = getVerificationMethodTypes({ didDocument: document }); + verificationMethodTypes.forEach((typeName: string) => { + const typeUrl = VERIFICATION_METHOD_TYPES[typeName]; + contextArray.push(typeUrl); + }); + document['@context'] = contextArray; + + return document as DidDocument; + } + + /** + * Resolves a DID Document based on the specified options. + * + * @param options - Configuration for resolving a DID Document. + * @param options.didUrl - The DID URL to resolve. + * @param options.resolutionOptions - Optional settings for the DID resolution process as defined in the DID Core specification. + * @returns A Promise that resolves to a `DidResolutionResult`, containing the resolved DID Document and associated metadata. + */ + public static async resolve(options: { + didUrl: string, + resolutionOptions?: DidResolutionOptions + }): Promise { + const { didUrl, resolutionOptions: _ } = options; + // TODO: Implement resolutionOptions as defined in https://www.w3.org/TR/did-core/#did-resolution + + const parsedDid = parseDid({ didUrl }); + if (!parsedDid) { + return { + '@context' : 'https://w3id.org/did-resolution/v1', + didDocument : null, + didDocumentMetadata : {}, + didResolutionMetadata : { + error : 'invalidDid', + errorMessage : `Cannot parse DID: ${didUrl}` + } + }; + } + + if (parsedDid.method !== 'web') { + return { + '@context' : 'https://w3id.org/did-resolution/v1', + didDocument : null, + didDocumentMetadata : {}, + didResolutionMetadata : { + error : 'methodNotSupported', + errorMessage : `Method not supported: ${parsedDid.method}` + } + }; + } + + let didDocument: DidDocument; + + try { + const url = DidWebMethod.getDocURL(parsedDid.did); + + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch DID Document: ${response.statusText}`); + } + + didDocument = await response.json() as DidDocument; + } catch (error: any) { + return { + '@context' : 'https://w3id.org/did-resolution/v1', + didDocument : null, + didDocumentMetadata : {}, + didResolutionMetadata : { + error : 'notFound', + errorMessage : `An unexpected error occurred while resolving DID: ${parsedDid.did}` + } + }; + } + + return { + '@context' : 'https://w3id.org/did-resolution/v1', + didDocument, + didDocumentMetadata : {}, + didResolutionMetadata : { + did: { + didString : parsedDid.did, + methodSpecificId : parsedDid.id, + method : parsedDid.method + } + } + }; + } + + /** + * Constructs the URL for a DID Document based on the specified DID identifier. + * Specifics can be found here https://w3c-ccg.github.io/did-method-web/#read-resolve + * + * @param did - The DID identifier to construct a URL for. + * @returns The URL for the DID Document. + */ + public static getDocURL(did: string): string { + // Step 1: Replace ":" with "/" in the method specific identifier + const parts = did.split(':'); + if (parts.length < 3) { + throw new Error('Invalid DID format'); + } + let path = parts.slice(2).join('/'); + + // Step 2: Percent decode the colon if the domain contains a port + const domainAndPath = path.split('/'); + domainAndPath[0] = decodeURIComponent(domainAndPath[0]); + path = domainAndPath.join('/'); + + // Step 3: Generate an HTTPS URL + let url = `https://${path}`; + + // Step 4: If no path has been specified, append /.well-known + if (!domainAndPath[1]) { + url += WELL_KNOWN; + } + + // Step 5: Append /did.json to complete the URL + url += DID_JSON; + + // URL validation + try { + new URL(url); // Validate the URL + } catch (error) { + throw new Error(`Invalid URL: ${url}`); + } + + return url; + } + + /** + * Validates a DID identifier. + * + * @param options - Configuration for validating a DID identifier. + * @param options.did - The DID identifier to validate. + * @returns - A boolean indicating whether the DID identifier is valid. + */ + public static validateIdentifier(options: { + did: string + }): boolean { + // Split the DID into its components + const parts = options.did.split(':'); + + // Check if the DID has three parts and starts with 'did:web' + if (parts.length !== 3 || parts[0] !== 'did' || parts[1] !== 'web') { + return false; + } + + // Validate the domain part + const domainParts = parts[2].split('.'); + if (domainParts.length < 2) { + return false; // Not a valid domain (requires at least one dot) + } + + // Ensure each part of the domain is non-empty and uses valid characters + for (let part of domainParts) { + if (part.length === 0 || !/^[a-zA-Z0-9-]+$/.test(part)) { + return false; + } + } + + // Check for valid TLD + const tld = domainParts[domainParts.length - 1]; + if (tld.length < 2 || !/^[a-zA-Z]+$/.test(tld)) { + return false; // TLDs are at least two characters and alphabetic + } + + return true; + } + +} \ No newline at end of file diff --git a/packages/dids/tests/did-web.spec.ts b/packages/dids/tests/did-web.spec.ts new file mode 100644 index 000000000..ab13f2eab --- /dev/null +++ b/packages/dids/tests/did-web.spec.ts @@ -0,0 +1,131 @@ +import * as sinon from 'sinon'; +import { DidWebMethod } from '../src/did-web.js'; +import { expect } from 'chai'; + +import DidWebResolveTestVector from '../../../test-vectors/did_web/resolve.json' assert { type: 'json' }; + +describe('DidWebMethod', () => { + describe('create()', () => { + it('creates a DID with Ed25519 keys, by default', async () => { + const portableDid = await DidWebMethod.create({ didWebId: 'did:web:example.com'}); + + // Verify expected result. + expect(portableDid).to.have.property('did', 'did:web:example.com'); + expect(portableDid).to.have.property('document'); + expect(portableDid.document).to.have.property('id', 'did:web:example.com'); + expect(portableDid).to.have.property('keySet'); + expect(portableDid.keySet).to.have.property('verificationMethodKeys'); + expect(portableDid.keySet.verificationMethodKeys).to.have.length(1); + expect(portableDid.keySet.verificationMethodKeys?.[0]).to.have.property('publicKeyJwk'); + expect(portableDid.keySet.verificationMethodKeys?.[0]).to.have.property('privateKeyJwk'); + expect(portableDid.keySet.verificationMethodKeys?.[0].publicKeyJwk).to.have.property('alg', 'EdDSA'); + expect(portableDid.keySet.verificationMethodKeys?.[0].publicKeyJwk).to.have.property('crv', 'Ed25519'); + }); + + it('creates a DID with secp256k1 keys, if specified', async () => { + const portableDid = await DidWebMethod.create({ didWebId: 'did:web:example.com', keyAlgorithm: 'secp256k1' }); + + // Verify expected result. + expect(portableDid).to.have.property('did', 'did:web:example.com'); + expect(portableDid).to.have.property('document'); + expect(portableDid.document).to.have.property('id', 'did:web:example.com'); + expect(portableDid).to.have.property('keySet'); + expect(portableDid.keySet).to.have.property('verificationMethodKeys'); + expect(portableDid.keySet.verificationMethodKeys).to.have.length(1); + expect(portableDid.keySet.verificationMethodKeys?.[0]).to.have.property('publicKeyJwk'); + expect(portableDid.keySet.verificationMethodKeys?.[0]).to.have.property('privateKeyJwk'); + expect(portableDid.keySet.verificationMethodKeys?.[0].publicKeyJwk).to.have.property('alg', 'ES256K'); + expect(portableDid.keySet.verificationMethodKeys?.[0].publicKeyJwk).to.have.property('crv', 'secp256k1'); + }); + + it('resolves a simple did:web', async () => { + // Setup stub so that a mocked response is returned rather than calling over the network. + const didUrl = 'did:web:example.com'; + const didDocMockResult = { id: 'did-doc-id' }; + const expectedResult = { + '@context' : 'https://w3id.org/did-resolution/v1', + didDocument : didDocMockResult, + didDocumentMetadata : {}, + didResolutionMetadata : { + did: { + didString : 'did:web:example.com', + methodSpecificId : 'example.com', + method : 'web' + } + } + }; + + const fetchStub = sinon.stub(global, 'fetch'); + // @ts-expect-error because we're only mocking ok and json() from global.fetch(). + fetchStub.returns(Promise.resolve({ + ok : true, + json : () => Promise.resolve(didDocMockResult) + })); + + const resolutionResult = await DidWebMethod.resolve({didUrl: didUrl}); + fetchStub.restore(); + + expect(resolutionResult).to.deep.equal(expectedResult); + expect(fetchStub.calledOnceWith( + 'https://example.com/.well-known/did.json' + )).to.be.true; + }); + + + // it('resolves a real linkedin did:web', async () => { + // const didUrl = 'did:web:www.linkedin.com'; + + // const resolutionResult = await DidWebMethod.resolve({didUrl: didUrl}); + // expect(resolutionResult.didDocument).to.have.property('id', 'did:web:www.linkedin.com'); + + // expect(JSON.stringify(resolutionResult)).to.deep.equal(JSON.stringify({'@context': 'https://w3id.org/did-resolution/v1','didDocument': {'id': 'did:web:www.linkedin.com','@context': ['https://www.w3.org/ns/did/v1',{'@base': 'did:web:www.linkedin.com'}],'service': [{'id': '#linkeddomains','type': 'LinkedDomains','serviceEndpoint': {'origins': ['https://www.linkedin.com/']}},{'id': '#hub','type': 'IdentityHub','serviceEndpoint': {'instances': ['https://hub.did.msidentity.com/v1.0/658728e7-1632-412a-9815-fe53f53ec58b']}}],'verificationMethod': [{'id': '#074cfbf193f046bcba5841ac4751e91bvcSigningKey-46682','controller': 'did:web:www.linkedin.com','type': 'EcdsaSecp256k1VerificationKey2019','publicKeyJwk': {'crv': 'secp256k1','kty': 'EC','x': 'NHIQivVR0HX7c0flpxgWQ7vRtbWDvr0UPN1nJ--0lyU','y': 'hYiIldgLRShym7vzflFrEkg6NYkayUHkDpV0RMjUEYE'}}],'authentication': ['#074cfbf193f046bcba5841ac4751e91bvcSigningKey-46682'],'assertionMethod': ['#074cfbf193f046bcba5841ac4751e91bvcSigningKey-46682']},'didDocumentMetadata': {},'didResolutionMetadata': {'did': {'didString': 'did:web:www.linkedin.com','methodSpecificId': 'www.linkedin.com','method': 'web'}}})); + // }); + }); + + describe('createDocument()', () => { + it('accepts an alternate default context', async () => { + + const keySet = await DidWebMethod.generateKeySet({ keyAlgorithm: 'Ed25519' }); + + const didDocument = await DidWebMethod.createDocument({ + did : 'did:web:example.com', + keySet : keySet, + defaultContext : 'https://www.w3.org/ns/did/v99', + publicKeyFormat : 'JsonWebKey2020' + }); + + expect(didDocument['@context']).to.include('https://www.w3.org/ns/did/v99'); + }); + }); + + describe('Web5TestVectorsDidWeb', () => { + it('resolve', async () => { + const vectors = DidWebResolveTestVector.vectors; + + for (const vector of vectors) { + const { input, errors, output } = vector; + + if (errors) { + const resolutionResult = await DidWebMethod.resolve({ didUrl: input.didUri }); + expect(resolutionResult.didResolutionMetadata.error).to.deep.equal(output.didResolutionMetadata.error); + } else { + const didUrl = input.didUri; + + const fetchStub = sinon.stub(global, 'fetch'); + + // @ts-expect-error because we're only mocking ok and json() from global.fetch(). + fetchStub.returns(Promise.resolve({ + ok : true, + json : () => Promise.resolve(output.didDocument) + })); + + const resolutionResult = await DidWebMethod.resolve({ didUrl: didUrl }); + fetchStub.restore(); + + expect(resolutionResult.didDocument).to.deep.equal(output.didDocument); + expect(fetchStub.calledOnceWith(Object.keys(input.mockServer)[0])).to.be.true; + } + } + }); + }); +}); \ No newline at end of file diff --git a/test-vectors/did_web/resolve.json b/test-vectors/did_web/resolve.json new file mode 100644 index 000000000..a4b6633ea --- /dev/null +++ b/test-vectors/did_web/resolve.json @@ -0,0 +1,101 @@ +{ + "description": "did:web resolution", + "vectors": [ + { + "description": "resolves to a well known URL", + "input": { + "didUri": "did:web:example.com", + "mockServer": { + "https://example.com/.well-known/did.json": { + "id": "did:web:example.com" + } + } + }, + "output": { + "didDocument": { + "id": "did:web:example.com" + }, + "didDocumentMetadata": {}, + "didResolutionMetadata": {} + } + }, + { + "description": "resolves to a URL with a path", + "input": { + "didUri": "did:web:w3c-ccg.github.io:user:alice", + "mockServer": { + "https://w3c-ccg.github.io/user/alice/did.json": { + "id": "did:web:w3c-ccg.github.io:user:alice" + } + } + }, + "output": { + "didDocument": { + "id": "did:web:w3c-ccg.github.io:user:alice" + }, + "didDocumentMetadata": {}, + "didResolutionMetadata": {} + } + }, + { + "description": "resolves to a URL with a path and a port", + "input": { + "didUri": "did:web:example.com%3A3000:user:alice", + "mockServer": { + "https://example.com:3000/user/alice/did.json": { + "id": "did:web:example.com%3A3000:user:alice" + } + } + }, + "output": { + "didDocument": { + "id": "did:web:example.com%3A3000:user:alice" + }, + "didDocumentMetadata": {}, + "didResolutionMetadata": {} + } + }, + { + "description": "methodNotSupported error returned when did method is not web", + "input": { + "didUri": "did:dht:gb46emk73wkenrut43ii67a3o5qctojcaucebth7r83pst6yeh8o" + }, + "output": { + "didDocument": null, + "didDocumentMetadata": {}, + "didResolutionMetadata": { + "error": "methodNotSupported" + } + }, + "errors": true + }, + { + "description": "notFound error returned when domain does not exist", + "input": { + "didUri": "did:web:doesnotexist.com" + }, + "output": { + "didDocument": null, + "didDocumentMetadata": {}, + "didResolutionMetadata": { + "error": "notFound" + } + }, + "errors": true + }, + { + "description": "invalidDid error returned for domain name with invalid character", + "input": { + "didUri": "did:web:invalidcharø.com" + }, + "output": { + "didDocument": null, + "didDocumentMetadata": {}, + "didResolutionMetadata": { + "error": "invalidDid" + } + }, + "errors": true + } + ] +} \ No newline at end of file