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

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

View file

@ -0,0 +1,453 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="generator" content="Docutils: https://docutils.sourceforge.io/" />
<title>Vault</title>
<style type="text/css">
/*
:Author: David Goodger (goodger@python.org)
:Id: $Id: html4css1.css 9511 2024-01-13 09:50:07Z milde $
:Copyright: This stylesheet has been placed in the public domain.
Default cascading style sheet for the HTML output of Docutils.
Despite the name, some widely supported CSS2 features are used.
See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to
customize this style sheet.
*/
/* used to remove borders from tables and images */
.borderless, table.borderless td, table.borderless th {
border: 0 }
table.borderless td, table.borderless th {
/* Override padding for "table.docutils td" with "! important".
The right padding separates the table cells. */
padding: 0 0.5em 0 0 ! important }
.first {
/* Override more specific margin styles with "! important". */
margin-top: 0 ! important }
.last, .with-subtitle {
margin-bottom: 0 ! important }
.hidden {
display: none }
.subscript {
vertical-align: sub;
font-size: smaller }
.superscript {
vertical-align: super;
font-size: smaller }
a.toc-backref {
text-decoration: none ;
color: black }
blockquote.epigraph {
margin: 2em 5em ; }
dl.docutils dd {
margin-bottom: 0.5em }
object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] {
overflow: hidden;
}
/* Uncomment (and remove this text!) to get bold-faced definition list terms
dl.docutils dt {
font-weight: bold }
*/
div.abstract {
margin: 2em 5em }
div.abstract p.topic-title {
font-weight: bold ;
text-align: center }
div.admonition, div.attention, div.caution, div.danger, div.error,
div.hint, div.important, div.note, div.tip, div.warning {
margin: 2em ;
border: medium outset ;
padding: 1em }
div.admonition p.admonition-title, div.hint p.admonition-title,
div.important p.admonition-title, div.note p.admonition-title,
div.tip p.admonition-title {
font-weight: bold ;
font-family: sans-serif }
div.attention p.admonition-title, div.caution p.admonition-title,
div.danger p.admonition-title, div.error p.admonition-title,
div.warning p.admonition-title, .code .error {
color: red ;
font-weight: bold ;
font-family: sans-serif }
/* Uncomment (and remove this text!) to get reduced vertical space in
compound paragraphs.
div.compound .compound-first, div.compound .compound-middle {
margin-bottom: 0.5em }
div.compound .compound-last, div.compound .compound-middle {
margin-top: 0.5em }
*/
div.dedication {
margin: 2em 5em ;
text-align: center ;
font-style: italic }
div.dedication p.topic-title {
font-weight: bold ;
font-style: normal }
div.figure {
margin-left: 2em ;
margin-right: 2em }
div.footer, div.header {
clear: both;
font-size: smaller }
div.line-block {
display: block ;
margin-top: 1em ;
margin-bottom: 1em }
div.line-block div.line-block {
margin-top: 0 ;
margin-bottom: 0 ;
margin-left: 1.5em }
div.sidebar {
margin: 0 0 0.5em 1em ;
border: medium outset ;
padding: 1em ;
background-color: #ffffee ;
width: 40% ;
float: right ;
clear: right }
div.sidebar p.rubric {
font-family: sans-serif ;
font-size: medium }
div.system-messages {
margin: 5em }
div.system-messages h1 {
color: red }
div.system-message {
border: medium outset ;
padding: 1em }
div.system-message p.system-message-title {
color: red ;
font-weight: bold }
div.topic {
margin: 2em }
h1.section-subtitle, h2.section-subtitle, h3.section-subtitle,
h4.section-subtitle, h5.section-subtitle, h6.section-subtitle {
margin-top: 0.4em }
h1.title {
text-align: center }
h2.subtitle {
text-align: center }
hr.docutils {
width: 75% }
img.align-left, .figure.align-left, object.align-left, table.align-left {
clear: left ;
float: left ;
margin-right: 1em }
img.align-right, .figure.align-right, object.align-right, table.align-right {
clear: right ;
float: right ;
margin-left: 1em }
img.align-center, .figure.align-center, object.align-center {
display: block;
margin-left: auto;
margin-right: auto;
}
table.align-center {
margin-left: auto;
margin-right: auto;
}
.align-left {
text-align: left }
.align-center {
clear: both ;
text-align: center }
.align-right {
text-align: right }
/* reset inner alignment in figures */
div.align-right {
text-align: inherit }
/* div.align-center * { */
/* text-align: left } */
.align-top {
vertical-align: top }
.align-middle {
vertical-align: middle }
.align-bottom {
vertical-align: bottom }
ol.simple, ul.simple {
margin-bottom: 1em }
ol.arabic {
list-style: decimal }
ol.loweralpha {
list-style: lower-alpha }
ol.upperalpha {
list-style: upper-alpha }
ol.lowerroman {
list-style: lower-roman }
ol.upperroman {
list-style: upper-roman }
p.attribution {
text-align: right ;
margin-left: 50% }
p.caption {
font-style: italic }
p.credits {
font-style: italic ;
font-size: smaller }
p.label {
white-space: nowrap }
p.rubric {
font-weight: bold ;
font-size: larger ;
color: maroon ;
text-align: center }
p.sidebar-title {
font-family: sans-serif ;
font-weight: bold ;
font-size: larger }
p.sidebar-subtitle {
font-family: sans-serif ;
font-weight: bold }
p.topic-title {
font-weight: bold }
pre.address {
margin-bottom: 0 ;
margin-top: 0 ;
font: inherit }
pre.literal-block, pre.doctest-block, pre.math, pre.code {
margin-left: 2em ;
margin-right: 2em }
pre.code .ln { color: gray; } /* line numbers */
pre.code, code { background-color: #eeeeee }
pre.code .comment, code .comment { color: #5C6576 }
pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold }
pre.code .literal.string, code .literal.string { color: #0C5404 }
pre.code .name.builtin, code .name.builtin { color: #352B84 }
pre.code .deleted, code .deleted { background-color: #DEB0A1}
pre.code .inserted, code .inserted { background-color: #A3D289}
span.classifier {
font-family: sans-serif ;
font-style: oblique }
span.classifier-delimiter {
font-family: sans-serif ;
font-weight: bold }
span.interpreted {
font-family: sans-serif }
span.option {
white-space: nowrap }
span.pre {
white-space: pre }
span.problematic, pre.problematic {
color: red }
span.section-subtitle {
/* font-size relative to parent (h1..h6 element) */
font-size: 80% }
table.citation {
border-left: solid 1px gray;
margin-left: 1px }
table.docinfo {
margin: 2em 4em }
table.docutils {
margin-top: 0.5em ;
margin-bottom: 0.5em }
table.footnote {
border-left: solid 1px black;
margin-left: 1px }
table.docutils td, table.docutils th,
table.docinfo td, table.docinfo th {
padding-left: 0.5em ;
padding-right: 0.5em ;
vertical-align: top }
table.docutils th.field-name, table.docinfo th.docinfo-name {
font-weight: bold ;
text-align: left ;
white-space: nowrap ;
padding-left: 0 }
/* "booktabs" style (no vertical lines) */
table.docutils.booktabs {
border: 0px;
border-top: 2px solid;
border-bottom: 2px solid;
border-collapse: collapse;
}
table.docutils.booktabs * {
border: 0px;
}
table.docutils.booktabs th {
border-bottom: thin solid;
text-align: left;
}
h1 tt.docutils, h2 tt.docutils, h3 tt.docutils,
h4 tt.docutils, h5 tt.docutils, h6 tt.docutils {
font-size: 100% }
ul.auto-toc {
list-style-type: none }
</style>
</head>
<body>
<div class="document" id="vault">
<h1 class="title">Vault</h1>
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:c0c446463d63752dc25080d52e0132ae87b0b43fd51edc7915ed1b919763b40e
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
<p><a class="reference external image-reference" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external image-reference" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/licence-AGPL--3-blue.png" /></a> <a class="reference external image-reference" href="https://github.com/OCA/server-auth/tree/16.0/vault"><img alt="OCA/server-auth" src="https://img.shields.io/badge/github-OCA%2Fserver--auth-lightgray.png?logo=github" /></a> <a class="reference external image-reference" href="https://translation.odoo-community.org/projects/server-auth-16-0/server-auth-16-0-vault"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external image-reference" href="https://runboat.odoo-community.org/builds?repo=OCA/server-auth&amp;target_branch=16.0"><img alt="Try me on Runboat" src="https://img.shields.io/badge/runboat-Try%20me-875A7B.png" /></a></p>
<p>This module implements a vault for secrets and files using end-to-end-encryption. The encryption and decryption happens in the browser using a vault specific shared master key. The master keys are encrypted using asymmetrically. For this the user has to enter a second password on the first login or if he needs to access data in a vault. The asymmetric keys are stored for a certain time in the browser storage.</p>
<p>The server can never access the secrets with the information available. Only people registered in the vault can decrypt or encrypt values in a vault. The meta data isnt encrypted to be able to search/filter for entries more easily.</p>
<p>This modules requires a secure context for the browser to work properly and therefore HTTPS support is required.</p>
<p>The <a class="reference external" href="https://github.com/fkantelberg/vault-recovery">vault-recovery</a> project focuses on disaster recovery in case of an incident to recover secrets from old database backups or old exports.</p>
<p><strong>Table of contents</strong></p>
<div class="contents local topic" id="contents">
<ul class="simple">
<li><a class="reference internal" href="#known-issues-roadmap" id="toc-entry-1">Known issues / Roadmap</a></li>
<li><a class="reference internal" href="#bug-tracker" id="toc-entry-2">Bug Tracker</a></li>
<li><a class="reference internal" href="#credits" id="toc-entry-3">Credits</a><ul>
<li><a class="reference internal" href="#authors" id="toc-entry-4">Authors</a></li>
<li><a class="reference internal" href="#contributors" id="toc-entry-5">Contributors</a></li>
<li><a class="reference internal" href="#maintainers" id="toc-entry-6">Maintainers</a></li>
</ul>
</li>
</ul>
</div>
<div class="section" id="known-issues-roadmap">
<h1><a class="toc-backref" href="#toc-entry-1">Known issues / Roadmap</a></h1>
<ul class="simple">
<li>Field and file history for restoration</li>
<li>Import improvement</li>
</ul>
<blockquote>
<ul class="simple">
<li>Support challenge-response/FIDO2</li>
<li>Support for argon2 and kdbx v4</li>
</ul>
</blockquote>
<ul>
<li><p class="first">When changing an entry from one vault to another existing vault, the values added on
this entry cannot be accessed, so the field vault is going to be readonly when it
is defined.</p>
<p>If you want to move entries between vaults you can use the export -&gt; import option.</p>
</li>
<li><p class="first">HTTPS or localhost (secure browser context) is required for the client side encryption</p>
</li>
</ul>
</div>
<div class="section" id="bug-tracker">
<h1><a class="toc-backref" href="#toc-entry-2">Bug Tracker</a></h1>
<p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/server-auth/issues">GitHub Issues</a>.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
<a class="reference external" href="https://github.com/OCA/server-auth/issues/new?body=module:%20vault%0Aversion:%2016.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
<p>Do not contact contributors directly about support or help with technical issues.</p>
</div>
<div class="section" id="credits">
<h1><a class="toc-backref" href="#toc-entry-3">Credits</a></h1>
<div class="section" id="authors">
<h2><a class="toc-backref" href="#toc-entry-4">Authors</a></h2>
<ul class="simple">
<li>initOS GmbH</li>
</ul>
</div>
<div class="section" id="contributors">
<h2><a class="toc-backref" href="#toc-entry-5">Contributors</a></h2>
<ul class="simple">
<li>Florian Kantelberg &lt;<a class="reference external" href="mailto:florian.kantelberg&#64;initos.com">florian.kantelberg&#64;initos.com</a>&gt;</li>
<li><a class="reference external" href="https://www.tecnativa.com">Tecnativa</a>:<ul>
<li>Carlos Roca</li>
</ul>
</li>
</ul>
</div>
<div class="section" id="maintainers">
<h2><a class="toc-backref" href="#toc-entry-6">Maintainers</a></h2>
<p>This module is maintained by the OCA.</p>
<a class="reference external image-reference" href="https://odoo-community.org">
<img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" />
</a>
<p>OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.</p>
<p>This module is part of the <a class="reference external" href="https://github.com/OCA/server-auth/tree/16.0/vault">OCA/server-auth</a> project on GitHub.</p>
<p>You are welcome to contribute. To learn how please visit <a class="reference external" href="https://odoo-community.org/page/Contribute">https://odoo-community.org/page/Contribute</a>.</p>
</div>
</div>
</div>
</body>
</html>

File diff suppressed because one or more lines are too long

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");
};

View file

@ -0,0 +1,209 @@
// © 2021 Florian Kantelberg - initOS GmbH
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
/* global QUnit */
odoo.define("vault.tests", function (require) {
"use strict";
var utils = require("vault.utils");
QUnit.module(
"vault",
{
before: function () {
utils.askpass = async function () {
return {
password: "test",
keyfile: "",
};
};
},
},
function () {
function is_keypair(keys, assert) {
assert.equal(keys.publicKey instanceof CryptoKey, true);
assert.equal(keys.publicKey.type, "public");
assert.equal(keys.privateKey instanceof CryptoKey, true);
assert.equal(keys.privateKey.type, "private");
}
QUnit.test("vault: Test conversion utils", async function (assert) {
assert.expect(7);
let text = "hello world";
let buf = utils.fromBinary(text);
assert.equal(true, buf instanceof ArrayBuffer);
assert.equal(text, utils.toBinary(buf));
assert.equal("", utils.toBinary(false));
text = "ImhlbGxvIHdvcmxkIg==";
buf = utils.fromBase64(text);
assert.equal(true, buf instanceof ArrayBuffer);
assert.equal(text, utils.toBase64(buf));
assert.equal("", utils.toBase64(false));
assert.equal("Hello World", utils.capitalize("hello world"));
});
QUnit.test("vault: Test generation utils", async function (assert) {
assert.expect(12);
let data = utils.generate_bytes(5);
assert.equal(true, data instanceof Uint8Array);
assert.equal(data.length, 5);
data = utils.generate_bytes(10);
assert.equal(data.length, 10);
data = utils.generate_iv_base64();
assert.equal(typeof data, "string");
assert.notEqual(data, utils.generate_iv_base64());
data = await utils.generate_key();
assert.equal(true, data instanceof CryptoKey);
data = await utils.generate_key_pair();
is_keypair(data, assert);
data = utils.generate_secret(10, "01");
assert.equal(data.length, 10);
let valid = true;
for (const c of data) if ("01".indexOf(c) < 0) valid = false;
assert.equal(valid, true);
});
QUnit.test("vault: Test asymmetric encryption", async function (assert) {
assert.expect(2);
const text = "hello world";
const key = await utils.generate_key_pair();
const crypted = await utils.asym_encrypt(key.publicKey, text);
assert.equal("string", typeof crypted);
assert.strictEqual(
text,
await utils.asym_decrypt(key.privateKey, crypted)
);
});
QUnit.test("vault: Test symmetric encryption", async function (assert) {
assert.expect(2);
const text = "hello world";
const key = await utils.generate_key();
const iv = utils.generate_iv_base64();
const crypted = await utils.sym_encrypt(key, text, iv);
assert.equal("string", typeof crypted);
assert.strictEqual(text, await utils.sym_decrypt(key, crypted, iv));
});
QUnit.test("vault: Test import/export", async function (assert) {
assert.expect(3);
const key = await utils.generate_key_pair();
let exported = await utils.export_public_key(key.publicKey);
let tmp = await utils.load_public_key(exported);
assert.deepEqual(key.publicKey, tmp);
const iv = utils.generate_bytes(10);
const salt = utils.generate_bytes(10);
const wrapper = await utils.derive_key("test", salt, 4000);
exported = await utils.export_private_key(key.privateKey, wrapper, iv);
tmp = await utils.load_private_key(
exported,
wrapper,
utils.toBase64(iv)
);
assert.deepEqual(key.privateKey, tmp);
const master_key = await utils.generate_key();
exported = await utils.wrap(master_key, key.publicKey);
tmp = await utils.unwrap(exported, key.privateKey);
assert.deepEqual(master_key, tmp);
});
QUnit.test("vault: Test vault class", async function (assert) {
assert.expect(12);
var vault = require("vault");
await vault._initialize_keys();
is_keypair(vault.keys, assert);
vault.keys = undefined;
await vault._import_from_store();
is_keypair(vault.keys, assert);
vault.keys = undefined;
await vault._import_from_database();
is_keypair(vault.keys, assert);
});
QUnit.test("vault: Importer/exporter", async function (assert) {
// The exporter won't skip empty keys
const child = {
uuid: "42a",
note: "test note child",
name: "test child",
url: "child.example.org",
fields: [],
files: [],
childs: [],
};
const data = {
type: "raw",
data: [
child,
{
uuid: "42",
note: "test note",
name: "test name",
url: "test.example.org",
fields: [
{name: "a", value: "Hello World"},
{name: "secret", value: "dlrow olleh"},
],
files: [],
childs: [child, child],
},
child,
],
};
assert.expect(2);
var Exporter = require("vault.export");
var Importer = require("vault.import");
var vault = require("vault");
await vault._initialize_keys();
const master_key = await utils.generate_key();
const importer = new Importer();
const imported = await importer.import(
master_key,
"test.json",
JSON.stringify(data)
);
const exporter = new Exporter();
const exported = await exporter.export(
master_key,
"test.json",
JSON.stringify(imported)
);
assert.equal(exported.type, "encrypted");
const pass = await utils.derive_key(
"test",
utils.fromBase64(exported.salt),
exported.iterations
);
const tmp = JSON.parse(
await utils.sym_decrypt(pass, exported.data, exported.iv)
);
assert.deepEqual(tmp, data.data);
});
}
);
});