diff --git a/setup/vault/odoo/addons/vault b/setup/vault/odoo/addons/vault new file mode 120000 index 0000000000..4ead31e201 --- /dev/null +++ b/setup/vault/odoo/addons/vault @@ -0,0 +1 @@ +../../../../vault \ No newline at end of file diff --git a/setup/vault/setup.py b/setup/vault/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/vault/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/setup/vault_share/odoo/addons/vault_share b/setup/vault_share/odoo/addons/vault_share new file mode 120000 index 0000000000..d8d730a844 --- /dev/null +++ b/setup/vault_share/odoo/addons/vault_share @@ -0,0 +1 @@ +../../../../vault_share \ No newline at end of file diff --git a/setup/vault_share/setup.py b/setup/vault_share/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/vault_share/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/vault/README.rst b/vault/README.rst index 1848e73c9c..50b4c38e64 100644 --- a/vault/README.rst +++ b/vault/README.rst @@ -7,7 +7,7 @@ Vault !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:e0b69ed2cd488c2635fec51457c0cb50c1bbd628cc768e68cd8d1d80c944ce2e + !! source digest: sha256:f5ab04a25cc568dea3db60cecb5e549d44da1d67f710b01698316ebfaaa79d28 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png @@ -17,13 +17,13 @@ Vault :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--auth-lightgray.png?logo=github - :target: https://github.com/OCA/server-auth/tree/15.0/vault + :target: https://github.com/OCA/server-auth/tree/16.0/vault :alt: OCA/server-auth .. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png - :target: https://translation.odoo-community.org/projects/server-auth-15-0/server-auth-15-0-vault + :target: https://translation.odoo-community.org/projects/server-auth-16-0/server-auth-16-0-vault :alt: Translate me on Weblate .. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png - :target: https://runboat.odoo-community.org/builds?repo=OCA/server-auth&target_branch=15.0 + :target: https://runboat.odoo-community.org/builds?repo=OCA/server-auth&target_branch=16.0 :alt: Try me on Runboat |badge1| |badge2| |badge3| |badge4| |badge5| @@ -65,7 +65,7 @@ Bug Tracker Bugs are tracked on `GitHub Issues `_. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed -`feedback `_. +`feedback `_. Do not contact contributors directly about support or help with technical issues. @@ -95,6 +95,6 @@ OCA, or the Odoo Community Association, is a nonprofit organization whose mission is to support the collaborative development of Odoo features and promote its widespread use. -This module is part of the `OCA/server-auth `_ project on GitHub. +This module is part of the `OCA/server-auth `_ project on GitHub. You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/vault/__manifest__.py b/vault/__manifest__.py index 09eb85ea28..2585046f3a 100644 --- a/vault/__manifest__.py +++ b/vault/__manifest__.py @@ -5,7 +5,7 @@ "name": "Vault", "summary": "Password vault integration in Odoo", "license": "AGPL-3", - "version": "15.0.2.1.0", + "version": "16.0.2.1.0", "website": "https://github.com/OCA/server-auth", "application": True, "author": "initOS GmbH, Odoo Community Association (OCA)", @@ -38,14 +38,10 @@ ], "web.assets_backend": [ "vault/static/lib/**/*.min.js", + "vault/static/src/**/*.xml", "vault/static/src/common/*.js", "vault/static/src/backend/*.scss", - "vault/static/src/backend/*.js", - "vault/static/src/legacy/vault_controller.js", - "vault/static/src/legacy/vault_widget.js", - ], - "web.assets_qweb": [ - "vault/static/src/**/*.xml", + "vault/static/src/backend/**/*.js", ], "web.tests_assets": [ "vault/static/tests/**/*.js", diff --git a/vault/controllers/main.py b/vault/controllers/main.py index c7ec7e70a8..1ca361dda3 100644 --- a/vault/controllers/main.py +++ b/vault/controllers/main.py @@ -4,6 +4,7 @@ import logging from odoo import _, http +from odoo.exceptions import AccessDenied from odoo.http import request _logger = logging.getLogger(__name__) @@ -136,6 +137,9 @@ def vault_replace(self, data): vault = request.env["vault"].with_context(vault_skip_log=True) for changes in data: record = vault.env[changes["model"]].browse(changes["id"]) + if not record.vault_id.allowed_write: + raise AccessDenied() + vault |= record.vault_id if record._name in ("vault.field", "vault.file"): record.write({k: v for k, v in changes.items() if k in ["iv", "value"]}) diff --git a/vault/models/abstract_vault_field.py b/vault/models/abstract_vault_field.py index 65c42ca55b..691d7b7d03 100644 --- a/vault/models/abstract_vault_field.py +++ b/vault/models/abstract_vault_field.py @@ -33,29 +33,25 @@ def _compute_master_key(self): rec.master_key = rec.vault_id.master_key def log_change(self, action): - self.ensure_one() if self.env.context.get("vault_skip_log"): return - self.entry_id.log_info( - f"{action} value {self.name} of {self.entry_id.complete_name} " - f"by {self.env.user.display_name}" - ) - - @api.model_create_single - def create(self, values): - res = super().create(values) + for rec in self: + rec.entry_id.log_info( + f"{action} value {rec.name} of {rec.entry_id.complete_name} " + f"by {self.env.user.display_name}" + ) + + @api.model_create_multi + def create(self, vals_list): + res = super().create(vals_list) res.log_change("Created") return res def unlink(self): - for rec in self: - rec.log_change("Deleted") - + self.log_change("Deleted") return super().unlink() def write(self, values): - for rec in self: - rec.log_change("Changed") - + self.log_change("Changed") return super().write(values) diff --git a/vault/models/vault.py b/vault/models/vault.py index 8d6064c51e..5aef99f230 100644 --- a/vault/models/vault.py +++ b/vault/models/vault.py @@ -1,4 +1,4 @@ -# © 2021 Florian Kantelberg - initOS GmbH +# © 2021-2024 Florian Kantelberg - initOS GmbH # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). import logging diff --git a/vault/models/vault_entry.py b/vault/models/vault_entry.py index 03677f0359..1f553c3561 100644 --- a/vault/models/vault_entry.py +++ b/vault/models/vault_entry.py @@ -140,28 +140,27 @@ def _search_expired(self, operator, value): return ["|", ("expire_date", ">=", datetime.now()), ("expire_date", "=", False)] def log_change(self, action): - self.ensure_one() if self.env.context.get("vault_skip_log"): return - self.log_info( - _("%(action)s entry %(name)s by %(user)s") - % { - "action": action, - "name": self.complete_name, - "user": self.env.user.display_name, - } - ) + for rec in self: + rec.log_info( + _("%(action)s entry %(name)s by %(user)s") + % { + "action": action, + "name": rec.complete_name, + "user": rec.env.user.display_name, + } + ) - @api.model_create_single - def create(self, values): - res = super().create(values) + @api.model_create_multi + def create(self, vals_list): + res = super().create(vals_list) res.log_change("Created") return res def unlink(self): - for rec in self: - rec.log_change("Deleted") + self.log_change("Deleted") return super().unlink() diff --git a/vault/models/vault_log.py b/vault/models/vault_log.py index 9189dd122d..3f44569c53 100644 --- a/vault/models/vault_log.py +++ b/vault/models/vault_log.py @@ -38,9 +38,9 @@ def _get_log_state(self): ("error", _("Error")), ] - @api.model - def create(self, values): - res = super().create(values) + @api.model_create_multi + def create(self, vals_list): + res = super().create(vals_list) if not self.env.context.get("skip_log", False): _logger.info("Vault log: %s", res.message) return res diff --git a/vault/models/vault_right.py b/vault/models/vault_right.py index 287a69961b..1f9fc3bc77 100644 --- a/vault/models/vault_right.py +++ b/vault/models/vault_right.py @@ -69,25 +69,25 @@ def _compute_public_key(self): rec.public_key = rec.user_id.active_key.public def log_access(self): - self.ensure_one() - rights = ", ".join( - sorted( - ["read"] - + [ - right - for right in ["create", "write", "share", "delete"] - if getattr(self, f"perm_{right}", False) - ] + for rec in self: + rights = ", ".join( + sorted( + ["read"] + + [ + right + for right in ["create", "write", "share", "delete"] + if getattr(rec, f"perm_{right}", False) + ] + ) ) - ) - self.vault_id.log_info( - f"Grant access to user {self.user_id.display_name}: {rights}" - ) + rec.vault_id.log_info( + f"Grant access to user {rec.user_id.display_name}: {rights}" + ) - @api.model - def create(self, values): - res = super().create(values) + @api.model_create_multi + def create(self, vals_list): + res = super().create(vals_list) if not res.allowed_share and not res.env.su: self.raise_access_error() @@ -98,8 +98,7 @@ def write(self, values): res = super().write(values) perms = ["perm_write", "perm_delete", "perm_share", "perm_create"] if any(x in values for x in perms): - for rec in self: - rec.log_access() + self.log_access() return res diff --git a/vault/static/description/index.html b/vault/static/description/index.html index 4cc4272298..475b75d97a 100644 --- a/vault/static/description/index.html +++ b/vault/static/description/index.html @@ -367,9 +367,9 @@

Vault

!! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -!! source digest: sha256:e0b69ed2cd488c2635fec51457c0cb50c1bbd628cc768e68cd8d1d80c944ce2e +!! source digest: sha256:f5ab04a25cc568dea3db60cecb5e549d44da1d67f710b01698316ebfaaa79d28 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! --> -

Beta License: AGPL-3 OCA/server-auth Translate me on Weblate Try me on Runboat

+

Beta License: AGPL-3 OCA/server-auth Translate me on Weblate Try me on Runboat

This module implements a vault for secrets and files using end-to-end-encryption. The encryption and decryption happens in the browser using a vault specific shared master key. The master keys are encrypted using asymmetrically. For this the user has to enter a second password on the first login or if he needs to access data in a vault. The asymmetric keys are stored for a certain time in the browser storage.

The server can never access the secrets with the information available. Only people registered in the vault can decrypt or encrypt values in a vault. The meta data isn’t encrypted to be able to search/filter for entries more easily.

This modules requires a secure context for the browser to work properly.

@@ -413,7 +413,7 @@

Bug Tracker

Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed -feedback.

+feedback.

Do not contact contributors directly about support or help with technical issues.

@@ -437,7 +437,7 @@

Maintainers

OCA, or the Odoo Community Association, is a nonprofit organization whose mission is to support the collaborative development of Odoo features and promote its widespread use.

-

This module is part of the OCA/server-auth project on GitHub.

+

This module is part of the OCA/server-auth project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

diff --git a/vault/static/src/backend/controller.esm.js b/vault/static/src/backend/controller.esm.js new file mode 100644 index 0000000000..3310fd0d2b --- /dev/null +++ b/vault/static/src/backend/controller.esm.js @@ -0,0 +1,382 @@ +/** @odoo-module alias=vault.controller **/ +// © 2021-2024 Florian Kantelberg - initOS GmbH +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import Dialog from "web.Dialog"; +import {FormController} from "@web/views/form/form_controller"; +import Importer from "vault.import"; +import {_lt} from "@web/core/l10n/translation"; +import framework from "web.framework"; +import {patch} from "@web/core/utils/patch"; +import {useService} from "@web/core/utils/hooks"; +import utils from "vault.utils"; +import vault from "vault"; + +patch(FormController.prototype, "vault", { + /** + * Re-encrypt the key if the user is getting selected + * + * @private + */ + async _vaultSendWizard() { + const record = this.model.root; + if (record.resModel !== "vault.send.wizard") return; + + if (!record.data.user_id || !record.data.public) return; + + const key = await vault.unwrap(record.data.key); + await record.update({key_user: await vault.wrap_with(key, record.data.public)}); + }, + + /** + * Re-encrypt the key if the entry is getting selected + * + * @private + * @param {Object} record + * @param {Object} changes + * @param {Object} options + */ + async _vaultStoreWizard() { + const record = this.model.root; + if ( + !record.data.entry_id || + !record.data.master_key || + !record.data.iv || + !record.data.secret_temporary + ) + return; + + const key = await vault.unwrap(record.data.key); + const secret = await utils.sym_decrypt( + key, + record.data.secret_temporary, + record.data.iv + ); + const master_key = await vault.unwrap(record.data.master_key); + + await record.update({ + secret: await utils.sym_encrypt(master_key, secret, record.data.iv), + }); + }, + + /** + * Generate a new key pair for the current user + * + * @private + */ + async _newVaultKeyPair() { + // Get the current private key + const private_key = await vault.get_private_key(); + + // Generate new keys + await vault.generate_keys(); + + const public_key = await vault.get_public_key(); + + // Re-encrypt the master keys + const master_keys = await this.rpc("/vault/rights/get"); + let result = {}; + for (const uuid in master_keys) { + result[uuid] = await utils.wrap( + await utils.unwrap(master_keys[uuid], private_key), + public_key + ); + } + + await this.rpc("/vault/rights/store", {keys: result}); + + // Re-encrypt the inboxes to not loose it + const inbox_keys = await this.rpc("/vault/inbox/get"); + result = {}; + for (const uuid in inbox_keys) { + result[uuid] = await utils.wrap( + await utils.unwrap(inbox_keys[uuid], private_key), + public_key + ); + } + + await this.rpc("/vault/inbox/store", {keys: result}); + }, + + /** + * Generate a new key pair and re-encrypt the master keys of the vaults + * + * @private + */ + async _vaultRegenerateKey() { + if (!utils.supported()) return; + + var self = this; + + Dialog.confirm( + self, + _lt("Do you really want to create a new key pair and set it active?"), + { + confirm_callback: function () { + return self._newVaultKeyPair(); + }, + } + ); + }, + + /** + * Handle the deletion of a vault.right field in the vault view properly by + * generating a new master key and re-encrypting everything in the vault to + * deny any future access to the vault. + * + * @private + * @param {Boolean} verify + * @param {Boolean} force + */ + async _reencryptVault(verify = false, force = false) { + const record = this.model.root; + + await vault._ensure_keys(); + + const self = this; + const master_key = await utils.generate_key(); + const current_key = await vault.unwrap(record.data.master_key); + + // This stores the additional changes made to rights, fields, and files + const changes = []; + const problems = []; + + async function reencrypt(model, type) { + // Load the entire data from the database + const records = await self.model.orm.searchRead( + model, + [["vault_id", "=", record.resId]], + ["iv", "value", "name", "entry_name"], + { + context: {vault_reencrypt: true}, + limit: 0, + } + ); + + for (const rec of records) { + const val = await utils.sym_decrypt(current_key, rec.value, rec.iv); + if (val === null) { + problems.push( + _.str.sprintf( + _lt("%s '%s' of entry '%s'"), + type, + rec.name, + rec.entry_name + ) + ); + continue; + } + + const iv = utils.generate_iv_base64(); + const encrypted = await utils.sym_encrypt(master_key, val, iv); + + changes.push({ + id: rec.id, + model: model, + value: encrypted, + iv: iv, + }); + } + } + + framework.blockUI(); + try { + // Update the rights. Load without limit + const rights = await self.model.orm.searchRead( + "vault.right", + [["vault_id", "=", record.resId]], + ["public_key"], + {limit: 0} + ); + + for (const right of rights) { + const key = await vault.wrap_with(master_key, right.public_key); + + changes.push({ + id: right.id, + model: "vault.right", + key: key, + }); + } + + // Re-encrypt vault.field and vault.file + await reencrypt("vault.field", "Field"); + await reencrypt("vault.file", "File"); + + if (problems.length && !force) { + framework.unblockUI(); + + Dialog.alert(self, "", { + title: _lt("The following entries are broken:"), + $content: $("
").html(problems.join("
\n")), + }); + } + + if (!verify) { + await this.rpc("/vault/replace", {data: changes}); + await this.model.root.load(); + } + } finally { + framework.unblockUI(); + } + }, + + /** + * Call the right importer in the import wizard onchange of the content field + * + * @private + */ + async _vaultImportWizard() { + const record = this.model.root; + if (record.resModel !== "vault.import.wizard") return; + + // Try to import the file on the fly and store the compatible JSON in the + // crypted_content field for the python backend + const importer = new Importer(); + const data = await importer.import( + await vault.unwrap(record.data.master_key), + record.data.name, + atob(record.data.content) + ); + + if (data) await record.update({crypted_content: JSON.stringify(data)}); + }, + + /** + * Ensure that a vault.right as the shared master_key set + * + * @private + * @param {Object} root + * @param {Object} right + */ + async _vaultEnsureRightKey(root, right) { + if (!root.data.master_key || right.data.master_key) return; + + const params = {user_id: right.data.user_id[0]}; + const user = await this.rpc("/vault/public", params); + + if (!user || !user.public_key) throw new TypeError("User has no public key"); + + await right.update({ + key: await vault.share(root.data.master_key, user.public_key), + }); + }, + + /** + * Ensures that the master_key of the vault and right lines are set + * + * @private + */ + async _vaultEnsureKeys() { + const root = this.model.root; + if (root.resModel !== "vault") return; + + if (!root.data.master_key) + await root.update({ + master_key: await vault.wrap(await utils.generate_key()), + }); + + if (root.data.right_ids) + for (const right of root.data.right_ids.records) + await this._vaultEnsureRightKey(root, right); + }, + + /** + * Check the model of the form and call the above functions for the right case + * + * @private + * @param {Object} button + */ + async _vaultAction(button) { + if (!utils.supported()) return false; + + const root = this.model.root; + switch (root.resModel) { + case "res.users": + if (button && button.name === "vault_generate_key") { + await this._vaultRegenerateKey(); + return false; + } + break; + case "vault": + if (button && button.name === "vault_reencrypt") { + await this._reencryptVault(false, true); + return false; + } else if (button && button.name === "vault_verify") { + await this._reencryptVault(true, false); + return false; + } + + await this._vaultEnsureKeys(); + break; + + case "vault.send.wizard": + await this._vaultSendWizard(); + break; + + case "vault.store.wizard": + await this._vaultStoreWizard(); + break; + + case "vault.import.wizard": + await this._vaultImportWizard(); + break; + } + + return true; + }, + + /** + * Add the required rpc service to the controller which will be used to + * get/store information from/to the vault controller + */ + setup() { + this._super(...arguments); + this.rpc = useService("rpc"); + }, + + /** + * Hook into the relevant functions + */ + async create() { + const _super = this._super.bind(this); + if (this.model.root.isDirty) await this._vaultAction(); + + const ret = await _super(...arguments); + return ret; + }, + + async onPagerUpdate() { + const _super = this._super.bind(this); + if (this.model.root.isDirty) await this._vaultAction(); + return await _super(...arguments); + }, + + async saveButtonClicked() { + const _super = this._super.bind(this); + if (this.model.root.isDirty) await this._vaultAction(); + return await _super(...arguments); + }, + + async beforeLeave() { + const _super = this._super.bind(this); + if (this.model.root.isDirty) await this._vaultAction(); + return await _super(...arguments); + }, + + async beforeUnload() { + const _super = this._super.bind(this); + if (this.model.root.isDirty) await this._vaultAction(); + return await _super(...arguments); + }, + + async beforeExecuteActionButton(clickParams) { + const _super = this._super.bind(this); + if (clickParams.special !== "cancel") { + const _continue = await this._vaultAction(clickParams); + if (!_continue) return false; + } + + return await _super(...arguments); + }, +}); diff --git a/vault/static/src/backend/export.esm.js b/vault/static/src/backend/export.esm.js index abb324f313..f14ac14cec 100644 --- a/vault/static/src/backend/export.esm.js +++ b/vault/static/src/backend/export.esm.js @@ -1,8 +1,8 @@ /** @odoo-module alias=vault.export **/ -// © 2021-2022 Florian Kantelberg - initOS GmbH +// © 2021-2024 Florian Kantelberg - initOS GmbH // License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -import {_t} from "web.core"; +import {_lt} from "@web/core/l10n/translation"; import utils from "vault.utils"; // This class handles the export to different formats by using a standardize @@ -86,7 +86,7 @@ export default class VaultExporter { async _export_json(master_key, data) { // Get the password for the exported file from the user const askpass = await utils.askpass( - _t("Please enter the password for the database") + _lt("Please enter the password for the database") ); let password = askpass.password || ""; if (askpass.keyfile) @@ -94,7 +94,7 @@ export default class VaultExporter { const iv = utils.generate_iv_base64(); const salt = utils.generate_bytes(utils.SaltLength).buffer; - const iterations = 4000; + const iterations = utils.Derive.iterations; const key = await utils.derive_key(password, salt, iterations); // Unwrap the master key and decrypt the entries diff --git a/vault/static/src/backend/fields/templates.xml b/vault/static/src/backend/fields/templates.xml new file mode 100644 index 0000000000..b5619af48b --- /dev/null +++ b/vault/static/src/backend/fields/templates.xml @@ -0,0 +1,121 @@ + + + +
+ + + + + + + + + +
+ ******* +
+
+ + +
+
+ + +
+ ******* +
+
+ +
+
+ + + + + + + + diff --git a/vault/static/src/backend/fields/vault_export_file.esm.js b/vault/static/src/backend/fields/vault_export_file.esm.js new file mode 100644 index 0000000000..779a31d612 --- /dev/null +++ b/vault/static/src/backend/fields/vault_export_file.esm.js @@ -0,0 +1,45 @@ +/** @odoo-module alias=vault.export.file **/ +// © 2021-2024 Florian Kantelberg - initOS GmbH +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import {BinaryField} from "@web/views/fields/binary/binary_field"; +import Exporter from "vault.export"; +import VaultMixin from "vault.mixin"; +import {_lt} from "@web/core/l10n/translation"; +import {downloadFile} from "@web/core/network/download"; +import {registry} from "@web/core/registry"; +import utils from "vault.utils"; + +export default class VaultExportFile extends VaultMixin(BinaryField) { + /** + * Call the exporter and download the finalized file + */ + async onFileDownload() { + if (!this.props.value) { + this.do_warn( + _lt("Save As..."), + _lt("The field is empty, there's nothing to save!") + ); + } else if (utils.supported()) { + const exporter = new Exporter(); + const content = JSON.stringify( + await exporter.export( + await this._getMasterKey(), + this.state.fileName, + this.props.value + ) + ); + + const buffer = new ArrayBuffer(content.length); + const arr = new Uint8Array(buffer); + for (let i = 0; i < content.length; i++) arr[i] = content.charCodeAt(i); + + const blob = new Blob([arr]); + await downloadFile(blob, this.state.fileName || ""); + } + } +} + +VaultExportFile.template = "vault.FileVaultExport"; + +registry.category("fields").add("vault_export_file", VaultExportFile); diff --git a/vault/static/src/backend/fields/vault_field.esm.js b/vault/static/src/backend/fields/vault_field.esm.js new file mode 100644 index 0000000000..e21979ccfb --- /dev/null +++ b/vault/static/src/backend/fields/vault_field.esm.js @@ -0,0 +1,200 @@ +/** @odoo-module alias=vault.field **/ +// © 2021-2024 Florian Kantelberg - initOS GmbH +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import {Component, useEffect, useRef, useState} from "@odoo/owl"; +import {useBus, useService} from "@web/core/utils/hooks"; +import VaultMixin from "vault.mixin"; +import {_lt} from "@web/core/l10n/translation"; +import {getActiveHotkey} from "@web/core/hotkeys/hotkey_service"; +import {registry} from "@web/core/registry"; +import utils from "vault.utils"; + +export default class VaultField extends VaultMixin(Component) { + setup() { + super.setup(); + + this.action = useService("action"); + this.input = useRef("input"); + this.span = useRef("span"); + this.state = useState({ + decrypted: false, + decryptedValue: "", + isDirty: false, + lastSetValue: null, + }); + + const self = this; + useEffect( + (inputEl) => { + if (inputEl) { + const onInput = self.onInput.bind(self); + const onKeydown = self.onKeydown.bind(self); + + inputEl.addEventListener("input", onInput); + inputEl.addEventListener("keydown", onKeydown); + return () => { + inputEl.removeEventListener("input", onInput); + inputEl.removeEventListener("keydown", onKeydown); + }; + } + }, + () => [self.input.el] + ); + + useEffect(() => { + const isInvalid = self.props.record + ? self.props.record.isInvalid(self.props.name) + : false; + + if (self.input.el && !self.state.isDirty && !isInvalid) { + Promise.resolve(self.getValue()).then((val) => { + if (!self.input.el) return; + + if (val) self.input.el.value = val; + else if (val !== "") + self.props.record.setInvalidField(self.props.name); + }); + self.state.lastSetValue = self.input.el.value; + } + }); + + useBus(self.env.bus, "RELATIONAL_MODEL:WILL_SAVE_URGENTLY", () => + self.commitChanges(true) + ); + useBus(self.env.bus, "RELATIONAL_MODEL:NEED_LOCAL_CHANGES", (ev) => + ev.detail.proms.push(self.commitChanges()) + ); + } + + /** + * Open a dialog to generate a new secret + * + * @param {Object} ev + */ + async _onGenerateValue(ev) { + ev.stopPropagation(); + + const password = await utils.generate_pass(); + await this.storeValue(password); + } + + /** + * Toggle between visible and invisible secret + * + * @param {Object} ev + */ + async _onShowValue(ev) { + ev.stopPropagation(); + + this.state.decrypted = !this.state.decrypted; + if (this.state.decrypted) { + this.state.decryptedValue = await this._decrypt(this.props.value); + } else { + this.state.decryptedValue = ""; + } + + await this.showValue(); + } + + /** + * Copy the decrypted secret to the clipboard + * + * @param {Object} ev + */ + async _onCopyValue(ev) { + ev.stopPropagation(); + + const value = await this._decrypt(this.props.value); + await navigator.clipboard.writeText(value); + } + + /** + * Send the secret to an inbox of an user + * + * @param {Object} ev + */ + async _onSendValue(ev) { + ev.stopPropagation(); + + await this.sendValue(this.props.value, ""); + } + + /** + * Get the decrypted value or a placeholder + * + * @returns the decrypted value or a placeholder + */ + get formattedValue() { + if (!this.props.value) return ""; + if (this.state.decrypted) return this.state.decryptedValue || "*******"; + return "*******"; + } + + /** + * Decrypt the value of the field + * + * @returns decrypted value + */ + async getValue() { + return await this._decrypt(this.props.value); + } + + /** + * Update the value shown + */ + async showValue() { + this.span.el.innerHTML = this.formattedValue; + } + + /** + * Handle input event and set the state to dirty + * + * @param {Object} ev + */ + onInput(ev) { + ev.stopPropagation(); + + this.state.isDirty = ev.target.value !== this.lastSetValue; + if (this.props.setDirty) this.props.setDirty(this.state.isDirty); + } + + /** + * Commit the changes of the input field to the record + * + * @param {Boolean} urgent + */ + async commitChanges(urgent) { + if (!this.input.el) return; + + this.state.isDirty = this.input.el.value !== this.lastSetValue; + if (this.state.isDirty || urgent) { + this.state.isDirty = false; + + const val = this.input.el.value || false; + if (val !== (this.props.value || false)) { + this.state.lastSetValue = this.input.el.value; + await this.storeValue(val); + this.props.setDirty(this.state.isDirty); + } + } + } + + /** + * Handle keyboard events and trigger changes + * + * @param {Object} ev + */ + onKeydown(ev) { + ev.stopPropagation(); + + const hotkey = getActiveHotkey(ev); + if (["enter", "tab", "shift+tab"].includes(hotkey)) this.commitChanges(false); + } +} + +VaultField.displayName = _lt("Vault Field"); +VaultField.supportedTypes = ["char"]; +VaultField.template = "vault.FieldVault"; + +registry.category("fields").add("vault_field", VaultField); diff --git a/vault/static/src/backend/fields/vault_file.esm.js b/vault/static/src/backend/fields/vault_file.esm.js new file mode 100644 index 0000000000..e2aba7eff0 --- /dev/null +++ b/vault/static/src/backend/fields/vault_file.esm.js @@ -0,0 +1,61 @@ +/** @odoo-module alias=vault.file **/ +// © 2021-2024 Florian Kantelberg - initOS GmbH +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import {BinaryField} from "@web/views/fields/binary/binary_field"; +import VaultMixin from "vault.mixin"; +import {_lt} from "@web/core/l10n/translation"; +import {downloadFile} from "@web/core/network/download"; +import {registry} from "@web/core/registry"; +import {useService} from "@web/core/utils/hooks"; +import utils from "vault.utils"; + +export default class VaultFile extends VaultMixin(BinaryField) { + setup() { + super.setup(); + + this.action = useService("action"); + } + + async update({data, name}) { + const encrypted = await this._encrypt(data); + return await super.update({data: encrypted, name: name}); + } + + /** + * Send the secret to an inbox of an user + * + * @param {Object} ev + */ + async _onSendValue(ev) { + ev.stopPropagation(); + + await this.sendValue("", this.props.value, this.state.fileName); + } + + /** + * Decrypt the file and download it + */ + async onFileDownload() { + if (!this.props.value) { + this.do_warn( + _lt("Save As..."), + _lt("The field is empty, there's nothing to save!") + ); + } else if (utils.supported()) { + const decrypted = await this._decrypt(this.props.value); + const base64 = atob(decrypted); + const buffer = new ArrayBuffer(base64.length); + const arr = new Uint8Array(buffer); + for (let i = 0; i < base64.length; i++) arr[i] = base64.charCodeAt(i); + + const blob = new Blob([arr]); + await downloadFile(blob, this.state.fileName || ""); + } + } +} + +VaultFile.displayName = _lt("Vault File"); +VaultFile.template = "vault.FileVault"; + +registry.category("fields").add("vault_file", VaultFile); diff --git a/vault/static/src/backend/fields/vault_inbox_field.esm.js b/vault/static/src/backend/fields/vault_inbox_field.esm.js new file mode 100644 index 0000000000..fd9ae7bbfa --- /dev/null +++ b/vault/static/src/backend/fields/vault_inbox_field.esm.js @@ -0,0 +1,49 @@ +/** @odoo-module alias=vault.inbox.field **/ +// © 2021-2024 Florian Kantelberg - initOS GmbH +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import VaultField from "vault.field"; +import VaultInboxMixin from "vault.inbox.mixin"; +import {_lt} from "@web/core/l10n/translation"; +import {registry} from "@web/core/registry"; +import utils from "vault.utils"; +import vault from "vault"; + +export default class VaultInboxField extends VaultInboxMixin(VaultField) { + /** + * Save the content in an entry of a vault + * + * @private + */ + async _onSaveValue() { + await this.saveValue("vault.field", this.props.value); + } + + /** + * Decrypt the data with the private key of the vault + * + * @private + * @param {String} data + * @returns the decrypted data + */ + async _decrypt(data) { + if (!utils.supported()) return null; + + const iv = this.props.record.data[this.props.fieldIV]; + const wrapped_key = this.props.record.data[this.props.fieldKey]; + + if (!iv || !wrapped_key) return false; + + const key = await vault.unwrap(wrapped_key); + return await utils.sym_decrypt(key, data, iv); + } +} + +VaultInboxField.defaultProps = { + ...VaultField.defaultProps, + fieldKey: "key", +}; +VaultInboxField.displayName = _lt("Vault Inbox Field"); +VaultInboxField.template = "vault.FieldVaultInbox"; + +registry.category("fields").add("vault_inbox_field", VaultInboxField); diff --git a/vault/static/src/backend/fields/vault_inbox_file.esm.js b/vault/static/src/backend/fields/vault_inbox_file.esm.js new file mode 100644 index 0000000000..224af9e122 --- /dev/null +++ b/vault/static/src/backend/fields/vault_inbox_file.esm.js @@ -0,0 +1,49 @@ +/** @odoo-module alias=vault.inbox.file **/ +// © 2021-2024 Florian Kantelberg - initOS GmbH +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import VaultFile from "vault.file"; +import VaultInboxMixin from "vault.inbox.mixin"; +import {_lt} from "@web/core/l10n/translation"; +import {registry} from "@web/core/registry"; +import utils from "vault.utils"; +import vault from "vault"; + +export default class VaultInboxFile extends VaultInboxMixin(VaultFile) { + /** + * Save the content in an entry of a vault + * + * @private + */ + async _onSaveValue() { + await this.saveValue("vault.file", this.props.value, this.state.fileName); + } + + /** + * Decrypt the data with the private key of the vault + * + * @private + * @param {String} data + * @returns the decrypted data + */ + async _decrypt(data) { + if (!utils.supported()) return null; + + const iv = this.props.record.data[this.props.fieldIV]; + const wrapped_key = this.props.record.data[this.props.fieldKey]; + + if (!iv || !wrapped_key) return false; + + const key = await vault.unwrap(wrapped_key); + return await utils.sym_decrypt(key, data, iv); + } +} + +VaultInboxFile.defaultProps = { + ...VaultFile.defaultProps, + fieldKey: "key", +}; +VaultInboxFile.displayName = _lt("Vault Inbox File"); +VaultInboxFile.template = "vault.FileVaultInbox"; + +registry.category("fields").add("vault_inbox_file", VaultInboxFile); diff --git a/vault/static/src/backend/fields/vault_inbox_mixin.esm.js b/vault/static/src/backend/fields/vault_inbox_mixin.esm.js new file mode 100644 index 0000000000..9bc3f2e2aa --- /dev/null +++ b/vault/static/src/backend/fields/vault_inbox_mixin.esm.js @@ -0,0 +1,64 @@ +/** @odoo-module alias=vault.inbox.mixin **/ +// © 2021-2024 Florian Kantelberg - initOS GmbH +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import {_lt} from "@web/core/l10n/translation"; +import {useService} from "@web/core/utils/hooks"; +import utils from "vault.utils"; +import vault from "vault"; + +export default (x) => { + class Extended extends x { + setup() { + super.setup(); + + if (!this.action) this.action = useService("action"); + } + + /** + * Save the content in an entry of a vault + * + * @private + * @param {String} model + * @param {String} value + * @param {String} name + */ + async saveValue(model, value, name = "") { + const key = await utils.generate_key(); + const iv = utils.generate_iv_base64(); + const decrypted = await this._decrypt(value); + + this.action.doAction({ + type: "ir.actions.act_window", + title: _lt("Store the secret in a vault"), + target: "new", + res_model: "vault.store.wizard", + views: [[false, "form"]], + context: { + default_model: model, + default_secret_temporary: await utils.sym_encrypt( + key, + decrypted, + iv + ), + default_name: name, + default_iv: iv, + default_key: await vault.wrap(key), + }, + }); + } + } + + Extended.props = { + ...x.props, + storeModel: {type: String, optional: true}, + }; + + Extended.extractProps = ({attrs}) => { + return { + storeModel: attrs.store, + }; + }; + + return Extended; +}; diff --git a/vault/static/src/backend/fields/vault_mixin.esm.js b/vault/static/src/backend/fields/vault_mixin.esm.js new file mode 100644 index 0000000000..96b447639b --- /dev/null +++ b/vault/static/src/backend/fields/vault_mixin.esm.js @@ -0,0 +1,197 @@ +/** @odoo-module alias=vault.mixin **/ +// © 2021-2024 Florian Kantelberg - initOS GmbH +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import {_lt} from "@web/core/l10n/translation"; +import {standardFieldProps} from "@web/views/fields/standard_field_props"; +import utils from "vault.utils"; +import vault from "vault"; + +export default (x) => { + class Extended extends x { + supported() { + return utils.supported(); + } + + // Control the visibility of the buttons + get showButton() { + return this.props.value; + } + get copyButton() { + return this.props.value; + } + get sendButton() { + return this.props.value; + } + get saveButton() { + return this.props.value; + } + get generateButton() { + return true; + } + get isNew() { + return Boolean(this.model.record.isNew); + } + + /** + * Set the value by encrypting it + * + * @param {String} value + * @param {Object} options + */ + async storeValue(value, options) { + if (!utils.supported()) return; + + const encrypted = await this._encrypt(value); + await this.props.update(encrypted, options); + } + + /** + * Send the value to an inbox + * + * @param {String} value_field + * @param {String} value_file + * @param {String} filename + */ + async sendValue(value_field = "", value_file = "", filename = "") { + if (!utils.supported()) return; + + if (!value_field && !value_file) return; + + let enc_field = false, + enc_file = false; + + // Prepare the key and iv for the reencryption + const key = await utils.generate_key(); + const iv = utils.generate_iv_base64(); + + // Reencrypt the field + if (value_field) { + const decrypted = await this._decrypt(value_field); + enc_field = await utils.sym_encrypt(key, decrypted, iv); + } + + // Reencrypt the file + if (value_file) { + const decrypted = await this._decrypt(value_file); + enc_file = await utils.sym_encrypt(key, decrypted, iv); + } + + // Call the wizard to handle the user selection and storage + this.action.doAction({ + type: "ir.actions.act_window", + title: _lt("Send the secret to another user"), + target: "new", + res_model: "vault.send.wizard", + views: [[false, "form"]], + context: { + default_secret: enc_field, + default_secret_file: enc_file, + default_filename: filename || false, + default_iv: iv, + default_key: await vault.wrap(key), + }, + }); + } + + /** + * Set the value of a different field + * + * @param {String} field + * @param {String} value + */ + async _setFieldValue(field, value) { + this.props.record.update({[field]: value}); + } + + /** + * Extract the IV or generate a new one if needed + * + * @returns the IV to use + */ + async _getIV() { + if (!utils.supported()) return null; + + // Read the IV from the field + let iv = this.props.record.data[this.props.fieldIV]; + if (iv) return iv; + + // Generate a new IV + iv = utils.generate_iv_base64(); + await this._setFieldValue(this.props.fieldIV, iv); + return iv; + } + + /** + * Extract the master key of the vault or generate a new one + * + * @returns the master key to use + */ + async _getMasterKey() { + if (!utils.supported()) return null; + + // Check if the master key is already extracted + if (this.key) return await vault.unwrap(this.key); + + // Get the wrapped master key from the field + this.key = this.props.record.data[this.props.fieldKey]; + if (this.key) return await vault.unwrap(this.key); + + // Generate a new master key and write it to the field + const key = await utils.generate_key(); + this.key = await vault.wrap(key); + await this._setFieldValue(this.props.fieldKey, this.key); + return key; + } + + /** + * Decrypt data with the master key stored in the vault + * + * @param {String} data + * @returns the decrypted data + */ + async _decrypt(data) { + if (!utils.supported()) return null; + + const iv = await this._getIV(); + const key = await this._getMasterKey(); + return await utils.sym_decrypt(key, data, iv); + } + + /** + * Encrypt data with the master key stored in the vault + * + * @param {String} data + * @returns the encrypted data + */ + async _encrypt(data) { + if (!utils.supported()) return null; + + const iv = await this._getIV(); + const key = await this._getMasterKey(); + return await utils.sym_encrypt(key, data, iv); + } + } + + Extended.defaultProps = { + ...x.defaultProps, + fieldIV: "iv", + fieldKey: "master_key", + }; + Extended.props = { + ...standardFieldProps, + ...x.props, + fieldKey: {type: String, optional: true}, + fieldIV: {type: String, optional: true}, + }; + Extended.extractProps = ({attrs, field}) => { + const extract_props = x.extractProps || (() => ({})); + return { + ...extract_props({attrs, field}), + fieldKey: attrs.key, + fieldIV: attrs.iv, + }; + }; + + return Extended; +}; diff --git a/vault/static/src/backend/import.esm.js b/vault/static/src/backend/import.esm.js index c2033d740b..3fccc76846 100644 --- a/vault/static/src/backend/import.esm.js +++ b/vault/static/src/backend/import.esm.js @@ -1,5 +1,5 @@ /** @odoo-module alias=vault.import **/ -// © 2021-2022 Florian Kantelberg - initOS GmbH +// © 2021-2024 Florian Kantelberg - initOS GmbH // License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). /* global kdbxweb */ diff --git a/vault/static/src/backend/templates.xml b/vault/static/src/backend/templates.xml index b6e8cb73b1..e4512a105d 100644 --- a/vault/static/src/backend/templates.xml +++ b/vault/static/src/backend/templates.xml @@ -1,41 +1,5 @@ - - - Key Management - - - - -