Initial commit: OCA Server Auth packages (29 packages)

This commit is contained in:
Ernad Husremovic 2025-08-29 15:43:06 +02:00
commit 3ed80311c4
1325 changed files with 127292 additions and 0 deletions

View file

@ -0,0 +1,413 @@
/** @odoo-module alias=vault.controller **/
// © 2021-2024 Florian Kantelberg - initOS GmbH
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import {AlertDialog} from "@web/core/confirmation_dialog/confirmation_dialog";
import Dialog from "web.Dialog";
import {FormController} from "@web/views/form/form_controller";
import Importer from "vault.import";
import {ListController} from "@web/views/list/list_controller";
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: $("<div/>").html(problems.join("<br>\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.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()) {
await this.dialogService.add(AlertDialog, {
title: _lt("Vault is not supported"),
body: _lt(
"A secure browser context is required. Please switch to " +
"https or contact your administrator"
),
});
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() {
if (this.props.resModel === "vault" && !utils.supported()) {
this.props.preventCreate = true;
this.props.preventEdit = true;
}
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 discard() {
const _super = this._super.bind(this);
if (this.model.root.resModel === "vault.entry")
this.model.env.bus.trigger("RELATIONAL_MODEL:ENCRYPT_FIELDS");
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);
},
});
patch(ListController.prototype, "vault", {
setup() {
this._super(...arguments);
if (this.props.resModel === "vault" && !utils.supported())
this.props.showButtons = false;
},
});

View file

@ -0,0 +1,128 @@
/** @odoo-module alias=vault.export **/
// © 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 utils from "vault.utils";
// This class handles the export to different formats by using a standardize
// JSON formatted data generated by the python backend.
//
// JSON format description:
//
// Entries are represented as objects with the following attributes
// `name`, `uuid`, `url`, `note`
// Specific fields of the entry. `uuid` is used for updating existing records
// `childs`
// Child entries
// `fields`, `files`
// List of encypted fields/files with `name`, `iv`, and `value`
//
export default class VaultExporter {
/**
* Encrypt a field of the above format properly for the backend to store.
* The changes are done inplace.
*
* @private
* @param {CryptoKey} master_key
* @param {Object} node
*/
async _export_json_entry(master_key, node) {
const fields = [];
for (const field of node.fields || [])
fields.push({
name: field.name,
value: await utils.sym_decrypt(master_key, field.value, field.iv),
});
const files = [];
for (const file of node.files || [])
files.push({
name: file.name,
value: await utils.sym_decrypt(master_key, file.value, file.iv),
});
const childs = [];
for (const entry of node.childs || [])
childs.push(await this._export_json_entry(master_key, entry));
return {
name: node.name || "",
uuid: node.uuid || null,
url: node.url || "",
note: node.note || "",
childs: childs,
fields: fields,
files: files,
};
}
/**
* Decrypt the data fro the JSON export.
*
* @private
* @param {CryptoKey} master_key
* @param {Object} data
* @returns the encrypted entry for the database
*/
async _export_json_data(master_key, data) {
const result = [];
for (const node of data)
result.push(await this._export_json_entry(master_key, node));
return JSON.stringify(result);
}
/**
* Export using JSON format. The database is stored in the `data` field of the JSON
* type and is an encrypted JSON object. For the encryption the needed encryption
* parameter `iv`, `salt` and `iterations` are stored in the file.
* This will add `iv` to fields and files and encrypt the `value`
*
* @private
* @param {CryptoKey} master_key
* @param {String} data
* @returns the encrypted entry for the database
*/
async _export_json(master_key, data) {
// Get the password for the exported file from the user
const askpass = await utils.askpass(
_lt("Please enter the password for the database")
);
let password = askpass.password || "";
if (askpass.keyfile)
password += await utils.digest(utils.toBinary(askpass.keyfile));
const iv = utils.generate_iv_base64();
const salt = utils.generate_bytes(utils.SaltLength).buffer;
const iterations = utils.Derive.iterations;
const key = await utils.derive_key(password, salt, iterations);
// Unwrap the master key and decrypt the entries
const content = await this._export_json_data(master_key, JSON.parse(data));
return {
type: "encrypted",
iv: iv,
salt: utils.toBase64(salt),
data: await utils.sym_encrypt(key, content, iv),
iterations: iterations,
};
}
/**
* The main export functions which checks the file ending and calls the right function
* to handle the rest of the export
*
* @private
* @param {CryptoKey} master_key
* @param {String} filename
* @param {String} content
* @returns the data importable by the backend or false on error
*/
async export(master_key, filename, content) {
if (!utils.supported()) return false;
if (filename.endsWith(".json"))
return await this._export_json(master_key, content);
return false;
}
}

View file

@ -0,0 +1,121 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates id="template" xml:space="preserve">
<t t-name="vault.Field.buttons.send" owl="1">
<button
t-if="sendButton"
t-on-click="_onSendValue"
class="btn btn-secondary btn-sm fa fa-share-alt o_vault_send"
title="Send the secret to an user"
aria-label="Send the secret to an user"
/>
</t>
<t t-name="vault.Field.buttons" owl="1">
<button
t-if="!state.decrypted &amp;&amp; showButton"
t-on-click="_onShowValue"
class="btn btn-secondary btn-sm fa fa-eye o_vault_show"
title="Show"
aria-label="Show"
/>
<button
t-elif="showButton"
t-on-click="_onShowValue"
class="btn btn-secondary btn-sm fa fa-eye-slash o_vault_show"
title="Hide"
aria-label="Hide"
/>
<button
t-if="copyButton"
t-on-click="_onCopyValue"
class="btn btn-secondary btn-sm fa fa-clipboard o_vault_clipboard"
title="Copy to clipboard"
aria-label="Copy to clipboard"
/>
<t t-call="vault.Field.buttons.send" />
</t>
<t t-name="vault.FieldVault" owl="1">
<div class="o_vault o_vault_error" t-if="!supported()">
<span>*******</span>
</div>
<div class="o_vault" t-elif="props.readonly">
<span class="o_vault_buttons">
<t t-call="vault.Field.buttons" />
</span>
<span t-esc="formattedValue" t-ref="span" />
</div>
<div class="o_vault" t-else="">
<span class="o_vault_buttons">
<button
t-if="generateButton"
t-on-click="_onGenerateValue"
class="btn btn-secondary btn-sm fa fa-lock o_vault_generate"
title="Generate"
aria-label="Generate"
/>
</span>
<input class="o_input" type="text" t-esc="formattedValue" t-ref="input" />
</div>
</t>
<t
t-name="vault.FileVault"
t-inherit="web.BinaryField"
t-inherit-mode="primary"
owl="1"
>
<xpath expr="//button[hasclass('o_clear_file_button')]" position="after">
<t t-call="vault.Field.buttons.send" />
</xpath>
</t>
<t t-name="vault.FieldVaultInbox" owl="1">
<div class="o_vault o_vault_error" t-if="!supported()">
<span>*******</span>
</div>
<div class="o_vault" t-elif="props.value">
<span class="o_vault_buttons">
<t t-call="vault.Field.buttons" />
<button
t-if="saveButton"
t-on-click="_onSaveValue"
class="btn btn-secondary btn-sm fa fa-save"
title="Save in a vault"
aria-label="Save in a vault"
/>
</span>
<span class="o_vault_inbox" t-esc="formattedValue" t-ref="span" />
</div>
</t>
<t t-name="vault.FileVaultInbox" owl="1">
<div class="o_vault o_vault_error" t-if="!supported()">
<span>*******</span>
</div>
<div class="o_vault" t-elif="props.value">
<span class="o_vault_buttons">
<button
t-if="saveButton"
t-on-click="_onSaveValue"
class="btn btn-secondary btn-sm fa fa-save"
title="Save in a vault"
aria-label="Save in a vault"
/>
</span>
<a class="o_form_uri" href="#" t-on-click.prevent="onFileDownload">
<span class="fa fa-download me-2" />
<t t-if="state.fileName" t-esc="state.fileName" />
</a>
</div>
</t>
<t t-name="vault.FileVaultExport" owl="1">
<a class="o_form_uri" href="#" t-on-click.prevent="onFileDownload">
<span class="fa fa-download me-2" />
<t t-if="state.fileName" t-esc="state.fileName" />
</a>
</t>
</templates>

View file

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

View file

@ -0,0 +1,205 @@
/** @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())
);
useBus(self.env.bus, "RELATIONAL_MODEL:ENCRYPT_FIELDS", () => {
this.state.decrypted = false;
this.showValue();
});
}
/**
* 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.state.lastSetValue || false)) {
this.state.lastSetValue = this.input.el.value;
this.state.decryptedValue = 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);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,244 @@
/** @odoo-module alias=vault.import **/
// © 2021-2024 Florian Kantelberg - initOS GmbH
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
/* global kdbxweb */
import {_t} from "web.core";
import framework from "web.framework";
import utils from "vault.utils";
async function encrypted_field(master_key, name, value) {
if (!value) return null;
const iv = utils.generate_iv_base64();
return {
name: name,
iv: iv,
value: await utils.sym_encrypt(master_key, value, iv),
};
}
// This class handles the import from different formats by returning
// an importable JSON formatted data which will be handled by the python
// backend.
//
// JSON format description:
//
// Entries are represented as objects with the following attributes
// `name`, `uuid`, `url`, `note`
// Specific fields of the entry. `uuid` is used for updating existing records
// `childs`
// Child entries
// `fields`, `files`
// List of encypted fields/files with `name`, `iv`, and `value`
//
export default class VaultImporter {
/**
* Encrypt a field of the above format properly for the backend to store.
* The changes are done inplace.
*
* @private
* @param {CryptoKey} master_key
* @param {Object} node
*/
async _import_json_entry(master_key, node) {
for (const field of node.fields || []) {
field.iv = utils.generate_iv_base64();
field.value = await utils.sym_encrypt(master_key, field.value, field.iv);
}
for (const file of node.files || []) {
file.iv = utils.generate_iv_base64();
file.value = await utils.sym_encrypt(master_key, file.value, file.iv);
}
for (const entry of node.childs || [])
await this._import_json_entry(master_key, entry);
}
/**
* Encrypt the data from the JSON import. This will add `iv` to fields and files
* and encrypt the `value`
*
* @private
* @param {CryptoKey} master_key
* @param {String} data
* @returns the encrypted entry for the database
*/
async _import_json_data(master_key, data) {
for (const node of data) await this._import_json_entry(master_key, node);
return data;
}
/**
* Load from an encrypted JSON file. Encrypt the data with similar format as
* described above. This will add `iv` to fields and files and encrypt the `value`
*
* @private
* @param {CryptoKey} master_key
* @param {Object} content
* @returns the encrypted entry for the database
*/
async _import_encrypted_json(master_key, content) {
const askpass = await utils.askpass(
_t("Please enter the password for the database")
);
let password = askpass.password || "";
if (askpass.keyfile)
password += await utils.digest(utils.toBinary(askpass.keyfile));
const key = await utils.derive_key(
password,
utils.fromBase64(content.salt),
content.iterations
);
const result = await utils.sym_decrypt(key, content.data, content.iv);
return await this._import_json_data(master_key, JSON.parse(result));
}
/**
* Import using JSON format. The database is stored in the `data` field of the JSON
* type and is either a JSON object or an encrypted JSON object. For the encryption
* the needed encryption parameter `iv`, `salt` and `iterations` are stored in the
* file. This will add `iv` to fields and files and encrypt the `value`
*
* @private
* @param {CryptoKey} master_key
* @param {String} data
* @returns the encrypted entry for the database
*/
async _import_json(master_key, data) {
// Unwrap the master key and encrypt the entries
const result = JSON.parse(data);
switch (result.type) {
case "encrypted":
return await this._import_encrypted_json(master_key, result);
case "raw":
return await this._import_json_data(master_key, result.data);
}
throw Error(_t("Unsupported file to import"));
}
/**
* Encrypt an entry from the kdbx file properly for the backend to store
*
* @private
* @param {CryptoKey} master_key
* @param {Object} entry
* @returns the encrypted entry for the database
*/
async _import_kdbx_entry(master_key, entry) {
let pass = entry.fields.Password;
if (pass) pass = pass.getText();
const res = {
uuid: entry.uuid && entry.uuid.id,
note: entry.fields.Notes,
name: entry.fields.Title,
url: entry.fields.URL,
fields: [
await encrypted_field(master_key, "Username", entry.fields.UserName),
await encrypted_field(master_key, "Password", pass),
],
files: [],
};
for (const name in entry.binaries)
res.files.push(
await encrypted_field(
master_key,
name,
utils.toBase64(entry.binaries[name].value)
)
);
return res;
}
/**
* Handle a kdbx group entry by creating an sub-entry and calling the right functions
* on the childs
*
* @private
* @param {CryptoKey} master_key
* @param {Object} group
* @returns the encrypted entry for the database
*/
async _import_kdbx_group(master_key, group) {
const res = {
uuid: group.uuid && group.uuid.id,
name: group.name,
note: group.notes,
childs: [],
};
for (const sub_group of group.groups || [])
res.childs.push(await this._import_kdbx_group(master_key, sub_group));
for (const entry of group.entries || [])
res.childs.push(await this._import_kdbx_entry(master_key, entry));
return res;
}
/**
* Load a kdbx file, encrypt the data, and return in the described JSON format
*
* @private
* @param {CryptoKey} master_key
* @param {String} data
* @returns the encrypted data for the backend
*/
async _import_kdbx(master_key, data) {
// Get the credentials of the keepass database
const askpass = await utils.askpass(
_t("Please enter the password for the keepass database")
);
// TODO: challenge-response
const credentials = new kdbxweb.Credentials(
(askpass.password && kdbxweb.ProtectedValue.fromString(askpass.password)) ||
null,
askpass.keyfile || null
);
// Convert the data to an ArrayBuffer
const buffer = utils.fromBinary(data);
// Decrypt the database
const db = await kdbxweb.Kdbx.load(buffer, credentials);
try {
// Unwrap the master key, format, and encrypt the database
framework.blockUI();
const result = [];
for (const group of db.groups)
result.push(await this._import_kdbx_group(master_key, group));
return result;
} finally {
framework.unblockUI();
}
}
/**
* The main import functions which checks the file ending and calls the right function
* to handle the rest of the import
*
* @private
* @param {CryptoKey} master_key
* @param {String} filename
* @param {String} content
* @returns the data importable by the backend or false on error
*/
async import(master_key, filename, content) {
if (!utils.supported()) return false;
if (filename.endsWith(".json"))
return await this._import_json(master_key, content);
else if (filename.endsWith(".kdbx"))
return await this._import_kdbx(master_key, content);
return false;
}
}

View file

@ -0,0 +1,12 @@
/** @odoo-module **/
import {ListRenderer} from "@web/views/list/list_renderer";
import {patch} from "@web/core/utils/patch";
patch(ListRenderer.prototype, "vault", {
getCellTitle(column) {
const _super = this._super.bind(this);
const attrs = column.rawAttrs || {};
if (attrs.widget !== "vault_field") return _super(...arguments);
},
});

View file

@ -0,0 +1,100 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates id="template" xml:space="preserve">
<div t-name="vault.askpass" class="o_form_view">
<label for="password" class="col-lg-auto col-form-label">
Please enter your password or upload a keyfile:
</label>
<table class="col o_group">
<tr>
<td class="o_td_label text-nowrap">
<label class="o_form_label">Enter your password:</label>
</td>
<td class="col-12">
<input
type="password"
name="password"
id="password"
required="required"
/>
</td>
</tr>
<tr t-if="confirm">
<td class="o_td_label text-nowrap">
<label class="o_form_label">Confirm your password:</label>
</td>
<td class="col-12">
<input
type="password"
name="confirm"
id="confirm"
required="required"
/>
</td>
</tr>
<tr>
<td class="o_td_label text-nowrap">
<label class="o_form_label">Keyfile:</label>
</td>
<td class="col-12">
<input
type="file"
name="keyfile"
id="keyfile"
required="required"
/>
</td>
</tr>
</table>
</div>
<div t-name="vault.generate_pass" class="o_form_view">
<label for="password" class="col-lg-auto col-form-label">
Generate a new secret:
</label>
<table class="col o_group">
<tr>
<td class="o_td_label">
<label class="o_form_label">Password:</label>
</td>
<td class="col-12">
<span id="password" class="col-12 text-monospace" />
</td>
</tr>
<tr>
<td class="o_td_label">
<label class="o_form_label">Length:</label>
</td>
<td class="col-12">
<input
type="range"
id="length"
min="8"
max="64"
value="15"
class="col-12 custom-range align-middle"
/>
</td>
</tr>
<tr>
<td class="o_td_label">
<label class="o_form_label">Characters:</label>
</td>
<td class="col-12">
<input type="checkbox" id="big_letter" checked="checked" />
<label class="o_form_label">A-Z</label>
<input type="checkbox" id="small_letter" checked="checked" />
<label class="o_form_label">a-z</label>
<input type="checkbox" id="digits" checked="checked" />
<label class="o_form_label">0-9</label>
<input type="checkbox" id="special" />
<label class="o_form_label">Special</label>
</td>
</tr>
</table>
</div>
</templates>

View file

@ -0,0 +1,24 @@
/** @odoo-module **/
// © 2021-2022 Florian Kantelberg - initOS GmbH
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import {registry} from "@web/core/registry";
export function vaultPreferencesItem(env) {
return {
type: "item",
id: "key_management",
description: env._t("Key Management"),
callback: async function () {
const actionDescription = await env.services.orm.call(
"res.users",
"action_get_vault"
);
actionDescription.res_id = env.services.user.userId;
env.services.action.doAction(actionDescription);
},
sequence: 55,
};
}
registry.category("user_menuitems").add("key", vaultPreferencesItem, {force: true});

View file

@ -0,0 +1,426 @@
/** @odoo-module alias=vault **/
// © 2021-2024 Florian Kantelberg - initOS GmbH
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import {_t} from "web.core";
import ajax from "web.ajax";
import {session} from "@web/session";
import utils from "vault.utils";
// Database name on the browser
const Database = "vault";
const indexedDB =
window.indexedDB ||
window.mozIndexedDB ||
window.webkitIndexedDB ||
window.msIndexedDB ||
window.shimIndexedDB;
// Expiration time of the vault store entries
const Expiration = 15 * 60 * 1000;
/**
* Ask the user to enter a password using a dialog and put the password together
*
* @param {Boolean} confirm
* @returns password
*/
async function askpassword(confirm = false) {
const askpass = await utils.askpass(
_t("Please enter the password for your private key"),
{confirm: confirm}
);
let password = askpass.password || "";
if (askpass.keyfile)
password += await utils.digest(utils.toBinary(askpass.keyfile));
return password;
}
// Vault implementation
class Vault {
/**
* Check if the user actually has keys otherwise generate them on init
*
* @override
*/
constructor() {
const self = this;
function waitAndCheck() {
if (!utils.supported()) return null;
if (odoo.isReady) self._initialize_keys();
else setTimeout(waitAndCheck, 500);
}
setTimeout(waitAndCheck, 500);
}
/**
* RPC call to the backend
*
* @param {String} url
* @param {Object} params
* @param {Object} options
* @returns promise
*/
rpc(url, params, options) {
return ajax.jsonRpc(url, "call", params, _.clone(options || {}));
}
/**
* Generate a new key pair and export to database and object store
*/
async generate_keys() {
this.keys = await utils.generate_key_pair();
this.time = new Date();
if (!(await this._export_to_database()))
throw Error(_t("Failed to export the keys to the database"));
await this._export_to_store();
}
/**
* Check if export to database is required due to key migration
*
* @private
* @param {String} password
*/
async _check_key_migration(password = null) {
if (!this.version) await this._export_to_database(password);
if (this.iterations < utils.Derive.iterations)
await this._export_to_database(password);
}
/**
* Lazy initialization of the keys which is not fully loading the keys
* into the javascript but ensures that keys exist in the database to
* to be loaded
*
* @private
*/
async _initialize_keys() {
// Get the uuid of the currently active keys from the database
this.uuid = await this._check_database();
if (this.uuid) {
// If the object store has the keys it's done
if (await this._import_from_store()) return;
// Otherwise an import from the database and export to the object store
// is needed
if (await this._import_from_database()) {
await this._export_to_store();
return true;
}
// This should be silent because it would influence the entire workflow
console.error("Failed to import the keys from the database");
return false;
}
// There are no keys in the database which means we have to generate them
return await this.generate_keys();
}
/**
* Ensure that the keys are available
*
* @private
*/
async _ensure_keys() {
// If the object store has the keys it's done
if (this.uuid && !this.time) await this._import_from_store();
// Check if the keys expired
const now = new Date();
if (this.time && now - this.time <= Expiration) return;
// Keys expired means that we have to get them again
this.keys = this.time = null;
// Clear the object store first
const store = await this._get_object_store();
store.clear();
// Import the keys from the database
if (!(await this._import_from_database()))
throw Error(_t("Failed to import keys from database"));
// Store the imported keys in the object store for the next calls
if (!(await this._export_to_store()))
throw Error(_t("Failed to export keys to object store"));
return;
}
/**
* Get the private key and check if the keys expired
*
* @returns the private key of the user
*/
async get_private_key() {
await this._ensure_keys();
return this.keys.privateKey;
}
/**
* Get the public key and check if the keys expired
*
* @returns the public key of the user
*/
async get_public_key() {
await this._ensure_keys();
return this.keys.publicKey;
}
/**
* Open the indexed DB and return object store using promise
*
* @private
* @returns a promise
*/
_get_object_store() {
return new Promise((resolve, reject) => {
const open = indexedDB.open(Database, 1);
open.onupgradeneeded = function () {
const db = open.result;
db.createObjectStore(Database, {keyPath: "id"});
};
open.onerror = function (event) {
reject(`error opening database ${event.target.errorCode}`);
};
open.onsuccess = function () {
const db = open.result;
const tx = db.transaction(Database, "readwrite");
resolve(tx.objectStore(Database));
tx.oncomplete = function () {
db.close();
};
};
});
}
/**
* Open the object store and extract the keys using the id
*
* @private
* @param {String} uuid
* @returns the result from the object store or false
*/
async _get_keys(uuid) {
const self = this;
return new Promise((resolve, reject) => {
self._get_object_store().then((store) => {
const request = store.get(uuid);
request.onerror = function (event) {
reject(`error opening database ${event.target.errorCode}`);
};
request.onsuccess = function () {
resolve(request.result);
};
});
});
}
/**
* Check if the keys exist in the database
*
* @returns the uuid of the currently active keys or false
*/
async _check_database() {
const params = await this.rpc("/vault/keys/get");
if (Object.keys(params).length && params.uuid) return params.uuid;
return false;
}
/**
* Check if the keys exist in the store
*
* @private
* @param {String} uuid
* @returns if the keys are in the object store
*/
async _check_store(uuid) {
if (!uuid) return false;
const result = await this._get_keys(uuid);
return Boolean(result && result.keys);
}
/**
* Import the keys from the indexed DB
*
* @private
* @returns if the import from the object store succeeded
*/
async _import_from_store() {
const data = await this._get_keys(this.uuid);
if (data) {
this.keys = data.keys;
this.time = data.time;
return true;
}
return false;
}
/**
* Export the current keys to the indexed DB
*
* @private
* @returns true
*/
async _export_to_store() {
const keys = {id: this.uuid, keys: this.keys, time: this.time};
const store = await this._get_object_store();
store.put(keys);
return true;
}
/**
* Export the key pairs to the backends
*
* @private
* @param {String} password
* @returns if the export to the database succeeded
*/
async _export_to_database(password = null) {
// Generate salt for the user key
this.salt = utils.generate_bytes(utils.SaltLength).buffer;
this.iterations = utils.Derive.iterations;
this.version = 1;
// Wrap the private key with the master key of the user
this.iv = utils.generate_bytes(utils.IVLength);
// Request the password from the user and derive the user key
const pass = await utils.derive_key(
password || (await askpassword(true)),
this.salt,
this.iterations
);
// Export the private key wrapped with the master key
const private_key = await utils.export_private_key(
await this.get_private_key(),
pass,
this.iv
);
// Export the public key
const public_key = await utils.export_public_key(await this.get_public_key());
const params = {
public: public_key,
private: private_key,
iv: utils.toBase64(this.iv),
iterations: this.iterations,
salt: utils.toBase64(this.salt),
version: this.version,
};
// Export to the server
const response = await this.rpc("/vault/keys/store", params);
if (response) {
this.uuid = response;
return true;
}
console.error("Failed to export keys to database");
return false;
}
/**
* Import the keys from the backend and decrypt the private key
*
* @private
* @returns if the import succeeded
*/
async _import_from_database() {
const params = await this.rpc("/vault/keys/get");
if (Object.keys(params).length) {
this.salt = utils.fromBase64(params.salt);
this.iterations = params.iterations;
this.version = params.version || 0;
// Request the password from the user and derive the user key
const raw_password = await askpassword(false);
let password = raw_password;
// Compatibility
if (!this.version) password = session.username + "|" + password;
const pass = await utils.derive_key(password, this.salt, this.iterations);
this.keys = {
publicKey: await utils.load_public_key(params.public),
privateKey: await utils.load_private_key(
params.private,
pass,
params.iv
),
};
this.time = new Date();
this.uuid = params.uuid;
this._check_key_migration(raw_password);
return true;
}
return false;
}
/**
* Wrap the master key with the own public key
*
* @param {CryptoKey} master_key
* @returns wrapped master key
*/
async wrap(master_key) {
return await utils.wrap(master_key, await this.get_public_key());
}
/**
* Wrap the master key with a public key given as string
*
* @param {CryptoKey} master_key
* @param {String} public_key
* @returns wrapped master key
*/
async wrap_with(master_key, public_key) {
const pub_key = await utils.load_public_key(public_key);
return await utils.wrap(master_key, pub_key);
}
/**
* Unwrap the master key with the own private key
*
* @param {CryptoKey} master_key
* @returns unwrapped master key
*/
async unwrap(master_key) {
return await utils.unwrap(master_key, await this.get_private_key());
}
/**
* Share a wrapped master key by unwrapping with own private key and wrapping with
* another key
*
* @param {String} master_key
* @param {String} public_key
* @returns wrapped master key
*/
async share(master_key, public_key) {
const key = await this.unwrap(master_key);
return await this.wrap_with(key, public_key);
}
}
export default new Vault();

View file

@ -0,0 +1,13 @@
.o_vault_inbox {
white-space: pre-wrap;
}
.o_field_cell .o_vault .o_vault_buttons {
float: right;
position: relative;
z-index: 10;
}
.o_vault .o_input {
width: auto;
}

View file

@ -0,0 +1,576 @@
/** @odoo-module alias=vault.utils **/
// © 2021-2024 Florian Kantelberg - initOS GmbH
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import {_t, qweb} from "web.core";
import Dialog from "web.Dialog";
const CryptoAPI = window.crypto.subtle;
// Some basic constants used for the entire vaults
const Hash = "SHA-512";
const HashLength = 10;
const IVLength = 12;
const SaltLength = 32;
const Asymmetric = {
name: "RSA-OAEP",
modulusLength: 4096,
publicExponent: new Uint8Array([1, 0, 1]),
hash: Hash,
};
const Derive = {
name: "PBKDF2",
iterations: 600001,
};
const Symmetric = {
name: "AES-GCM",
length: 256,
};
/**
* Checks if the CryptoAPI is available and the vault feature supported
*
* @returns if vault is supported
*/
function supported() {
return Boolean(CryptoAPI);
}
/**
* Converts an ArrayBuffer to an ASCII string
*
* @param {ArrayBuffer} buffer
* @returns the data converted to a string
*/
function toBinary(buffer) {
if (!buffer) return "";
const chars = Array.from(new Uint8Array(buffer)).map(function (b) {
return String.fromCharCode(b);
});
return chars.join("");
}
/**
* Converts an ASCII string to an ArrayBuffer
*
* @param {String} binary
* @returns the data converted to an ArrayBuffer
*/
function fromBinary(binary) {
const len = binary.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i);
return bytes.buffer;
}
/**
* Converts an ArrayBuffer to a Base64 encoded string
*
* @param {ArrayBuffer} buffer
* @returns Base64 string
*/
function toBase64(buffer) {
if (!buffer) return "";
const chars = Array.from(new Uint8Array(buffer)).map(function (b) {
return String.fromCharCode(b);
});
return btoa(chars.join(""));
}
/**
* Converts an Base64 encoded string to an ArrayBuffer
*
* @param {String} base64
* @returns the data converted to an ArrayBuffer
*/
function fromBase64(base64) {
if (!base64) {
const bytes = new Uint8Array(0);
return bytes.buffer;
}
const binary_string = atob(base64);
const len = binary_string.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) bytes[i] = binary_string.charCodeAt(i);
return bytes.buffer;
}
/**
* Generate random bytes used for salts or IVs
*
* @param {int} length
* @returns an array with length random bytes
*/
function generate_bytes(length) {
const buf = new Uint8Array(length);
window.crypto.getRandomValues(buf);
return buf;
}
/**
* Generate random bytes used for salts or IVs encoded as base64
*
* @returns base64 string
*/
function generate_iv_base64() {
return toBase64(generate_bytes(IVLength));
}
/**
* Generate a random secret with specific characters
*
* @param {int} length
* @param {String} characters
* @returns base64 string
*/
function generate_secret(length, characters) {
let result = "";
const len = characters.length;
for (const k of generate_bytes(length))
result += characters[Math.floor((len * k) / 256)];
return result;
}
/**
* Generate a symmetric key for encrypting and decrypting
*
* @returns symmetric key
*/
async function generate_key() {
return await CryptoAPI.generateKey(Symmetric, true, ["encrypt", "decrypt"]);
}
/**
* Generate an asymmetric key pair for encrypting, decrypting, wrapping and unwrapping
*
* @returns asymmetric key
*/
async function generate_key_pair() {
return await CryptoAPI.generateKey(Asymmetric, true, [
"wrapKey",
"unwrapKey",
"decrypt",
"encrypt",
]);
}
/**
* Generate a hash of the given data
*
* @param {String} data
* @returns base64 encoded hash of the data
*/
async function digest(data) {
const encoder = new TextEncoder();
return toBase64(await CryptoAPI.digest(Hash, encoder.encode(data)));
}
/**
* Ask the user to enter a password using a dialog
*
* @param {String} title of the dialog
* @param {Object} options
* @returns promise
*/
function askpass(title, options = {}) {
var self = this;
if (options.password === undefined) options.password = true;
if (options.keyfile === undefined) options.keyfile = true;
return new Promise((resolve, reject) => {
var dialog = new Dialog(self, {
title: title,
$content: $(qweb.render("vault.askpass", options)),
buttons: [
{
text: _t("Enter"),
classes: "btn-primary",
click: async function (ev) {
ev.stopPropagation();
const password = this.$("#password").val();
const keyfile = this.$("#keyfile")[0].files[0];
if (!password && !keyfile) {
Dialog.alert(this, _t("Missing password"));
return;
}
if (options.confirm) {
const confirm = this.$("#confirm").val();
if (confirm !== password) {
Dialog.alert(this, _t("The passwords aren't matching"));
return;
}
}
dialog.close();
let keyfile_content = null;
if (keyfile) keyfile_content = fromBinary(await keyfile.text());
resolve({
password: password,
keyfile: keyfile_content,
});
},
},
{
text: _t("Cancel"),
click: function (ev) {
ev.stopPropagation();
dialog.close();
reject(_t("Cancelled"));
},
},
],
});
dialog.open();
});
}
/**
* Ask the user to enter a password using a dialog
*
* @param {String} title of the dialog
* @param {Object} options
* @returns promise
*/
function generate_pass(title, options = {}) {
var self = this;
const $content = $(qweb.render("vault.generate_pass", options));
const $password = $content.find("#password")[0];
const $length = $content.find("#length")[0];
const $big = $content.find("#big_letter")[0];
const $small = $content.find("#small_letter")[0];
const $digits = $content.find("#digits")[0];
const $special = $content.find("#special")[0];
var password = null;
function gen_pass() {
let characters = "";
if ($big.checked) characters += "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
if ($small.checked) characters += "abcdefghijklmnopqrstuvwxyz";
if ($digits.checked) characters += "0123456789";
if ($special.checked) characters += "!?$%&/()[]{}|<>,;.:-_#+*\\";
if (characters)
$password.innerHTML = password = generate_secret($length.value, characters);
}
$length.onchange =
$big.onchange =
$small.onchange =
$digits.onchange =
$special.onchange =
gen_pass;
gen_pass();
return new Promise((resolve, reject) => {
var dialog = new Dialog(self, {
title: title,
$content: $content,
buttons: [
{
text: _t("Enter"),
classes: "btn-primary",
click: async function (ev) {
ev.stopPropagation();
if (!password) throw new Error(_t("Missing password"));
dialog.close();
resolve(password);
},
},
{
text: _t("Cancel"),
click: function (ev) {
ev.stopPropagation();
dialog.close();
reject(_t("Cancelled"));
},
},
],
});
dialog.open();
});
}
/**
* Derive a key using the given data, salt and iterations using PBKDF2
*
* @param {String} data
* @param {String} salt
* @param {int} iterations
* @returns the derived key
*/
async function derive_key(data, salt, iterations) {
const enc = new TextEncoder();
const material = await CryptoAPI.importKey(
"raw",
enc.encode(data),
Derive.name,
false,
["deriveBits", "deriveKey"]
);
return await CryptoAPI.deriveKey(
{
name: Derive.name,
salt: salt,
iterations: iterations,
hash: Hash,
},
material,
Symmetric,
false,
["wrapKey", "unwrapKey", "encrypt", "decrypt"]
);
}
/**
* Encrypt the data using a public key
*
* @param {CryptoKey} public_key
* @param {String} data
* @returns the encrypted data
*/
async function asym_encrypt(public_key, data) {
if (!data) return data;
const enc = new TextEncoder();
return toBase64(
await CryptoAPI.encrypt({name: Asymmetric.name}, public_key, enc.encode(data))
);
}
/**
* Decrypt the data using the own private key
*
* @param {CryptoKey} private_key
* @param {String} crypted
* @returns the decrypted data
*/
async function asym_decrypt(private_key, crypted) {
if (!crypted) return crypted;
const dec = new TextDecoder();
return dec.decode(
await CryptoAPI.decrypt(
{name: Asymmetric.name},
private_key,
fromBase64(crypted)
)
);
}
/**
* Symmetrically encrypt the data using a master key
*
* @param {CryptoKey} key
* @param {String} data
* @param {String} iv
* @returns the encrypted data
*/
async function sym_encrypt(key, data, iv) {
if (!data) return data;
const hash = await digest(data);
const enc = new TextEncoder();
return toBase64(
await CryptoAPI.encrypt(
{name: Symmetric.name, iv: fromBase64(iv), tagLength: 128},
key,
enc.encode(hash.slice(0, HashLength) + data)
)
);
}
/**
* Symmetrically decrypt the data using a master key
*
* @param {CryptoKey} key
* @param {String} crypted
* @param {String} iv
* @returns the decrypted data
*/
async function sym_decrypt(key, crypted, iv) {
if (!crypted) return crypted;
try {
const dec = new TextDecoder();
const message = dec.decode(
await CryptoAPI.decrypt(
{name: Symmetric.name, iv: fromBase64(iv), tagLength: 128},
key,
fromBase64(crypted)
)
);
const data = message.slice(HashLength);
const hash = await digest(data);
// Compare the hash and return if integer
if (hash.slice(0, HashLength) === message.slice(0, HashLength)) return data;
console.error("Invalid data hash");
// Wrong hash
return null;
} catch (err) {
console.error(err);
return null;
}
}
/**
* Load a public key
*
* @param {String} public_key
* @returns the public key as CryptoKey
*/
async function load_public_key(public_key) {
return await CryptoAPI.importKey("spki", fromBase64(public_key), Asymmetric, true, [
"wrapKey",
"encrypt",
]);
}
/**
* Load a private key
*
* @param {String} private_key
* @param {CryptoKey} key
* @param {String} iv
* @returns the private key as CryptoKey
*/
async function load_private_key(private_key, key, iv) {
return await CryptoAPI.unwrapKey(
"pkcs8",
fromBase64(private_key),
key,
{name: Symmetric.name, iv: fromBase64(iv), tagLength: 128},
Asymmetric,
true,
["unwrapKey", "decrypt"]
);
}
/**
* Export a public key in spki format
*
* @param {CryptoKey} public_key
* @returns the public key as string
*/
async function export_public_key(public_key) {
return toBase64(await CryptoAPI.exportKey("spki", public_key));
}
/**
* Export a private key in pkcs8 format
*
* @param {String} private_key
* @param {CryptoKey} key
* @param {String} iv
* @returns the public key as CryptoKey
*/
async function export_private_key(private_key, key, iv) {
return toBase64(
await CryptoAPI.wrapKey("pkcs8", private_key, key, {
name: Symmetric.name,
iv: iv,
tagLength: 128,
})
);
}
/**
* Wrap the master key with the own public key
*
* @param {CryptoKey} key
* @param {CryptoKey} public_key
* @returns wrapped master key
*/
async function wrap(key, public_key) {
return toBase64(await CryptoAPI.wrapKey("raw", key, public_key, Asymmetric));
}
/**
* Unwrap the master key with the own private key
*
* @param {CryptoKey} key
* @param {CryptoKey} private_key
* @returns unwrapped master key
*/
async function unwrap(key, private_key) {
return await CryptoAPI.unwrapKey(
"raw",
fromBase64(key),
private_key,
Asymmetric,
Symmetric,
true,
["encrypt", "decrypt"]
);
}
/**
* Capitalize each word of the string
*
* @param {String} s
* @returns capitalized string
*/
function capitalize(s) {
return s.toLowerCase().replace(/\b\w/g, function (c) {
return c.toUpperCase();
});
}
export default {
// Constants
Asymmetric: Asymmetric,
Derive: Derive,
Hash: Hash,
HashLength: HashLength,
IVLength: IVLength,
SaltLength: SaltLength,
Symmetric: Symmetric,
// Crypto utility functions
askpass: askpass,
asym_decrypt: asym_decrypt,
asym_encrypt: asym_encrypt,
derive_key: derive_key,
digest: digest,
export_private_key: export_private_key,
export_public_key: export_public_key,
generate_bytes: generate_bytes,
generate_iv_base64: generate_iv_base64,
generate_key: generate_key,
generate_key_pair: generate_key_pair,
generate_pass: generate_pass,
generate_secret: generate_secret,
load_private_key: load_private_key,
load_public_key: load_public_key,
sym_decrypt: sym_decrypt,
sym_encrypt: sym_encrypt,
unwrap: unwrap,
wrap: wrap,
// Utility functions
capitalize: capitalize,
fromBase64: fromBase64,
fromBinary: fromBinary,
toBase64: toBase64,
toBinary: toBinary,
supported: supported,
};

View file

@ -0,0 +1,96 @@
/** @odoo-module alias=vault.inbox **/
// © 2021-2024 Florian Kantelberg - initOS GmbH
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import utils from "vault.utils";
const data = {};
let key = false;
let iv = false;
const fields = [
"key",
"iv",
"public",
"encrypted",
"secret",
"encrypted_file",
"filename",
"secret_file",
"submit",
];
function toggle_required(element, value) {
if (value) element.setAttribute("required", "required");
else element.removeAttribute("required");
}
// Encrypt the value and store it in the right input field
async function encrypt_and_store(value, target) {
if (!utils.supported()) return false;
// Find all the possible elements which are needed
for (const id of fields) if (!data[id]) data[id] = document.getElementById(id);
// We expect a public key here otherwise we can't procceed
if (!data.public.value) return;
const public_key = await utils.load_public_key(data.public.value);
// Create a new key if not already present
if (!key) {
key = await utils.generate_key();
data.key.value = await utils.wrap(key, public_key);
}
// Create a new IV if not already present
if (!iv) {
iv = utils.generate_iv_base64();
data.iv.value = iv;
}
// Encrypt the value symmetrically and store it in the field
const val = await utils.sym_encrypt(key, value, iv);
data[target].value = val;
return Boolean(val);
}
document.getElementById("secret").onchange = async function () {
if (!utils.supported()) return false;
if (!this.value) return;
const required = await encrypt_and_store(this.value, "encrypted");
toggle_required(data.secret, required);
toggle_required(data.secret_file, !required);
data.submit.removeAttribute("disabled");
};
document.getElementById("secret_file").onchange = async function () {
if (!utils.supported()) return false;
if (!this.files.length) return;
const file = this.files[0];
const reader = new FileReader();
let content = null;
const promise = new Promise((resolve) => {
reader.onload = () => {
if (reader.result.indexOf(",") >= 0) content = reader.result.split(",")[1];
resolve();
};
});
reader.readAsDataURL(file);
await promise;
if (!content) return;
const required = await encrypt_and_store(content, "encrypted_file");
toggle_required(data.secret, !required);
toggle_required(data.secret_file, required);
data.filename.value = file.name;
data.submit.removeAttribute("disabled");
};