@punkomonk/add-ssb-client
Charles E. Lehner 4 years ago
commit 03caefa0fb
Signed by: cel
GPG Key ID: C28D95BB012367EA

1
.gitignore vendored

@ -0,0 +1 @@
/node_modules

@ -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.

@ -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

@ -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 <destination>';
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();
}
}

37
package-lock.json generated

@ -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="
}
}
}

@ -0,0 +1,8 @@
{
"license": "ISC",
"version": "v0.1.0",
"dependencies": {
"base64-url": "2.2",
"pull-stream": "3.6"
}
}

@ -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;
Loading…
Cancel
Save