diff --git a/CHANGELOG.md b/CHANGELOG.md index 9729181..d23f216 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # did-io ChangeLog +## 3.0.0 - + +### Changed +- **BREAKING**: `parseDid` changed to return `scheme` and `method` instead of + only `prefix`. + +### Added +- **BREAKING**: `CachedResolver` now validates DIDs before resolving them. +- `CachedResolver.use` now accepts a second optional method parameter. +- Tests were added for the new validators. + ## 2.0.0 - 2022-06-02 ### Changed diff --git a/README.md b/README.md index 1c18d0f..18894b8 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,8 @@ const resolver = new CachedResolver({max: 100}); // defaults to 100 On its own, the resolver does not know how to fetch or resolve any DID methods. Support for each one has to be enabled explicitly. It uses a [Chai](https://www.chaijs.com/)-like plugin architecture, where each driver -is loaded via `.use(driver)`. +is loaded via `.use(driver)`. Optionally method can be passed in as the second +parameter. ```js import * as didKey from '@digitalbazaar/did-method-key'; diff --git a/lib/CachedResolver.js b/lib/CachedResolver.js index bfd0673..04dec8b 100644 --- a/lib/CachedResolver.js +++ b/lib/CachedResolver.js @@ -1,7 +1,9 @@ /*! - * Copyright (c) 2021 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2021-2022 Digital Bazaar, Inc. All rights reserved. */ +import {DidResolverError} from './DidResolverError.js'; import {parseDid} from './did-io.js'; +import {validateDid} from './validators.js'; import {LruCache} from '@digitalbazaar/lru-memoize'; export class CachedResolver { @@ -20,15 +22,20 @@ export class CachedResolver { this._cache = new LruCache({max, maxAge, updateAgeOnGet, ...cacheOptions}); this._methods = new Map(); } - - use(driver) { - const methodName = driver.method; + /** + * Registers a driver for a did:method. + * + * @param {object} driver - A did method driver. + * @param {string} [method] - An optional method. + */ + use(driver, method) { + const methodName = method || driver.method; this._methods.set(methodName, driver); } /** * Gets the DID Document, by selecting a registered driver based on the DID - * prefix (DID method). + * method (DID method). * Either `did` or `url` param is required. * * @param {object} options - Options hashmap. @@ -42,10 +49,7 @@ export class CachedResolver { */ async get({did, url, ...args} = {}) { did = did || url; - if(!did) { - throw new TypeError('A string "did" or "url" parameter is required.'); - } - + validateDid({did}); const method = this._methodForDid(did); return this._cache.memoize({ @@ -67,7 +71,10 @@ export class CachedResolver { async generate({method, ...args}) { const driver = this._methods.get(method); if(!driver) { - throw new Error(`Driver for DID method "${method}" not found.`); + throw new DidResolverError({ + message: `Driver for DID method "${method}" not found.`, + code: 'methodNotSupported' + }); } return driver.generate(args); @@ -80,11 +87,14 @@ export class CachedResolver { * @private */ _methodForDid(did) { - const {prefix} = parseDid({did}); - const method = this._methods.get(prefix); - if(!method) { - throw new Error(`Driver for DID ${did} not found.`); + const {method} = parseDid({did}); + const methodDriver = this._methods.get(method); + if(!methodDriver) { + throw new DidResolverError({ + message: `Driver for DID ${did} not found.`, + code: 'methodNotSupported' + }); } - return method; + return methodDriver; } } diff --git a/lib/DidResolverError.js b/lib/DidResolverError.js new file mode 100644 index 0000000..0bdd439 --- /dev/null +++ b/lib/DidResolverError.js @@ -0,0 +1,20 @@ +/*! + * Copyright (c) 2021-2022 Digital Bazaar, Inc. All rights reserved. + */ + +/** + * Error for throwing did syntax related errors. + * + * @param {object} options - Options to use. + * @param {string} options.message - An error message. + * @param {string} options.code - A DID core error. + * @param {object} options.params - Params to be passed to the base Error Class. + * + */ +export class DidResolverError extends Error { + constructor({message, code, params}) { + super(message, params); + this.name = 'DidResolverError'; + this.code = code; + } +} diff --git a/lib/did-io.js b/lib/did-io.js index 6d02b1d..6e2d068 100644 --- a/lib/did-io.js +++ b/lib/did-io.js @@ -194,22 +194,34 @@ export function _methodById({doc, methodId}) { } /** - * Parses the DID into various component (currently, only cares about prefix). + * Parses the DID into scheme and method components. * * @example * parseDid({did: 'did:v1:test:nym'}); - * // -> {prefix: 'v1'} + * // -> {scheme: 'did', method: 'v1'} * * @param {string} did - DID uri. * - * @returns {{prefix: string}} Returns the method prefix (without `did:`). + * @returns {{scheme: string, method: string}} Returns the scheme and method. */ export function parseDid({did}) { if(!did) { throw new TypeError('DID cannot be empty.'); } - const prefix = did.split(':').slice(1, 2).join(':'); + const [scheme, method] = did.split(':'); - return {prefix}; + return {scheme, method}; +} + +/** + * Determines if a DID has URL characters in it. + * + * @param {object} options - Options to use. + * @param {string} options.did - A DID. + * + * @returns {boolean} Does the DID potentially have a query or a fragment in it? + */ +export function isDidUrl({did}) { + return /[\/#?]/.test(did); } diff --git a/lib/index.js b/lib/index.js index f99bb49..ae81f71 100644 --- a/lib/index.js +++ b/lib/index.js @@ -5,9 +5,12 @@ export { approvesMethodFor, findVerificationMethod, initKeys, + isDidUrl, parseDid } from './did-io.js'; export {CachedResolver} from './CachedResolver.js'; +export {DidResolverError} from './DidResolverError.js'; + export {VERIFICATION_RELATIONSHIPS} from './constants.js'; diff --git a/lib/validators.js b/lib/validators.js new file mode 100644 index 0000000..5231014 --- /dev/null +++ b/lib/validators.js @@ -0,0 +1,83 @@ +/*! + * Copyright (c) 2022 Digital Bazaar, Inc. All rights reserved. + */ +import {isDidUrl} from './did-io.js'; +import {DidResolverError} from './DidResolverError.js'; + +/** + * Determines if a DID contains URL characters and + * then validates either the DID URL or DID. + * + * @param {object} options - Options to use. + * @param {string} options.did - A DID. + * + * @throws {DidResolverError} Throws if DID is invalid. + * + * @returns {undefined} Returns on success. + */ +export function validateDid({did}) { + if(!did) { + throw new TypeError('The parameter "did" is required.'); + } + if(isDidUrl({did})) { + if(!isValidDidUrl({didUrl: did})) { + throw new DidResolverError({ + message: `Invalid DID URL "${did}"`, + code: 'invalidDidUrl' + }); + } + return; + } + if(!isValidDid({did})) { + throw new DidResolverError({ + message: `Invalid DID "${did}"`, + code: 'invalidDid' + }); + } +} + +/** + * Validates a DID, but not a DID URL. + * This function comes from the did-test-suite. + * + * @see https://github.com/w3c/did-test-suite/ + * + * @param {object} options - Options to use. + * @param {string} options.did - A prospective DID. + * + * @returns {boolean} - Returns true if DID is valid. +*/ +export function isValidDid({did}) { + const didRegex1 = new RegExp('^did:(?[a-z0-9]+):' + + '(?([a-zA-Z0-9\\.\\-_]|%[0-9a-fA-F]{2}|:)+$)'); + const didRegex2 = /:$/; + return didRegex1.test(did) && !didRegex2.test(did); +} + +/** + * Validates a DID URL including the fragment and queries. + * This function comes from the did-test-suite. + * + * @see https://github.com/w3c/did-test-suite/ + * + * @param {object} options - Options to use. + * @param {string} options.didUrl - A prospective DID URL. + * + * @returns {boolean} - Returns true or false. +*/ +export function isValidDidUrl({didUrl}) { + const pchar = '[a-zA-Z0-9\\-\\._~]|%[0-9a-fA-F]{2}|[!$&\'()*+,;=:@]'; + const didUrlPattern = + '^' + + 'did:' + + '([a-z0-9]+)' + // method_name + '(:' + // method-specific-id + '([a-zA-Z0-9\\.\\-_]|%[0-9a-fA-F]{2})+' + + ')+' + + '((/(' + pchar + ')+)+)?' + // path-abempty + '(\\?(' + pchar + '|/|\\?)+)?' + // [ "?" query ] + '(#(' + pchar + '|/|\\?)+)?' + // [ "#" fragment ] + '$' + ; + return new RegExp(didUrlPattern).test(didUrl); +} diff --git a/test/did-io.spec.js b/test/01-did-io.spec.js similarity index 93% rename from test/did-io.spec.js rename to test/01-did-io.spec.js index 1bf81c6..6972223 100644 --- a/test/did-io.spec.js +++ b/test/01-did-io.spec.js @@ -17,9 +17,13 @@ const MOCK_KEY = { }; describe('parseDid', () => { - it('should return main did method identifier', async () => { - const {prefix} = parseDid({did: 'did:v1:test:nym:abcd'}); - expect(prefix).to.equal('v1'); + it('should return did method', async () => { + const {method} = parseDid({did: 'did:v1:test:nym:abcd'}); + expect(method).to.equal('v1'); + }); + it('should return did scheme', async () => { + const {scheme} = parseDid({did: 'did:v1:test:nym:abcd'}); + expect(scheme).to.equal('did'); }); }); diff --git a/test/02-did-io-validators.spec.js b/test/02-did-io-validators.spec.js new file mode 100644 index 0000000..4744c15 --- /dev/null +++ b/test/02-did-io-validators.spec.js @@ -0,0 +1,115 @@ +/*! + * Copyright (c) 2022 Digital Bazaar, Inc. All rights reserved. + */ +import chai from 'chai'; +import {testDid} from './helpers.js'; +import {isValidDid, isValidDidUrl} from '../lib/validators.js'; +import {DidResolverError} from '../lib/DidResolverError.js'; +import { + typeErrors, + invalidDids, + invalidDidSyntax, + invalidDidUrlSyntax, + invalidDidUrls, + validDids, + validDidUrls, +} from './mock.data.js'; + +const should = chai.should(); + +describe('validateDid', () => { + describe('should not throw', () => { + const inputs = new Set([...validDids, ...validDidUrls]); + for(const input of inputs) { + it(`should validate ${input}`, async () => { + const error = testDid(input); + should.not.exist(error, `Expected no error for did ${input}`); + }); + } + }); + describe('should throw `TypeError`', () => { + for(const input of typeErrors) { + it(`should not validate ${input}`, async () => { + const error = testDid(input); + should.exist(error, `Expected error for did ${input}`); + error.should.be.instanceof( + TypeError, + `Expected a TypeError for ${input}` + ); + }); + } + }); + describe('should throw `invalidDid`', () => { + const inputs = [...invalidDidSyntax, 'did:key:z4345345:']; + for(const input of inputs) { + it(`should not validate ${input}`, async () => { + const error = testDid(input); + should.exist(error, `Expected error for did ${input}`); + error.should.be.instanceof( + DidResolverError, + `Expected a DidResolverError for ${input}` + ); + error.code.should.equal( + 'invalidDid', + `Expected ${input} to be an invalid did.` + ); + }); + } + }); + describe('should throw `invalidDidUrl`', () => { + for(const invalidDidUrl of invalidDidUrlSyntax) { + it(`should not validate ${invalidDidUrl}`, async () => { + const error = testDid(invalidDidUrl); + should.exist(error, `Expected error for did url ${invalidDidUrl}`); + error.should.be.instanceof( + DidResolverError, + `Expected a DidResolverError for ${invalidDidUrl}` + ); + error.code.should.equal( + 'invalidDidUrl', + `Expected ${invalidDidUrl} to be an invalid did url` + ); + }); + } + }); +}); + +describe('isValidDidUrl', () => { + for(const validDidUrl of validDidUrls) { + it(`should validate ${validDidUrl}`, async () => { + const result = isValidDidUrl({didUrl: validDidUrl}); + should.exist(result, `Expected result for ${validDidUrl} to exist.`); + result.should.be.a( + 'boolean', 'Expected isValidDidUrl to return a boolean'); + result.should.equal(true, `Expected ${validDidUrl} to validate`); + }); + } + for(const invalidDidUrl of invalidDidUrls) { + it(`should not validate ${invalidDidUrl}`, async () => { + const result = isValidDidUrl({didUrl: invalidDidUrl}); + should.exist(result, `Expected result for ${invalidDidUrl} to exist.`); + result.should.be.a( + 'boolean', 'Expected isValidDidUrl to return a boolean'); + result.should.equal(false, `Expected ${invalidDidUrl} to not validate`); + }); + } +}); + +describe('isValidDid', () => { + for(const validDid of validDids) { + it(`should validate ${validDid}`, async () => { + const result = isValidDid({did: validDid}); + should.exist(result, `Expected result for ${validDid} to exist.`); + result.should.be.a('boolean', 'Expected isValidDid to return a boolean'); + result.should.equal(true, `Expected ${validDid} to validate`); + }); + } + for(const invalidDid of invalidDids) { + it(`should not validate ${invalidDid}`, async () => { + const result = isValidDid({did: invalidDid}); + should.exist(result, `Expected result for ${invalidDid} to exist.`); + result.should.be.a('boolean', 'Expected isValidDid to return a boolean'); + result.should.equal(false, `Expected ${invalidDid} to not validate`); + }); + } +}); diff --git a/test/helpers.js b/test/helpers.js new file mode 100644 index 0000000..988d8b2 --- /dev/null +++ b/test/helpers.js @@ -0,0 +1,12 @@ +/*! + * Copyright (c) 2022 Digital Bazaar, Inc. All rights reserved. + */ +import {validateDid} from '../lib/validators.js'; + +export function testDid(did) { + try { + validateDid({did}); + } catch(e) { + return e; + } +} diff --git a/test/mock.data.js b/test/mock.data.js new file mode 100644 index 0000000..27b6cec --- /dev/null +++ b/test/mock.data.js @@ -0,0 +1,64 @@ +// these come from the did test suite and are here to ensure +// the RegExp ported over for dids and did urls behave as expected +export const validDidUrls = [ + 'did:example:123', + 'did:example:123456789abcdefghi', + 'did:example:123#ZC2jXTO6t4R501bfCXv3RxarZyUbdP2w_psLwMuY6ec', + 'did:example:123#keys-1', + 'did:example:123456/path', + 'did:example:123456/path/multiple/path', + 'did:example:123456/1path/2multiple/3path', + 'did:example:123456/1-path/2-multiple/3-path', + 'did:example:123456/path%20with%20space', + 'did:example:123456?versionId=1', + 'did:example:123#public-key-0', + 'did:example:123#sig_064bebcc', + 'did:example:123?service=agent&relativeRef=/credentials#degree', + 'did:example:abc:def-hij#klm', + 'did:orb:bafkreiazah4qrybzyapmrmk2dhldz24vfmavethcrgcoq7qhic63zz55ru:EiAag4' + + 'cmgxAE2isL5HG3mxjS7WRq4l-xyyTgULCAcEHQQQ#nMef0L2qNWVe8yt97ap0vH7kQK2oFdm4z' + + 'kQkYL7ymOo' +]; + +export const typeErrors = [ + false, + 0, + undefined, + null, + NaN +]; + +export const invalidDidSyntax = [ + 'STRING', + 'did:', + 'did:example' +]; + +export const invalidDidUrlSyntax = [ + 'did:example:id/validPath/ invalid path /', + 'did:example:id/validPath/^invalid^path^/' +]; + +export const invalidDidUrls = [ + ...typeErrors, + ...invalidDidSyntax, + ...invalidDidUrlSyntax +]; + +export const validDids = [ + 'did:example:123', + 'did:example:123456789abcdefghi', + 'did:example:123456789-abcdefghi', + 'did:example:123456789_abcdefghi', + 'did:example:123456789%20abcdefghi', + 'did:example:123abc:123456789abcdefghi', + 'did:example:abc%00', + 'did:example::::::abc:::123' +]; + +export const invalidDids = [ + ...invalidDidUrls, + 'did:example:123#ZC2jXTO6t4R501bfCXv3RxarZyUbdP2w_psLwMuY6ec', + 'did:example:123#keys-1', + 'did:example:abc:::' +];