From 0c0b78711da5ad0e6767dcb3c8e709262bc9490f Mon Sep 17 00:00:00 2001 From: Devin Rousso Date: Wed, 29 Mar 2023 08:20:35 -0600 Subject: [PATCH 01/10] refactor initial connection flow to support additional arguments if we want to add support for configuring the initial connection (e.g. permissions) we need to have a way to provide a config alongside everything else (e.g. `pubkey`, `origin`, etc.) we could encode any additional stuff as JSON and add it as query parameters to the URL passed to `web5://`, as that's where the connect popup is created, but we'd potentially run into issues with the length of the URL (as well as it just not being the greatest design to encode everything into the URL) we already are leveraging a `WebSocket` to listen for when the user does something with the connect popup (e.g. allow, deny, close, etc.), but we wouldn't want to send a separate message at the same time as the `web5://` request as that could be abused as a timing attack (e.g. the user accepts the connection request before a permissions message is received) instead, slightly shift the order/placement of things such that we can guarantee the order of events between the `web5://` request and the `WebSocket` 1. the client opens a bunch of `ws://localhost:${port}/connections/${pubkey}` 2. the client requests `web5://connect/${pubkey}` 3. the server connects to the `ws://` from step 1 on one of the `port` and saves a reference to the `WebSocket` along with the corresponding `pubkey` 4. the server receives the `web5://` from step 2 and saves the `pubkey` for later use to verify that the originator did in fact request `web5://` 5. the server sends `{ req: 'request', res: 'received' }` through the `WebSocket` from step 1 to indicate to the client that the `web5://` was received (since there's no other way for the client to know when the the server has received (let alone finished handling) the `web5://` request) 6. the client receives the message from step 5 7. the client sends `{ req: 'connect' }` asking for the connection popup to be displayed (and this is where we'd include any permissions) 8. the server receives the message from step 7 and confirms that the `pubkey` associated with the `WebSocket` was previously saved in step 4 (thereby transitively affirming that the client did request `web5://` with that same `pubkey`) 9. the server generates a `pin` and `nonce` and etc. to show the connection popup 10. the server sends `{ req: 'connect', res: 'requested', pin, nonce }` (though the `pin` has been encrypted using the `pubkey`) using the data from step 9 for display in the page to allow the user to visually confirm the right page/connection 11. the user does something with the connection popup from step 10 (e.g. allow, deny, close, etc.) 12. the server sends `{ req: 'connect', res: '...', data } with the `...` being an enum representing the user's choice from step 11 13. the client receives the message from step 12 and handles the user's choice accordingly this approach allows us to have even greater flexibility with what additional data to provide alongside the `{ req: 'connect' }` and decouples as much as possible from the `web5://` request since ultimately that is just a user interaction check (and may have disappear in the future depending on how true that user interaction guarantee is on all platforms) --- src/Web5.js | 108 ++++++++++++++++++++++++++++++++-------------------- 1 file changed, 66 insertions(+), 42 deletions(-) diff --git a/src/Web5.js b/src/Web5.js index 064bcda24..daf705895 100644 --- a/src/Web5.js +++ b/src/Web5.js @@ -138,9 +138,6 @@ class Web5 extends EventTarget { } } - const encodedOrigin = this.#dwn.SDK.Encoder.stringToBase64Url(location.origin); - triggerProtocolHandler(`web5://connect/${this.#keys.encoded.publicKey}/${encodedOrigin}`); - function destroySocket(socket) { socket.close(); socket.removeEventListener('open', handleOpen); @@ -174,58 +171,82 @@ class Web5 extends EventTarget { const json = parseJSON(event.data); - switch (json?.type) { - case 'connected': - if (!json.data) { - removeSocket(socket); + if (json?.error) { + console.error(json.error); + return; + } + + switch (json?.req) { + case 'request': + switch (json.res) { + case 'received': + socket.send(JSON.stringify({ req: 'connect' })); return; } + break; - this.#connection = json.data; - await storage.set(connectionLocation, this.#connection); + case 'connect': + switch (json.res) { + case 'connected': + if (!json.data) { + // stop listening to this socket as it is missing required data + removeSocket(socket); + return; + } + + this.#connection = json.data; + await storage.set(connectionLocation, this.#connection); + + // Register DID on initial connection + await this.#did.register({ + connected: true, + did: this.#connection.did, + endpoint: `http://localhost:${this.#connection.port}/dwn`, + }); + + this.dispatchEvent(new CustomEvent('connection', { detail: this.#connection })); + + // tear down everything as this connection has been allowed + sockets.forEach(destroySocket); + sockets.clear(); + return; - // Register DID on initial connection - await this.#did.register({ - connected: true, - did: this.#connection.did, - endpoint: `http://localhost:${this.#connection.port}/dwn`, - }); + case 'requested': + if (!json.data) { + // stop listening to this socket as it is missing required data + removeSocket(socket); + return; + } + + try { + await decodePin(json.data, this.#keys.decoded.secretKey); + } catch { + // stop listening to this socket as it has sent us invalid data + removeSocket(socket); + return; + } + + this.dispatchEvent(new CustomEvent('open', { detail: json.data })); + return; - this.dispatchEvent(new CustomEvent('connection', { detail: this.#connection })); - break; + case 'blocked': + case 'denied': + case 'closed': + this.dispatchEvent(new CustomEvent('close')); - case 'requested': - if (!json.data) { - removeSocket(socket); + // tear down everything as this connection has been denied + sockets.forEach(destroySocket); + sockets.clear(); return; - } - try { - await decodePin(json.data, this.#keys.decoded.secretKey); - } catch { - removeSocket(socket); + case 'unknown': return; } - - this.dispatchEvent(new CustomEvent('open', { detail: json.data })); - return; - - case 'blocked': - case 'denied': - case 'closed': - this.dispatchEvent(new CustomEvent('close')); break; - - case 'unknown': - return; - - default: - removeSocket(socket); - return; } - sockets.forEach(destroySocket); - sockets.clear(); + // stop listening to this socket as it has sent us unexpected data + removeSocket(socket); }; const sockets = new Set(); @@ -236,6 +257,9 @@ class Web5 extends EventTarget { socket.addEventListener('open', handleOpen); socket.addEventListener('error', handleError); } + + const encodedOrigin = this.#dwn.SDK.Encoder.stringToBase64Url(location.origin); + triggerProtocolHandler(`web5://connect/${this.#keys.encoded.publicKey}/${encodedOrigin}`); } async #createSignedMessage(resolvedAuthor, message, data) { From e57fd34dbae8ef78031d9d166c211d61a7bbd958 Mon Sep 17 00:00:00 2001 From: Devin Rousso Date: Sun, 2 Apr 2023 21:59:45 -0600 Subject: [PATCH 02/10] require that the `pubkey` be provided when requesting `http://localhost:${port}/dwn/${pubkey}` this allows the `desktop-agent` to ensure that the requester has called `connect` --- src/Web5.js | 37 +++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/src/Web5.js b/src/Web5.js index daf705895..93fe2c14f 100644 --- a/src/Web5.js +++ b/src/Web5.js @@ -98,23 +98,6 @@ class Web5 extends EventTarget { return; } - this.#connection = await storage.get(connectionLocation); - if (this.#connection) { - // Register DID on reconnection - await this.#did.register({ - connected: true, - did: this.#connection.did, - endpoint: `http://localhost:${this.#connection.port}/dwn`, - }); - - this.dispatchEvent(new CustomEvent('connection', { detail: this.#connection })); - return; - } - - if (options?.silent) { - return; - } - if (!this.#keys) { const keys = await storage.get(keysLocation); if (keys) { @@ -125,6 +108,20 @@ class Web5 extends EventTarget { secretKey: this.#dwn.SDK.Encoder.base64UrlToBytes(keys.secretKey), }, }; + + // only attempt to load the connection data if we already have keys + this.#connection = await storage.get(connectionLocation); + if (this.#connection) { + // Register DID on reconnection + await this.#did.register({ + connected: true, + did: this.#connection.did, + endpoint: `http://localhost:${this.#connection.port}/dwn/${this.#keys.encoded.publicKey}`, + }); + + this.dispatchEvent(new CustomEvent('connection', { detail: this.#connection })); + return; + } } else { const keys = nacl.box.keyPair(); this.#keys = { @@ -138,6 +135,10 @@ class Web5 extends EventTarget { } } + if (options?.silent) { + return; + } + function destroySocket(socket) { socket.close(); socket.removeEventListener('open', handleOpen); @@ -201,7 +202,7 @@ class Web5 extends EventTarget { await this.#did.register({ connected: true, did: this.#connection.did, - endpoint: `http://localhost:${this.#connection.port}/dwn`, + endpoint: `http://localhost:${this.#connection.port}/dwn/${this.#keys.encoded.publicKey}`, }); this.dispatchEvent(new CustomEvent('connection', { detail: this.#connection })); From 7e97636d1f5cb2c075dd5dc26c132bd70582ae5f Mon Sep 17 00:00:00 2001 From: Devin Rousso Date: Sun, 2 Apr 2023 22:00:50 -0600 Subject: [PATCH 03/10] add support for basic `permissionsRequests` provided as part of the `options = { }` given when calling `connect` included alongside the `{ req: 'connect' }` sent to the `desktop-agent` expected to be an array of --- examples/test-dashboard/desktop-agent.html | 12 +++++++++++- src/Web5.js | 8 +++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/examples/test-dashboard/desktop-agent.html b/examples/test-dashboard/desktop-agent.html index 09e21d596..4678697ad 100644 --- a/examples/test-dashboard/desktop-agent.html +++ b/examples/test-dashboard/desktop-agent.html @@ -256,7 +256,17 @@ connect_button.addEventListener('click', async event => { event.preventDefault(); - web5.connect(); + web5.connect({ + permissionRequests: [ + { + schema: 'foo/text' + }, + { + protocol: 'test', + schema: 'test/post' + } + ] + }); }); async function logConsole(obj, message = '') { diff --git a/src/Web5.js b/src/Web5.js index 93fe2c14f..ea39d96f6 100644 --- a/src/Web5.js +++ b/src/Web5.js @@ -94,6 +94,8 @@ class Web5 extends EventTarget { const connectionLocation = options?.connectionLocation ?? 'web5-connection'; const keysLocation = options?.keysLocation ?? 'web5-keys'; + const permissionRequests = structuredClone(options?.permissionRequests); + if (this.#connection) { return; } @@ -181,7 +183,11 @@ class Web5 extends EventTarget { case 'request': switch (json.res) { case 'received': - socket.send(JSON.stringify({ req: 'connect' })); + var connectMessage = { req: 'connect' }; + if (permissionRequests) { + connectMessage.permissionRequests = permissionRequests; + } + socket.send(JSON.stringify(connectMessage)); return; } break; From f18449f99fb95baffbce3343356204e1d9947180 Mon Sep 17 00:00:00 2001 From: Devin Rousso Date: Mon, 3 Apr 2023 12:36:22 -0600 Subject: [PATCH 04/10] separate out the `keys`/`connection` loading logic for reusability --- src/Web5.js | 104 +++++++++++++++++++++++++++++++++------------------- 1 file changed, 66 insertions(+), 38 deletions(-) diff --git a/src/Web5.js b/src/Web5.js index ea39d96f6..7db2e3403 100644 --- a/src/Web5.js +++ b/src/Web5.js @@ -96,47 +96,11 @@ class Web5 extends EventTarget { const permissionRequests = structuredClone(options?.permissionRequests); - if (this.#connection) { + const connectionAlreadyExists = await this.#loadConnection({ storage, connectionLocation, keysLocation }); + if (connectionAlreadyExists) { return; } - if (!this.#keys) { - const keys = await storage.get(keysLocation); - if (keys) { - this.#keys = { - encoded: keys, - decoded: { - publicKey: this.#dwn.SDK.Encoder.base64UrlToBytes(keys.publicKey), - secretKey: this.#dwn.SDK.Encoder.base64UrlToBytes(keys.secretKey), - }, - }; - - // only attempt to load the connection data if we already have keys - this.#connection = await storage.get(connectionLocation); - if (this.#connection) { - // Register DID on reconnection - await this.#did.register({ - connected: true, - did: this.#connection.did, - endpoint: `http://localhost:${this.#connection.port}/dwn/${this.#keys.encoded.publicKey}`, - }); - - this.dispatchEvent(new CustomEvent('connection', { detail: this.#connection })); - return; - } - } else { - const keys = nacl.box.keyPair(); - this.#keys = { - encoded: { - publicKey: this.#dwn.SDK.Encoder.bytesToBase64Url(keys.publicKey), - secretKey: this.#dwn.SDK.Encoder.bytesToBase64Url(keys.secretKey), - }, - decoded: keys, - }; - await storage.set(keysLocation, this.#keys.encoded); - } - } - if (options?.silent) { return; } @@ -317,6 +281,70 @@ class Web5 extends EventTarget { return response ?? { status: { code: 503, detail: 'Service Unavailable' } }; } + + async #loadKeys(options) { + const storage = options.storage; + const keysLocation = options.keysLocation; + + if (this.#keys) { + return true; + } + + const encoded = await storage.get(keysLocation); + if (encoded) { + this.#keys = { + encoded, + decoded: { + publicKey: this.#dwn.SDK.Encoder.base64UrlToBytes(encoded.publicKey), + secretKey: this.#dwn.SDK.Encoder.base64UrlToBytes(encoded.secretKey), + }, + }; + return true; + } + + if (!options.silent) { + const decoded = nacl.box.keyPair(); + this.#keys = { + encoded: { + publicKey: this.#dwn.SDK.Encoder.bytesToBase64Url(decoded.publicKey), + secretKey: this.#dwn.SDK.Encoder.bytesToBase64Url(decoded.secretKey), + }, + decoded, + }; + await storage.set(keysLocation, this.#keys.encoded); + } + + return false; + } + + async #loadConnection(options) { + const storage = options.storage; + const connectionLocation = options.connectionLocation; + + if (this.#connection) { + return true; + } + + const keysAlreadyExist = await this.#loadKeys(options); + if (!keysAlreadyExist) { + return false; + } + + this.#connection = await storage.get(connectionLocation); + if (!this.#connection) { + return false; + } + + // Register DID on reconnection + await this.#did.register({ + connected: true, + did: this.#connection.did, + endpoint: `http://localhost:${this.#connection.port}/dwn/${this.#keys.encoded.publicKey}`, + }); + + this.dispatchEvent(new CustomEvent('connection', { detail: this.#connection })); + return true; + } } export { From 30f09eb8d124496331139b53cf81c9eda5434109 Mon Sep 17 00:00:00 2001 From: Devin Rousso Date: Mon, 3 Apr 2023 14:22:03 -0600 Subject: [PATCH 05/10] use JSON-RPC instead of custom `req`/`res` structure --- src/Web5.js | 95 +++++++------------- src/json-rpc/JSONRPCError.js | 25 ++++++ src/json-rpc/JSONRPCSocket.js | 165 ++++++++++++++++++++++++++++++++++ 3 files changed, 223 insertions(+), 62 deletions(-) create mode 100644 src/json-rpc/JSONRPCError.js create mode 100644 src/json-rpc/JSONRPCSocket.js diff --git a/src/Web5.js b/src/Web5.js index 7db2e3403..6d5323eb5 100644 --- a/src/Web5.js +++ b/src/Web5.js @@ -2,13 +2,13 @@ import nacl from 'tweetnacl'; import { Web5DID } from './did/Web5DID.js'; import { Web5DWN } from './dwn/Web5DWN.js'; +import { JSONRPCSocket } from './json-rpc/JSONRPCSocket.js'; import { LocalStorage } from './storage/LocalStorage.js'; import { AppTransport } from './transport/AppTransport.js'; import { HTTPTransport } from './transport/HTTPTransport.js'; import { decodePin, isUnsignedMessage, - parseJSON, parseURL, triggerProtocolHandler, } from './utils.js'; @@ -109,7 +109,7 @@ class Web5 extends EventTarget { socket.close(); socket.removeEventListener('open', handleOpen); socket.removeEventListener('error', handleError); - socket.removeEventListener('message', handleMessage); + socket.removeEventListener('notification', handleNotification); } const removeSocket = (socket) => { @@ -124,7 +124,7 @@ class Web5 extends EventTarget { function handleOpen(event) { const socket = event.target; - socket.addEventListener('message', handleMessage); + socket.addEventListener('notification', handleNotification); } function handleError(event) { @@ -133,39 +133,21 @@ class Web5 extends EventTarget { removeSocket(socket); } - const handleMessage = async (event) => { + const handleNotification = async (event) => { const socket = event.target; + const { method, params } = event.detail; - const json = parseJSON(event.data); - - if (json?.error) { - console.error(json.error); - return; - } - - switch (json?.req) { - case 'request': - switch (json.res) { - case 'received': - var connectMessage = { req: 'connect' }; - if (permissionRequests) { - connectMessage.permissionRequests = permissionRequests; - } - socket.send(JSON.stringify(connectMessage)); - return; - } - break; - - case 'connect': - switch (json.res) { - case 'connected': - if (!json.data) { + switch (method) { + case 'requested-web5': + try { + const connection = await socket.sendRequest('connect', { permissionRequests }); + if (!connection) { // stop listening to this socket as it is missing required data removeSocket(socket); return; } - this.#connection = json.data; + this.#connection = connection; await storage.set(connectionLocation, this.#connection); // Register DID on initial connection @@ -176,44 +158,31 @@ class Web5 extends EventTarget { }); this.dispatchEvent(new CustomEvent('connection', { detail: this.#connection })); - - // tear down everything as this connection has been allowed - sockets.forEach(destroySocket); - sockets.clear(); - return; - - case 'requested': - if (!json.data) { - // stop listening to this socket as it is missing required data - removeSocket(socket); - return; - } - - try { - await decodePin(json.data, this.#keys.decoded.secretKey); - } catch { - // stop listening to this socket as it has sent us invalid data - removeSocket(socket); - return; - } - - this.dispatchEvent(new CustomEvent('open', { detail: json.data })); - return; - - case 'blocked': - case 'denied': - case 'closed': + } catch { this.dispatchEvent(new CustomEvent('close')); + } + + sockets.forEach(destroySocket); + sockets.clear(); + return; - // tear down everything as this connection has been denied - sockets.forEach(destroySocket); - sockets.clear(); + case 'show-pin': + if (!params) { + // stop listening to this socket as it is missing required data + removeSocket(socket); return; + } - case 'unknown': + try { + await decodePin(params, this.#keys.decoded.secretKey); + } catch { + // stop listening to this socket as it has sent us invalid data + removeSocket(socket); return; } - break; + + this.dispatchEvent(new CustomEvent('open', { detail: params })); + return; } // stop listening to this socket as it has sent us unexpected data @@ -222,11 +191,13 @@ class Web5 extends EventTarget { const sockets = new Set(); for (let port = 55_500; port <= 55_600; ++port) { - const socket = new WebSocket(`ws://localhost:${port}/connections/${this.#keys.encoded.publicKey}`); + const socket = new JSONRPCSocket(); sockets.add(socket); socket.addEventListener('open', handleOpen); socket.addEventListener('error', handleError); + + socket.open(`ws://localhost:${port}/connections/${this.#keys.encoded.publicKey}`); } const encodedOrigin = this.#dwn.SDK.Encoder.stringToBase64Url(location.origin); diff --git a/src/json-rpc/JSONRPCError.js b/src/json-rpc/JSONRPCError.js new file mode 100644 index 000000000..c0d85e831 --- /dev/null +++ b/src/json-rpc/JSONRPCError.js @@ -0,0 +1,25 @@ +class JSONRPCError extends Error { + #code; + #data; + + constructor(code, message, data = { }) { + super(message); + + this.name = 'JSONRPCError'; + + this.#code = code; + this.#data = data; + } + + get code() { + return this.#code; + } + + get data() { + return this.#data; + } +} + +export { + JSONRPCError, +}; diff --git a/src/json-rpc/JSONRPCSocket.js b/src/json-rpc/JSONRPCSocket.js new file mode 100644 index 000000000..6b14519e2 --- /dev/null +++ b/src/json-rpc/JSONRPCSocket.js @@ -0,0 +1,165 @@ +import { parseJSON } from '../utils.js'; +import { JSONRPCError } from './JSONRPCError.js'; + +class JSONRPCSocket extends EventTarget { + #socket = null; + #ready = false; + + #nextRequestID = 0; + #requestHandlersForID = new Map(); + + #bound = { }; + + constructor() { + super(); + + this.#bound['open'] = this.#handleOpen.bind(this); + this.#bound['message'] = this.#handleMessage.bind(this); + this.#bound['close'] = this.#handleClose.bind(this); + this.#bound['error'] = this.#handleError.bind(this); + } + + open(url) { + if (this.#ready) { + return; + } + + this.#socket = new WebSocket(url); + this.#socket.addEventListener('open', this.#bound['open'], { capture: true, passive: true, once: true }); + this.#socket.addEventListener('message', this.#bound['message'], { capture: true, passive: true }); + this.#socket.addEventListener('close', this.#bound['close'], { capture: true, passive: true, once: true }); + this.#socket.addEventListener('error', this.#bound['error'], { capture: true, passive: true, once: true }); + } + + sendRequest(method, params = undefined, callback = undefined) { + if (!this.#ready) { + throw 'not ready'; + } + + const id = ++this.#nextRequestID; + + this.#socket.send(JSON.stringify({ id, method, params })); + + if (!callback) { + return new Promise((resolve, reject) => { + this.#requestHandlersForID.set(id, { resolve, reject }); + }); + } + + this.#requestHandlersForID.set(id, { + resolve: callback, + reject: callback, + }); + } + + sendNotification(method, params = undefined) { + if (!this.#ready) { + throw 'not ready'; + } + + this.#socket.send(JSON.stringify({ method, params })); + } + + close() { + const closed = this.#close(); + if (!closed) { + return; + } + + this.dispatchEvent(new CustomEvent('close')); + } + + #close() { + if (!this.#ready) { + return false; + } + + this.#ready = false; + + this.#socket.removeEventListener('open', this.#bound['open'], { capture: true }); + this.#socket.removeEventListener('message', this.#bound['message'], { capture: true }); + this.#socket.removeEventListener('close', this.#bound['close'], { capture: true }); + this.#socket.removeEventListener('error', this.#bound['error'], { capture: true }); + this.#socket.close(); + this.#socket = null; + + this.#nextRequestID = 0; + + for (const { reject } of Array.from(this.#requestHandlersForID.values())) { + reject(new Error('closed')); + } + this.#requestHandlersForID.clear(); + + return true; + } + + #error(error) { + const closed = this.#close(); + if (!closed) { + return; + } + + this.dispatchEvent(new CustomEvent('error'), { detail: error }); + } + + #handleOpen() { + this.#ready = true; + + this.dispatchEvent(new CustomEvent('open')); + } + + #handleMessage(event) { + const json = parseJSON(event.data); + if (!json) { + // stop listening to this socket as it is missing required data + this.#error('invalid message'); + return; + } + + const id = json.id; + if (!id) { + if (!json.method) { + // stop listening to this socket as it is missing required data + this.#error('invalid notification'); + return; + } + + this.dispatchEvent(new CustomEvent('notification', { detail: { + method: json.method, + params: json.params ?? { }, + } })); + return; + } + + const { resolve, reject } = this.#requestHandlersForID.get(id) ?? { }; + if (!resolve || !reject) { + // stop listening to this socket as it is in an unexpected state + this.#error('invalid id'); + return; + } + + if (json.error) { + if (!json.error.code || !json.error.message) { + // stop listening to this socket as it is missing required data + this.#error('invalid error'); + return; + } + + reject(new JSONRPCError(json.error.code, json.error.message, json.error.data)); + } else { + resolve(json.result); + } + } + + #handleClose() { + this.close(); + } + + #handleError(event) { + this.#error(event.error); + } +} + +export { + JSONRPCSocket, +}; From 9a4bc15a4681be1b0f0f2b6c0021292a41a8b0f8 Mon Sep 17 00:00:00 2001 From: Devin Rousso Date: Mon, 3 Apr 2023 15:38:16 -0600 Subject: [PATCH 06/10] add support for updating permissions --- examples/test-dashboard/desktop-agent.html | 26 ++++++ src/Web5.js | 97 +++++++++++++++++++--- 2 files changed, 111 insertions(+), 12 deletions(-) diff --git a/examples/test-dashboard/desktop-agent.html b/examples/test-dashboard/desktop-agent.html index 4678697ad..ac299f111 100644 --- a/examples/test-dashboard/desktop-agent.html +++ b/examples/test-dashboard/desktop-agent.html @@ -39,6 +39,7 @@ +
Security Code:
@@ -246,6 +247,9 @@ myDid = event.detail.did; alert('Connection succeeded!'); }); + web5.addEventListener('update', (event) => { + alert('Update succeeded!'); + }); web5.addEventListener('close', (event) => { alert('Connection was denied'); }); @@ -269,6 +273,28 @@ }); }); + update_button.addEventListener('click', async event => { + event.preventDefault(); + + web5.update({ + permissionRequests: [ + { + schema: 'foo/text' + }, + { + schema: 'foo/json' + }, + { + schema: 'foo/avatar' + }, + { + protocol: 'test', + schema: 'test/post' + } + ] + }); + }); + async function logConsole(obj, message = '') { if (obj instanceof Response) { console.log(message, await obj.json()); diff --git a/src/Web5.js b/src/Web5.js index 6d5323eb5..09faab41a 100644 --- a/src/Web5.js +++ b/src/Web5.js @@ -167,21 +167,10 @@ class Web5 extends EventTarget { return; case 'show-pin': - if (!params) { + if (!this.#decodePinAndDispatchOpenEvent(params)) { // stop listening to this socket as it is missing required data removeSocket(socket); - return; - } - - try { - await decodePin(params, this.#keys.decoded.secretKey); - } catch { - // stop listening to this socket as it has sent us invalid data - removeSocket(socket); - return; } - - this.dispatchEvent(new CustomEvent('open', { detail: params })); return; } @@ -204,6 +193,75 @@ class Web5 extends EventTarget { triggerProtocolHandler(`web5://connect/${this.#keys.encoded.publicKey}/${encodedOrigin}`); } + async update(options = { }) { + const storage = options?.storage ?? new LocalStorage(); + const connectionLocation = options?.connectionLocation ?? 'web5-connection'; + const keysLocation = options?.keysLocation ?? 'web5-keys'; + + const permissionRequests = structuredClone(options?.permissionRequests); + + const connectionAlreadyExists = await this.#loadConnection({ storage, connectionLocation, keysLocation }); + if (!connectionAlreadyExists) { + throw 'must call connect() before calling update()'; + } + + function destroySocket() { + socket.close(); + socket.removeEventListener('open', handleOpen); + socket.removeEventListener('error', removeSocket); + socket.removeEventListener('notification', handleNotification); + } + + const removeSocket = () => { + destroySocket(socket); + + this.dispatchEvent(new CustomEvent('error')); + }; + + const handleOpen = async () => { + socket.addEventListener('notification', handleNotification); + + try { + const connection = await socket.sendRequest('update', { permissionRequests }); + if (!connection) { + // stop listening to this socket as it is missing required data + removeSocket(); + return; + } + + this.#connection = { ...this.#connection, connection }; + await storage.set(connectionLocation, this.#connection); + + this.dispatchEvent(new CustomEvent('update', { detail: this.#connection })); + } catch { + this.dispatchEvent(new CustomEvent('close')); + } + + destroySocket(); + }; + + const handleNotification = async (event) => { + const { method, params } = event.detail; + + switch (method) { + case 'show-pin': + if (!this.#decodePinAndDispatchOpenEvent(params)) { + // stop listening to this socket as it is missing required data + removeSocket(); + } + return; + } + + // stop listening to this socket as it has sent us unexpected data + removeSocket(); + }; + + const socket = new JSONRPCSocket(); + socket.addEventListener('open', handleOpen); + socket.addEventListener('error', removeSocket); + socket.open(`ws://localhost:${this.#connection.port}/connections/${this.#keys.encoded.publicKey}`); + } + async #createSignedMessage(resolvedAuthor, message, data) { const authorizationSignatureInput = this.#dwn.SDK.Jws.createSignatureInput({ keyId: resolvedAuthor.did + '#key-1', @@ -253,6 +311,21 @@ class Web5 extends EventTarget { return response ?? { status: { code: 503, detail: 'Service Unavailable' } }; } + async #decodePinAndDispatchOpenEvent(data) { + if (!data) { + return false; + } + + try { + await decodePin(data, this.#keys.decoded.secretKey); + } catch { + return false; + } + + this.dispatchEvent(new CustomEvent('open', { detail: data })); + return true; + } + async #loadKeys(options) { const storage = options.storage; const keysLocation = options.keysLocation; From 4a0df44ebd0a112c3e1c585bc7b68681fab8e1f6 Mon Sep 17 00:00:00 2001 From: Devin Rousso Date: Tue, 4 Apr 2023 20:20:19 -0500 Subject: [PATCH 07/10] add support for `interface`, `method`, `recordId`, and `contextId` in `permissionRequests` --- examples/test-dashboard/desktop-agent.html | 32 ++++++++-------------- 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/examples/test-dashboard/desktop-agent.html b/examples/test-dashboard/desktop-agent.html index ac299f111..3049786c2 100644 --- a/examples/test-dashboard/desktop-agent.html +++ b/examples/test-dashboard/desktop-agent.html @@ -262,13 +262,10 @@ web5.connect({ permissionRequests: [ - { - schema: 'foo/text' - }, - { - protocol: 'test', - schema: 'test/post' - } + { interface: 'Records', method: 'Write', schema: 'foo/text' }, + { interface: 'Records', method: 'Query', schema: 'foo/text' }, + { interface: 'Records', method: 'Write', protocol: 'test', schema: 'test/post' }, + { interface: 'Records', method: 'Query', protocol: 'test', schema: 'test/post' } ] }); }); @@ -278,19 +275,14 @@ web5.update({ permissionRequests: [ - { - schema: 'foo/text' - }, - { - schema: 'foo/json' - }, - { - schema: 'foo/avatar' - }, - { - protocol: 'test', - schema: 'test/post' - } + { interface: 'Records', method: 'Write', schema: 'foo/text' }, + { interface: 'Records', method: 'Query', schema: 'foo/text' }, + { interface: 'Records', method: 'Write', schema: 'foo/json' }, + { interface: 'Records', method: 'Query', schema: 'foo/json' }, + { interface: 'Records', method: 'Write', schema: 'foo/avatar' }, + { interface: 'Records', method: 'Query', schema: 'foo/avatar' }, + { interface: 'Records', method: 'Write', protocol: 'test', schema: 'test/post' }, + { interface: 'Records', method: 'Query', protocol: 'test', schema: 'test/post' } ] }); }); From f6c90f7ddd593c6290dacd2466ec47464c7bd5b9 Mon Sep 17 00:00:00 2001 From: Devin Rousso Date: Wed, 5 Apr 2023 11:25:56 -0500 Subject: [PATCH 08/10] update docs for `permissionRequests` and `update` --- README.md | 139 +++++++++++++++++++++++++++++++++++++++++++++++++++- src/Web5.js | 4 ++ 2 files changed, 142 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index cf67b5509..d86ec9da9 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,15 @@ Controls whether to prompt the user to start a new connection if a connection ha Defaults to `false`. + +##### **`permissionRequests`** + +Allows for restricting the scope of access to the underlying DWeb Node. + +If provided, `permissionRequests` must be an array of [`scope`](https://identity.foundation/decentralized-web-node/spec/#permissionsrequest) objects. + +Defaults to `undefined`. + #### **Events** After calling `connect`, at least one of the following events will be dispatched on the `Web5` instance: @@ -301,7 +310,135 @@ document.querySelector('#connect_button').addEventListener('click', async event console.error(event); }); - web5.connect(); + web5.connect({ + permissionRequests: [ + { + interface: 'Records', + method: 'Write', + protocol: 'https://decentralized-music.org/protocol' + } + ] + }); +}); +``` + +### **`web5.update(options)`** + +Enables an app to update their connection to a user's local identity app, or update an in-app DID to represent the user (e.g. if the user does not have an identity app). + +> NOTE: This method **_MUST_** be called _after_ `web5.connect()`. + +> NOTE: At least one of the following **_MUST_** be provided: `permissionRequests`. + +#### **`options`** + +The `update` method optionally accepts an object with the following properties: + +##### **`storage`** + +Used by `update` to store connection data/keys/etc. for reuse when calling `update` again (e.g. during another session). + +If provided, `storage` must be an object that has the same methods as [`Storage`](/TBD54566975/web5-js/tree/main/src/storage/Storage.js). + +If not provided, an instance of [`LocalStorage`](/TBD54566975/web5-js/tree/main/src/storage/LocalStorage.js) is used instead. + +##### **`connectionLocation`** + +Controls where in `storage` the connection data is stored. + +Defaults to `'web5-connection'`. + +##### **`keysLocation`** + +Controls where in `storage` the connection keys are stored. + +Defaults to `'web5-keys'`. + +##### **`silent`** + +Controls whether to prompt the user to start a new connection if a connection has not already been established. + +Defaults to `false`. + +##### **`permissionRequests`** + +Allows for restricting the scope of access to the underlying DWeb Node. + +If provided, `permissionRequests` must be an array of [`scope`](https://identity.foundation/decentralized-web-node/spec/#permissionsrequest) objects. + +Defaults to `undefined`. + +> NOTE: `permissionRequests` are not additive, so all desired permissions must be provided together. + +#### **Events** + +After calling `connect`, at least one of the following events will be dispatched on the `Web5` instance: + +##### **`'open'`** + +A `'open'` event is dispatched if a local identity app is found, containing a verification pin and port number used for the connection. + +- **`event.detail.pin`** - *`number`*: 1-4 digit numerical code that must be displayed to the user to ensure they are connecting to the desired local identity app. + +##### **`'update'`** + +A `'update'` event is dispatched if the user accepts the update. + +##### **`'close'`** + +A `'close'` event is dispatched if the user refuses to accept or respond to the update attempt for any reason. + +##### **`'error'`** + +An `'error'` event is dispached if anything goes wrong (e.g. `Web5` was unable to find any local identity apps). + +#### **Example** + +```javascript +document.querySelector('#connect_button').addEventListener('click', async event => { + + event.preventDefault(); + + const web5 = new Web5(); + + web5.addEventListener('open', (event) => { + const { pin } = event.detail + document.querySelector('#pin_code_text').textContent = pin; + }); + + web5.addEventListener('update', (event) => { + alert('Update succeeded!'); + }); + + web5.addEventListener('close', (event) => { + alert('Update was denied'); + }); + + web5.addEventListener('error', (event) => { + console.error(event); + }); + + web5.update({ + permissionRequests: [ + { + interface: 'Records', + method: 'Write', + protocol: 'https://decentralized-music.org/protocol' + }, + { + interface: 'Records', + method: 'Write', + protocol: 'https://decentralized-music.org/protocol', + schema: 'https://decentralized-music.org/protocol/playlist' + }, + { + interface: 'Records', + method: 'Write', + protocol: 'https://decentralized-music.org/protocol', + schema: 'https://decentralized-music.org/protocol/track' + } + ] + }); }); ``` diff --git a/src/Web5.js b/src/Web5.js index 09faab41a..fd4cd7772 100644 --- a/src/Web5.js +++ b/src/Web5.js @@ -200,6 +200,10 @@ class Web5 extends EventTarget { const permissionRequests = structuredClone(options?.permissionRequests); + if (!permissionRequests) { + throw 'must provide at least one of: permissionRequests'; + } + const connectionAlreadyExists = await this.#loadConnection({ storage, connectionLocation, keysLocation }); if (!connectionAlreadyExists) { throw 'must call connect() before calling update()'; From be9c9dbd47e673b45adbeee7fa56c1e4c2057a02 Mon Sep 17 00:00:00 2001 From: Devin Rousso Date: Thu, 6 Apr 2023 10:03:43 -0500 Subject: [PATCH 09/10] rename `update` to `changePermissions` for clarity --- README.md | 96 +++++++--------------- examples/test-dashboard/desktop-agent.html | 24 +++--- src/Web5.js | 50 +++++------ 3 files changed, 60 insertions(+), 110 deletions(-) diff --git a/README.md b/README.md index d86ec9da9..78e055207 100644 --- a/README.md +++ b/README.md @@ -322,57 +322,23 @@ document.querySelector('#connect_button').addEventListener('click', async event }); ``` -### **`web5.update(options)`** +### **`web5.changePermissions(permissionRequests)`** -Enables an app to update their connection to a user's local identity app, or update an in-app DID to represent the user (e.g. if the user does not have an identity app). +Enables an app to change the permissions of their connection to a user's local identity app, or an in-app DID to represent the user (e.g. if the user does not have an identity app). > NOTE: This method **_MUST_** be called _after_ `web5.connect()`. -> NOTE: At least one of the following **_MUST_** be provided: `permissionRequests`. - -#### **`options`** - -The `update` method optionally accepts an object with the following properties: - -##### **`storage`** - -Used by `update` to store connection data/keys/etc. for reuse when calling `update` again (e.g. during another session). - -If provided, `storage` must be an object that has the same methods as [`Storage`](/TBD54566975/web5-js/tree/main/src/storage/Storage.js). - -If not provided, an instance of [`LocalStorage`](/TBD54566975/web5-js/tree/main/src/storage/LocalStorage.js) is used instead. - -##### **`connectionLocation`** - -Controls where in `storage` the connection data is stored. - -Defaults to `'web5-connection'`. - -##### **`keysLocation`** - -Controls where in `storage` the connection keys are stored. - -Defaults to `'web5-keys'`. - -##### **`silent`** - -Controls whether to prompt the user to start a new connection if a connection has not already been established. - -Defaults to `false`. - -##### **`permissionRequests`** +#### **`permissionRequests`** Allows for restricting the scope of access to the underlying DWeb Node. -If provided, `permissionRequests` must be an array of [`scope`](https://identity.foundation/decentralized-web-node/spec/#permissionsrequest) objects. - -Defaults to `undefined`. +`permissionRequests` must be an array of [`scope`](https://identity.foundation/decentralized-web-node/spec/#permissionsrequest) objects. > NOTE: `permissionRequests` are not additive, so all desired permissions must be provided together. #### **Events** -After calling `connect`, at least one of the following events will be dispatched on the `Web5` instance: +After calling `changePermissions`, at least one of the following events will be dispatched on the `Web5` instance: ##### **`'open'`** @@ -380,13 +346,13 @@ A `'open'` event is dispatched if a local identity app is found, containing a ve - **`event.detail.pin`** - *`number`*: 1-4 digit numerical code that must be displayed to the user to ensure they are connecting to the desired local identity app. -##### **`'update'`** +##### **`'change'`** -A `'update'` event is dispatched if the user accepts the update. +A `'change'` event is dispatched if the user accepts the change. ##### **`'close'`** -A `'close'` event is dispatched if the user refuses to accept or respond to the update attempt for any reason. +A `'close'` event is dispatched if the user refuses to accept or respond to the change attempt for any reason. ##### **`'error'`** @@ -406,39 +372,37 @@ document.querySelector('#connect_button').addEventListener('click', async event document.querySelector('#pin_code_text').textContent = pin; }); - web5.addEventListener('update', (event) => { - alert('Update succeeded!'); + web5.addEventListener('change', (event) => { + alert('Change succeeded!'); }); web5.addEventListener('close', (event) => { - alert('Update was denied'); + alert('Change was denied'); }); web5.addEventListener('error', (event) => { console.error(event); }); - web5.update({ - permissionRequests: [ - { - interface: 'Records', - method: 'Write', - protocol: 'https://decentralized-music.org/protocol' - }, - { - interface: 'Records', - method: 'Write', - protocol: 'https://decentralized-music.org/protocol', - schema: 'https://decentralized-music.org/protocol/playlist' - }, - { - interface: 'Records', - method: 'Write', - protocol: 'https://decentralized-music.org/protocol', - schema: 'https://decentralized-music.org/protocol/track' - } - ] - }); + web5.changePermissions([ + { + interface: 'Records', + method: 'Write', + protocol: 'https://decentralized-music.org/protocol' + }, + { + interface: 'Records', + method: 'Write', + protocol: 'https://decentralized-music.org/protocol', + schema: 'https://decentralized-music.org/protocol/playlist' + }, + { + interface: 'Records', + method: 'Write', + protocol: 'https://decentralized-music.org/protocol', + schema: 'https://decentralized-music.org/protocol/track' + } + ]); }); ``` diff --git a/examples/test-dashboard/desktop-agent.html b/examples/test-dashboard/desktop-agent.html index 3049786c2..fdc8e94f7 100644 --- a/examples/test-dashboard/desktop-agent.html +++ b/examples/test-dashboard/desktop-agent.html @@ -247,7 +247,7 @@ myDid = event.detail.did; alert('Connection succeeded!'); }); - web5.addEventListener('update', (event) => { + web5.addEventListener('change', (event) => { alert('Update succeeded!'); }); web5.addEventListener('close', (event) => { @@ -273,18 +273,16 @@ update_button.addEventListener('click', async event => { event.preventDefault(); - web5.update({ - permissionRequests: [ - { interface: 'Records', method: 'Write', schema: 'foo/text' }, - { interface: 'Records', method: 'Query', schema: 'foo/text' }, - { interface: 'Records', method: 'Write', schema: 'foo/json' }, - { interface: 'Records', method: 'Query', schema: 'foo/json' }, - { interface: 'Records', method: 'Write', schema: 'foo/avatar' }, - { interface: 'Records', method: 'Query', schema: 'foo/avatar' }, - { interface: 'Records', method: 'Write', protocol: 'test', schema: 'test/post' }, - { interface: 'Records', method: 'Query', protocol: 'test', schema: 'test/post' } - ] - }); + web5.changePermissions([ + { interface: 'Records', method: 'Write', schema: 'foo/text' }, + { interface: 'Records', method: 'Query', schema: 'foo/text' }, + { interface: 'Records', method: 'Write', schema: 'foo/json' }, + { interface: 'Records', method: 'Query', schema: 'foo/json' }, + { interface: 'Records', method: 'Write', schema: 'foo/avatar' }, + { interface: 'Records', method: 'Query', schema: 'foo/avatar' }, + { interface: 'Records', method: 'Write', protocol: 'test', schema: 'test/post' }, + { interface: 'Records', method: 'Query', protocol: 'test', schema: 'test/post' } + ]); }); async function logConsole(obj, message = '') { diff --git a/src/Web5.js b/src/Web5.js index fd4cd7772..53739d6a9 100644 --- a/src/Web5.js +++ b/src/Web5.js @@ -18,8 +18,11 @@ class Web5 extends EventTarget { #did; #transports; + #storage = null; #keys = null; + #keysLocation = null; #connection = null; + #connectionLocation = null; constructor(options = { }) { super(); @@ -90,13 +93,13 @@ class Web5 extends EventTarget { } async connect(options = { }) { - const storage = options?.storage ?? new LocalStorage(); - const connectionLocation = options?.connectionLocation ?? 'web5-connection'; - const keysLocation = options?.keysLocation ?? 'web5-keys'; + this.#storage = options?.storage ?? new LocalStorage(); + this.#connectionLocation = options?.connectionLocation ?? 'web5-connection'; + this.#keysLocation = options?.keysLocation ?? 'web5-keys'; const permissionRequests = structuredClone(options?.permissionRequests); - const connectionAlreadyExists = await this.#loadConnection({ storage, connectionLocation, keysLocation }); + const connectionAlreadyExists = await this.#loadConnection(options); if (connectionAlreadyExists) { return; } @@ -148,7 +151,7 @@ class Web5 extends EventTarget { } this.#connection = connection; - await storage.set(connectionLocation, this.#connection); + await this.#storage.set(this.#connectionLocation, this.#connection); // Register DID on initial connection await this.#did.register({ @@ -193,21 +196,12 @@ class Web5 extends EventTarget { triggerProtocolHandler(`web5://connect/${this.#keys.encoded.publicKey}/${encodedOrigin}`); } - async update(options = { }) { - const storage = options?.storage ?? new LocalStorage(); - const connectionLocation = options?.connectionLocation ?? 'web5-connection'; - const keysLocation = options?.keysLocation ?? 'web5-keys'; - - const permissionRequests = structuredClone(options?.permissionRequests); - - if (!permissionRequests) { - throw 'must provide at least one of: permissionRequests'; + async changePermissions(permissionRequests) { + if (!this.#connection || !this.#keys) { + throw 'must call connect() before calling changePermissions()'; } - const connectionAlreadyExists = await this.#loadConnection({ storage, connectionLocation, keysLocation }); - if (!connectionAlreadyExists) { - throw 'must call connect() before calling update()'; - } + permissionRequests = structuredClone(permissionRequests); function destroySocket() { socket.close(); @@ -234,9 +228,9 @@ class Web5 extends EventTarget { } this.#connection = { ...this.#connection, connection }; - await storage.set(connectionLocation, this.#connection); + await this.#storage.set(this.#connectionLocation, this.#connection); - this.dispatchEvent(new CustomEvent('update', { detail: this.#connection })); + this.dispatchEvent(new CustomEvent('change', { detail: this.#connection })); } catch { this.dispatchEvent(new CustomEvent('close')); } @@ -330,15 +324,12 @@ class Web5 extends EventTarget { return true; } - async #loadKeys(options) { - const storage = options.storage; - const keysLocation = options.keysLocation; - + async #loadKeys(options = { }) { if (this.#keys) { return true; } - const encoded = await storage.get(keysLocation); + const encoded = await this.#storage.get(this.#keysLocation); if (encoded) { this.#keys = { encoded, @@ -359,16 +350,13 @@ class Web5 extends EventTarget { }, decoded, }; - await storage.set(keysLocation, this.#keys.encoded); + await this.#storage.set(this.#keysLocation, this.#keys.encoded); } return false; } - async #loadConnection(options) { - const storage = options.storage; - const connectionLocation = options.connectionLocation; - + async #loadConnection(options = { }) { if (this.#connection) { return true; } @@ -378,7 +366,7 @@ class Web5 extends EventTarget { return false; } - this.#connection = await storage.get(connectionLocation); + this.#connection = await this.#storage.get(this.#connectionLocation); if (!this.#connection) { return false; } From 3926ba28d50941b5c0b001596635a3322621faa9 Mon Sep 17 00:00:00 2001 From: Devin Rousso Date: Mon, 10 Apr 2023 14:06:06 -0600 Subject: [PATCH 10/10] add examples for human readable `protocol`/`schema` --- examples/test-dashboard/desktop-agent.html | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/examples/test-dashboard/desktop-agent.html b/examples/test-dashboard/desktop-agent.html index fdc8e94f7..0cf02c480 100644 --- a/examples/test-dashboard/desktop-agent.html +++ b/examples/test-dashboard/desktop-agent.html @@ -281,7 +281,13 @@ { interface: 'Records', method: 'Write', schema: 'foo/avatar' }, { interface: 'Records', method: 'Query', schema: 'foo/avatar' }, { interface: 'Records', method: 'Write', protocol: 'test', schema: 'test/post' }, - { interface: 'Records', method: 'Query', protocol: 'test', schema: 'test/post' } + { interface: 'Records', method: 'Query', protocol: 'test', schema: 'test/post' }, + + // tests for permissions UI + { interface: 'Records', method: 'Write', schema: 'https://json-schema.org/learn/examples/address.schema.json' }, + { interface: 'Records', method: 'Write', schema: 'https://json-schema.org/learn/examples/calendar.schema.json' }, + { interface: 'Records', method: 'Write', schema: 'https://json-schema.org/learn/examples/card.schema.json' }, + { interface: 'Records', method: 'Write', schema: 'https://json-schema.org/learn/examples/geographical-location.schema.json' } ]); });