mirror of
https://github.com/bringout/oca-server-auth.git
synced 2026-04-19 10:52:02 +02:00
Initial commit: OCA Server Auth packages (29 packages)
This commit is contained in:
commit
3ed80311c4
1325 changed files with 127292 additions and 0 deletions
|
|
@ -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;
|
||||
},
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 && 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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
@ -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});
|
||||
|
|
@ -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();
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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");
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue