Initial commit: OCA Server Auth packages (29 packages)

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

View file

@ -0,0 +1,47 @@
# Vault
Odoo addon: vault
## Installation
```bash
pip install odoo-bringout-oca-server-auth-vault
```
## Dependencies
This addon depends on:
- base_setup
- web
## Manifest Information
- **Name**: Vault
- **Version**: 16.0.1.0.3
- **Category**: Vault
- **License**: AGPL-3
- **Installable**: False
## Source
Based on [OCA/server-auth](https://github.com/OCA/server-auth) branch 16.0, addon `vault`.
## License
This package maintains the original AGPL-3 license from the upstream Odoo project.
## Documentation
- Overview: doc/OVERVIEW.md
- Architecture: doc/ARCHITECTURE.md
- Models: doc/MODELS.md
- Controllers: doc/CONTROLLERS.md
- Wizards: doc/WIZARDS.md
- Reports: doc/REPORTS.md
- Security: doc/SECURITY.md
- Install: doc/INSTALL.md
- Usage: doc/USAGE.md
- Configuration: doc/CONFIGURATION.md
- Dependencies: doc/DEPENDENCIES.md
- Troubleshooting: doc/TROUBLESHOOTING.md
- FAQ: doc/FAQ.md

View file

@ -0,0 +1,32 @@
# Architecture
```mermaid
flowchart TD
U[Users] -->|HTTP| V[Views and QWeb Templates]
V --> C[Controllers]
V --> W[Wizards Transient Models]
C --> M[Models and ORM]
W --> M
M --> R[Reports]
DX[Data XML] --> M
S[Security ACLs and Groups] -. enforces .-> M
subgraph Vault Module - vault
direction LR
M:::layer
W:::layer
C:::layer
V:::layer
R:::layer
S:::layer
DX:::layer
end
classDef layer fill:#eef8ff,stroke:#6ea8fe,stroke-width:1px
```
Notes
- Views include tree/form/kanban templates and report templates.
- Controllers provide website/portal routes when present.
- Wizards are UI flows implemented with `models.TransientModel`.
- Data XML loads data/demo records; Security defines groups and access.

View file

@ -0,0 +1,3 @@
# Configuration
Refer to Odoo settings for vault. Configure related models, access rights, and options as needed.

View file

@ -0,0 +1,17 @@
# Controllers
HTTP routes provided by this module.
```mermaid
sequenceDiagram
participant U as User/Client
participant C as Module Controllers
participant O as ORM/Views
U->>C: HTTP GET/POST (routes)
C->>O: ORM operations, render templates
O-->>U: HTML/JSON/PDF
```
Notes
- See files in controllers/ for route definitions.

View file

@ -0,0 +1,6 @@
# Dependencies
This addon depends on:
- [base_setup](../../odoo-bringout-oca-ocb-base_setup)
- [web](../../odoo-bringout-oca-ocb-web)

View file

@ -0,0 +1,4 @@
# FAQ
- Q: Which Odoo version? A: 16.0 (OCA/OCB packaged).
- Q: How to enable? A: Start server with --addon vault or install in UI.

View file

@ -0,0 +1,7 @@
# Install
```bash
pip install odoo-bringout-oca-server-auth-vault"
# or
uv pip install odoo-bringout-oca-server-auth-vault"
```

View file

@ -0,0 +1,25 @@
# Models
Detected core models and extensions in vault.
```mermaid
classDiagram
class res_users_key
class vault
class vault_abstract
class vault_abstract_field
class vault_entry
class vault_field
class vault_file
class vault_inbox
class vault_inbox_log
class vault_log
class vault_right
class vault_tag
class res_config_settings
class res_users
```
Notes
- Classes show model technical names; fields omitted for brevity.
- Items listed under _inherit are extensions of existing models.

View file

@ -0,0 +1,6 @@
# Overview
Packaged Odoo addon: vault. Provides features documented in upstream Odoo 16 under this addon.
- Source: OCA/OCB 16.0, addon vault
- License: LGPL-3

View file

@ -0,0 +1,3 @@
# Reports
This module does not define custom reports.

View file

@ -0,0 +1,46 @@
# Security
Access control and security definitions in vault.
## Access Control Lists (ACLs)
Model access permissions defined in:
- **[ir.model.access.csv](../vault/security/ir.model.access.csv)**
- 15 model access rules
## Record Rules
Row-level security rules defined in:
- **[ir_rule.xml](../vault/security/ir_rule.xml)**
## Security Groups & Configuration
Security groups and permissions defined in:
- **[ir_rule.xml](../vault/security/ir_rule.xml)**
- **[vault_security.xml](../vault/security/vault_security.xml)**
- 2 security groups defined
```mermaid
graph TB
subgraph "Security Layers"
A[Users] --> B[Groups]
B --> C[Access Control Lists]
C --> D[Models]
B --> E[Record Rules]
E --> F[Individual Records]
end
```
Security files overview:
- **[ir.model.access.csv](../vault/security/ir.model.access.csv)**
- Model access permissions (CRUD rights)
- **[ir_rule.xml](../vault/security/ir_rule.xml)**
- Security groups, categories, and XML-based rules
- **[vault_security.xml](../vault/security/vault_security.xml)**
- Security groups, categories, and XML-based rules
Notes
- Access Control Lists define which groups can access which models
- Record Rules provide row-level security (filter records by user/group)
- Security groups organize users and define permission sets
- All security is enforced at the ORM level by Odoo

View file

@ -0,0 +1,5 @@
# Troubleshooting
- Ensure Python and Odoo environment matches repo guidance.
- Check database connectivity and logs if startup fails.
- Validate that dependent addons listed in DEPENDENCIES.md are installed.

View file

@ -0,0 +1,7 @@
# Usage
Start Odoo including this addon (from repo root):
```bash
python3 scripts/nix_odoo_web_server.py --db-name mydb --addon vault
```

View file

@ -0,0 +1,3 @@
# Wizards
This module does not include UI wizards.

View file

@ -0,0 +1,43 @@
[project]
name = "odoo-bringout-oca-server-auth-vault"
version = "16.0.0"
description = "Vault - Password vault integration in Odoo"
authors = [
{ name = "Ernad Husremovic", email = "hernad@bring.out.ba" }
]
dependencies = [
"odoo-bringout-oca-ocb-base_setup>=16.0.0",
"odoo-bringout-oca-ocb-web>=16.0.0",
"requests>=2.25.1"
]
readme = "README.md"
requires-python = ">= 3.11"
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Office/Business",
]
[project.urls]
homepage = "https://github.com/bringout/0"
repository = "https://github.com/bringout/0"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.metadata]
allow-direct-references = true
[tool.hatch.build.targets.wheel]
packages = ["vault"]
[tool.rye]
managed = true
dev-dependencies = [
"pytest>=8.4.1",
]

View file

@ -0,0 +1,103 @@
=====
Vault
=====
..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:c0c446463d63752dc25080d52e0132ae87b0b43fd51edc7915ed1b919763b40e
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
:target: https://odoo-community.org/page/development-status
:alt: Beta
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--auth-lightgray.png?logo=github
:target: https://github.com/OCA/server-auth/tree/16.0/vault
:alt: OCA/server-auth
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
:target: https://translation.odoo-community.org/projects/server-auth-16-0/server-auth-16-0-vault
:alt: Translate me on Weblate
.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
:target: https://runboat.odoo-community.org/builds?repo=OCA/server-auth&target_branch=16.0
:alt: Try me on Runboat
|badge1| |badge2| |badge3| |badge4| |badge5|
This module implements a vault for secrets and files using end-to-end-encryption. The encryption and decryption happens in the browser using a vault specific shared master key. The master keys are encrypted using asymmetrically. For this the user has to enter a second password on the first login or if he needs to access data in a vault. The asymmetric keys are stored for a certain time in the browser storage.
The server can never access the secrets with the information available. Only people registered in the vault can decrypt or encrypt values in a vault. The meta data isn't encrypted to be able to search/filter for entries more easily.
This modules requires a secure context for the browser to work properly and therefore HTTPS support is required.
The `vault-recovery <https://github.com/fkantelberg/vault-recovery>`_ project focuses on disaster recovery in case of an incident to recover secrets from old database backups or old exports.
**Table of contents**
.. contents::
:local:
Known issues / Roadmap
======================
* Field and file history for restoration
* Import improvement
* Support challenge-response/FIDO2
* Support for argon2 and kdbx v4
* 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.
If you want to move entries between vaults you can use the export -> import option.
* HTTPS or localhost (secure browser context) is required for the client side encryption
Bug Tracker
===========
Bugs are tracked on `GitHub Issues <https://github.com/OCA/server-auth/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
`feedback <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**>`_.
Do not contact contributors directly about support or help with technical issues.
Credits
=======
Authors
~~~~~~~
* initOS GmbH
Contributors
~~~~~~~~~~~~
* Florian Kantelberg <florian.kantelberg@initos.com>
* `Tecnativa <https://www.tecnativa.com>`_:
* Carlos Roca
Maintainers
~~~~~~~~~~~
This module is maintained by the OCA.
.. image:: https://odoo-community.org/logo.png
:alt: Odoo Community Association
:target: https://odoo-community.org
OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.
This module is part of the `OCA/server-auth <https://github.com/OCA/server-auth/tree/16.0/vault>`_ project on GitHub.
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

View file

@ -0,0 +1,143 @@
::
┌───────┐ ┏━━━━━━━━━━━━━┓ ╔═══════════╗
│ input │ ┃ unencrypted ┃ ║ encrypted ║
└───────┘ ┗━━━━━━━━━━━━━┛ ╚═══════════╝
Vault
=====
Each vault stores entries with enrypted fields and files in a tree like structure. The access is controlled per vault. Every added user can read the secrets of a vault. Otherwise the users can receive permission to share the vault with other users, to write secrets in the vault, or to delete entries of the vault. The databases stores the public and password protected private key of each user. The password used for the private key is derived from a password entered by the user and should be different than the password used for the login. Keep in mind that the meta information like field name or file names aren't encrypted.
Shared-key encryption
=====================
To be able to securely share sensitive data between all users a shared-key encryption is used. All users share a common secret for each vault. This secret is encrypted by the public key of each user to grant access to the user by using the private key to restore the secret.
Encryption of master key
------------------------
::
. ┏━━━━━━━━━━━━┓
┃ Master key ┃
┗━━━━━━━━━━━━┛
┏━━━━━━━━━━━━━━━━━┓ ┃
┃ User ┃ ▼
┃ ┃ ┏━━━━━━━━━┓
┃ ┏━━━━━━━━━━━━━┓ ┃ ┃ encrypt ┃ ╔════════════╗
┃ ┃ Public key ┃━━━━▶┃ (RSA) ┃━━━━━▶║ Master key ║
┃ ┗━━━━━━━━━━━━━┛ ┃ ┗━━━━━━━━━┛ ╚════════════╝
┃ ╔═════════════╗ ┃
┃ ║ Private key ║ ┃
┃ ╚═════════════╝ ┃
┗━━━━━━━━━━━━━━━━━┛
Decryption of master key
------------------------
::
. ┌──────────┐ ┏━━━━━━━━━━┓
│ Password │━━━━▶┃ derive ┃
└──────────┘ ┃ (PBKDF2) ┃
┗━━━━━━━━━━┛
┏━━━━━━━━━━━━━━━━━┓ ▼ ╔════════════╗
┃ User ┃ ┏━━━━━━━━━━┓ ║ Master key ║
┃ ┃ ┃ Password ┃ ╚════════════╝
┃ ┏━━━━━━━━━━━━━┓ ┃ ┗━━━━━━━━━━┛ ┃
┃ ┃ Public key ┃ ┃ ┃ ▼
┃ ┗━━━━━━━━━━━━━┛ ┃ ▼ ┏━━━━━━━━━┓
┃ ╔═════════════╗ ┃ ┏━━━━━━━━┓ ┏━━━━━━━━━━━━━┓ ┃ decrypt ┃ ┏━━━━━━━━━━━━┓
┃ ║ Private key ║━━━━━┃ unlock ┃━━▶┃ Private key ┃━━━▶┃ (RSA) ┃━━━━━▶┃ Master key ┃
┃ ╚═════════════╝ ┃ ┗━━━━━━━━┛ ┗━━━━━━━━━━━━━┛ ┗━━━━━━━━━┛ ┗━━━━━━━━━━━━┛
┗━━━━━━━━━━━━━━━━━┛
Symmetric encryption of the data
================================
The symmetric cipher AES is used with the common master key to encrypt/decrypt the secrets of the vaults. The encryption parameter and encrypted data is stored in the database while everything else happens in the browser.
Encryption of data
------------------
::
. ┏━━━━━━━━━━━━┓
┃ Master key ┃
┗━━━━━━━━━━━━┛
┃ ┏━━━━━━━━━━━━━━━━━━┓
▼ ┃ Database ┃
┏━━━━━━━━━┓ ┃ ┃
┏━━━━━━━━━━━━┓ ┃ encrypt ┃ ┃╔════════════════╗┃
┃ Plain text ┃━━▶┃ (AES) ┃━━━▶║ Encrypted data ║┃
┗━━━━━━━━━━━━┛ ┗━━━━━━━━━┛ ┃╚════════════════╝┃
┃ ┃┏━━━━━━━━━━━━━━━━┓┃
┗━━━━━━━━▶┃ Parameters ┃┃
┃┗━━━━━━━━━━━━━━━━┛┃
┗━━━━━━━━━━━━━━━━━━┛
Decryption of data
------------------
::
. ┏━━━━━━━━━━━━┓
┃ Master key ┃
┗━━━━━━━━━━━━┛
┏━━━━━━━━━━━━━━━━━━┓ ┃
┃ Database ┃ ▼
┃ ┃ ┏━━━━━━━━━┓
┃╔════════════════╗┃ ┃ decrypt ┃ ┏━━━━━━━━━━━━┓
┃║ Encrypted data ║━━━▶┃ (AES) ┃━━▶┃ Plain text ┃
┃╚════════════════╝┃ ┗━━━━━━━━━┛ ┗━━━━━━━━━━━━┛
┃┏━━━━━━━━━━━━━━━━┓┃ ▲
┃┃ Parameters ┃━━━━━━━━┛
┃┗━━━━━━━━━━━━━━━━┛┃
┗━━━━━━━━━━━━━━━━━━┛
Inbox
=====
This allows an user to receive encrypted secrets by external or internal Odoo users. External users have to use either the owner specific inbox link from his preferences or the link of an already created inbox. The value is symmetrically encrypted. The key for the encryption is wrapped with the public key of the user of the inbox to grant the user the access to the key. Internal users can directly send a secret from a vault entry to another user who has enabled this feature. If a direct link is used the access counter and expiration time can block an overwrite.
Encryption of inbox
-------------------
::
. ┏━━━━━━━━━━━━┓
┃ Plain data ┃
┗━━━━━━━━━━━━┛
┏━━━━━━━━━━━━━━━━━┓ ┃
┃ User ┃ ▼
┃ ┃ ┏━━━━━━━━━┓
┃ ┏━━━━━━━━━━━━━┓ ┃ ┃ encrypt ┃ ╔════════════════╗
┃ ┃ Public key ┃━━━━▶┃ (RSA) ┃━━━━━▶║ Encrypted data ║
┃ ┗━━━━━━━━━━━━━┛ ┃ ┗━━━━━━━━━┛ ╚════════════════╝
┃ ╔═════════════╗ ┃
┃ ║ Private key ║ ┃
┃ ╚═════════════╝ ┃
┗━━━━━━━━━━━━━━━━━┛
Decryption of inbox
-------------------
::
. ┌──────────┐ ┏━━━━━━━━━━┓
│ Password │━━━━▶┃ derive ┃
└──────────┘ ┃ (PBKDF2) ┃
┗━━━━━━━━━━┛
┏━━━━━━━━━━━━━━━━━┓ ▼ ╔════════════════╗
┃ User ┃ ┏━━━━━━━━━━┓ ║ Encrypted data ║
┃ ┃ ┃ Password ┃ ╚════════════════╝
┃ ┏━━━━━━━━━━━━━┓ ┃ ┗━━━━━━━━━━┛ ┃
┃ ┃ Public key ┃ ┃ ┃ ▼
┃ ┗━━━━━━━━━━━━━┛ ┃ ▼ ┏━━━━━━━━━┓
┃ ╔═════════════╗ ┃ ┏━━━━━━━━┓ ┏━━━━━━━━━━━━━┓ ┃ decrypt ┃ ┏━━━━━━━━━━━━┓
┃ ║ Private key ║━━━━━┃ unlock ┃━━▶┃ Private key ┃━━━▶┃ (RSA) ┃━━━━━▶┃ Plain data ┃
┃ ╚═════════════╝ ┃ ┗━━━━━━━━┛ ┗━━━━━━━━━━━━━┛ ┗━━━━━━━━━┛ ┗━━━━━━━━━━━━┛
┗━━━━━━━━━━━━━━━━━┛

View file

@ -0,0 +1,4 @@
# © 2021 Florian Kantelberg - initOS GmbH
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import controllers, models, wizards

View file

@ -0,0 +1,50 @@
# © 2021 Florian Kantelberg - initOS GmbH
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{
"name": "Vault",
"summary": "Password vault integration in Odoo",
"license": "AGPL-3",
"version": "16.0.1.0.3",
"website": "https://github.com/OCA/server-auth",
"application": True,
"author": "initOS GmbH, Odoo Community Association (OCA)",
"category": "Vault",
"depends": ["base_setup", "web"],
"data": [
"security/ir.model.access.csv",
"security/ir_rule.xml",
"security/vault_security.xml",
"views/res_config_settings_views.xml",
"views/res_users_views.xml",
"views/vault_entry_views.xml",
"views/vault_field_views.xml",
"views/vault_file_views.xml",
"views/vault_log_views.xml",
"views/vault_inbox_views.xml",
"views/vault_right_views.xml",
"views/vault_views.xml",
"views/menuitems.xml",
"views/templates.xml",
"wizards/vault_export_wizard.xml",
"wizards/vault_import_wizard.xml",
"wizards/vault_send_wizard.xml",
"wizards/vault_store_wizard.xml",
],
"assets": {
"vault.assets_frontend": [
"vault/static/src/common/*.js",
"vault/static/src/frontend/*.js",
],
"web.assets_backend": [
"vault/static/lib/**/*.min.js",
"vault/static/src/**/*.xml",
"vault/static/src/common/*.js",
"vault/static/src/backend/*.scss",
"vault/static/src/backend/**/*.js",
],
"web.tests_assets": [
"vault/static/tests/**/*.js",
],
},
}

View file

@ -0,0 +1,4 @@
# © 2021 Florian Kantelberg - initOS GmbH
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import main

View file

@ -0,0 +1,152 @@
# © 2021 Florian Kantelberg - initOS GmbH
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
from odoo import _, http
from odoo.exceptions import AccessDenied
from odoo.http import request
_logger = logging.getLogger(__name__)
class Controller(http.Controller):
@http.route("/vault/inbox/<string:token>", type="http", auth="public")
def vault_inbox(self, token):
ctx = {"disable_footer": True, "token": token}
# Find the right token
inbox = request.env["vault.inbox"].sudo().find_inbox(token)
user = request.env["res.users"].sudo().find_user_of_inbox(token)
if len(inbox) == 1 and inbox.accesses > 0:
ctx.update({"name": inbox.name, "public": inbox.user_id.active_key.public})
elif len(inbox) == 0 and len(user) == 1:
ctx["public"] = user.active_key.public
# A valid token would mean we found a public key
if not ctx.get("public"):
ctx["error"] = _("Invalid token")
return request.render("vault.inbox", ctx)
# Just render if GET method
if request.httprequest.method != "POST":
return request.render("vault.inbox", ctx)
# Check the param
name = request.params.get("name")
secret = request.params.get("encrypted")
secret_file = request.params.get("encrypted_file")
filename = request.params.get("filename")
iv = request.params.get("iv")
key = request.params.get("key")
if not name:
ctx["error"] = _("Please specify a name")
return request.render("vault.inbox", ctx)
if not secret and not secret_file:
ctx["error"] = _("No secret found")
return request.render("vault.inbox", ctx)
if secret_file and not filename:
ctx["error"] = _("Missing filename")
return request.render("vault.inbox", ctx)
if not iv or not key:
ctx["error"] = _("Something went wrong with the encryption")
return request.render("vault.inbox", ctx)
try:
inbox.store_in_inbox(
name,
secret,
secret_file,
iv,
key,
user,
filename,
ip=request.httprequest.remote_addr,
)
except Exception as e:
_logger.exception(e)
ctx["error"] = _(
"An error occured. Please contact the user or administrator"
)
return request.render("vault.inbox", ctx)
ctx["message"] = _("Successfully stored")
return request.render("vault.inbox", ctx)
@http.route("/vault/public", type="json")
def vault_public(self, user_id):
"""Get the public key of a specific user"""
user = request.env["res.users"].sudo().browse(user_id).exists()
if not user or not user.keys:
return {}
return {"public_key": user.active_key.public}
@http.route("/vault/inbox/get", auth="user", type="json")
def vault_get_inbox(self):
inboxes = request.env.user.inbox_ids
return {inbox.token: inbox.key for inbox in inboxes}
@http.route("/vault/inbox/store", auth="user", type="json")
def vault_store_inbox(self, keys):
if not isinstance(keys, dict):
return
for inbox in request.env.user.inbox_ids:
key = keys.get(inbox.token)
if isinstance(key, str):
inbox.key = key
@http.route("/vault/keys/store", auth="user", type="json")
def vault_store_keys(self, **kwargs):
"""Store the key pair for the current user"""
return request.env["res.users.key"].store(**kwargs)
@http.route("/vault/keys/get", auth="user", type="json")
def vault_get_keys(self):
"""Get the currently active key pair"""
return request.env.user.get_vault_keys()
@http.route("/vault/rights/get", auth="user", type="json")
def vault_get_right_keys(self):
"""Get the master keys from the vault.right records"""
rights = request.env.user.vault_right_ids
return {right.vault_id.uuid: right.key for right in rights}
@http.route("/vault/rights/store", auth="user", type="json")
def vault_store_right_keys(self, keys):
"""Store the master keys to the specific vault.right records"""
if not isinstance(keys, dict):
return
for right in request.env.user.vault_right_ids:
master_key = keys.get(right.vault_id.uuid)
if isinstance(master_key, str):
right.sudo().key = master_key
@http.route("/vault/replace", auth="user", type="json")
def vault_replace(self, data):
"""Replace the master keys and values within a single transaction"""
if not isinstance(data, list):
return
vault = request.env["vault"].with_context(vault_skip_log=True)
for changes in data:
record = vault.env[changes["model"]].browse(changes["id"])
if not record.vault_id.allowed_write:
raise AccessDenied()
vault |= record.vault_id
if record._name in ("vault.field", "vault.file"):
record.write({k: v for k, v in changes.items() if k in ["iv", "value"]})
elif record._name == "vault.right":
record.write({k: v for k, v in changes.items() if k in ["key"]})
for v in vault:
v._log_entry("Replaced the keys", "info")
vault.sudo().write({"reencrypt_required": False})

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,19 @@
# © 2021 Florian Kantelberg - initOS GmbH
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import (
abstract_vault,
abstract_vault_field,
res_config_settings,
res_users,
res_users_key,
vault,
vault_entry,
vault_field,
vault_file,
vault_inbox,
vault_inbox_log,
vault_log,
vault_right,
vault_tag,
)

View file

@ -0,0 +1,77 @@
# © 2021 Florian Kantelberg - initOS GmbH
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
from odoo import _, api, models
from odoo.exceptions import AccessError
_logger = logging.getLogger(__name__)
class AbstractVault(models.AbstractModel):
"""Models must have the following fields:
`perm_user`: The permissions are computed for this user
`allowed_read`: The current user can read from the vault
`allowed_create`: The current user can read from the vault
`allowed_write`: The current user has write access to the vault
`allowed_share`: The current user can share the vault with other users
`allowed_delete`: The current user can delete the vault or entries of it
"""
_name = "vault.abstract"
_description = _("Abstract model to implement general access rights")
@api.model
def raise_access_error(self):
raise AccessError(
_(
"The requested operation can not be completed due to security "
"restrictions."
)
)
def check_access_rule(self, operation):
super().check_access_rule(operation)
if self.env.su:
return
# We have to recompute if the user of the environment changed
if self.env.user != self.mapped("perm_user"):
vault = self if self._name == "vault" else self.mapped("vault_id")
vault._compute_access()
# Shortcut for vault.right because only the share right is required
if self._name == "vault.right":
if not self.filtered("allowed_share"):
self.raise_access_error()
return
# Check the operation and matching permissions
if operation == "read" and not self.filtered("allowed_read"):
self.raise_access_error()
if operation == "create" and not self.filtered("allowed_create"):
self.raise_access_error()
if operation == "write" and not self.filtered("allowed_write"):
self.raise_access_error()
if operation == "unlink" and not self.filtered("allowed_delete"):
self.raise_access_error()
def _log_entry(self, msg, state):
raise NotImplementedError()
def log_entry(self, msg):
return self._log_entry(msg, None)
def log_info(self, msg):
return self._log_entry(msg, "info")
def log_warn(self, msg):
return self._log_entry(msg, "warn")
def log_error(self, msg):
return self._log_entry(msg, "error")

View file

@ -0,0 +1,57 @@
# © 2021 Florian Kantelberg - initOS GmbH
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
from odoo import _, api, fields, models
_logger = logging.getLogger(__name__)
class AbstractVaultField(models.AbstractModel):
_name = "vault.abstract.field"
_description = _("Abstract model to implement basic fields for encryption")
entry_id = fields.Many2one("vault.entry", ondelete="cascade", required=True)
entry_name = fields.Char(related="entry_id.complete_name")
vault_id = fields.Many2one(related="entry_id.vault_id")
master_key = fields.Char(compute="_compute_master_key", store=False)
perm_user = fields.Many2one(related="vault_id.perm_user", store=False)
allowed_read = fields.Boolean(related="vault_id.allowed_read", store=False)
allowed_create = fields.Boolean(related="vault_id.allowed_create", store=False)
allowed_write = fields.Boolean(related="vault_id.allowed_write", store=False)
allowed_share = fields.Boolean(related="vault_id.allowed_share", store=False)
allowed_delete = fields.Boolean(related="vault_id.allowed_delete", store=False)
name = fields.Char(required=True)
iv = fields.Char()
@api.depends("entry_id.vault_id.master_key")
def _compute_master_key(self):
for rec in self:
rec.master_key = rec.vault_id.master_key
def log_change(self, action):
if self.env.context.get("vault_skip_log"):
return
for rec in self:
rec.entry_id.log_info(
f"{action} value {rec.name} of {rec.entry_id.complete_name} "
f"by {self.env.user.display_name}"
)
@api.model_create_multi
def create(self, vals_list):
res = super().create(vals_list)
res.log_change("Created")
return res
def unlink(self):
self.log_change("Deleted")
return super().unlink()
def write(self, values):
self.log_change("Changed")
return super().write(values)

View file

@ -0,0 +1,16 @@
# © 2021 Florian Kantelberg - initOS GmbH
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = "res.config.settings"
module_vault_share = fields.Boolean()
group_vault_export = fields.Boolean(
"Export Vaults", implied_group="vault.group_vault_export"
)
group_vault_import = fields.Boolean(
"Import Vaults", implied_group="vault.group_vault_import"
)

View file

@ -0,0 +1,78 @@
# © 2021 Florian Kantelberg - initOS GmbH
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
from uuid import uuid4
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
class ResUsers(models.Model):
_inherit = "res.users"
active_key = fields.Many2one(
"res.users.key",
compute="_compute_active_key",
store=False,
)
keys = fields.One2many("res.users.key", "user_id", readonly=True)
vault_right_ids = fields.One2many("vault.right", "user_id", readonly=True)
inbox_ids = fields.One2many("vault.inbox", "user_id")
inbox_enabled = fields.Boolean(default=True)
inbox_link = fields.Char(compute="_compute_inbox_link", readonly=True, store=False)
inbox_token = fields.Char(default=lambda self: uuid4(), readonly=True)
@api.depends("keys", "keys.current")
def _compute_active_key(self):
for rec in self:
keys = rec.sudo().keys.filtered("current")
rec.active_key = keys[0] if keys else None
@api.depends("inbox_token")
def _compute_inbox_link(self):
base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url")
for rec in self:
rec.inbox_link = f"{base_url}/vault/inbox/{rec.inbox_token}"
@api.model
def action_get_vault(self):
action = self.sudo().env.ref("vault.action_res_users_keys")
result = action.read()[0]
result["res_id"] = self.env.uid
return result
def action_new_inbox_token(self):
self.ensure_one()
self.sudo().inbox_token = uuid4()
return self.action_get_vault()
def action_invalidate_key(self):
"""Disable the current key and remove all accesses to the vaults"""
self.ensure_one()
self.keys.write({"current": False})
self.vault_right_ids.sudo().unlink()
self.inbox_ids.unlink()
self.env["vault"].search([])._compute_access()
return self.action_get_vault()
@api.model
def find_user_of_inbox(self, token):
return self.search([("inbox_token", "=", token), ("inbox_enabled", "=", True)])
def get_vault_keys(self):
self.ensure_one()
if not self.active_key:
return {}
return {
"iterations": self.active_key.iterations,
"iv": self.active_key.iv,
"private": self.active_key.private,
"public": self.active_key.public,
"salt": self.active_key.salt,
"uuid": self.active_key.uuid,
"version": self.active_key.version,
}

View file

@ -0,0 +1,82 @@
# © 2021 Florian Kantelberg - initOS GmbH
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
import re
from hashlib import sha256
from uuid import uuid4
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
_logger = logging.getLogger(__name__)
class ResUsersKey(models.Model):
_name = "res.users.key"
_description = _("User data of a vault")
_rec_name = "fingerprint"
_order = "create_date DESC"
user_id = fields.Many2one("res.users", required=True)
uuid = fields.Char(default=lambda self: uuid4(), required=True, readonly=True)
current = fields.Boolean(default=True, readonly=True)
fingerprint = fields.Char(compute="_compute_fingerprint", store=True)
public = fields.Char(required=True, readonly=True)
salt = fields.Char(required=True, readonly=True)
iv = fields.Char(required=True, readonly=True)
iterations = fields.Integer(required=True, readonly=True)
version = fields.Integer(readonly=True)
# Encrypted with master password of user
private = fields.Char(required=True, readonly=True)
@api.depends("public")
def _compute_fingerprint(self):
for rec in self:
if rec.public:
hashed = sha256(rec.public.encode()).hexdigest()
rec.fingerprint = ":".join(re.findall(r".{2}", hashed))
else:
rec.fingerprint = False
def _prepare_values(self, iterations, iv, private, public, salt, version):
return {
"iterations": iterations,
"iv": iv,
"private": private,
"public": public,
"salt": salt,
"user_id": self.env.uid,
"current": True,
"version": version,
}
def store(self, iterations, iv, private, public, salt, version):
if not all(isinstance(x, str) and x for x in [public, private, iv, salt]):
raise ValidationError(_("Invalid parameter"))
if not isinstance(iterations, int) or iterations < 4000:
raise ValidationError(_("Invalid parameter"))
if not isinstance(version, int):
raise ValidationError(_("Invalid parameter"))
domain = [
("user_id", "=", self.env.uid),
("private", "=", private),
]
key = self.search(domain)
if not key:
# Disable all current keys
self.env.user.keys.write({"current": False})
rec = self.create(
self._prepare_values(iterations, iv, private, public, salt, version)
)
return rec.uuid
return False
def extract_public_key(self, user):
user = self.sudo().search([("user_id", "=", user), ("current", "=", True)])
return user.public or None

View file

@ -0,0 +1,165 @@
# © 2021-2024 Florian Kantelberg - initOS GmbH
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
from uuid import uuid4
from odoo import _, api, fields, models
_logger = logging.getLogger(__name__)
class Vault(models.Model):
_name = "vault"
_description = _("Vault")
_inherit = ["vault.abstract"]
_order = "name"
user_id = fields.Many2one(
"res.users",
"Owner",
readonly=True,
default=lambda self: self.env.user,
required=True,
)
right_ids = fields.One2many(
"vault.right",
"vault_id",
"Rights",
default=lambda self: self._get_default_rights(),
)
entry_ids = fields.One2many("vault.entry", "vault_id", "Entries")
field_ids = fields.One2many("vault.field", "vault_id", "Fields")
file_ids = fields.One2many("vault.file", "vault_id", "Files")
log_ids = fields.One2many("vault.log", "vault_id", "Log", readonly=True)
reencrypt_required = fields.Boolean(default=False)
# Access control
perm_user = fields.Many2one("res.users", compute="_compute_access", store=False)
allowed_read = fields.Boolean(compute="_compute_access", store=False)
allowed_create = fields.Boolean(compute="_compute_access", store=False)
allowed_share = fields.Boolean(compute="_compute_access", store=False)
allowed_write = fields.Boolean(compute="_compute_access", store=False)
allowed_delete = fields.Boolean(compute="_compute_access", store=False)
master_key = fields.Char(
compute="_compute_master_key",
inverse="_inverse_master_key",
store=False,
)
uuid = fields.Char(default=lambda self: uuid4(), required=True, readonly=True)
name = fields.Char(required=True)
note = fields.Text()
_sql_constraints = [
("uuid_uniq", "UNIQUE(uuid)", _("The UUID must be unique.")),
]
@api.depends("right_ids.user_id")
def _compute_access(self):
user = self.env.user
for rec in self.sudo():
rec.perm_user = user.id
if user == rec.user_id:
rec.write(
{
"allowed_create": True,
"allowed_share": True,
"allowed_write": True,
"allowed_delete": True,
"allowed_read": True,
}
)
continue
rights = rec.right_ids
rec.allowed_read = user in rights.mapped("user_id")
rec.allowed_create = user in rights.filtered("perm_create").mapped(
"user_id"
)
rec.allowed_share = user in rights.filtered("perm_share").mapped("user_id")
rec.allowed_write = user in rights.filtered("perm_write").mapped("user_id")
rec.allowed_delete = user in rights.filtered("perm_delete").mapped(
"user_id"
)
@api.depends("right_ids.key")
def _compute_master_key(self):
domain = [("user_id", "=", self.env.uid)]
for rec in self:
rights = rec.right_ids.filtered_domain(domain)
rec.master_key = rights[0].key if rights else False
def _inverse_master_key(self):
domain = [("user_id", "=", self.env.uid)]
for rec in self:
rights = rec.right_ids.filtered_domain(domain)
if rights and not rights.key:
rights.key = rec.master_key
def _get_default_rights(self):
return [
(
0,
0,
{
"user_id": self.env.uid,
"perm_create": True,
"perm_write": True,
"perm_delete": True,
"perm_share": True,
},
)
]
def _log_entry(self, msg, state):
self.ensure_one()
return (
self.env["vault.log"]
.sudo()
.create(
{
"vault_id": self.id,
"user_id": self.env.uid,
"message": msg,
"state": state,
}
)
)
def share_public_keys(self):
self.ensure_one()
result = []
for right in self.right_ids:
result.append({"user": right.user_id.id, "public": right.public_key})
return result
def action_open_import_wizard(self):
self.ensure_one()
wizard = self.env.ref("vault.view_vault_import_wizard")
return {
"name": _("Import from file"),
"type": "ir.actions.act_window",
"view_mode": "form",
"res_model": "vault.import.wizard",
"views": [(wizard.id, "form")],
"view_id": wizard.id,
"target": "new",
"context": {"default_vault_id": self.id},
}
def action_open_export_wizard(self):
self.ensure_one()
wizard = self.env.ref("vault.view_vault_export_wizard")
return {
"name": _("Export to file"),
"type": "ir.actions.act_window",
"view_mode": "form",
"res_model": "vault.export.wizard",
"views": [(wizard.id, "form")],
"view_id": wizard.id,
"target": "new",
"context": {"default_vault_id": self.id},
}

View file

@ -0,0 +1,215 @@
# © 2021 Florian Kantelberg - initOS GmbH
# Copyright 2022 Tecnativa - Víctor Martínez
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
from datetime import datetime
from uuid import uuid4
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
_logger = logging.getLogger(__name__)
class VaultEntry(models.Model):
_name = "vault.entry"
_description = _("Entry inside a vault")
_inherit = ["vault.abstract"]
_order = "complete_name"
_rec_name = "complete_name"
parent_id = fields.Many2one(
"vault.entry",
"Parent",
ondelete="cascade",
domain="[('vault_id', '=', vault_id)]",
)
child_ids = fields.One2many("vault.entry", "parent_id", "Child")
vault_id = fields.Many2one("vault", "Vault", ondelete="cascade", required=True)
user_id = fields.Many2one(related="vault_id.user_id")
field_ids = fields.One2many("vault.field", "entry_id", "Fields")
file_ids = fields.One2many("vault.file", "entry_id", "Files")
log_ids = fields.One2many("vault.log", "entry_id", "Log", readonly=True)
perm_user = fields.Many2one(related="vault_id.perm_user", store=False)
allowed_read = fields.Boolean(related="vault_id.allowed_read", store=False)
allowed_create = fields.Boolean(related="vault_id.allowed_create", store=False)
allowed_share = fields.Boolean(related="vault_id.allowed_share", store=False)
allowed_write = fields.Boolean(related="vault_id.allowed_write", store=False)
allowed_delete = fields.Boolean(related="vault_id.allowed_delete", store=False)
complete_name = fields.Char(
compute="_compute_complete_name",
store=True,
readonly=True,
recursive=True,
)
uuid = fields.Char(default=lambda self: uuid4(), required=True, copy=False)
name = fields.Char(required=True)
url = fields.Char()
note = fields.Text()
tags = fields.Many2many("vault.tag")
expire_date = fields.Datetime("Expires on", default=False)
expired = fields.Boolean(
compute="_compute_expired",
search="_search_expired",
store=False,
)
_sql_constraints = [
("vault_uuid_uniq", "UNIQUE(vault_id, uuid)", _("The UUID must be unique.")),
]
@api.constrains("parent_id")
def _check_parent_id(self):
if not self._check_recursion():
raise ValidationError(_("You can not create recursive entries."))
@api.depends("name", "parent_id.complete_name")
def _compute_complete_name(self):
for rec in self:
if rec.parent_id:
rec.complete_name = f"{rec.parent_id.complete_name} / {rec.name}"
else:
rec.complete_name = rec.name
@api.model
def search_panel_select_range(self, field_name, **kwargs):
"""We add the following contexts related to searchpanel:
- entry_short_name: Show just the name instead of full path.
- from_search_panel: It will be used to overwrite domain.
Remove the limit of records (default is 200).
"""
return super(
VaultEntry,
self.with_context(from_search_panel=True, entry_short_name=True),
).search_panel_select_range(field_name, **kwargs)
def search_read(self, domain=None, fields=None, offset=0, limit=None, order=None):
"""Changes related to searchpanel:
- Add a domain to only show records with children.
"""
domain = domain if domain else []
if self.env.context.get("from_search_panel"):
domain += [("child_ids", "!=", False)]
return super().search_read(
domain=domain, fields=fields, offset=offset, limit=limit, order=order
)
def copy_data(self, default=None):
self.ensure_one()
if default is None:
default = {}
if "name" not in default:
default["name"] = _("%s (copy)", self.name)
if "field_ids" not in default:
default["field_ids"] = [
(0, 0, field.copy_data()[0]) for field in self.field_ids
]
if "file_ids" not in default:
default["file_ids"] = [
(0, 0, field.copy_data()[0]) for field in self.file_ids
]
return super().copy_data(default)
@api.depends("name", "complete_name")
def _compute_display_name(self):
if not self.env.context.get("entry_short_name", False):
return super()._compute_display_name()
for record in self:
record.display_name = record.name
@api.depends("expire_date")
def _compute_expired(self):
now = datetime.now()
for rec in self:
rec.expired = rec.expire_date and now > rec.expire_date
def _search_expired(self, operator, value):
if (operator not in ["=", "!="]) or (value not in [True, False]):
return []
if (operator, value) in [("=", True), ("!=", False)]:
return [("expire_date", "<", datetime.now())]
return ["|", ("expire_date", ">=", datetime.now()), ("expire_date", "=", False)]
def log_change(self, action):
if self.env.context.get("vault_skip_log"):
return
for rec in self:
rec.log_info(
_("%(action)s entry %(name)s by %(user)s")
% {
"action": action,
"name": rec.complete_name,
"user": rec.env.user.display_name,
}
)
@api.model_create_multi
def create(self, vals_list):
res = super().create(vals_list)
res.log_change("Created")
return res
def unlink(self):
self.log_change("Deleted")
return super().unlink()
def _log_entry(self, msg, state):
self.ensure_one()
return (
self.env["vault.log"]
.sudo()
.create(
{
"vault_id": self.vault_id.id,
"entry_id": self.id,
"user_id": self.env.uid,
"message": msg,
"state": state,
}
)
)
def action_open_import_wizard(self):
self.ensure_one()
wizard = self.env.ref("vault.view_vault_import_wizard")
return {
"name": _("Import from file"),
"type": "ir.actions.act_window",
"view_mode": "form",
"res_model": "vault.import.wizard",
"views": [(wizard.id, "form")],
"view_id": wizard.id,
"target": "new",
"context": {
"default_vault_id": self.vault_id.id,
"default_parent_id": self.id,
},
}
def action_open_export_wizard(self):
self.ensure_one()
wizard = self.env.ref("vault.view_vault_export_wizard")
return {
"name": _("Export to file"),
"type": "ir.actions.act_window",
"view_mode": "form",
"res_model": "vault.export.wizard",
"views": [(wizard.id, "form")],
"view_id": wizard.id,
"target": "new",
"context": {
"default_vault_id": self.vault_id.id,
"default_entry_id": self.id,
},
}

View file

@ -0,0 +1,17 @@
# © 2021 Florian Kantelberg - initOS GmbH
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
from odoo import _, fields, models
_logger = logging.getLogger(__name__)
class VaultField(models.Model):
_name = "vault.field"
_description = _("Field of a vault")
_order = "name"
_inherit = ["vault.abstract.field", "vault.abstract"]
value = fields.Char(required=True)

View file

@ -0,0 +1,25 @@
# © 2021 Florian Kantelberg - initOS GmbH
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
from odoo import _, api, fields, models
_logger = logging.getLogger(__name__)
class VaultFile(models.Model):
_name = "vault.file"
_description = _("File of a vault")
_order = "name"
_inherit = ["vault.abstract.field", "vault.abstract"]
value = fields.Binary(attachment=False)
@api.model
def search_read(self, *args, **kwargs):
if self.env.context.get("vault_reencrypt"):
return super(VaultFile, self.with_context(bin_size=False)).search_read(
*args, **kwargs
)
return super().search_read(*args, **kwargs)

View file

@ -0,0 +1,114 @@
# © 2021 Florian Kantelberg - initOS GmbH
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
from datetime import datetime, timedelta
from uuid import uuid4
from odoo import _, api, fields, models
_logger = logging.getLogger(__name__)
class VaultInbox(models.Model):
_name = "vault.inbox"
_description = _("Vault share incoming secrets")
token = fields.Char(default=lambda self: uuid4(), readonly=True, copy=False)
inbox_link = fields.Char(
compute="_compute_inbox_link",
readonly=True,
help="Using this link you can write to the current inbox. If you want people "
"to create new inboxes you should give them your inbox link from your key "
"management.",
)
user_id = fields.Many2one(
"res.users",
"Vault",
required=True,
)
name = fields.Char(required=True)
secret = fields.Char(readonly=True)
filename = fields.Char()
secret_file = fields.Binary(attachment=False, readonly=True)
key = fields.Char(required=True)
iv = fields.Char(required=True)
accesses = fields.Integer(
"Access counter",
default=1,
help="If this is 0 the inbox can't be written using the link",
)
expiration = fields.Datetime(
default=lambda self: datetime.now() + timedelta(days=7),
help="If expired the inbox can't be written using the link",
)
log_ids = fields.One2many("vault.inbox.log", "inbox_id", "Log", readonly=True)
_sql_constraints = [
(
"value_check",
"CHECK(secret IS NOT NULL OR secret_file IS NOT NULL)",
_("No value found"),
),
]
@api.depends("token")
def _compute_inbox_link(self):
base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url")
for rec in self:
rec.inbox_link = f"{base_url}/vault/inbox/{rec.token}"
def read(self, *args, **kwargs):
# Always load the binary instead of the size
return super(VaultInbox, self.with_context(bin_size=False)).read(
*args, **kwargs
)
@api.model
def find_inbox(self, token):
return self.search([("token", "=", token)])
def store_in_inbox(
self,
name,
secret,
secret_file,
iv,
key,
user,
filename,
ip=None,
):
log_info = {"name": user.name, "ip": ip or "n/a"}
if len(self) == 0:
log = _("Created by %(name)s via %(ip)s") % log_info
return self.create(
{
"name": name,
"accesses": 0,
"iv": iv,
"key": key,
"secret": secret or None,
"secret_file": secret_file or None,
"filename": filename,
"user_id": user.id,
"log_ids": [(0, 0, {"name": log})],
}
)
self.ensure_one()
if self.accesses > 0 and datetime.now() < self.expiration:
log = _("Written by %(name)s via %(ip)s") % log_info
self.write(
{
"accesses": self.accesses - 1,
"iv": iv,
"key": key,
"secret": secret or None,
"secret_file": secret_file or None,
"filename": filename,
"log_ids": [(0, 0, {"name": log})],
}
)
return self

View file

@ -0,0 +1,22 @@
# © 2021 Florian Kantelberg - initOS GmbH
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
from odoo import _, fields, models
_logger = logging.getLogger(__name__)
class VaultInboxLog(models.Model):
_name = "vault.inbox.log"
_description = _("Vault inbox log")
_order = "create_date DESC"
inbox_id = fields.Many2one(
"vault.inbox",
ondelete="cascade",
readonly=True,
required=True,
)
name = fields.Char(readonly=True)

View file

@ -0,0 +1,46 @@
# © 2021 Florian Kantelberg - initOS GmbH
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
from odoo import _, api, fields, models
_logger = logging.getLogger(__name__)
class VaultLog(models.Model):
_name = "vault.log"
_description = _("Log entry of a vault")
_order = "create_date DESC"
_rec_name = "message"
vault_id = fields.Many2one(
"vault",
"Vault",
ondelete="cascade",
required=True,
readonly=True,
)
entry_id = fields.Many2one(
"vault.entry",
"Entry",
ondelete="cascade",
readonly=True,
)
user_id = fields.Many2one("res.users", "User", required=True, readonly=True)
state = fields.Selection(lambda self: self._get_log_state(), readonly=True)
message = fields.Char(readonly=True, required=True)
def _get_log_state(self):
return [
("info", _("Information")),
("warn", _("Warning")),
("error", _("Error")),
]
@api.model_create_multi
def create(self, vals_list):
res = super().create(vals_list)
if not self.env.context.get("skip_log", False):
_logger.info("Vault log: %s", res.message)
return res

View file

@ -0,0 +1,110 @@
# © 2021 Florian Kantelberg - initOS GmbH
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import _, api, fields, models
class VaultRight(models.Model):
_name = "vault.right"
_description = _("Vault rights")
_inherit = ["vault.abstract"]
_order = "user_id"
vault_id = fields.Many2one(
"vault",
"Vault",
readonly=True,
required=True,
ondelete="cascade",
)
master_key = fields.Char(related="vault_id.master_key", readonly=True, store=False)
user_id = fields.Many2one(
"res.users",
"User",
domain=[("keys", "!=", False)],
required=True,
)
public_key = fields.Char(compute="_compute_public_key", readonly=True, store=False)
perm_create = fields.Boolean(
"Create",
default=lambda self: self._get_is_owner(),
help="Allow to create in the vault",
)
perm_write = fields.Boolean(
"Write",
default=lambda self: self._get_is_owner(),
help="Allow to write to the vault",
)
perm_share = fields.Boolean(
"Share",
default=lambda self: self._get_is_owner(),
help="Allow to share a vault with new users",
)
perm_delete = fields.Boolean(
"Delete",
default=lambda self: self._get_is_owner(),
help="Allow to delete a vault",
)
perm_user = fields.Many2one(related="vault_id.perm_user", store=False)
allowed_read = fields.Boolean(related="vault_id.allowed_read", store=False)
allowed_create = fields.Boolean(related="vault_id.allowed_create", store=False)
allowed_write = fields.Boolean(related="vault_id.allowed_write", store=False)
allowed_share = fields.Boolean(related="vault_id.allowed_share", store=False)
allowed_delete = fields.Boolean(related="vault_id.allowed_delete", store=False)
# Encrypted with the public key of the user
key = fields.Char()
_sql_constraints = (
("user_uniq", "UNIQUE(user_id, vault_id)", _("The user must be unique")),
)
def _get_is_owner(self):
return self.env.user == self.vault_id.user_id
@api.depends("user_id")
def _compute_public_key(self):
for rec in self:
rec.public_key = rec.user_id.active_key.public
def log_access(self):
for rec in self:
rights = ", ".join(
sorted(
["read"]
+ [
right
for right in ["create", "write", "share", "delete"]
if getattr(rec, f"perm_{right}", False)
]
)
)
rec.vault_id.log_info(
f"Grant access to user {rec.user_id.display_name}: {rights}"
)
@api.model_create_multi
def create(self, vals_list):
res = super().create(vals_list)
if not res.allowed_share and not res.env.su:
self.raise_access_error()
res.log_access()
return res
def write(self, values):
res = super().write(values)
perms = ["perm_write", "perm_delete", "perm_share", "perm_create"]
if any(x in values for x in perms):
self.log_access()
return res
def unlink(self):
for rec in self:
rec.vault_id.log_info(f"Removed user {self.user_id.display_name}")
rec.vault_id.reencrypt_required = True
return super().unlink()

View file

@ -0,0 +1,16 @@
# © 2021 Florian Kantelberg - initOS GmbH
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import _, fields, models
class VaultTag(models.Model):
_name = "vault.tag"
_description = _("Vault tag")
_order = "name"
name = fields.Char(required=True)
_sql_constraints = [
("name_uniq", "unique(name)", _("The tag must be unique!")),
]

View file

@ -0,0 +1,4 @@
* Florian Kantelberg <florian.kantelberg@initos.com>
* `Tecnativa <https://www.tecnativa.com>`_:
* Carlos Roca

View file

@ -0,0 +1,7 @@
This module implements a vault for secrets and files using end-to-end-encryption. The encryption and decryption happens in the browser using a vault specific shared master key. The master keys are encrypted using asymmetrically. For this the user has to enter a second password on the first login or if he needs to access data in a vault. The asymmetric keys are stored for a certain time in the browser storage.
The server can never access the secrets with the information available. Only people registered in the vault can decrypt or encrypt values in a vault. The meta data isn't encrypted to be able to search/filter for entries more easily.
This modules requires a secure context for the browser to work properly and therefore HTTPS support is required.
The `vault-recovery <https://github.com/fkantelberg/vault-recovery>`_ project focuses on disaster recovery in case of an incident to recover secrets from old database backups or old exports.

View file

@ -0,0 +1,14 @@
* Field and file history for restoration
* Import improvement
* Support challenge-response/FIDO2
* Support for argon2 and kdbx v4
* 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.
If you want to move entries between vaults you can use the export -> import option.
* HTTPS or localhost (secure browser context) is required for the client side encryption

View file

@ -0,0 +1,16 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_vault,access_vault,model_vault,base.group_user,1,1,1,1
access_vault_entry,access_vault_entry,model_vault_entry,base.group_user,1,1,1,1
access_vault_export_wizard,access_vault_export_wizard,model_vault_export_wizard,base.group_user,1,1,1,1
access_vault_field,access_vault_field,model_vault_field,base.group_user,1,1,1,1
access_vault_file,access_vault_file,model_vault_file,base.group_user,1,1,1,1
access_vault_import_wizard,access_vault_import_wizard,model_vault_import_wizard,base.group_user,1,1,1,1
access_vault_import_wizard_path,access_vault_import_wizard_path,model_vault_import_wizard_path,base.group_user,1,1,1,1
access_vault_inbox,access_vault_inbox,model_vault_inbox,base.group_user,1,1,1,1
access_vault_inbox_log,access_vault_inbox_log,model_vault_inbox_log,base.group_user,1,1,1,1
access_vault_log,access_vault_log,model_vault_log,base.group_user,1,0,0,0
access_vault_right,access_vault_right,model_vault_right,base.group_user,1,1,1,1
access_vault_send_wizard,access_vault_send_wizard,model_vault_send_wizard,base.group_user,1,1,1,1
access_vault_store_wizard,access_vault_store_wizard,model_vault_store_wizard,base.group_user,1,1,1,1
access_vault_tag,access_vault_tag,model_vault_tag,base.group_user,1,1,1,1
access_vault_users_key,access_res_users_key,model_res_users_key,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_vault access_vault model_vault base.group_user 1 1 1 1
3 access_vault_entry access_vault_entry model_vault_entry base.group_user 1 1 1 1
4 access_vault_export_wizard access_vault_export_wizard model_vault_export_wizard base.group_user 1 1 1 1
5 access_vault_field access_vault_field model_vault_field base.group_user 1 1 1 1
6 access_vault_file access_vault_file model_vault_file base.group_user 1 1 1 1
7 access_vault_import_wizard access_vault_import_wizard model_vault_import_wizard base.group_user 1 1 1 1
8 access_vault_import_wizard_path access_vault_import_wizard_path model_vault_import_wizard_path base.group_user 1 1 1 1
9 access_vault_inbox access_vault_inbox model_vault_inbox base.group_user 1 1 1 1
10 access_vault_inbox_log access_vault_inbox_log model_vault_inbox_log base.group_user 1 1 1 1
11 access_vault_log access_vault_log model_vault_log base.group_user 1 0 0 0
12 access_vault_right access_vault_right model_vault_right base.group_user 1 1 1 1
13 access_vault_send_wizard access_vault_send_wizard model_vault_send_wizard base.group_user 1 1 1 1
14 access_vault_store_wizard access_vault_store_wizard model_vault_store_wizard base.group_user 1 1 1 1
15 access_vault_tag access_vault_tag model_vault_tag base.group_user 1 1 1 1
16 access_vault_users_key access_res_users_key model_res_users_key base.group_user 1 1 1 1

View file

@ -0,0 +1,111 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="vault_access_default" model="ir.rule">
<field name="name">vault.access.default</field>
<field name="model_id" ref="vault.model_vault" />
<field
name="domain_force"
>['|', ('user_id', '=', user.id), ('right_ids.user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_user'))]" />
<field name="perm_create" eval="1" />
<field name="perm_write" eval="1" />
<field name="perm_unlink" eval="1" />
<field name="perm_read" eval="1" />
</record>
<record id="vault_log_access_default" model="ir.rule">
<field name="name">vault.log.access.read</field>
<field name="model_id" ref="vault.model_vault_log" />
<field
name="domain_force"
>['|', ('vault_id.user_id', '=', user.id), ('vault_id.right_ids.user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_user'))]" />
<field name="perm_create" eval="1" />
<field name="perm_write" eval="1" />
<field name="perm_unlink" eval="1" />
<field name="perm_read" eval="1" />
</record>
<record id="vault_entry_access_default" model="ir.rule">
<field name="name">vault.entry.access.default</field>
<field name="model_id" ref="vault.model_vault_entry" />
<field
name="domain_force"
>['|', ('vault_id.user_id', '=', user.id), ('vault_id.right_ids.user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_user'))]" />
<field name="perm_create" eval="1" />
<field name="perm_write" eval="1" />
<field name="perm_unlink" eval="1" />
<field name="perm_read" eval="1" />
</record>
<record id="vault_field_access_default" model="ir.rule">
<field name="name">vault.field.access.default</field>
<field name="model_id" ref="vault.model_vault_field" />
<field
name="domain_force"
>['|', ('vault_id.user_id', '=', user.id), ('vault_id.right_ids.user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_user'))]" />
<field name="perm_create" eval="1" />
<field name="perm_write" eval="1" />
<field name="perm_unlink" eval="1" />
<field name="perm_read" eval="1" />
</record>
<record id="vault_file_access_default" model="ir.rule">
<field name="name">vault.file.access.default</field>
<field name="model_id" ref="vault.model_vault_file" />
<field
name="domain_force"
>['|', ('vault_id.user_id', '=', user.id), ('vault_id.right_ids.user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_user'))]" />
<field name="perm_create" eval="1" />
<field name="perm_write" eval="1" />
<field name="perm_unlink" eval="1" />
<field name="perm_read" eval="1" />
</record>
<record id="res_users_key_access_default" model="ir.rule">
<field name="name">res.users.key.access.default</field>
<field name="model_id" ref="vault.model_res_users_key" />
<field name="domain_force">[('user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_user'))]" />
<field name="perm_create" eval="1" />
<field name="perm_write" eval="1" />
<field name="perm_unlink" eval="1" />
<field name="perm_read" eval="1" />
</record>
<record id="vault_inbox_owner" model="ir.rule">
<field name="name">vault.inbox.access.owner</field>
<field name="model_id" ref="vault.model_vault_inbox" />
<field name="domain_force">[('user_id', '=', user.id)]</field>
<field name="perm_create" eval="1" />
<field name="perm_write" eval="1" />
<field name="perm_unlink" eval="1" />
<field name="perm_read" eval="1" />
</record>
<record id="vault_right_access_default" model="ir.rule">
<field name="name">vault.right.access.default</field>
<field name="model_id" ref="vault.model_vault_right" />
<field
name="domain_force"
>[('vault_id.right_ids.user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_user'))]" />
<field name="perm_create" eval="1" />
<field name="perm_write" eval="1" />
<field name="perm_unlink" eval="1" />
<field name="perm_read" eval="1" />
</record>
<record id="vault_inbox_log_owner" model="ir.rule">
<field name="name">vault.inbox.log.access.owner</field>
<field name="model_id" ref="vault.model_vault_inbox_log" />
<field name="domain_force">[('inbox_id.user_id', '=', user.id)]</field>
<field name="perm_create" eval="1" />
<field name="perm_write" eval="1" />
<field name="perm_unlink" eval="1" />
<field name="perm_read" eval="1" />
</record>
</odoo>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="group_vault_export" model="res.groups">
<field name="name">Allow to export vaults</field>
<field name="category_id" ref="base.module_category_hidden" />
<field name="users" eval="[(4, ref('base.user_admin'))]" />
</record>
<record id="group_vault_import" model="res.groups">
<field name="name">Allow to import vaults</field>
<field name="category_id" ref="base.module_category_hidden" />
<field name="users" eval="[(4, ref('base.user_admin'))]" />
</record>
</odoo>

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

View file

@ -0,0 +1,11 @@
# © 2021 Florian Kantelberg - initOS GmbH
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import (
test_controller,
test_log,
test_rights,
test_user,
test_vault,
test_widgets,
)

View file

@ -0,0 +1,229 @@
# © 2021 Florian Kantelberg - initOS GmbH
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import json
import logging
from unittest.mock import MagicMock
from odoo.tests import TransactionCase
from odoo.tools import mute_logger
from odoo.addons.website.tools import MockRequest
from ..controllers import main
_logger = logging.getLogger(__name__)
class TestController(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.controller = main.Controller()
cls.user = cls.env["res.users"].create(
{"login": "test", "email": "test@test", "name": "test"}
)
cls.user.inbox_token = "42"
cls.user.keys.current = False
cls.key = cls.env["res.users.key"].create(
{
"user_id": cls.user.id,
"public": "a public key",
"salt": "42",
"iv": "2424",
"iterations": 4000,
"private": "24",
"current": True,
}
)
cls.inbox = cls.env["vault.inbox"].create(
{
"user_id": cls.user.id,
"name": "Inbox",
"key": "4",
"iv": "1",
"secret": "old secret",
"secret_file": "old file",
"accesses": 100,
}
)
@mute_logger("odoo.sql_db")
def test_vault_inbox(self):
def return_context(template, context):
self.assertEqual(template, "vault.inbox")
return json.dumps(context)
def load(response):
return json.loads(response.data)
with MockRequest(self.env) as request_mock:
request_mock.render = return_context
response = load(self.controller.vault_inbox(""))
self.assertIn("error", response)
response = load(self.controller.vault_inbox(self.user.inbox_token))
self.assertNotIn("error", response)
self.assertEqual(response["public"], self.user.active_key.public)
# Try to eliminate each error step by step
request_mock.httprequest.method = "POST"
request_mock.params = {}
response = load(self.controller.vault_inbox(self.user.inbox_token))
self.assertIn("error", response)
request_mock.params["name"] = "test"
response = load(self.controller.vault_inbox(self.user.inbox_token))
self.assertIn("error", response)
request_mock.params.update(
{"encrypted": "secret", "encrypted_file": "file"}
)
response = load(self.controller.vault_inbox(self.user.inbox_token))
self.assertIn("error", response)
request_mock.params["filename"] = "filename"
response = load(self.controller.vault_inbox(self.user.inbox_token))
self.assertIn("error", response)
self.assertEqual(self.inbox.secret, "old secret")
self.assertEqual(self.inbox.secret_file, b"old file")
# Store something successfully
request_mock.params.update({"iv": "iv", "key": "key"})
response = load(self.controller.vault_inbox(self.inbox.token))
self.assertNotIn("error", response)
self.assertEqual(self.inbox.secret, "secret")
self.assertEqual(self.inbox.secret_file, b"file")
# Test a duplicate inbox
self.inbox.copy().token = self.inbox.token
response = load(self.controller.vault_inbox(self.inbox.token))
self.assertIn("error", response)
def raise_error(*args, **kwargs):
raise TypeError()
# Catch internal errors
try:
request_mock.httprequest.remote_addr = "127.0.0.1"
self.env["vault.inbox"]._patch_method("store_in_inbox", raise_error)
response = load(self.controller.vault_inbox(self.user.inbox_token))
finally:
self.env["vault.inbox"]._revert_method("store_in_inbox")
self.assertIn("error", response)
@mute_logger("odoo.sql_db")
def test_vault_public(self):
with MockRequest(self.env):
no_key = self.env["res.users"].create(
{"login": "keyless", "email": "test@test", "name": "test"}
)
response = self.controller.vault_public(user_id=no_key.id)
self.assertEqual(response, {})
response = self.controller.vault_public(user_id=self.user.id)
self.assertEqual(response["public_key"], self.key.public)
@mute_logger("odoo.sql_db")
def test_vault_replace(self):
with MockRequest(self.env):
vault = self.env["vault"].create({"name": "Vault"})
right = vault.right_ids[:1]
entry = self.env["vault.entry"].create(
{"name": "Test Entry", "vault_id": vault.id}
)
field = self.env["vault.field"].create(
{"entry_id": entry.id, "name": "Test", "value": "hello"}
)
file = self.env["vault.file"].create(
{"entry_id": entry.id, "name": "Test", "value": b"hello"}
)
right.write({"key": "invalid"})
self.controller.vault_replace(None)
self.assertEqual(field.value, "hello")
self.assertEqual(file.value, b"hello")
vault.reencrypt_required = True
self.controller.vault_replace(
[
{"model": field._name, "id": field.id, "value": "test"},
{"model": file._name, "id": file.id, "value": "test"},
{"model": right._name, "id": right.id, "key": "changed"},
]
)
self.assertEqual(field.value, "test")
self.assertEqual(file.value, b"test")
self.assertEqual(right.key, "changed")
self.assertFalse(vault.reencrypt_required)
@mute_logger("odoo.sql_db")
def test_vault_store(
self,
):
with MockRequest(self.env):
mock = MagicMock()
try:
self.env["res.users.key"]._patch_method("store", mock)
self.controller.vault_store_keys()
mock.assert_called_once()
finally:
self.env["res.users.key"]._revert_method("store")
@mute_logger("odoo.sql_db")
def test_vault_keys_get(self):
with MockRequest(self.env):
mock = MagicMock()
try:
self.env["res.users"]._patch_method("get_vault_keys", mock)
self.controller.vault_get_keys()
mock.assert_called_once()
finally:
self.env["res.users"]._revert_method("get_vault_keys")
@mute_logger("odoo.sql_db")
def test_vault_right_keys(self):
with MockRequest(self.env):
self.assertFalse(self.controller.vault_get_right_keys())
# New vault with user as owner and only right
vault = self.env["vault"].create({"name": "Vault"})
response = self.controller.vault_get_right_keys()
self.assertEqual(response, {vault.uuid: vault.right_ids.key})
@mute_logger("odoo.sql_db")
def test_vault_store_right_key(self):
with MockRequest(self.env):
vault = self.env["vault"].create({"name": "Vault"})
self.controller.vault_store_right_keys(None)
self.controller.vault_store_right_keys({vault.uuid: "new key"})
self.assertEqual(vault.right_ids.key, "new key")
@mute_logger("odoo.sql_db")
def test_vault_inbox_keys(self):
with MockRequest(self.env):
self.assertFalse(self.controller.vault_get_inbox())
inbox = self.inbox.copy({"user_id": self.env.uid})
response = self.controller.vault_get_inbox()
self.assertEqual(response, {inbox.token: inbox.key})
@mute_logger("odoo.sql_db")
def test_vault_store_inbox_key(self):
with MockRequest(self.env):
inbox = self.inbox.copy({"user_id": self.env.uid})
inbox.user_id = self.env.user
self.controller.vault_store_inbox(None)
self.controller.vault_store_inbox({inbox.token: "new key"})
self.assertEqual(inbox.key, "new key")

View file

@ -0,0 +1,115 @@
# © 2021 Florian Kantelberg - initOS GmbH
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
from datetime import datetime
from uuid import uuid4
from odoo.tests import TransactionCase
_logger = logging.getLogger(__name__)
class TestShare(TransactionCase):
def test_user_inbox(self):
user = self.env["res.users"].create(
{"login": "test", "email": "test@test", "name": "test"}
)
user.action_new_inbox_token()
model = self.env["res.users"]
token = user.inbox_token
self.assertEqual(user, model.find_user_of_inbox(token))
self.assertIn(token, user.inbox_link)
user.inbox_enabled = False
self.assertEqual(model, model.find_user_of_inbox(token))
user.action_new_inbox_token()
self.assertNotEqual(user.inbox_token, token)
def test_inbox(self):
model = self.env["vault.inbox"]
user = self.env.user
vals = {
"name": f"Inbox {user.name}",
"secret": "secret",
"iv": "iv",
"user": user,
"key": "key",
"secret_file": "",
"filename": "",
}
# Should create a new inbox for the user
inbox = model.store_in_inbox(**vals)
self.assertEqual(inbox.secret, "secret")
self.assertEqual(inbox.accesses, 0)
self.assertIn(inbox.token, inbox.inbox_link)
# No change because of no accesses left
vals["secret"] = "new secret"
inbox.store_in_inbox(**vals)
self.assertEqual(inbox.secret, "secret")
self.assertEqual(inbox.accesses, 0)
# Change expected because 5 accesses left
inbox.accesses = 5
inbox.store_in_inbox(**vals)
self.assertEqual(inbox.secret, "new secret")
self.assertEqual(inbox.accesses, 4)
# No change because expired
vals["secret"] = "newer secret"
inbox.expiration = datetime(1970, 1, 1)
inbox.store_in_inbox(**vals)
self.assertEqual(inbox.secret, "new secret")
self.assertEqual(inbox.accesses, 4)
# Search for shares
self.assertEqual(inbox, model.find_inbox(inbox.token))
self.assertEqual(model, model.find_inbox(uuid4()))
def test_send_wizard(self):
user = self.env.user
wiz = self.env["vault.send.wizard"].create(
{
"name": uuid4(),
"iv": "iv",
"key_user": "key",
"key": "k",
"secret": uuid4(),
"user_id": user.id,
}
)
# Create a new inbox
wiz.action_send()
self.assertTrue(self.env["vault.inbox"].search([("name", "=", wiz.name)]))
def test_store_wizard(self):
vault = self.env["vault"].create({"name": "Vault"})
entry = self.env["vault.entry"].create({"vault_id": vault.id, "name": "Entry"})
wiz = self.env["vault.store.wizard"].create(
{
"vault_id": vault.id,
"entry_id": entry.id,
"name": uuid4(),
"iv": "iv",
"key": "k",
"secret": uuid4(),
"secret_temporary": "temp",
"model": "vault.field",
}
)
vault.right_ids.write({"key": uuid4()})
self.assertEqual(wiz.master_key, vault.right_ids.key)
wiz.action_store()
self.assertEqual(wiz.name, entry.field_ids.name)

View file

@ -0,0 +1,41 @@
# © 2021 Florian Kantelberg - initOS GmbH
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
from odoo.tests import TransactionCase
_logger = logging.getLogger(__name__)
class TestLog(TransactionCase):
def test_not_implemeneted(self):
with self.assertRaises(NotImplementedError):
self.env["vault.abstract"].log_entry("test")
with self.assertRaises(NotImplementedError):
self.env["vault.abstract"].log_info("test")
with self.assertRaises(NotImplementedError):
self.env["vault.abstract"].log_warn("test")
with self.assertRaises(NotImplementedError):
self.env["vault.abstract"].log_error("test")
def test_log_created(self):
vault = self.env["vault"].create({"name": "Vault"})
entry = self.env["vault.entry"].create({"vault_id": vault.id, "name": "Entry"})
vault.log_ids.unlink()
vault.log_info("info")
self.assertEqual(vault.log_ids.mapped("state"), ["info"])
self.assertEqual(entry.log_ids.mapped("state"), [])
entry.log_warn("warn")
self.assertEqual(vault.log_ids.mapped("state"), ["info", "warn"])
self.assertEqual(entry.log_ids.mapped("state"), ["warn"])
entry.log_error("error")
self.assertEqual(vault.log_ids.mapped("state"), ["info", "warn", "error"])
self.assertEqual(entry.log_ids.mapped("state"), ["warn", "error"])

View file

@ -0,0 +1,155 @@
# © 2021 Florian Kantelberg - initOS GmbH
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
from odoo.exceptions import AccessError
from odoo.tests import TransactionCase
_logger = logging.getLogger(__name__)
class TestAccessRights(TransactionCase):
def setUp(self):
super().setUp()
self.user = self.env["res.users"].create(
{"login": "user", "name": "tester", "email": "user@localhost"}
)
self.vault = self.env["vault"].create({"name": "Vault"})
self.entry = self.env["vault.entry"].create(
{"vault_id": self.vault.id, "name": "Entry"}
)
self.field = self.env["vault.field"].create(
{"entry_id": self.entry.id, "name": "Field", "value": "Value"}
)
self.vault.right_ids.write({"key": "Owner"})
def test_vault_reencrypt(self):
right = self.env["vault.right"].create(
{
"vault_id": self.vault.id,
"user_id": self.user.id,
"perm_create": False,
}
)
assert not self.vault.reencrypt_required
right.unlink()
assert self.vault.reencrypt_required
def test_public_key(self):
key = self.env["res.users.key"].create(
{
"user_id": self.vault.user_id.id,
"public": "a public key",
"salt": "42",
"iv": "2424",
"iterations": 4000,
"private": "24",
}
)
self.assertTrue(self.vault.right_ids.public_key)
self.assertEqual(key.public, self.vault.right_ids.public_key)
def test_owner_access(self):
# The owner can always access despite the permissions
for obj in [self.field, self.entry, self.vault]:
obj.name = "Owned"
right = self.vault.right_ids
right.perm_write = False
obj.name = "Owned"
right.perm_delete = False
obj.unlink()
def test_no_create(self):
self.env["vault.right"].create(
{
"vault_id": self.vault.id,
"user_id": self.user.id,
"perm_create": False,
}
)
for obj in [self.field, self.entry, self.vault]:
with self.assertRaises(AccessError):
obj.with_user(self.user).check_access_rule("create")
def test_no_right(self):
# No right defined for test user means access denied
for obj in [self.field, self.entry, self.vault]:
with self.assertRaises(AccessError):
self.assertTrue(obj.with_user(self.user).read())
with self.assertRaises(AccessError):
obj.with_user(self.user).name = "Owned"
with self.assertRaises(AccessError):
obj.with_user(self.user).unlink()
def test_no_permission(self):
# Defined right but no write permission means access denied
self.env["vault.right"].create(
{
"vault_id": self.vault.id,
"user_id": self.user.id,
"perm_create": False,
"perm_write": False,
"perm_delete": False,
}
)
for obj in [self.field, self.entry, self.vault]:
self.assertTrue(obj.with_user(self.user).read())
with self.assertRaises(AccessError):
obj.with_user(self.user).name = "Owned"
with self.assertRaises(AccessError):
obj.with_user(self.user).unlink()
def test_granted(self):
# Granted write permission allows writing
self.env["vault.right"].create(
{
"vault_id": self.vault.id,
"user_id": self.user.id,
"perm_write": True,
"perm_delete": True,
}
)
for obj in [self.field, self.entry, self.vault]:
self.assertTrue(obj.with_user(self.user).read())
obj.with_user(self.user).name = "Owned"
obj.with_user(self.user).unlink()
def test_owner_share(self):
self.env["vault.right"].create(
{"vault_id": self.vault.id, "user_id": self.user.id}
)
def test_user_share_no_right(self):
# No right defined means AccessError
with self.assertRaises(AccessError):
self.env["vault.right"].with_user(self.user).create(
{"vault_id": self.vault.id, "user_id": 2}
)
def test_user_share_no_permission(self):
# Created right but no permission to share
right = self.env["vault.right"].create(
{"vault_id": self.vault.id, "user_id": self.user.id, "perm_share": False}
)
with self.assertRaises(AccessError):
right.with_user(self.user).create({"vault_id": self.vault.id, "user_id": 2})
def test_user_share_granted(self):
# Granted permission to share
right = self.env["vault.right"].create(
{"vault_id": self.vault.id, "user_id": self.user.id, "perm_share": True}
)
right.with_user(self.user).create({"vault_id": self.vault.id, "user_id": 2})
right.unlink()

View file

@ -0,0 +1,60 @@
# © 2021 Florian Kantelberg - initOS GmbH
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
from odoo.tests import TransactionCase
_logger = logging.getLogger(__name__)
class TestShare(TransactionCase):
def test_user_inbox(self):
user = self.env["res.users"].create(
{"login": "test", "email": "test@test", "name": "test"}
)
user.action_new_inbox_token()
model = self.env["res.users"]
token = user.inbox_token
self.assertEqual(user, model.find_user_of_inbox(token))
self.assertIn(token, user.inbox_link)
user.inbox_enabled = False
self.assertEqual(model, model.find_user_of_inbox(token))
user.action_new_inbox_token()
self.assertNotEqual(user.inbox_token, token)
def test_user_key_management(self):
action = self.env.ref("vault.action_res_users_keys")
self.assertEqual(action.id, self.env["res.users"].action_get_vault()["id"])
def test_invalidation(self):
self.env["res.users.key"].store(
40000, "invalid", "invalid", "invalid", "invalid", 42
)
self.assertTrue(self.env.user.keys.filtered("current"))
vault = self.env["vault"].create({"name": "Test"})
self.assertTrue(vault.right_ids)
inbox = self.env["vault.inbox"].create(
{
"name": "Inbox Test",
"secret": "secret",
"iv": "iv",
"user_id": self.env.uid,
"key": "key",
"secret_file": "",
"filename": "",
}
)
self.env.user.action_invalidate_key()
self.assertFalse(self.env.user.keys.filtered("current"))
self.assertFalse(inbox.exists())
self.assertFalse(vault.right_ids.exists())

View file

@ -0,0 +1,166 @@
# © 2021 Florian Kantelberg - initOS GmbH
# Copyright 2022 Tecnativa - Víctor Martínez
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
from datetime import datetime
from odoo.exceptions import ValidationError
from odoo.tests import TransactionCase
_logger = logging.getLogger(__name__)
class TestVault(TransactionCase):
def setUp(self):
super().setUp()
self.vault = self.env["vault"].create({"name": "Vault"})
self.entry = self.env["vault.entry"].create(
{"vault_id": self.vault.id, "name": "Entry"}
)
self.child = self.env["vault.entry"].create(
{"vault_id": self.vault.id, "name": "Child", "parent_id": self.entry.id}
)
def test_entry_path(self):
self.assertEqual(self.entry.complete_name, "Entry")
self.assertEqual(self.child.complete_name, "Entry / Child")
def test_wizard_actions(self):
values = self.child.action_open_import_wizard()
self.assertEqual(values["context"]["default_parent_id"], self.child.id)
values = self.vault.action_open_import_wizard()
self.assertNotIn("default_parent_id", values["context"])
values = self.child.action_open_export_wizard()
self.assertEqual(values["context"]["default_entry_id"], self.child.id)
values = self.vault.action_open_export_wizard()
self.assertNotIn("default_entry_id", values["context"])
def test_master_key(self):
right = self.vault.right_ids
self.assertEqual(self.vault.master_key, right.master_key)
self.vault.master_key = "test"
self.assertEqual(right.key, "test")
def test_share_public_key(self):
key = self.env["res.users.key"].create(
{
"user_id": self.vault.user_id.id,
"public": "a public key",
"salt": "42",
"iv": "2424",
"iterations": 4000,
"private": "24",
}
)
expected = {"user": 1, "public": key.public}
self.assertIn(expected, self.vault.share_public_keys())
def test_keys(self):
key = self.env["res.users.key"].create(
{
"user_id": self.vault.user_id.id,
"public": "a public key",
"salt": "42",
"iv": "2424",
"iterations": 4000,
"private": "24",
}
)
self.assertEqual(set("0123456789abcdef:"), set(key.fingerprint))
key.public = ""
self.assertEqual(key.fingerprint, False)
def test_store_keys(self):
model = self.env["res.users.key"]
# Raise some errors because of wrong parameters
with self.assertRaises(ValidationError):
model.store(1, "iv", "private", "public", 42, 42)
with self.assertRaises(ValidationError):
model.store(3000, "iv", "private", "public", "salt", 42)
with self.assertRaises(ValidationError):
model.store(4000, "iv", "private", "public", "salt", "abc")
# Actually store a new key
uuid = model.store(4000, "iv", "private", "public", "salt", 42)
rec = model.search([("uuid", "=", uuid)])
self.assertEqual(rec.private, "private")
self.assertTrue(rec.current)
# Don't store the same again
uuid = model.store(4000, "iv", "private", "public", "salt", 42)
self.assertFalse(uuid)
# Store a new one and disable the old one
uuid = model.store(4000, "iv", "more private", "public", "salt", 42)
self.assertFalse(rec.current)
rec = model.search([("uuid", "=", uuid)])
self.assertEqual(rec.private, "more private")
self.assertTrue(rec.current)
# Try to extract the public key again
user_id = self.env["res.users"].search([], limit=1, order="id DESC").id
public = model.extract_public_key(user_id + 1)
self.assertFalse(public)
public = model.extract_public_key(self.env.uid)
self.assertEqual(public, "public")
def test_vault_keys(self):
keys = self.env.user.get_vault_keys()
self.assertEqual(keys, {})
data = {
"user_id": self.env.user.id,
"public": "a public key",
"salt": "42",
"iv": "2424",
"iterations": 4000,
"private": "24",
}
self.env["res.users.key"].create(data)
keys = self.env.user.get_vault_keys()
for key in ["private", "public", "iv", "salt", "iterations"]:
self.assertEqual(keys[key], data[key])
def test_vault_entry_recursion(self):
child = self.env["vault.entry"].create(
{"vault_id": self.vault.id, "name": "Entry", "parent_id": self.entry.id}
)
with self.assertRaises(ValidationError):
self.entry.parent_id = child.id
def test_search_expired(self):
entry = self.env["vault.entry"]
self.assertEqual(entry._search_expired("in", []), [])
domain = entry._search_expired("=", True)
self.assertEqual(domain[0][:2], ("expire_date", "<"))
self.assertIsInstance(domain[0][2], datetime)
domain = entry._search_expired("!=", False)
self.assertEqual(domain[0][:2], ("expire_date", "<"))
self.assertIsInstance(domain[0][2], datetime)
domain = entry._search_expired("=", False)
self.assertTrue(domain[0] == "|")
self.assertIn(("expire_date", "=", False), domain)
self.assertTrue(any(("expire_date", ">=") == d[:2] for d in domain))
def test_vault_entry_search_panel_limit(self):
res = self.entry.search_panel_select_range("parent_id")
total_items = self.env["vault.entry"].search_count([("child_ids", "!=", False)])
self.assertEqual(len(res["values"]), total_items)

View file

@ -0,0 +1,156 @@
# © 2021 Florian Kantelberg - initOS GmbH
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import json
import logging
from uuid import uuid4
from odoo.exceptions import UserError
from odoo.tests import TransactionCase
_logger = logging.getLogger(__name__)
TestChild = {
"uuid": "42a",
"note": "test note child",
"name": "test child",
"url": "child.example.org",
"fields": [],
"files": [],
"childs": [],
}
TestData = [
{
"uuid": "42b",
"note": "test note child",
"name": "don't import",
"url": "child.example.org",
"fields": [],
"files": [],
"childs": [],
},
TestChild,
{
"uuid": "42",
"note": "test note",
"name": "test name",
"url": "test.example.org",
"fields": [
{"name": "a", "value": "Hello World", "iv": "abcd"},
{"name": "secret", "value": "dlrow olleh", "iv": "abcd"},
{"name": "secret", "value": "dlrow olle", "iv": "abcd"},
],
"files": [
{"name": "a", "value": "Hello World", "iv": "abcd"},
{"name": "secret", "value": "dlrow olleh", "iv": "abcd"},
{},
],
"childs": [
{
"uuid": "42aa",
"note": "test note subchild",
"name": "test subchild",
"url": "subchild.example.org",
"fields": [],
"files": [],
"childs": [],
},
TestChild,
],
},
TestChild,
]
class TestWidgets(TransactionCase):
def setUp(self):
super().setUp()
self.vault = self.env["vault"].create({"name": "Vault"})
self.entry = self.env["vault.entry"].create(
{"vault_id": self.vault.id, "name": "Entry"}
)
def test_path_generation(self):
wiz = self.env["vault.import.wizard"].create(
{"vault_id": self.vault.id, "crypted_content": json.dumps(TestData)}
)
wiz._onchange_content()
paths = wiz.path.search([("uuid", "=", wiz.uuid)]).mapped("name")
self.assertEqual(len(paths), 6)
self.assertIn("test name / test child", paths)
self.assertIn("test child", paths)
self.assertIn("test name", paths)
def test_import(self):
uuid = uuid4()
path = self.env["vault.import.wizard.path"].create(
{"name": "test", "uuid": uuid}
)
wiz = self.env["vault.import.wizard"].create(
{
"vault_id": self.vault.id,
"crypted_content": json.dumps(TestData),
"path": path.id,
"uuid": uuid,
}
)
wiz.action_import()
# We have duplicates
uuids = {"42", "42a", "42aa", self.entry.uuid}
self.assertSetEqual(set(self.vault.entry_ids.mapped("uuid")), uuids)
# Creation is depth-first which will cause the 42a to move up
self.assertEqual(self.vault.entry_ids.mapped("child_ids.uuid"), ["42aa"])
# This will cause an overwrite of the field
domain = [("entry_id.uuid", "=", "42"), ("name", "=", "secret")]
rec = self.env["vault.field"].search(domain)
self.assertEqual(rec.mapped("value"), ["dlrow olle"])
# Field with missing keys should fail
with self.assertRaises(UserError):
TestChild["fields"].append({"name": "12", "value": "eulav"})
wiz.crypted_content = json.dumps([TestChild])
wiz.action_import()
def test_export(self):
child = self.env["vault.entry"].create(
{"vault_id": self.vault.id, "name": "Child", "parent_id": self.entry.id}
)
second = self.env["vault.entry"].create(
{"vault_id": self.vault.id, "name": "second"}
)
wiz = self.env["vault.export.wizard"].create({"vault_id": self.vault.id})
# Export without entry should export entire vault
wiz._onchange_content()
entries = json.loads(wiz.content)
self.assertEqual({e["uuid"] for e in entries}, {second.uuid, self.entry.uuid})
self.assertEqual(len(entries), 2)
wiz.entry_id = self.entry
# Export the entire tree
wiz._onchange_content()
entries = json.loads(wiz.content)
self.assertEqual(len(entries), 1)
self.assertEqual(entries[0]["uuid"], self.entry.uuid)
self.assertEqual(entries[0]["childs"][0]["uuid"], child.uuid)
# Skip exporting childs
wiz.include_childs = False
wiz._onchange_content()
entries = json.loads(wiz.content)
self.assertEqual(len(entries), 1)
self.assertEqual(entries[0]["uuid"], self.entry.uuid)
self.assertEqual(len(entries[0]["childs"]), 0)

View file

@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="action_vault" model="ir.actions.act_window">
<field name="name">Vault</field>
<field name="res_model">vault</field>
<field name="view_mode">tree,form</field>
</record>
<record id="action_vault_entry" model="ir.actions.act_window">
<field name="name">All Entries</field>
<field name="res_model">vault.entry</field>
<field name="view_mode">tree,form</field>
<field name="context">{'search_default_vault': 1}</field>
<field
name="view_ids"
eval="[(5,0,0),
(0,0,{'view_mode':'tree', 'view_id': ref('view_vault_entry_full_tree')}),
(0,0,{'view_mode':'form', 'view_id': ref('view_vault_entry_overview_form')})]"
/>
</record>
<record id="action_vault_inbox" model="ir.actions.act_window">
<field name="name">Inbox</field>
<field name="res_model">vault.inbox</field>
<field name="view_mode">tree,form</field>
</record>
<record id="action_vault_right" model="ir.actions.act_window">
<field name="name">Rights</field>
<field name="res_model">vault.right</field>
<field name="view_mode">tree</field>
<field name="view_id" ref="view_vault_right_overview_tree" />
<field name="search_view_id" ref="view_vault_right_overview_search" />
</record>
<menuitem
id="menu_vault"
groups="base.group_user"
action="action_vault"
web_icon="vault,static/description/icon.png"
/>
<menuitem
id="menu_vault_entry"
groups="base.group_user"
parent="menu_vault"
action="action_vault_entry"
sequence="30"
/>
<menuitem
id="menu_vault_inbox"
groups="base.group_user"
parent="vault.menu_vault"
action="action_vault_inbox"
sequence="40"
/>
<menuitem
id="menu_vault_right"
groups="base.group_user"
parent="vault.menu_vault"
action="action_vault_right"
sequence="50"
/>
</odoo>

View file

@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="res_config_settings_view_form" model="ir.ui.view">
<field name="name">res.config.settings.view.form</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="base_setup.res_config_settings_view_form" />
<field name="arch" type="xml">
<div id="user_default_rights" position="after">
<h2>Vault</h2>
<div class="row mt16 o_settings_container" id="vault">
<div class="col-xs-12 col-md-6 o_setting_box" id="vault_share">
<div class="o_setting_left_pane">
<field name="module_vault_share" />
</div>
<div class="o_setting_right_pane">
<label for="module_vault_share" string="Vault Share" />
<div class="text-muted">
Allow the usage to share secrets with external users
</div>
</div>
</div>
<div class="col-xs-12 col-md-6 o_setting_box" id="vault_share">
<div class="o_setting_left_pane">
<field name="group_vault_import" />
</div>
<div class="o_setting_right_pane">
<label for="group_vault_import" />
<div class="text-muted">
Allow all users to import vaults accessible to them
</div>
</div>
</div>
<div class="col-xs-12 col-md-6 o_setting_box" id="vault_share">
<div class="o_setting_left_pane">
<field name="group_vault_export" />
</div>
<div class="o_setting_right_pane">
<label for="group_vault_export" />
<div class="text-muted">
Allow all users to export vaults accessible to them
</div>
</div>
</div>
</div>
</div>
</field>
</record>
</odoo>

View file

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="view_res_users_key_tree" model="ir.ui.view">
<field name="model">res.users.key</field>
<field name="arch" type="xml">
<tree editable="bottom">
<field name="current" readonly="1" />
<field name="fingerprint" readonly="1" />
</tree>
</field>
</record>
<record id="view_res_users_key_form" model="ir.ui.view">
<field name="model">res.users.key</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="fingerprint" readonly="1" />
</group>
</sheet>
</form>
</field>
</record>
<record id="view_users_form_keys_modif" model="ir.ui.view">
<field name="model">res.users</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="inbox_link" widget="url" />
<field name="inbox_enabled" />
</group>
<field name="keys" />
</sheet>
<footer>
<button
name="vault_generate_key"
string="New private key"
class="btn-primary"
/>
<button
name="action_new_inbox_token"
type="object"
string="New inbox link"
class="btn-primary"
/>
<button
name="action_invalidate_key"
type="object"
string="Invalidate private key"
class="btn-secondary"
confirm="You will loose access to all vaults and your inbox. Do you want to continue?"
/>
<button special="cancel" string="Cancel" class="btn-secondary" />
</footer>
</form>
</field>
</record>
<record id="action_res_users_keys" model="ir.actions.act_window">
<field name="name">Manage my keys</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">res.users</field>
<field name="target">new</field>
<field name="view_mode">form</field>
</record>
<record id="action_res_users_keys_view" model="ir.actions.act_window.view">
<field eval="10" name="sequence" />
<field name="view_mode">form</field>
<field name="view_id" ref="view_users_form_keys_modif" />
<field name="act_window_id" ref="action_res_users_keys" />
</record>
</odoo>

View file

@ -0,0 +1,93 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<template id="inbox">
<t t-call="web.login_layout">
<t t-call-assets="vault.assets_frontend" t-css="false" defer_load="True" />
<form
class="oe_login_form"
role="form"
t-attf-action="/vault/inbox/{{ token }}"
method="post"
onsubmit="this.action = this.action + location.hash"
>
<input
type="hidden"
name="csrf_token"
t-att-value="request.csrf_token()"
/>
<input type="hidden" id="token" name="token" t-att-value="token" />
<input type="hidden" id="public" name="public" t-att-value="public" />
<input type="hidden" id="encrypted" name="encrypted" />
<input type="hidden" id="encrypted_file" name="encrypted_file" />
<input type="hidden" id="filename" name="filename" />
<input type="hidden" id="iv" name="iv" />
<input type="hidden" id="key" name="key" />
<div class="form-group">
<label for="name">Name of your secret:</label>
<input
type="text"
t-if="name"
name="name"
readonly="readonly"
class="form-control"
t-att-value="name"
/>
<input
type="text"
t-else=""
name="name"
required="required"
autofocus="autofocus"
class="form-control"
/>
</div>
<div class="form-group">
<label for="secret">Secret to share:</label>
<input
placeholder="Secret"
type="text"
id="secret"
name="secret"
required="required"
autofocus="autofocus"
class="form-control"
/>
</div>
<div class="form-group">
<label for="secret">File to share:</label>
<input
type="file"
placeholder="Secret"
id="secret_file"
name="secret_file"
required="required"
class="form-control"
/>
</div>
<p class="alert alert-danger" t-if="error" role="alert" t-esc="error" />
<p
class="alert alert-success"
t-if="message"
role="status"
t-esc="message"
/>
<div
t-attf-class="clearfix text-center mb-1 {{'pt-2' if form_small else 'pt-3'}}"
>
<button
id="submit"
type="submit"
class="btn btn-primary btn-block"
disabled="disabled"
>Submit secret</button>
</div>
</form>
</t>
</template>
</odoo>

View file

@ -0,0 +1,191 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="view_vault_entry_tree" model="ir.ui.view">
<field name="model">vault.entry</field>
<field name="arch" type="xml">
<tree decoration-muted="expired">
<field name="expired" invisible="1" />
<field name="complete_name" />
<field name="tags" widget="many2many_tags" />
</tree>
</field>
</record>
<record id="view_vault_entry_full_tree" model="ir.ui.view">
<field name="model">vault.entry</field>
<field name="arch" type="xml">
<tree decoration-muted="expired">
<field name="expired" invisible="1" />
<field name="vault_id" />
<field name="complete_name" />
<field name="tags" widget="many2many_tags" />
</tree>
</field>
</record>
<record id="view_vault_entry_form" model="ir.ui.view">
<field name="model">vault.entry</field>
<field name="arch" type="xml">
<form>
<header>
<button
type="object"
name="action_open_import_wizard"
string="Import from file"
groups="vault.group_vault_import"
/>
<button
type="object"
name="action_open_export_wizard"
string="Export to file"
groups="vault.group_vault_export"
/>
</header>
<sheet>
<field name="perm_user" invisible="1" />
<field name="allowed_create" invisible="1" />
<field name="allowed_share" invisible="1" />
<field name="allowed_write" invisible="1" />
<field name="allowed_delete" invisible="1" />
<group>
<group>
<field
name="vault_id"
invisible="1"
force_save="1"
attrs="{'readonly': [('vault_id', '!=', False)]}"
/>
<field
name="parent_id"
options="{'no_open': true, 'no_create_edit': true, 'no_quick_create': true}"
/>
<field name="complete_name" invisible="1" />
<field name="name" />
<field name="url" widget="url" />
<field name="tags" widget="many2many_tags" />
</group>
<group>
<field name="create_date" />
<field name="write_date" />
<field name="expire_date" />
</group>
</group>
<notebook>
<page string="Content">
<label for="field_ids" />
<field
name="field_ids"
context="{'default_entry_id': active_id}"
options="{'create': [('allowed_create', '=', True)], 'delete': [('allowed_delete', '=', True)], 'no_open': true}"
>
<tree editable="bottom">
<field name="vault_id" invisible="1" />
<field name="entry_id" invisible="1" />
<field name="iv" invisible="1" />
<field name="master_key" invisible="1" />
<field name="name" />
<field
name="value"
widget="vault_field"
type="field_type"
/>
<field name="write_date" />
</tree>
</field>
<label for="file_ids" />
<field
name="file_ids"
context="{'default_entry_id': active_id}"
options="{'create': [('allowed_create', '=', True)], 'delete': [('allowed_delete', '=', True)], 'no_open': True}"
>
<tree editable="bottom">
<field name="vault_id" invisible="1" />
<field name="entry_id" invisible="1" />
<field name="iv" invisible="1" />
<field name="master_key" invisible="1" />
<field name="name" invisible="1" />
<field
name="value"
widget="vault_file"
filename="name"
/>
<field name="write_date" />
</tree>
</field>
</page>
<page string="Note">
<field name="note" />
</page>
<page string="Childs">
<field
name="child_ids"
context="{'default_parent_id': active_id, 'default_vault_id': vault_id}"
/>
</page>
<page string="Log">
<field name="log_ids" options="{'no_open': True}" />
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="view_vault_entry_overview_form" model="ir.ui.view">
<field name="model">vault.entry</field>
<field name="mode">primary</field>
<field name="priority">100</field>
<field name="inherit_id" ref="view_vault_entry_form" />
<field name="arch" type="xml">
<field name="vault_id" position="attributes">
<attribute name="invisible">0</attribute>
</field>
</field>
</record>
<record id="view_vault_entry_search" model="ir.ui.view">
<field name="name">vault.entry.search</field>
<field name="model">vault.entry</field>
<field name="arch" type="xml">
<search>
<field name="complete_name" operator="ilike" />
<field name="name" operator="ilike" />
<field name="tags" operator="ilike" />
<field name="note" operator="ilike" />
<filter
string="Expired"
name="expired"
domain="[('expired', '=', True)]"
/>
<filter
string="Not Expired"
name="not_expired"
domain="[('expired', '!=', True)]"
/>
<filter
string="Vault"
name="vault"
domain="[]"
context="{'group_by': 'vault_id'}"
/>
<searchpanel>
<field
name="vault_id"
string="Vaults"
enable_counters="1"
limit="0"
/>
<field
name="parent_id"
string="Entries"
enable_counters="1"
limit="0"
/>
</searchpanel>
</search>
</field>
</record>
</odoo>

View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="view_vault_field_form" model="ir.ui.view">
<field name="model">vault.field</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="vault_id" invisible="1" />
<field name="entry_id" invisible="1" />
<field name="iv" invisible="1" />
<field name="master_key" invisible="1" />
<field name="name" />
<field name="value" widget="vault" type="field_type" />
<field name="write_date" />
</group>
</sheet>
</form>
</field>
</record>
<record id="view_vault_field_tree" model="ir.ui.view">
<field name="model">vault.field</field>
<field name="arch" type="xml">
<tree create="false" delete="false">
<field name="vault_id" invisible="1" />
<field name="entry_id" invisible="1" />
<field name="iv" invisible="1" />
<field name="master_key" invisible="1" />
<field name="name" />
<field name="value" widget="vault" type="field_type" />
<field name="write_date" />
</tree>
</field>
</record>
</odoo>

View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="view_vault_file_form" model="ir.ui.view">
<field name="model">vault.file</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="vault_id" invisible="1" />
<field name="entry_id" invisible="1" />
<field name="iv" invisible="1" />
<field name="master_key" invisible="1" />
<field name="name" invisible="1" />
<field name="value" widget="vault_file" filename="name" />
<field name="write_date" />
</group>
</sheet>
</form>
</field>
</record>
<record id="view_vault_file_tree" model="ir.ui.view">
<field name="model">vault.file</field>
<field name="arch" type="xml">
<tree create="false" delete="false">
<field name="vault_id" invisible="1" />
<field name="entry_id" invisible="1" />
<field name="iv" invisible="1" />
<field name="master_key" invisible="1" />
<field name="name" invisible="1" />
<field name="value" widget="vault_file" filename="name" />
<field name="write_date" />
</tree>
</field>
</record>
</odoo>

View file

@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="view_vault_inbox_tree" model="ir.ui.view">
<field name="model">vault.inbox</field>
<field name="arch" type="xml">
<tree create="false">
<field name="name" />
<field name="inbox_link" widget="url" />
</tree>
</field>
</record>
<record id="view_vault_inbox_form" model="ir.ui.view">
<field name="model">vault.inbox</field>
<field name="arch" type="xml">
<form create="false">
<sheet>
<field name="user_id" invisible="1" />
<field name="iv" invisible="1" />
<field name="key" invisible="1" />
<field name="filename" invisible="1" />
<group>
<field name="inbox_link" widget="url" />
<field name="name" />
<field name="accesses" />
<field name="expiration" />
<field
name="secret"
widget="vault_inbox_field"
attrs="{'invisible': [('secret', '=', False)]}"
/>
<field
name="secret_file"
filename="filename"
widget="vault_inbox_file"
attrs="{'invisible': [('secret_file', '=', False)]}"
/>
</group>
<label for="log_ids" />
<field name="log_ids" options="{'no_open': True}">
<tree>
<field name="name" />
<field name="create_date" />
</tree>
</field>
</sheet>
</form>
</field>
</record>
</odoo>

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="view_vault_log_tree" model="ir.ui.view">
<field name="model">vault.log</field>
<field name="arch" type="xml">
<tree
decoration-danger="state == 'error'"
decoration-warning="state == 'warn'"
decoration-info="state == 'info'"
>
<field name="user_id" />
<field name="message" />
<field name="create_date" />
<field name="state" invisible="1" />
</tree>
</field>
</record>
</odoo>

View file

@ -0,0 +1,73 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="view_right_tree" model="ir.ui.view">
<field name="model">vault.right</field>
<field name="arch" type="xml">
<tree editable="bottom" decoration-danger="not key">
<field name="vault_id" invisible="1" />
<field name="master_key" invisible="1" />
<field name="key" invisible="1" />
<field name="public_key" invisible="1" />
<field name="user_id" />
<field name="perm_create" />
<field name="perm_write" />
<field name="perm_share" />
<field name="perm_delete" />
</tree>
</field>
</record>
<record id="view_right_form" model="ir.ui.view">
<field name="model">vault.right</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="user_id" />
<field name="perm_create" />
<field name="perm_write" />
<field name="perm_share" />
<field name="perm_delete" />
</group>
</sheet>
</form>
</field>
</record>
<record id="view_vault_right_overview_tree" model="ir.ui.view">
<field name="model">vault.right</field>
<field name="arch" type="xml">
<tree create="false">
<field name="vault_id" />
<field name="user_id" />
<field name="perm_create" />
<field name="perm_write" />
<field name="perm_share" />
<field name="perm_delete" />
</tree>
</field>
</record>
<record id="view_vault_right_overview_search" model="ir.ui.view">
<field name="name">vault.right.overview.search</field>
<field name="model">vault.right</field>
<field name="arch" type="xml">
<search>
<field name="user_id" operator="ilike" />
<field name="vault_id" operator="ilike" />
<group expand="0" string="Grouped">
<filter
name="user_id"
string="By user"
context="{'group_by': 'user_id'}"
/>
<filter
name="vault_id"
string="By vault"
context="{'group_by': 'vault_id'}"
/>
</group>
</search>
</field>
</record>
</odoo>

View file

@ -0,0 +1,109 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="view_vault_search" model="ir.ui.view">
<field name="name">vault.search</field>
<field name="model">vault</field>
<field name="arch" type="xml">
<search>
<field name="name" operator="ilike" />
<field name="note" operator="ilike" />
</search>
</field>
</record>
<record id="action_open_entries" model="ir.actions.act_window">
<field name="name">Entries</field>
<field name="res_model">vault.entry</field>
<field name="view_mode">tree,form</field>
<field name="domain">[("vault_id", "=", active_id)]</field>
<field name="context">{
"default_vault_id": active_id,
"searchpanel_default_vault_id": active_id}
</field>
<field name="search_view_id" ref="view_vault_entry_search" />
</record>
<record id="view_vault_tree" model="ir.ui.view">
<field name="model">vault</field>
<field name="arch" type="xml">
<tree>
<field name="name" />
<field name="user_id" />
<field name="note" />
</tree>
</field>
</record>
<record id="view_vault_form" model="ir.ui.view">
<field name="model">vault</field>
<field name="arch" type="xml">
<form>
<header>
<button
type="object"
name="action_open_import_wizard"
string="Import from file"
groups="vault.group_vault_import"
/>
<button
type="object"
name="action_open_export_wizard"
string="Export to file"
groups="vault.group_vault_export"
/>
<button name="vault_verify" string="Verify" />
<button
name="vault_reencrypt"
string="Re-encrypt"
attrs="{'invisible': [('reencrypt_required', '=', True)]}"
/>
<button
name="vault_reencrypt"
string="Re-encrypt"
class="oe_highlight"
attrs="{'invisible': [('reencrypt_required', '=', False)]}"
/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button
class="oe_stat_button"
name="vault.action_open_entries"
string="Entries"
type="action"
icon="fa-bars"
/>
</div>
<group>
<field name="reencrypt_required" invisible="1" />
<field name="allowed_share" invisible="1" />
<field name="allowed_write" invisible="1" />
<field name="allowed_create" invisible="1" />
<field name="allowed_delete" invisible="1" />
<field name="master_key" invisible="1" required="1" />
<field name="name" />
<field name="user_id" />
<field name="note" />
</group>
<notebook>
<page
string="Rights"
attrs="{'invisible':[('allowed_share', '=', False)]}"
>
<field
name="right_ids"
context="{'default_vault_id': active_id}"
options="{'create': [('allowed_share', '=', True)], 'delete': [('allowed_share', '=', True)]}"
/>
</page>
<page string="Log">
<field name="log_ids" options="{'no_open': True}" />
</page>
</notebook>
</sheet>
</form>
</field>
</record>
</odoo>

View file

@ -0,0 +1,9 @@
# © 2021 Florian Kantelberg - initOS GmbH
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import (
vault_export_wizard,
vault_import_wizard,
vault_send_wizard,
vault_store_wizard,
)

View file

@ -0,0 +1,71 @@
# © 2021 Florian Kantelberg - initOS GmbH
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import json
import logging
from datetime import datetime
from odoo import _, api, fields, models
_logger = logging.getLogger(__name__)
class ExportWizard(models.TransientModel):
_name = "vault.export.wizard"
_description = _("Export wizard for vaults")
vault_id = fields.Many2one("vault", "Vault")
entry_id = fields.Many2one(
"vault.entry", "Entries", domain="[('vault_id', '=', vault_id)]"
)
master_key = fields.Char(related="vault_id.master_key")
name = fields.Char(default=lambda self: self._default_name())
content = fields.Binary("Download", attachment=False)
include_childs = fields.Boolean(default=True)
@api.onchange("vault_id", "entry_id")
def _onchange_content(self):
for rec in self.with_context(skip_log=True):
rec.content = self._export_content(
rec.vault_id,
rec.entry_id,
rec.include_childs,
)
def _default_name(self):
return datetime.now().strftime("database-%Y%m%d-%H%M.json")
def _export_content(self, vault=None, entry=None, include_childs=False):
if entry:
entries = entry
elif vault:
entries = vault.entry_ids.filtered_domain([("parent_id", "=", False)])
else:
return json.dumps([])
data = [self._export_entry(x, include_childs) for x in entries]
return json.dumps(data)
@api.model
def _export_field(self, rec):
def ensure_string(x):
return x.decode() if isinstance(x, bytes) else x
return {f: ensure_string(rec[f]) for f in ["name", "iv", "value"]}
@api.model
def _export_entry(self, entry, include_childs=False):
if include_childs:
childs = [self._export_entry(x, include_childs) for x in entry.child_ids]
else:
childs = []
return {
"uuid": entry.uuid,
"name": entry.name,
"note": entry.note,
"url": entry.url,
"fields": entry.field_ids.mapped(self._export_field),
"files": entry.file_ids.mapped(self._export_field),
"childs": childs,
}

View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="view_vault_export_wizard" model="ir.ui.view">
<field name="model">vault.export.wizard</field>
<field name="arch" type="xml">
<form>
<sheet>
<field name="vault_id" invisible="1" />
<field name="entry_id" invisible="1" />
<field name="master_key" invisible="1" />
<field name="name" invisible="1" />
<group>
<field
name="content"
widget="vault_export_file"
filename="name"
readonly="1"
/>
<field name="include_childs" />
</group>
</sheet>
<footer>
<button type="special" string="Close" special="cancel" />
</footer>
</form>
</field>
</record>
</odoo>

View file

@ -0,0 +1,134 @@
# © 2021 Florian Kantelberg - initOS GmbH
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import json
import logging
from uuid import uuid4
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class ImportWizardPath(models.TransientModel):
_name = "vault.import.wizard.path"
_description = _("Import wizard path for vaults")
name = fields.Char(required=True)
uuid = fields.Char(required=True)
class ImportWizard(models.TransientModel):
_name = "vault.import.wizard"
_description = _("Import wizard for vaults")
vault_id = fields.Many2one("vault", "Vault")
parent_id = fields.Many2one(
"vault.entry",
"Parent Entry",
domain="[('vault_id', '=', vault_id)]",
)
master_key = fields.Char(related="vault_id.master_key")
name = fields.Char()
content = fields.Binary("Database", attachment=False)
crypted_content = fields.Char()
uuid = fields.Char(default=lambda self: uuid4())
path = fields.Many2one(
"vault.import.wizard.path",
"Path to import",
default="",
domain="[('uuid', '=', uuid)]",
)
@api.onchange("crypted_content", "content")
def _onchange_content(self):
for rec in self:
if rec.crypted_content:
for entry in json.loads(rec.crypted_content or []):
rec._create_path(entry)
def _create_path(self, entry, path=None):
self.ensure_one()
p = f"{path} / {entry['name']}" if path else entry["name"]
if "name" in entry:
self.env["vault.import.wizard.path"].create({"uuid": self.uuid, "name": p})
for child in entry.get("childs", []):
self._create_path(child, p)
def _import_field(self, entry, model, data):
if not data:
return
# Only copy specific fields
vals = {f: data[f] for f in ["name", "iv", "value"]}
# Update already existing records
domain = [("entry_id", "=", entry.id), ("name", "=", data["name"])]
rec = model.search(domain)
if rec:
rec.write(vals)
else:
rec.create({"entry_id": entry.id, **vals})
def _import_entry(self, entry, parent=None, path=None):
p = f"{path} / {entry['name']}" if path else entry["name"]
result = self.env["vault.entry"]
if p.startswith(self.path.name or ""):
if not parent:
parent = self.env["vault.entry"]
# Update existing records if already imported
rec = self.env["vault.entry"]
if entry.get("uuid"):
domain = [
("uuid", "=", entry["uuid"]),
("vault_id", "=", self.vault_id.id),
]
rec = rec.search(domain, limit=1)
# If record not found create a new one
vals = {f: entry.get(f) for f in ["name", "note", "url", "uuid"]}
if not rec:
rec = rec.create(
{"vault_id": self.vault_id.id, "parent_id": parent.id, **vals}
)
else:
rec.write({"parent_id": parent.id, **vals})
# Create/update the entry fields
for field in entry.get("fields", []):
self._import_field(rec, self.env["vault.field"], field)
# Create/update the entry files
for file in entry.get("files", []):
self._import_field(rec, self.env["vault.file"], file)
result |= rec
else:
rec = None
# Create the sub-entries
for child in entry.get("childs", []):
result |= self._import_entry(child, rec, p)
return result
def action_import(self):
self.ensure_one()
try:
data = json.loads(self.crypted_content)
entries = self.env["vault.entry"]
for entry in data:
entries |= self.with_context(skip_log=True)._import_entry(
entry, self.parent_id
)
self.vault_id.log_entry(f"Imported entries from file {self.name}")
except Exception as e:
raise UserError(_("Invalid file to import from")) from e

View file

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="view_vault_import_wizard" model="ir.ui.view">
<field name="model">vault.import.wizard</field>
<field name="arch" type="xml">
<form>
<sheet>
<field name="master_key" invisible="1" />
<field name="vault_id" invisible="1" />
<field name="crypted_content" invisible="1" />
<field name="name" invisible="1" />
<field name="uuid" invisible="1" />
<div>The files must end on one of the supported file type:</div>
<ul>
<li>Custom JSON format <b>.json</b></li>
<li>Keepass Database <b>.kdbx</b></li>
</ul>
<group>
<field name="content" filename="name" />
<field
name="parent_id"
options="{'no_create_edit': True, 'no_open': True}"
/>
<field
name="path"
attrs="{'invisible': [('crypted_content', '=', False)]}"
options="{'no_create_edit': True, 'no_open': True}"
/>
</group>
</sheet>
<footer>
<button
type="object"
name="action_import"
string="Import"
class="oe_highlight"
attrs="{'invisible': [('crypted_content', '=', False)]}"
/>
<button
type="object"
name="action_import"
string="Import"
attrs="{'invisible': [('crypted_content', '!=', False)]}"
/>
or
<button type="special" string="Cancel" special="cancel" />
</footer>
</form>
</field>
</record>
</odoo>

View file

@ -0,0 +1,56 @@
# © 2021 Florian Kantelberg - initOS GmbH
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
from odoo import _, fields, models
from odoo.exceptions import ValidationError
_logger = logging.getLogger(__name__)
class VaultSendWizard(models.TransientModel):
_name = "vault.send.wizard"
_description = _("Wizard to send another user a secret")
user_id = fields.Many2one(
"res.users",
"User",
required=True,
domain=[("keys", "!=", False), ("inbox_enabled", "=", True)],
)
name = fields.Char(required=True)
public = fields.Char(related="user_id.active_key.public")
iv = fields.Char(required=True)
key_user = fields.Char(required=True)
key = fields.Char(required=True)
secret = fields.Char()
secret_file = fields.Char()
filename = fields.Char()
_sql_constraints = [
(
"value_check",
"CHECK(secret IS NOT NULL OR secret_file IS NOT NULL)",
_("No value found"),
),
]
def action_send(self):
if not self.secret and not self.secret_file:
raise ValidationError(_("Neither a secret nor file was given"))
self.ensure_one()
self.env["vault.inbox"].sudo().create(
{
"name": self.name,
"accesses": 0,
"secret": self.secret,
"secret_file": self.secret_file,
"iv": self.iv,
"key": self.key_user,
"user_id": self.user_id.id,
"filename": self.filename,
"log_ids": [(0, 0, {"name": _("Created by %s") % self.user_id.name})],
}
)

View file

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="view_vault_send_wizard" model="ir.ui.view">
<field name="model">vault.send.wizard</field>
<field name="arch" type="xml">
<form>
<sheet>
<div>
You can only send the secret to the user who has generated a key-pair.
If an user is not showing please ask him to generate these.
</div>
<group>
<field name="iv" invisible="1" />
<field name="key" invisible="1" />
<field name="key_user" invisible="1" />
<field name="secret" invisible="1" />
<field name="public" invisible="1" />
<field
name="user_id"
options="{'no_open': true, 'no_create_edit': true, 'no_quick_create': true}"
/>
<field name="name" />
</group>
</sheet>
<footer>
<button
type="object"
name="action_send"
string="Send"
class="oe_highlight"
/>
or
<button type="special" string="Cancel" special="cancel" />
</footer>
</form>
</field>
</record>
</odoo>

View file

@ -0,0 +1,47 @@
# © 2021 Florian Kantelberg - initOS GmbH
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
from odoo import _, api, fields, models
_logger = logging.getLogger(__name__)
class VaultStoreWizard(models.TransientModel):
_name = "vault.store.wizard"
_description = _("Wizard store a shared secret in a vault")
vault_id = fields.Many2one("vault", "Vault", required=True)
entry_id = fields.Many2one(
"vault.entry",
"Entry",
domain="[('vault_id', '=', vault_id)]",
required=True,
)
model = fields.Char(required=True)
master_key = fields.Char(compute="_compute_master_key", store=False)
name = fields.Char(required=True)
iv = fields.Char(required=True)
key = fields.Char(required=True)
secret = fields.Char(required=True)
secret_temporary = fields.Char(required=True)
@api.depends("entry_id", "vault_id")
def _compute_master_key(self):
for rec in self:
rec.master_key = rec.vault_id.master_key
def action_store(self):
self.ensure_one()
try:
self.env[self.model].create(
{
"entry_id": self.entry_id.id,
"name": self.name,
"iv": self.iv,
"value": self.secret,
}
)
except Exception as e:
_logger.exception(e)

View file

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="view_vault_store_wizard" model="ir.ui.view">
<field name="model">vault.store.wizard</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="iv" invisible="1" />
<field name="key" invisible="1" />
<field name="master_key" invisible="1" />
<field name="secret" invisible="1" />
<field name="secret_temporary" invisible="1" />
<field
name="vault_id"
options="{'no_create_edit': True, 'no_open': True, 'no_quick_create': true}"
/>
<field
name="entry_id"
attrs="{'invisible': [('vault_id', '=', False)]}"
options="{'no_create_edit': True, 'no_open': True, 'no_quick_create': true}"
/>
<field name="name" />
</group>
</sheet>
<footer>
<button
type="object"
name="action_store"
string="Store"
class="oe_highlight"
/>
or
<button type="special" string="Cancel" special="cancel" />
</footer>
</form>
</field>
</record>
</odoo>