From 4d0da6afba2a1cecfd1c867702177e28db013a2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Valyi?= Date: Thu, 3 Sep 2026 13:10:22 -0300 Subject: [PATCH] [ADD] certificate_data_encryption: add module --- certificate_data_encryption/README.rst | 128 ++++++++++++ certificate_data_encryption/__init__.py | 4 + certificate_data_encryption/__manifest__.py | 18 ++ .../models/__init__.py | 1 + .../models/certificate.py | 62 ++++++ certificate_data_encryption/pyproject.toml | 3 + .../readme/CONTRIBUTORS.md | 2 + .../readme/DESCRIPTION.md | 15 ++ certificate_data_encryption/readme/USAGE.md | 18 ++ certificate_data_encryption/tests/__init__.py | 1 + .../tests/test_certificate_data_encryption.py | 197 ++++++++++++++++++ 11 files changed, 449 insertions(+) create mode 100644 certificate_data_encryption/README.rst create mode 100644 certificate_data_encryption/__init__.py create mode 100644 certificate_data_encryption/__manifest__.py create mode 100644 certificate_data_encryption/models/__init__.py create mode 100644 certificate_data_encryption/models/certificate.py create mode 100644 certificate_data_encryption/pyproject.toml create mode 100644 certificate_data_encryption/readme/CONTRIBUTORS.md create mode 100644 certificate_data_encryption/readme/DESCRIPTION.md create mode 100644 certificate_data_encryption/readme/USAGE.md create mode 100644 certificate_data_encryption/tests/__init__.py create mode 100644 certificate_data_encryption/tests/test_certificate_data_encryption.py diff --git a/certificate_data_encryption/README.rst b/certificate_data_encryption/README.rst new file mode 100644 index 000000000..03de0a865 --- /dev/null +++ b/certificate_data_encryption/README.rst @@ -0,0 +1,128 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +=========================== +Certificate Data Encryption +=========================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:41d07edb455548e16222dca07093317dbcc0b014e6a8f471710a4b70dc1fb00b + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/license-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--env-lightgray.png?logo=github + :target: https://github.com/OCA/server-env/tree/18.0/certificate_data_encryption + :alt: OCA/server-env +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/server-env-18-0/server-env-18-0-certificate_data_encryption + :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-env&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module allows to store the passwords of the certificates and +private keys managed by the core ``certificate`` module in an +**encrypted** and **per environment** way (dev, staging, production), +instead of clear text in the database. + +It connects the core ``certificate`` module with the OCA server-env +encryption mechanism (``server_environment_data_encryption`` and +``data_encryption``): the passwords become environment managed fields +(``server.env.mixin``) whose values are stored encrypted in the +``encrypted.data`` table using a Fernet key per environment. + +Covered fields: + +- ``certificate.certificate.pkcs12_password`` +- ``certificate.key.password`` + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Follow the configuration of the ``server_environment`` and +``server_environment_data_encryption`` modules: + +- define ``running_env`` in the Odoo configuration file; + +- define one Fernet key per environment in the ``[options]`` section, + e.g. ``encryption_key_prod = ZZZ``; + +- generate the keys with: + ``python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key())'``. + +The passwords are **no longer stored in the** +``certificate.certificate`` and ``certificate.key`` **tables**: they are +set/changed from the forms (the screen shows which environment is being +edited) and are stored encrypted in the ``encrypted.data`` table, per +environment. + +If no encryption key is configured for the current environment, the +module has no effect (default behavior of +``server_environment_data_encryption``). + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Akretion + +Contributors +------------ + +- `Akretion `__: + + - Raphaël Valyi + +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. + +.. |maintainer-rvalyi| image:: https://github.com/rvalyi.png?size=40px + :target: https://github.com/rvalyi + :alt: rvalyi + +Current `maintainer `__: + +|maintainer-rvalyi| + +This module is part of the `OCA/server-env `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/certificate_data_encryption/__init__.py b/certificate_data_encryption/__init__.py new file mode 100644 index 000000000..9728bf991 --- /dev/null +++ b/certificate_data_encryption/__init__.py @@ -0,0 +1,4 @@ +# Copyright (C) 2026 Akretion (http://www.akretion.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import models diff --git a/certificate_data_encryption/__manifest__.py b/certificate_data_encryption/__manifest__.py new file mode 100644 index 000000000..483df1dda --- /dev/null +++ b/certificate_data_encryption/__manifest__.py @@ -0,0 +1,18 @@ +# Copyright (C) 2026 Akretion (http://www.akretion.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Certificate Data Encryption", + "summary": "Store certificate passwords encrypted by environment", + "version": "18.0.1.0.0", + "development_status": "Beta", + "category": "Tools", + "website": "https://github.com/OCA/server-env", + "author": "Akretion, Odoo Community Association (OCA)", + "maintainers": ["rvalyi"], + "license": "AGPL-3", + "depends": [ + "certificate", + "server_environment_data_encryption", + ], +} diff --git a/certificate_data_encryption/models/__init__.py b/certificate_data_encryption/models/__init__.py new file mode 100644 index 000000000..e478b7ab4 --- /dev/null +++ b/certificate_data_encryption/models/__init__.py @@ -0,0 +1 @@ +from . import certificate diff --git a/certificate_data_encryption/models/certificate.py b/certificate_data_encryption/models/certificate.py new file mode 100644 index 000000000..f1a1ec392 --- /dev/null +++ b/certificate_data_encryption/models/certificate.py @@ -0,0 +1,62 @@ +# Copyright (C) 2026 Akretion (http://www.akretion.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, models +from odoo.exceptions import ValidationError + + +class Certificate(models.Model): + _name = "certificate.certificate" + _inherit = ["certificate.certificate", "server.env.mixin"] + + @property + def _server_env_fields(self): + return {"pkcs12_password": {}} + + def _compute_server_env(self): + # While a record is being created (e.g. when Odoo precomputes the + # stored computed fields such as ``pem_certificate`` on a new + # record), the encrypted value cannot be resolved yet: the + # encrypted data store is keyed by record id. Skipping the + # computation for new records keeps the value provided in the + # creation values available, so the certificate data can still be + # extracted from the file exactly like without this module. + real_records = self.filtered(lambda r: r.id) + return super(Certificate, real_records)._compute_server_env() + + @api.constrains("content", "pem_certificate") + def _constrains_certificate_loaded(self): + # The password is environment managed: it is no longer stored in + # the table and may be written alone (e.g. to define the value of + # another environment from the running one) or not be defined at + # all for the current environment. Check the file consistency + # directly from the content, as reading ``pem_certificate`` here + # could re-trigger its computation in the middle of a create (the + # check is done again once it is computed anyway). + for cert in self.filtered(lambda c: c.content and c.pkcs12_password): + content = cert.with_context(bin_size=False).content + password = cert.pkcs12_password.encode() + leaf_pem, _additional_pems, _format = cert._parse_certificate_content( + content, password + ) + if not leaf_pem: + raise ValidationError( + _( + "This certificate could not be loaded. " + "Either the content or the password is erroneous." + ) + ) + + +class CertificateKey(models.Model): + _name = "certificate.key" + _inherit = ["certificate.key", "server.env.mixin"] + + @property + def _server_env_fields(self): + return {"password": {}} + + def _compute_server_env(self): + # See the comment in certificate.certificate._compute_server_env. + real_records = self.filtered(lambda r: r.id) + return super(CertificateKey, real_records)._compute_server_env() diff --git a/certificate_data_encryption/pyproject.toml b/certificate_data_encryption/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/certificate_data_encryption/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/certificate_data_encryption/readme/CONTRIBUTORS.md b/certificate_data_encryption/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..b05e046d9 --- /dev/null +++ b/certificate_data_encryption/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Akretion](https://www.akretion.com/pt-BR): + - Raphaël Valyi \<\> diff --git a/certificate_data_encryption/readme/DESCRIPTION.md b/certificate_data_encryption/readme/DESCRIPTION.md new file mode 100644 index 000000000..a423feb7c --- /dev/null +++ b/certificate_data_encryption/readme/DESCRIPTION.md @@ -0,0 +1,15 @@ +This module allows to store the passwords of the certificates and +private keys managed by the core ``certificate`` module in an +**encrypted** and **per environment** way (dev, staging, production), +instead of clear text in the database. + +It connects the core ``certificate`` module with the OCA server-env +encryption mechanism (``server_environment_data_encryption`` and +``data_encryption``): the passwords become environment managed fields +(``server.env.mixin``) whose values are stored encrypted in the +``encrypted.data`` table using a Fernet key per environment. + +Covered fields: + +- ``certificate.certificate.pkcs12_password`` +- ``certificate.key.password`` diff --git a/certificate_data_encryption/readme/USAGE.md b/certificate_data_encryption/readme/USAGE.md new file mode 100644 index 000000000..05f402424 --- /dev/null +++ b/certificate_data_encryption/readme/USAGE.md @@ -0,0 +1,18 @@ +Follow the configuration of the ``server_environment`` and +``server_environment_data_encryption`` modules: + +- define ``running_env`` in the Odoo configuration file; +- define one Fernet key per environment in the ``[options]`` section, + e.g. ``encryption_key_prod = ZZZ``; + +- generate the keys with: ``python -c 'from cryptography.fernet import + Fernet; print(Fernet.generate_key())'``. + +The passwords are **no longer stored in the** ``certificate.certificate`` +and ``certificate.key`` **tables**: they are set/changed from the forms +(the screen shows which environment is being edited) and are stored +encrypted in the ``encrypted.data`` table, per environment. + +If no encryption key is configured for the current environment, the +module has no effect (default behavior of +``server_environment_data_encryption``). diff --git a/certificate_data_encryption/tests/__init__.py b/certificate_data_encryption/tests/__init__.py new file mode 100644 index 000000000..dc7db7840 --- /dev/null +++ b/certificate_data_encryption/tests/__init__.py @@ -0,0 +1 @@ +from . import test_certificate_data_encryption diff --git a/certificate_data_encryption/tests/test_certificate_data_encryption.py b/certificate_data_encryption/tests/test_certificate_data_encryption.py new file mode 100644 index 000000000..b4b031c79 --- /dev/null +++ b/certificate_data_encryption/tests/test_certificate_data_encryption.py @@ -0,0 +1,197 @@ +# Copyright (C) 2026 Akretion (http://www.akretion.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import base64 +import datetime + +from cryptography import x509 +from cryptography.fernet import Fernet +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.serialization import pkcs12 +from cryptography.x509.oid import NameOID + +from odoo.exceptions import ValidationError +from odoo.tests import TransactionCase +from odoo.tools.config import config + + +def _generate_pkcs12(password, common_name="Test Certificate"): + """Build a base64 encoded PKCS12 archive protected by ``password``.""" + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)]) + now = datetime.datetime.now(datetime.timezone.utc) + cert = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(days=1)) + .not_valid_after(now + datetime.timedelta(days=365)) + .sign(key, hashes.SHA256()) + ) + p12 = pkcs12.serialize_key_and_certificates( + name=b"test", + key=key, + cert=cert, + cas=None, + encryption_algorithm=serialization.BestAvailableEncryption(password.encode()), + ) + return base64.b64encode(p12) + + +class TestCertificateDataEncryption(TransactionCase): + """Check certificate passwords are stored encrypted per environment.""" + + def setUp(self): + super().setUp() + self._old_running_env = config.get("running_env", "") + self._old_keys = { + key: config.get(key, "") + for key in ("encryption_key_test", "encryption_key_prod") + } + config["running_env"] = "test" + config["encryption_key_test"] = Fernet.generate_key().decode() + config["encryption_key_prod"] = Fernet.generate_key().decode() + + def tearDown(self): + config["running_env"] = self._old_running_env + for key, value in self._old_keys.items(): + config[key] = value + return super().tearDown() + + def _create_certificate(self, password="secret-password", **kwargs): + vals = { + "name": "Test Certificate", + "content": _generate_pkcs12(password), + "pkcs12_password": password, + } + vals.update(kwargs) + return self.env["certificate.certificate"].create(vals) + + def test_password_not_stored_in_certificate_table(self): + cert = self._create_certificate() + self.env.cr.execute( + "SELECT pkcs12_password FROM certificate_certificate WHERE id = %s", + (cert.id,), + ) + self.assertFalse(self.env.cr.fetchone()[0]) + # but still readable for the running (test) environment + self.assertEqual(cert.pkcs12_password, "secret-password") + # and the certificate data could be extracted from the file + self.assertTrue(cert.pem_certificate) + self.assertFalse(cert.loading_error) + + def test_password_stored_encrypted_in_encrypted_data(self): + cert = self._create_certificate() + encrypted = ( + self.env["encrypted.data"] + .sudo() + .search( + [ + ("name", "=", f"certificate.certificate,{cert.id}"), + ("environment", "=", "test"), + ] + ) + ) + self.assertTrue(encrypted) + # the Fernet blob must not contain the clear password + self.assertNotIn(b"secret-password", encrypted.encrypted_data) + + def test_password_is_per_environment(self): + cert = self._create_certificate() + # no value defined for the prod environment yet + self.assertFalse(cert.with_context(environment="prod").pkcs12_password) + # define the prod value from the running environment. As the + # content is shared between environments, the password must be + # consistent with it; the point of storing it per environment is + # that a copy of the database without the encryption key of an + # environment cannot read its value. + cert.with_context(environment="prod").write( + {"pkcs12_password": "secret-password"} + ) + self.assertEqual( + cert.with_context(environment="prod").pkcs12_password, + "secret-password", + ) + # the test environment value is unchanged and stays readable + self.assertEqual(cert.pkcs12_password, "secret-password") + # one encrypted row per environment + encrypted = ( + self.env["encrypted.data"] + .sudo() + .search([("name", "=", f"certificate.certificate,{cert.id}")]) + ) + self.assertEqual(len(encrypted), 2) + self.assertEqual({rec.environment for rec in encrypted}, {"test", "prod"}) + for rec in encrypted: + self.assertNotIn(b"secret-password", rec.encrypted_data) + # still nothing in the certificate table + self.env.cr.execute( + "SELECT pkcs12_password FROM certificate_certificate WHERE id = %s", + (cert.id,), + ) + self.assertFalse(self.env.cr.fetchone()[0]) + + def test_password_is_validated_per_environment(self): + cert = self._create_certificate() + # writing a password inconsistent with the content for another + # environment is rejected, even if the current environment value + # is valid + with self.assertRaises(ValidationError): + cert.with_context(environment="prod").write( + {"pkcs12_password": "prod-password"} + ) + + def test_certificate_wrong_password(self): + with self.assertRaises(ValidationError): + self.env["certificate.certificate"].create( + { + "name": "Broken Certificate", + "content": _generate_pkcs12("secret-password"), + "pkcs12_password": "wrong-password", + } + ) + + @staticmethod + def _generate_encrypted_pem_key(password): + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + pem = key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.BestAvailableEncryption( + password.encode() + ), + ) + return base64.b64encode(pem) + + def test_key_password_encrypted(self): + key = self.env["certificate.key"].create( + { + "name": "Test Key", + "content": self._generate_encrypted_pem_key("key-secret"), + "password": "key-secret", + } + ) + self.assertTrue(key.pem_key) + self.assertFalse(key.loading_error) + self.env.cr.execute( + "SELECT password FROM certificate_key WHERE id = %s", (key.id,) + ) + self.assertFalse(self.env.cr.fetchone()[0]) + # readable from the running environment + self.assertEqual(key.password, "key-secret") + # and stored encrypted in the encrypted data store + encrypted = ( + self.env["encrypted.data"] + .sudo() + .search( + [ + ("name", "=", f"certificate.key,{key.id}"), + ("environment", "=", "test"), + ] + ) + ) + self.assertTrue(encrypted) + self.assertNotIn(b"key-secret", encrypted.encrypted_data)