diff --git a/README.md b/README.md index cf67b5509..78e055207 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,99 @@ 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.changePermissions(permissionRequests)`** + +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()`. + +#### **`permissionRequests`** + +Allows for restricting the scope of access to the underlying DWeb Node. + +`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 `changePermissions`, 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. + +##### **`'change'`** + +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 change 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('change', (event) => { + alert('Change succeeded!'); + }); + + web5.addEventListener('close', (event) => { + alert('Change was denied'); + }); + + web5.addEventListener('error', (event) => { + console.error(event); + }); + + 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 09e21d596..0cf02c480 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('change', (event) => { + alert('Update succeeded!'); + }); web5.addEventListener('close', (event) => { alert('Connection was denied'); }); @@ -256,7 +260,35 @@ connect_button.addEventListener('click', async event => { event.preventDefault(); - web5.connect(); + web5.connect({ + permissionRequests: [ + { 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' } + ] + }); + }); + + update_button.addEventListener('click', async event => { + event.preventDefault(); + + 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' }, + + // 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' } + ]); }); async function logConsole(obj, message = '') { diff --git a/src/Web5.js b/src/Web5.js index 064bcda24..53739d6a9 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'; @@ -18,8 +18,11 @@ class Web5 extends EventTarget { #did; #transports; + #storage = null; #keys = null; + #keysLocation = null; #connection = null; + #connectionLocation = null; constructor(options = { }) { super(); @@ -90,24 +93,14 @@ 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'; - if (this.#connection) { - return; - } + const permissionRequests = structuredClone(options?.permissionRequests); - 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 })); + const connectionAlreadyExists = await this.#loadConnection(options); + if (connectionAlreadyExists) { return; } @@ -115,37 +108,11 @@ class Web5 extends EventTarget { 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), - }, - }; - } 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); - } - } - - 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); socket.removeEventListener('error', handleError); - socket.removeEventListener('message', handleMessage); + socket.removeEventListener('notification', handleNotification); } const removeSocket = (socket) => { @@ -160,7 +127,7 @@ class Web5 extends EventTarget { function handleOpen(event) { const socket = event.target; - socket.addEventListener('message', handleMessage); + socket.addEventListener('notification', handleNotification); } function handleError(event) { @@ -169,73 +136,128 @@ class Web5 extends EventTarget { removeSocket(socket); } - const handleMessage = async (event) => { + const handleNotification = async (event) => { const socket = event.target; + const { method, params } = event.detail; + + 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 = connection; + await this.#storage.set(this.#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.#keys.encoded.publicKey}`, + }); + + this.dispatchEvent(new CustomEvent('connection', { detail: this.#connection })); + } catch { + this.dispatchEvent(new CustomEvent('close')); + } - const json = parseJSON(event.data); + sockets.forEach(destroySocket); + sockets.clear(); + return; - switch (json?.type) { - case 'connected': - if (!json.data) { + case 'show-pin': + if (!this.#decodePinAndDispatchOpenEvent(params)) { + // stop listening to this socket as it is missing required data removeSocket(socket); - return; } + return; + } + + // stop listening to this socket as it has sent us unexpected data + removeSocket(socket); + }; + + const sockets = new Set(); + for (let port = 55_500; port <= 55_600; ++port) { + const socket = new JSONRPCSocket(); + sockets.add(socket); - this.#connection = json.data; - await storage.set(connectionLocation, this.#connection); + socket.addEventListener('open', handleOpen); + socket.addEventListener('error', handleError); - // Register DID on initial connection - await this.#did.register({ - connected: true, - did: this.#connection.did, - endpoint: `http://localhost:${this.#connection.port}/dwn`, - }); + socket.open(`ws://localhost:${port}/connections/${this.#keys.encoded.publicKey}`); + } - this.dispatchEvent(new CustomEvent('connection', { detail: this.#connection })); - break; + const encodedOrigin = this.#dwn.SDK.Encoder.stringToBase64Url(location.origin); + triggerProtocolHandler(`web5://connect/${this.#keys.encoded.publicKey}/${encodedOrigin}`); + } - case 'requested': - if (!json.data) { - removeSocket(socket); - return; - } + async changePermissions(permissionRequests) { + if (!this.#connection || !this.#keys) { + throw 'must call connect() before calling changePermissions()'; + } - try { - await decodePin(json.data, this.#keys.decoded.secretKey); - } catch { - removeSocket(socket); + permissionRequests = structuredClone(permissionRequests); + + 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.dispatchEvent(new CustomEvent('open', { detail: json.data })); - return; + this.#connection = { ...this.#connection, connection }; + await this.#storage.set(this.#connectionLocation, this.#connection); - case 'blocked': - case 'denied': - case 'closed': + this.dispatchEvent(new CustomEvent('change', { detail: this.#connection })); + } catch { this.dispatchEvent(new CustomEvent('close')); - break; + } - case 'unknown': - return; + destroySocket(); + }; - default: - removeSocket(socket); + 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; } - sockets.forEach(destroySocket); - sockets.clear(); + // stop listening to this socket as it has sent us unexpected data + removeSocket(); }; - 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}`); - sockets.add(socket); - - socket.addEventListener('open', handleOpen); - socket.addEventListener('error', handleError); - } + 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) { @@ -286,6 +308,79 @@ 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 = { }) { + if (this.#keys) { + return true; + } + + const encoded = await this.#storage.get(this.#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 this.#storage.set(this.#keysLocation, this.#keys.encoded); + } + + return false; + } + + async #loadConnection(options = { }) { + if (this.#connection) { + return true; + } + + const keysAlreadyExist = await this.#loadKeys(options); + if (!keysAlreadyExist) { + return false; + } + + this.#connection = await this.#storage.get(this.#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 { 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, +};