You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
ssb-did-resolver/resolver.js

310 lines
9.7 KiB

// 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;