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.
311 lines
9.8 KiB
311 lines
9.8 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]+$/;
|
|
|
|
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;
|