// 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]+$/; const dateTimeRe = /^\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\dZ$/; 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) { if (!dateTimeRe.test(versionTime)) return cb({error: 'invalidVersionTime'}, Null, {}); const versionDate = new Date(versionTime); const timestampQuery = {$lte: versionDate.getTime() + 1000}; // 5 pull( self.sbot.query.read({ reverse: true, limit: 1, query: [ {$filter: { value: { author: feedId, content: {type: 'did-document-update'}, timestamp: timestampQuery } }} ] }), 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 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, {}); // 11 const created = didDocumentMetadata.created = timestampToDateTime(firstMsg.value.timestamp); if (created) didDocumentMetadata.created = created; // 12 if (firstMsg.key !== msg.key) { 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;