From d19cf0f24a197a4683a653c9b6d6ee5d415e036a Mon Sep 17 00:00:00 2001 From: Marius David Wieschollek Date: Sun, 19 Nov 2023 14:25:33 +0100 Subject: Add EncryptionService and MODEL_TYPE Signed-off-by: Marius David Wieschollek --- package.json | 23 +++--- src/ClassLoader/BasicClassLoader.js | 2 - src/ClassLoader/DefaultClassLoader.js | 2 +- src/Collection/AbstractCollection.js | 57 +++++++++++++++ src/Encryption/CSEv1Encryption.js | 6 +- src/Event/EventEmitter.js | 127 +++++++++++++++++++++++++++++++++ src/Model/CustomField/AbstractField.js | 2 + src/Model/Folder/Folder.js | 2 + src/Model/Password/Password.js | 2 + src/Model/Server/Server.js | 2 + src/Model/Session/Session.js | 2 + src/Model/Setting/Setting.js | 2 + src/Model/Tag/Tag.js | 2 + src/Services/EncryptionService.js | 91 +++++++++++++++++++++++ 14 files changed, 306 insertions(+), 16 deletions(-) create mode 100644 src/Event/EventEmitter.js create mode 100644 src/Services/EncryptionService.js diff --git a/package.json b/package.json index 25e4258..317fdd9 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,21 @@ { "name": "passwords-client", - "version": "1.0.0.BUILD", + "version": "1.0.0", "description": "JS client library for the Nextcloud Passwords app", "main": "src/main.js", "exports": { - ".": "src/main.js", - "./models": "src/models.js", - "./errors": "src/errors.js", - "./utility": "src/utility.js", - "./boolean-state": "src/State/BooleanState.js", - "./basic-class-loader": "src/ClassLoader/BasicClassLoader.js", - "./default-class-loader": "src/ClassLoader/DefaultClassLoader.js", - "./enhanced-class-loader": "src/ClassLoader/EnhancedClassLoader.js" + ".": "./src/main.js", + "./models": "./src/models.js", + "./errors": "./src/errors.js", + "./utility": "./src/utility.js", + "./passlink": "./src/PassLink/PassLink", + "./boolean-state": "./src/State/BooleanState.js", + "./event-emitter": "./src/Event/EventEmitter.js", + "./client": "./src/Client/PasswordsClient.js", + "./basic-client": "./src/Client/BasicPasswordsClient.js", + "./basic-class-loader": "./src/ClassLoader/BasicClassLoader.js", + "./default-class-loader": "./src/ClassLoader/DefaultClassLoader.js", + "./enhanced-class-loader": "./src/ClassLoader/EnhancedClassLoader.js" }, "author": "Marius Wieschollek", "license": "ISC", @@ -26,7 +30,6 @@ "url": "https://git.mdns.eu/nextcloud/passwords-client.git" }, "dependencies": { - "eventemitter3": "^4.0.7", "libsodium": "0.7.10", "libsodium-wrappers": "0.7.10", "url-parse": "^1.5.3", diff --git a/src/ClassLoader/BasicClassLoader.js b/src/ClassLoader/BasicClassLoader.js index d4ef1e8..43b9aa2 100644 --- a/src/ClassLoader/BasicClassLoader.js +++ b/src/ClassLoader/BasicClassLoader.js @@ -1,5 +1,3 @@ -import ObjectMerger from "../Utility/ObjectMerger"; - export default class BasicClassLoader { constructor(classes = {}) { diff --git a/src/ClassLoader/DefaultClassLoader.js b/src/ClassLoader/DefaultClassLoader.js index 00ff4b3..16ff9ec 100644 --- a/src/ClassLoader/DefaultClassLoader.js +++ b/src/ClassLoader/DefaultClassLoader.js @@ -36,7 +36,6 @@ import ExportV1Encryption from "../Encryption/ExportV1Encryption"; import CSEv1Keychain from "../Encryption/Keychain/CSEv1Keychain"; import Cache from "../Cache/Cache"; import BooleanState from "../State/BooleanState"; -import EventEmitter from "eventemitter3"; import ResponseContentTypeError from "../Exception/ResponseContentTypeError"; import ResponseDecodingError from "../Exception/ResponseDecodingError"; import UnknownPropertyError from "../Exception/UnknownPropertyError"; @@ -67,6 +66,7 @@ import HashService from "../Services/HashService"; import Logger from "../Logger/Logger"; import DefectField from "../Model/CustomField/DefectField"; import PreconditionFailedError from "../Exception/Http/PreconditionFailedError"; +import EventEmitter from "../Event/EventEmitter"; export default class DefaultClassLoader extends BasicClassLoader { diff --git a/src/Collection/AbstractCollection.js b/src/Collection/AbstractCollection.js index d7de25c..ac00ccb 100644 --- a/src/Collection/AbstractCollection.js +++ b/src/Collection/AbstractCollection.js @@ -81,6 +81,63 @@ export default class AbstractCollection { return null; } + /** + * Get the first item from the collection + * + * @returns {AbstractModel|null} + */ + first() { + if(this._elements.length === 0) return null; + return this._elements[0]; + } + + /** + * Get the last item from the collection + * + * @returns {AbstractModel|null} + */ + last() { + if(this._elements.length === 0) return null; + return this._elements[this._elements.length - 1]; + } + + /** + * Applies the callback to every item in the collection and returns the result + * + * @param {Function} callback + * @returns {Array<*>} + */ + map(callback) { + let items = [], + collection = this.getReference(); + + for(let item of collection) { + items.push(callback(item)); + } + + return items; + } + + /** + * Get a reference to the internal items array + * + * @returns {AbstractModel[]} + * @api + */ + getReference() { + return this._elements; + } + + /** + * Get a clone of the internal items array + * + * @returns {AbstractModel[]} + * @api + */ + getClone() { + return this._elements.slice(0); + } + /** * * @param {(AbstractModel|AbstractModel[])} elements diff --git a/src/Encryption/CSEv1Encryption.js b/src/Encryption/CSEv1Encryption.js index 10e29a9..9005947 100644 --- a/src/Encryption/CSEv1Encryption.js +++ b/src/Encryption/CSEv1Encryption.js @@ -50,7 +50,7 @@ export default class CSEv1Encryption { key = this._keychain.getCurrentKey(); for(let field of fields) { - let data = object[field]; + let data = object[field]; if(data === null || data === undefined || data.length === 0) continue; object[field] = this._encryptString(data, key); @@ -78,7 +78,7 @@ export default class CSEv1Encryption { key = this._keychain.getKey(object.cseKey); for(let field of fields) { - let data = object[field]; + let data = object[field]; if(data === null || data.length === 0) continue; object[field] = this._decryptString(data, key); @@ -120,7 +120,7 @@ export default class CSEv1Encryption { /** * Encrypt the message with the given key * - * @param {Uint8Array} message + * @param {String} message * @param {Uint8Array} key * @returns {Uint8Array} * @private diff --git a/src/Event/EventEmitter.js b/src/Event/EventEmitter.js new file mode 100644 index 0000000..3a6db25 --- /dev/null +++ b/src/Event/EventEmitter.js @@ -0,0 +1,127 @@ +export default class EventEmitter { + + constructor() { + this._listeners = {}; + this._once = {}; + } + + /** + * @param {String|String[]} event + * @param {Object} data + * + * @return {Promise} + */ + async emit(event, data) { + if(Array.isArray(event)) { + event.forEach((event) => {this.emit(event, data);}); + return; + } + + await this._notifyListeners(event, data); + await this._notifyOnce(event, data); + } + + /** + * @param {String|String[]} event + * @param {Function} callback + * + * @return EventEmitter + */ + on(event, callback) { + if(Array.isArray(event)) { + event.forEach((event) => {this.on(event, callback);}); + return this; + } + + if(!this._listeners.hasOwnProperty(event)) { + this._listeners[event] = []; + } + + this._listeners[event].push(callback); + + return this; + } + + /** + * @param {String|String[]} event + * @param {Function} callback + * + * @return EventEmitter + */ + off(event, callback) { + if(Array.isArray(event)) { + event.forEach((event) => {this.off(event, callback);}); + return this; + } + + if(!this._listeners.hasOwnProperty(event)) { + return this; + } + + for(let i = 0; i < this._listeners[event].length; i++) { + if(this._listeners[event][i] === callback) { + this._listeners[event].splice(i, 1); + i--; + } + } + + return this; + } + + /** + * @param {String} event + * @param {Function} callback + * + * @return EventEmitter + */ + once(event, callback) { + if(!this._once.hasOwnProperty(event)) { + this._once[event] = []; + } + + this._once[event].push(callback); + + return this; + } + + /** + * @param {String} event + * @param {Object} data + * @return {Promise} + * @private + */ + async _notifyListeners(event, data) { + if(!this._listeners.hasOwnProperty(event)) { + return; + } + + for(let callback of this._listeners[event]) { + try { + await callback(data); + } catch(e) { + console.error(e); + } + } + } + + /** + * @param {String} event + * @param {Object} data + * @return {Promise} + * @private + */ + async _notifyOnce(event, data) { + if(!this._once.hasOwnProperty(event)) { + return; + } + + let callback; + while(callback = this._once[event].pop()) { + try { + await callback(data); + } catch(e) { + console.error(e); + } + } + } +} \ No newline at end of file diff --git a/src/Model/CustomField/AbstractField.js b/src/Model/CustomField/AbstractField.js index d1576d7..cd6c250 100644 --- a/src/Model/CustomField/AbstractField.js +++ b/src/Model/CustomField/AbstractField.js @@ -2,6 +2,8 @@ import AbstractModel from '../AbstractModel'; export default class AbstractField extends AbstractModel { + get MODEL_TYPE() {return 'custom-field';} + /** * @param {String} value */ diff --git a/src/Model/Folder/Folder.js b/src/Model/Folder/Folder.js index 46c4b76..531629e 100644 --- a/src/Model/Folder/Folder.js +++ b/src/Model/Folder/Folder.js @@ -3,6 +3,8 @@ import AbstractRevisionModel from '../AbstractRevisionModel'; export default class Folder extends AbstractRevisionModel { + get MODEL_TYPE() {return 'folder';} + /** * * @param {Object} [data={}] diff --git a/src/Model/Password/Password.js b/src/Model/Password/Password.js index da2692f..1a64a7d 100644 --- a/src/Model/Password/Password.js +++ b/src/Model/Password/Password.js @@ -3,6 +3,8 @@ import AbstractRevisionModel from '../AbstractRevisionModel'; export default class Password extends AbstractRevisionModel { + get MODEL_TYPE() {return 'password';} + /** * * @param {Object} [data={}] diff --git a/src/Model/Server/Server.js b/src/Model/Server/Server.js index 6c36ba3..792ac6b 100644 --- a/src/Model/Server/Server.js +++ b/src/Model/Server/Server.js @@ -5,6 +5,8 @@ import ObjectMerger from '../../Utility/ObjectMerger'; export default class Server extends AbstractModel { + get MODEL_TYPE() {return 'server';} + /** * * @param {Object} data diff --git a/src/Model/Session/Session.js b/src/Model/Session/Session.js index 1a2bcf4..b7653f6 100644 --- a/src/Model/Session/Session.js +++ b/src/Model/Session/Session.js @@ -1,5 +1,7 @@ export default class Session { + get MODEL_TYPE() {return 'session';} + constructor(user = null, token = null, id = null, authorized = false) { this._user = user; this._token = token; diff --git a/src/Model/Setting/Setting.js b/src/Model/Setting/Setting.js index e790f89..4d4d65d 100644 --- a/src/Model/Setting/Setting.js +++ b/src/Model/Setting/Setting.js @@ -2,6 +2,8 @@ import InvalidScopeError from '../../Exception/InvalidScopeError'; export default class Setting { + get MODEL_TYPE() {return 'setting';} + /** * @return {String} */ diff --git a/src/Model/Tag/Tag.js b/src/Model/Tag/Tag.js index 1cff8b7..b8f851e 100644 --- a/src/Model/Tag/Tag.js +++ b/src/Model/Tag/Tag.js @@ -3,6 +3,8 @@ import AbstractRevisionModel from '../AbstractRevisionModel'; export default class Tag extends AbstractRevisionModel { + get MODEL_TYPE() {return 'tag';} + /** * * @param {Object} [data={}] diff --git a/src/Services/EncryptionService.js b/src/Services/EncryptionService.js new file mode 100644 index 0000000..7f81498 --- /dev/null +++ b/src/Services/EncryptionService.js @@ -0,0 +1,91 @@ +import sodium from 'libsodium-wrappers'; + +export default class EncryptionService { + + constructor() { + } + + /** + * Encrypt the message with the given key and return a hex encoded string + * + * @param {String} message + * @param {String} key + * @returns {String} + * @private + */ + encrypt(message, passphrase) { + let {key, salt} = this._passwordToKey(passphrase); + let encrypted = this._encrypt(message, key); + + return sodium.to_hex(new Uint8Array([...salt, ...encrypted])); + } + + /** + * Decrypt the hex or base64 encoded message with the given key + * + * @param {String} encodedString + * @param {Uint8Array} key + * @returns {String} + * @private + */ + _decryptString(encodedString, passphrase) { + let salt = encodedString.slice(0, sodium.crypto_pwhash_SALTBYTES), + text = encodedString.slice(sodium.crypto_pwhash_SALTBYTES), + key = this._passwordToKey(passphrase, salt); + return sodium.to_string(this._decrypt(text, key)); + } + + /** + * Encrypt the message with the given key + * + * @param {String} message + * @param {String} key + * @returns {Uint8Array} + * @private + */ + _encrypt(message, key) { + let nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); + + return new Uint8Array([...nonce, ...sodium.crypto_secretbox_easy(message, nonce, key)]); + } + + /** + * Decrypt the message with the given key + * + * @param {Uint8Array} encrypted + * @param {Uint8Array} key + * @returns {Uint8Array} + * @private + */ + _decrypt(encrypted, key) { + if(encrypted.length < sodium.crypto_secretbox_NONCEBYTES + sodium.crypto_secretbox_MACBYTES) throw new Error('Invalid encrypted text length'); + + let nonce = encrypted.slice(0, sodium.crypto_secretbox_NONCEBYTES), + ciphertext = encrypted.slice(sodium.crypto_secretbox_NONCEBYTES); + + return sodium.crypto_secretbox_open_easy(ciphertext, nonce, key); + } + + // noinspection JSMethodCanBeStatic + /** + * + * @param password + * @param salt + * @returns {Uint8Array} + * @private + */ + _passwordToKey(password) { + let salt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES); + + let key = sodium.crypto_pwhash( + sodium.crypto_box_SEEDBYTES, + password, + salt, + sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE, + sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE, + sodium.crypto_pwhash_ALG_DEFAULT + ); + + return {key, salt}; + } +} \ No newline at end of file -- cgit v1.2.3 From 33eee91ccb2aa40e0aefb7c4a4045f3fba677bc8 Mon Sep 17 00:00:00 2001 From: Marius David Wieschollek Date: Sun, 19 Nov 2023 14:30:45 +0100 Subject: Fix versioning Signed-off-by: Marius David Wieschollek --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 317fdd9..7a3f6f6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "passwords-client", - "version": "1.0.0", + "version": "1.0.0.BUILD", "description": "JS client library for the Nextcloud Passwords app", "main": "src/main.js", "exports": { -- cgit v1.2.3 From 4f0186c60d9351c58659d69c5f2db103ec9ac3f2 Mon Sep 17 00:00:00 2001 From: Marius David Wieschollek Date: Sat, 23 Dec 2023 20:41:39 +0100 Subject: check HTTP status code first Signed-off-by: Marius David Wieschollek --- src/Network/ApiRequest.js | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/src/Network/ApiRequest.js b/src/Network/ApiRequest.js index a4ce535..774189c 100644 --- a/src/Network/ApiRequest.js +++ b/src/Network/ApiRequest.js @@ -128,11 +128,19 @@ export default class ApiRequest { this._updateSessionId(httpResponse); + if(!httpResponse.ok) { + let error = this._getHttpError(httpResponse); + this._api.emit('request.error', error); + throw error; + } + if(this._responseType !== null && contentType && contentType.indexOf(this._responseType) === -1) { let error = this._api.getClass('exception.contenttype', this._responseType, contentType, httpResponse); this._api.emit('request.error', error); throw error; - } else if(contentType && contentType.indexOf('application/json') !== -1) { + } + + if(contentType && contentType.indexOf('application/json') !== -1) { await this._processJsonResponse(httpResponse, response); } else { await this._processBinaryResponse(httpResponse, response); @@ -237,12 +245,6 @@ export default class ApiRequest { * @private */ async _processJsonResponse(httpResponse, response) { - if(!httpResponse.ok) { - let error = this._getHttpError(httpResponse); - this._api.emit('request.error', error); - throw error; - } - try { let json = await httpResponse.json(); response.setData(json); @@ -260,12 +262,6 @@ export default class ApiRequest { * @private */ async _processBinaryResponse(httpResponse, response) { - if(!httpResponse.ok) { - let error = this._getHttpError(httpResponse); - this._api.emit('request.error', error); - throw error; - } - try { let blob = await httpResponse.blob(); response.setData(blob); -- cgit v1.2.3