From 03caefa0fbf85a3253c2a7abb428428cdd227c0b Mon Sep 17 00:00:00 2001 From: "Charles E. Lehner" Date: Sat, 24 Jul 2021 00:35:13 -0400 Subject: [PATCH] Init --- .gitignore | 1 + LICENSE.md | 13 ++ README.md | 16 +++ gen-test.js | 257 ++++++++++++++++++++++++++++++++++++++ package-lock.json | 37 ++++++ package.json | 8 ++ resolver.js | 310 ++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 642 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 gen-test.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 resolver.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..07e6e47 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/node_modules diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..b941859 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,13 @@ +Copyright (c) 2021 Charles E. Lehner + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5a64949 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# SSB DID Resolver + +[DID Resolver][] implementation for [did:ssb][]. + +## Test vector generator + +`gen-test.js` generates implementation files for [DID Test Suite][]. +To use it, run it on the command-line, passing the path to the `implementations` directory of +`did-test-suite`. That will resolve some SSB DIDs and write some files to the directory. +``` +$ node gen-test ../did-test-suite/packages/did-core-test-server/suites/implementations +``` + +[DID Resolver]: https://www.w3.org/TR/did-core/#dfn-did-resolvers +[did:ssb]: %dvDyHqLfb0SYOe8lxlUA0ZQquydaSiG4eA/HXrVbVYw=.sha256 +[DID Test Suite]: https://github.com/w3c/did-test-suite diff --git a/gen-test.js b/gen-test.js new file mode 100644 index 0000000..1ec0ea9 --- /dev/null +++ b/gen-test.js @@ -0,0 +1,257 @@ +// did:ssb test vector generator for DID Test Suite + +const DIDSSBResolver = require('./resolver'); + +const dest = process.argv[2]; +if (!dest) throw 'Usage: node gen-test.js '; + +const path = require('path'); +const fs = require('fs'); + +const didJsonFilename = path.join(dest, 'did-ssb.json'); +const resolverJsonFilename = path.join(dest, 'resolver-ssb.json'); +const dereferencerJsonFilename = path.join(dest, 'dereferencer-ssb.json'); + +const testCommon = { + didMethod: 'did:ssb', + implementation: 'ssb-did-resolver', + implementer: 'Secure Scuttlebutt Consortium', +}; + +const didMethodInputs = [ + { + did: 'did:ssb:ed25519:f_6sQ6d2CMxRUhLpspgGIulDxDCwYD7DzFzPNr7u5AU', + resolutionOptions: {}, + representationResolutionOptions: {} + } +]; + +const resolverInputs = [ + { + function: 'resolve', + did: 'did:ssb:ed25519:f_6sQ6d2CMxRUhLpspgGIulDxDCwYD7DzFzPNr7u5AU', + resolutionOptions: {} + }, + { + function: 'resolveRepresentation', + did: 'did:ssb:ed25519:f_6sQ6d2CMxRUhLpspgGIulDxDCwYD7DzFzPNr7u5AU', + resolutionOptions: {} + }, + { + function: 'resolve', + did: 'did:ssb:ed25519:6RpN4Ztw3jLwzQtHl8XpnnR58LWZTAjwq2vvfyx7zkc', + resolutionOptions: {} + } +]; + +const dereferencerInputs = [ + { + didUrl: 'did:ssb:ed25519:f_6sQ6d2CMxRUhLpspgGIulDxDCwYD7DzFzPNr7u5AU', + dereferenceOptions: {} + }, + { + didUrl: 'did:ssb:ed25519:f_6sQ6d2CMxRUhLpspgGIulDxDCwYD7DzFzPNr7u5AU?versionId=%25Vlo6kAc%2BIbGGBhD2MUi2r3ULz%2FNAGBWwGb%2FEMa4w4FI%3D.sha256', + dereferenceOptions: {} + }, + { + didUrl: 'did:ssb:ed25519:f_6sQ6d2CMxRUhLpspgGIulDxDCwYD7DzFzPNr7u5AU?versionTime=2021-07-24T03:38:46Z', + dereferenceOptions: {} + } +]; + +let didParameters = { + versionId: 'did:ssb:ed25519:f_6sQ6d2CMxRUhLpspgGIulDxDCwYD7DzFzPNr7u5AU?versionId=%25Vlo6kAc%2BIbGGBhD2MUi2r3ULz%2FNAGBWwGb%2FEMa4w4FI%3D.sha256', + versionTime: 'did:ssb:ed25519:f_6sQ6d2CMxRUhLpspgGIulDxDCwYD7DzFzPNr7u5AU?versionTime=2021-07-24T03:38:46Z' +}; + +const ssbClient = require('ssb-client'); +let closeSbot, resolver; +ssbClient(function (err, sbot, config) { + if (err) throw err; + closeSbot = sbot.close.bind(sbot); + resolver = new DIDSSBResolver(sbot, config); + generateReports(done) +}); + +function done() { + closeSbot(function (err) { + if (err && err !== true) throw err; + }); +} + +function generateReports(next) { + let reportWaiting = 3; + generateMethodReport(reportNext); + generateResolverReport(reportNext); + generateDereferencerReport(reportNext); + function reportNext() { + if (--reportWaiting) return; + next(); + } +} + +function generateMethodReport(next) { + let contentTypes = {}; + let supportedContentTypes = []; + let dids = []; + let didMethodReport = { + ...testCommon, + supportedContentTypes, + dids, + didParameters, + }; + let methodWaiting = 0; + didMethodInputs.forEach(function (input) { + methodWaiting++; + const did = input.did; + const didReport = didMethodReport[did] = {}; + dids.push(did); + let didWaiting = 2; + let resResult, resReprResult; + resolver.resolve(did, input.resolutionOptions, function (didResolutionMetadata, didDocument, didDocumentMetadata) { + if (didResolutionMetadata.error) throw didResolutionMetadata.error; + resResult = {didResolutionMetadata, didDocument, didDocumentMetadata}; + didNext(); + }); + resolver.resolveRepresentation(did, input.representationResolutionOptions, function (didResolutionMetadata, didDocumentStream, didDocumentMetadata) { + if (didResolutionMetadata.error) throw didResolutionMetadata.error; + resReprResult = {didResolutionMetadata, didDocumentStream, didDocumentMetadata}; + didNext(); + }); + function didNext() { + if (--didWaiting) return; + const {didDocument} = resResult; + let representationSpecificEntries = {}; + let properties = {}; + for (const property in didDocument) switch (property) { + case '@context': + representationSpecificEntries[property] = didDocument[property]; + break; + default: + properties[property] = didDocument[property]; + break; + } + didReport.didDocumentDataModel = { + properties + }; + const contentType = resReprResult.didResolutionMetadata.contentType; + if (!contentType) throw new Error('Expected contentType'); + if (!contentTypes[contentType]) { + contentTypes[contentType] = true + supportedContentTypes.push(contentType); + } + didReport[contentType] = { + didDocumentDataModel: { + representationSpecificEntries + }, + representation: resReprResult.didDocumentStream, + didDocumentMetadata: resReprResult.didDocumentMetadata, + didResolutionMetadata: resReprResult.didResolutionMetadata + }; + methodNext(); + } + }); + function methodNext() { + if (--methodWaiting) return; + const report = JSON.stringify(didMethodReport, null, 2); + fs.writeFileSync(didJsonFilename, report); + next(); + } +} + +function getOutcome(error, deactivated) { + if (error) switch (error) { + case 'invalidDid': return 'invalidDidErrorOutcome'; + case 'invalidDidUrl': return 'invalidDidUrlErrorOutcome'; + case 'notFound': return 'notFoundErrorOutcome'; + case 'representationNotSupported': return 'representationNotSupportedErrorOutcome'; + default: throw new Error('Unknown error: ' + didResolutionMetadata.error); + } + if (deactivated) return 'deactivatedOutcome'; + return 'defaultOutcome'; +} + +function generateResolverReport(next) { + let expectedOutcomes = {}; + let executions = []; + const didResolverReport = { + ...testCommon, + expectedOutcomes, + executions + }; + let executionWaiting = resolverInputs.length; + resolverInputs.forEach(function (input, i) { + switch (input.function) { + case 'resolve': + resolver.resolve(input.did, input.resolutionOptions, function (didResolutionMetadata, didDocument, didDocumentMetadata) { + executions[i] = { + function: input.function, + input: {did: input.did, resolutionOptions: input.resolutionOptions}, + output: {didResolutionMetadata, didDocument, didDocumentMetadata} + }; + const outcome = getOutcome(didResolutionMetadata.error, didDocumentMetadata.deactivated); + const idxs = expectedOutcomes[outcome] || (expectedOutcomes[outcome] = []); + idxs.push(i); + executionNext(); + }); + break; + case 'resolveRepresentation': + resolver.resolveRepresentation(input.did, input.resolutionOptions, function (didResolutionMetadata, didDocumentStream, didDocumentMetadata) { + executions[i] = { + function: input.function, + input: {did: input.did, resolutionOptions: input.resolutionOptions}, + output: {didResolutionMetadata, didDocumentStream, didDocumentMetadata} + }; + const outcome = getOutcome(didResolutionMetadata.error, didDocumentMetadata.deactivated); + const idxs = expectedOutcomes[outcome] || (expectedOutcomes[outcome] = []); + idxs.push(i); + executionNext(); + }); + break; + default: + throw new Error('Unknown function: ' + input.function); + } + }); + function executionNext() { + if (--executionWaiting) return; + for (const outcome in expectedOutcomes) { + expectedOutcomes[outcome].sort(); + } + const report = JSON.stringify(didResolverReport, null, 2); + fs.writeFileSync(resolverJsonFilename, report); + next(); + } +} + +function generateDereferencerReport(next) { + let expectedOutcomes = {}; + let executions = []; + const didUrlDereferencerReport = { + ...testCommon, + expectedOutcomes, + executions + }; + let executionWaiting = dereferencerInputs.length; + dereferencerInputs.forEach(function (input, i) { + resolver.dereference(input.didUrl, input.dereferenceOptions, function (dereferencingMetadata, contentStream, contentMetadata) { + executions[i] = { + function: 'dereference', + input, + output: {dereferencingMetadata, contentStream, contentMetadata} + }; + const outcome = getOutcome(dereferencingMetadata.error, contentMetadata.deactivated); + const idxs = expectedOutcomes[outcome] || (expectedOutcomes[outcome] = []); + idxs.push(i); + executionNext(); + }); + }); + function executionNext() { + if (--executionWaiting) return; + for (const outcome in expectedOutcomes) { + expectedOutcomes[outcome].sort(); + } + const report = JSON.stringify(didUrlDereferencerReport, null, 2); + fs.writeFileSync(dereferencerJsonFilename, report); + next(); + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..674771f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,37 @@ +{ + "name": "ssb-did-resolver", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "dependencies": { + "base64-url": "*", + "pull-stream": "*" + } + }, + "node_modules/base64-url": { + "version": "2.2.0", + "resolved": "http://localhost:33853/-/blobs/get/&M4DZ2DC8x5WIjq/p7r9x3BCJUQfUHm5uVXHOs1XXnPE=.sha256", + "integrity": "sha256-M4DZ2DC8x5WIjq/p7r9x3BCJUQfUHm5uVXHOs1XXnPE=", + "license": "ISC" + }, + "node_modules/pull-stream": { + "version": "3.6.14", + "resolved": "http://localhost:35233/-/blobs/get/&WUect/OXBboO6Xft04OCQ06Vf39ZSNag27SC1kxtrFw=.sha256", + "integrity": "sha256-WUect/OXBboO6Xft04OCQ06Vf39ZSNag27SC1kxtrFw=", + "license": "MIT" + } + }, + "dependencies": { + "base64-url": { + "version": "2.2.0", + "resolved": "http://localhost:33853/-/blobs/get/&M4DZ2DC8x5WIjq/p7r9x3BCJUQfUHm5uVXHOs1XXnPE=.sha256", + "integrity": "sha256-M4DZ2DC8x5WIjq/p7r9x3BCJUQfUHm5uVXHOs1XXnPE=" + }, + "pull-stream": { + "version": "3.6.14", + "resolved": "http://localhost:35233/-/blobs/get/&WUect/OXBboO6Xft04OCQ06Vf39ZSNag27SC1kxtrFw=.sha256", + "integrity": "sha256-WUect/OXBboO6Xft04OCQ06Vf39ZSNag27SC1kxtrFw=" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9b0b2e8 --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "license": "ISC", + "version": "v0.1.0", + "dependencies": { + "base64-url": "2.2", + "pull-stream": "3.6" + } +} diff --git a/resolver.js b/resolver.js new file mode 100644 index 0000000..345a170 --- /dev/null +++ b/resolver.js @@ -0,0 +1,310 @@ +// did:ssb resolver + +// did:ssb specification: v0.1 2021-07-22 +// &LXcZ4lpcpi3tEwqu+HqQmVUFVsqr0BMo1UNjy0bFafA=.sha256 + +const Base64Url = require('base64-url'); +const pull = require('pull-stream'); +const qs = require('querystring'); + +function Resolver(sbot, config) { + this.sbot = sbot; + this.config = config; +} + +const didRe = /^did:ssb:(ed25519):([0-9a-zA-Z._\-]+)$/; +const didUrlRe = /^(did:[^?#\/]*)(?:\/([^?#]*))?(?:\?([^#]*))?(?:#(.*))?$/; +const ssbRefRe = /^(@|%|&)([A-Za-z0-9\/+]{43}=)\.([\w\d]+)$/; +const ssbMsgIdRe = /^%[A-Za-z0-9\/+]{43}=\.[\w\d]+$/; + +function parseDIDURL(uri) { + const m = didUrlRe.exec(uri); + if (!m) return null; + return { + did: m[1], + path: m[2], + query: m[3], + fragment: m[4] + }; +} + +function parseSsbFeedIdFromDID(did) { + const m = didRe.exec(did); + if (!m) return null; + return '@' + Base64Url.unescape(m[2]) + '.' + m[1]; +} + +function parseSsbMsgId(id) { + if (ssbMsgIdRe.test(id)) return id; +} + +function timestampToDateTime(ts) { + const d = new Date(ts); + if (isNaN(d)) return; + return d.toISOString().replace(/\.\d\d\dZ$/, 'Z'); +} + +function serializeDIDDocument(didDocument, contentType) { + switch (contentType) { + case 'application/did+json': + case 'application/did+ld+json': + return JSON.stringify(didDocument); + } +} + +function parseDIDDocumentStream(didDocumentStream, contentType) { + switch (contentType) { + case 'application/did+json': + case 'application/did+ld+json': + return JSON.parse(didDocumentStream); + } +} + +// https://viewer.scuttlebot.io/web/%25B79jjfhYHrr%2BCPSDpr2S5vRGWUJ5vwyfzbYCp0OyV9k%3D.sha256#read-resolve +Resolver.prototype.resolve = function (did, resolutionOptions, cb) { + this.resolveOrResolveRepresentation(did, resolutionOptions, false, cb); +}; + +Resolver.prototype.resolveRepresentation = function (did, resolutionOptions, cb) { + this.resolveOrResolveRepresentation(did, resolutionOptions, true, cb); +}; + +Resolver.prototype.resolveOrResolveRepresentation = function (did, resolutionOptions, calledResolveRepresentation, cb) { + const self = this; + let msg, nextMsg, firstMsg, contentType, didDocument, didDocumentStream, accept, versionId, versionTime; + const Null = calledResolveRepresentation ? '' : null; + for (const option in resolutionOptions) switch (option) { + case 'versionId': versionId = resolutionOptions.versionId; break; + case 'versionTime': versionTime = resolutionOptions.versionTime; break; + case 'accept': accept = resolutionOptions.accept; break; + default: return cb({error: 'resolutionOptionNotSupported', option}, Null, {}); + } + // Note: this functions accepts versionId and versionTime as resolution options (resolution input metadata), + // even though they are not specified as such. + // 1 + let didDocumentMetadata = {}; + // 2 + let didResolutionMetadata = {}; + // 3 + const feedId = parseSsbFeedIdFromDID(did); + if (!feedId) return cb({error: 'invalidDid'}, Null, {}); + // 4 + if (versionId) { + const msgId = parseSsbMsgId(versionId); + if (!msgId) return cb({error: 'invalidVersionId'}, Null, {}); + self.sbot.get(msgId, function (err, value) { + if (err) return cb({error: 'ssbMessageMissing'}, Null, {}); + if (value.author !== feedId) return cb({error: 'ssbMessageInvalidAuthor'}, Null, {}); + if (value.content.type !== 'did-document-update') return cb({error: 'notFound'}, Null, {}); + msg = {key: msgId, value}; + gotLatestMessage(); + }); + } else if (versionTime) { + const versionDate = new Date(versionTime); + // 5 + pull( + self.sbot.query.read({ + reverse: true, + limit: 1, + query: [ + {$filter: { + value: { + author: feedId, + content: {type: 'did-document-update'}, + timestamp: {$lte: versionDate.getTime()} + } + }} + ] + }), + pull.collect(function (err, msgs) { + if (err) return cb({error: 'queryFailed', + errorMessage: String(err.message || err)}, Null, {}); + msg = msgs[0]; + gotLatestMessage() + }) + ) + } else { + // 6 + pull( + self.sbot.query.read({ + reverse: true, + limit: 1, + query: [ + {$filter: { + value: { + author: feedId, + content: {type: 'did-document-update'} + } + }} + ] + }), + pull.collect(function (err, msgs) { + if (err) return cb({error: 'queryFailed', + errorMessage: String(err.message || err)}, Null, {}); + msg = msgs[0]; + gotLatestMessage() + }) + ) + } + function gotLatestMessage() { + // 7 + if (!msg) return cb({error: 'notFound'}, Null, {}); + // 8 + didDocumentMetadata.versionId = msg.key; + // 9 + if (versionId || versionTime) { + pull( + self.sbot.query.read({ + reverse: false, + limit: 1, + query: [ + {$filter: { + value: { + author: feedId, + timestamp: {$gt: msg.value.timestamp}, + content: {type: 'did-document-update'} + } + }} + ] + }), + pull.collect(function (err, msgs) { + if (err) return cb({error: 'queryFailed', + errorMessage: String(err.message || err)}, Null, {}); + nextMsg = msgs[0]; + gotNextMessage(); + }) + ) + } else { + gotNextMessage(); + } + } + function gotNextMessage() { + if (nextMsg) { + // 9.1 + didDocumentMetadata.nextVersionId = nextMsg.key; + // 9.2 + const nextUpdate = timestampToDateTime(nextMsg.value.timestamp); + if (nextUpdate) didDocumentMetadata.nextUpdate = nextUpdate; + } + // 10 + // TODO: update spec: updated should not be set for initial update (Create) operation + // const updated = didDocumentMetadata.updated = timestampToDateTime(msg.value.timestamp); + // if (updated) didDocumentMetadata.updated = updated; + // 11 + pull( + self.sbot.query.read({ + reverse: false, + limit: 1, + query: [ + {$filter: { + value: { + author: feedId, + content: {type: 'did-document-update'} + } + }} + ] + }), + pull.collect(function (err, msgs) { + if (err) return cb({error: 'queryFailed', + errorMessage: String(err.message || err)}, Null, {}); + firstMsg = msgs[0]; + gotFirstMessage(); + }) + ) + } + function gotFirstMessage() { + if (!firstMsg) return cb({error: 'ssbMessageMissing'}, Null, {}); + // 12 + const created = didDocumentMetadata.created = timestampToDateTime(firstMsg.value.timestamp); + if (created) didDocumentMetadata.created = created; + if (firstMsg.key !== msg.key) { + // TODO: update spec + const updated = didDocumentMetadata.updated = timestampToDateTime(msg.value.timestamp); + if (updated) didDocumentMetadata.updated = updated; + } + // 13 + contentType = msg.value.content.contentType || 'application/did+json'; + // 14 + const blobLink = msg.value.content.didDocumentBlob; + if (blobLink) { + let blobId; + if (typeof blobLink === 'string') { + // 14.1 + blobId = blobLink; + } else if (typeof blobLink === 'object' && blobLink !== null) { + // 14.2.1 + blobId = blobLink.link; + // 14.2.2 + if (blobLink.type) contentType = blobLink.type; + } else { + // 14.3 + return cb({error: 'ssbInvalidBlobLink'}, Null, {}); + } + // 14.4 + getBlob(blobId, function (err, data) { + didDocumentStream = data; + gotDocumentOrStream(); + }) + } else if (msg.value.content.didDocumentString) { + // 15 + didDocumentStream = msg.value.content.didDocumentString; + gotDocumentOrStream(); + } else if (msg.value.content.didDocument) { + // 16 + didDocument = msg.value.content.didDocument; + gotDocumentOrStream(); + } else { + // 17 + return cb({error: 'ssbInvalidUpdate'}, Null, {}); + } + } + function gotDocumentOrStream() { + // TODO: specify use of accept option + if (accept && accept !== contentType) { + // TODO: convert between representations? + return cb({error: 'representationNotSupported'}, Null, {}); + } + if (calledResolveRepresentation) { + // 18.1 + didResolutionMetadata.contentType = contentType; + if (!didDocumentStream) { + // 18.2 + didDocumentStream = serializeDIDDocument(didDocument, contentType); + if (!didDocumentStream) return cb({error: 'representationNotSupported'}, Null, {}); + } + // 18.3 + cb(didResolutionMetadata, didDocumentStream, didDocumentMetadata); + } else { + // 19.1 + if (!didDocument) { + try { + didDocument = parseDIDDocumentStream(didDocumentStream, contentType); + } catch(e) { + return cb({error: 'invalidRepresentation', errorMessage: e.message}, Null, {}); + } + if (!didDocument) return cb({error: 'representationNotSupported'}, Null, {}); + } + // 19.2 + cb(didResolutionMetadata, didDocument, didDocumentMetadata); + } + } +}; + +Resolver.prototype.dereference = function (didUrl, dereferenceOptions, cb) { + let accept; + for (const option in dereferenceOptions) switch (option) { + case 'accept': accept = dereferenceOptions.accept; break; + default: return cb({error: 'dereferencingOptionNotSupported', option}, null, {}); + } + const parts = parseDIDURL(didUrl); + if (!didUrl) return cb({error: 'invalidDidUrl'}, null, {}); + if (parts.path) return cb({error: 'didPathNotSupported'}, null, {}); + // The caller is expected to deal with the fragment + if (parts.fragment) return cb({error: 'didFragmentNotSupported'}, null, {}); + const didParameters = parts.query ? qs.decode(parts.query) : {}; + const resolutionOptions = {...didParameters}; + if (accept) resolutionOptions.accept = accept; + this.resolveRepresentation(parts.did, resolutionOptions, cb); +}; + +module.exports = Resolver;