diff --git a/base_field_encrypted/README.rst b/base_field_encrypted/README.rst new file mode 100644 index 0000000000..547fdd80f2 --- /dev/null +++ b/base_field_encrypted/README.rst @@ -0,0 +1,143 @@ +==================== +Base Field Encrypted +==================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:06e7f89cc87e56516e52403b660a0a674b99f951dc070feeb58654e05f89946c + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/base_field_encrypted + :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-base_field_encrypted + :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 provides a generic mixin to symmetrically encrypt data in the database +while maintaining a standard Python workflow for developers. + +Odoo natively handles `password="True"` on views by sending plaintext data +to the client, where the browser masks it. This module intercepts reads and writes +to implement actual "data at rest" encryption using the `cryptography` library. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +To use this module, you need to configure a master encryption key in your +``odoo.conf`` file: + +1. Generate a URL-safe base64-encoded 32-byte key. You have two options: + + **Option A (Recommended - UI Wizard):** + - Log in as an Administrator (with "Settings" access). + - Go to Settings > Technical > Security > Generate Encryption Key (Fernet). + - Copy the generated key. + + **Option B (Terminal):** + .. code-block:: python + + from cryptography.fernet import Fernet + print(Fernet.generate_key().decode()) + +2. Add the copied key to your configuration file under the ``[options]`` section: + + .. code-block:: ini + + [options] + encryption_key = + +3. Restart your Odoo server. + +If no key is configured, or the key is invalid, the module will log a warning +and fallback to storing data in plaintext to prevent data loss. + +**WARNING:** The encryption key is NOT stored in the database. If you lose +the key, all previously encrypted fields will become permanently unreadable. +Keep your ``odoo.conf`` safe. + +Usage +===== + +To use the encryption capabilities in your own custom models: + +1. Inherit the mixin in your model: + + .. code-block:: python + + class MyIntegration(models.Model): + _name = 'my.integration' + _inherit = ['encryption.mixin'] + + api_secret = fields.Char(string="API Secret", encrypted=True) + +2. In your XML view, use the native `password="True"` attribute so the frontend masks it: + + .. code-block:: xml + + + +Internal Python code can access `record.api_secret` normally and will receive the +decrypted plaintext value. The web client will only receive `********`. + +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 +======= + +Contributors +~~~~~~~~~~~~ + +* Antonio Ruban + +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-antoniodavid| image:: https://github.com/antoniodavid.png?size=40px + :target: https://github.com/antoniodavid + :alt: antoniodavid + +Current `maintainer `__: + +|maintainer-antoniodavid| + +This module is part of the `OCA/server-auth `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/base_field_encrypted/__init__.py b/base_field_encrypted/__init__.py new file mode 100644 index 0000000000..9e4a8d95eb --- /dev/null +++ b/base_field_encrypted/__init__.py @@ -0,0 +1,3 @@ +from . import models +from . import wizards +from . import tools diff --git a/base_field_encrypted/__manifest__.py b/base_field_encrypted/__manifest__.py new file mode 100644 index 0000000000..b456d5200f --- /dev/null +++ b/base_field_encrypted/__manifest__.py @@ -0,0 +1,15 @@ +{ + "name": "Base Field Encrypted", + "summary": "Symmetric encryption for fields in Odoo using cryptography (Fernet)", + "version": "16.0.1.0.0", + "category": "Tools", + "author": "Odoo Community Association (OCA)", + "website": "https://github.com/OCA/server-auth", + "license": "AGPL-3", + "depends": ["base"], + "data": [ + "security/ir.model.access.csv", + "wizards/generate_encryption_key_wizard_views.xml", + ], + "maintainers": ["antoniodavid"], +} diff --git a/base_field_encrypted/i18n/es.po b/base_field_encrypted/i18n/es.po new file mode 100644 index 0000000000..30c87a4f88 --- /dev/null +++ b/base_field_encrypted/i18n/es.po @@ -0,0 +1,89 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * base_field_encrypted +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-03-06 00:00+0000\n" +"PO-Revision-Date: 2024-03-06 00:00+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: base_field_encrypted +#: model:ir.model.fields,help:base_field_encrypted.field_generate_encryption_key_wizard__key +msgid "Copy this key and paste it into your odoo.conf file. It will never be permanently saved in the database." +msgstr "Copia esta llave y pégala en tu archivo odoo.conf. Jamás será guardada permanentemente en la base de datos." + +#. module: base_field_encrypted +#: model_terms:ir.ui.view,arch_db:base_field_encrypted.view_generate_encryption_key_wizard_form +msgid "Danger!" +msgstr "¡Peligro!" + +#. module: base_field_encrypted +#: model:ir.actions.act_window,name:base_field_encrypted.action_generate_encryption_key_wizard +msgid "Generate Encryption Key" +msgstr "Generar Llave de Encriptación" + +#. module: base_field_encrypted +#: model:ir.ui.menu,name:base_field_encrypted.menu_generate_encryption_key +msgid "Generate Encryption Key (Fernet)" +msgstr "Generar Llave Encriptación (Fernet)" + +#. module: base_field_encrypted +#: model:ir.model,name:base_field_encrypted.model_generate_encryption_key_wizard +msgid "Generate Encryption Key Wizard" +msgstr "Asistente para Generar Llave de Encriptación" + +#. module: base_field_encrypted +#: model_terms:ir.ui.view,arch_db:base_field_encrypted.view_generate_encryption_key_wizard_form +msgid "Generate Another Key" +msgstr "Generar Otra Llave" + +#. module: base_field_encrypted +#: model:ir.model.fields,field_description:base_field_encrypted.field_generate_encryption_key_wizard__message +msgid "Instructions" +msgstr "Instrucciones" + +#. module: base_field_encrypted +#: model:ir.model.fields,field_description:base_field_encrypted.field_generate_encryption_key_wizard__key +msgid "New Encryption Key" +msgstr "Nueva Llave de Encriptación" + +#. module: base_field_encrypted +#: model_terms:ir.ui.view,arch_db:base_field_encrypted.view_generate_encryption_key_wizard_form +msgid "This is your only chance to see this key. If you close this window without copying it, you will have to generate a new one." +msgstr "Esta es tu única oportunidad de ver esta llave. Si cierras esta ventana sin copiarla, tendrás que generar una nueva." + +#. module: base_field_encrypted +#: model_terms:ir.ui.view,arch_db:base_field_encrypted.view_generate_encryption_key_wizard_form +msgid "Encryption Key Generator" +msgstr "Generador de Llaves de Encriptación" + +#. module: base_field_encrypted +#: model_terms:ir.ui.view,arch_db:base_field_encrypted.view_generate_encryption_key_wizard_form +msgid "" +"1. Copy the generated key above.\n" +"2. Paste it into your odoo.conf file under the [options] section:\n" +"\n" +"[options]\n" +"encryption_key = PASTE_THE_KEY_HERE\n" +"\n" +"3. Restart your Odoo server.\n" +"\n" +"⚠️ IMPORTANT: This key is NOT saved in the database for security reasons. If you lose it and had encrypted data, that data will be lost forever!" +msgstr "" +"1. Copia la llave generada arriba.\n" +"2. Pégala en tu archivo odoo.conf debajo de la sección [options]:\n" +"\n" +"[options]\n" +"encryption_key = PEGA_AQUÍ_LA_LLAVE\n" +"\n" +"3. Reinicia tu servidor Odoo.\n" +"\n" +"⚠️ IMPORTANTE: Esta llave NO se guarda en la base de datos por seguridad. ¡Si la pierdes y tenías datos encriptados, se perderán para siempre!" diff --git a/base_field_encrypted/models/__init__.py b/base_field_encrypted/models/__init__.py new file mode 100644 index 0000000000..344095bfe3 --- /dev/null +++ b/base_field_encrypted/models/__init__.py @@ -0,0 +1 @@ +from . import encryption_mixin diff --git a/base_field_encrypted/models/encryption_mixin.py b/base_field_encrypted/models/encryption_mixin.py new file mode 100644 index 0000000000..9e71f83f81 --- /dev/null +++ b/base_field_encrypted/models/encryption_mixin.py @@ -0,0 +1,106 @@ +import logging +import types + +from odoo import api, models + +from ..tools import crypto + +_logger = logging.getLogger(__name__) + + +class EncryptionMixin(models.AbstractModel): + _name = "encryption.mixin" + _description = "Mixin to support encrypted fields" + + @api.model + def _valid_field_parameter(self, field, name): + """Tell Odoo that 'encrypted=True' is a valid parameter for fields on this model""" + return name == "encrypted" or super()._valid_field_parameter(field, name) + + @api.model + def _get_encrypted_fields(self): + """Helper to retrieve all encrypted fields on the current model.""" + return [ + name + for name, field in self._fields.items() + if getattr(field, "encrypted", False) + ] + + @api.model + def _setup_complete(self): + """ + Dynamically patch the `convert_to_record` method of fields marked as `encrypted=True`. + This is the most reliable hook in Odoo 16 to intercept data just before it is returned + to the Python application from the cache. + """ + res = super()._setup_complete() + + for name, field in self._fields.items(): + if getattr(field, "encrypted", False) and not getattr( + field, "_encrypted_patched", False + ): + _logger.info( + "Patching encrypted field '%s' on model '%s'", name, self._name + ) + + # Save original bound methods + orig_convert_to_record = field.convert_to_record + + def new_convert_to_record( + self_field, value, record, _orig=orig_convert_to_record + ): + # Standard Odoo conversion first + val = _orig(value, record) + # If it's an encrypted token, decrypt it before giving it to the python code + if val and isinstance(val, str) and val.startswith("gAAAAAB"): + return crypto.decrypt(val) + return val + + # Bind the new method to the field instance + field.convert_to_record = types.MethodType(new_convert_to_record, field) + field._encrypted_patched = True + + return res + + @api.model_create_multi + def create(self, vals_list): + """Encrypt values on creation and prevent dummy value '********' from being saved.""" + encrypted_fields = self._get_encrypted_fields() + if encrypted_fields: + for vals in vals_list: + for f in encrypted_fields: + val = vals.get(f) + if val == "********": + del vals[f] + elif val: + vals[f] = crypto.encrypt(val) + return super().create(vals_list) + + def write(self, vals): + """Encrypt values on write and prevent dummy value '********' from overwriting.""" + encrypted_fields = self._get_encrypted_fields() + if encrypted_fields: + for f in encrypted_fields: + val = vals.get(f) + if val == "********": + del vals[f] + elif val: + vals[f] = crypto.encrypt(val) + if not vals: + return True + return super().write(vals) + + def read(self, fields=None, load="_classic_read"): + """ + Intercept public reads (e.g., from the web client). + Replace the plaintext cache values with the dummy mask '********'. + """ + res = super().read(fields, load) + if not self.env.context.get("decrypt_fields"): + encrypted_fields = self._get_encrypted_fields() + if encrypted_fields: + for rec in res: + for f in encrypted_fields: + if rec.get(f): + rec[f] = "********" + return res diff --git a/base_field_encrypted/readme/CONFIGURE.rst b/base_field_encrypted/readme/CONFIGURE.rst new file mode 100644 index 0000000000..5ddea261d1 --- /dev/null +++ b/base_field_encrypted/readme/CONFIGURE.rst @@ -0,0 +1,31 @@ +To use this module, you need to configure a master encryption key in your +``odoo.conf`` file: + +1. Generate a URL-safe base64-encoded 32-byte key. You have two options: + + **Option A (Recommended - UI Wizard):** + - Log in as an Administrator (with "Settings" access). + - Go to Settings > Technical > Security > Generate Encryption Key (Fernet). + - Copy the generated key. + + **Option B (Terminal):** + .. code-block:: python + + from cryptography.fernet import Fernet + print(Fernet.generate_key().decode()) + +2. Add the copied key to your configuration file under the ``[options]`` section: + + .. code-block:: ini + + [options] + encryption_key = + +3. Restart your Odoo server. + +If no key is configured, or the key is invalid, the module will log a warning +and fallback to storing data in plaintext to prevent data loss. + +**WARNING:** The encryption key is NOT stored in the database. If you lose +the key, all previously encrypted fields will become permanently unreadable. +Keep your ``odoo.conf`` safe. diff --git a/base_field_encrypted/readme/CONTRIBUTORS.rst b/base_field_encrypted/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..f8b9e1a8c8 --- /dev/null +++ b/base_field_encrypted/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Antonio Ruban diff --git a/base_field_encrypted/readme/DESCRIPTION.rst b/base_field_encrypted/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..e0bbde215c --- /dev/null +++ b/base_field_encrypted/readme/DESCRIPTION.rst @@ -0,0 +1,6 @@ +This module provides a generic mixin to symmetrically encrypt data in the database +while maintaining a standard Python workflow for developers. + +Odoo natively handles `password="True"` on views by sending plaintext data +to the client, where the browser masks it. This module intercepts reads and writes +to implement actual "data at rest" encryption using the `cryptography` library. diff --git a/base_field_encrypted/readme/USAGE.rst b/base_field_encrypted/readme/USAGE.rst new file mode 100644 index 0000000000..f4a3b15c16 --- /dev/null +++ b/base_field_encrypted/readme/USAGE.rst @@ -0,0 +1,20 @@ +To use the encryption capabilities in your own custom models: + +1. Inherit the mixin in your model: + + .. code-block:: python + + class MyIntegration(models.Model): + _name = 'my.integration' + _inherit = ['encryption.mixin'] + + api_secret = fields.Char(string="API Secret", encrypted=True) + +2. In your XML view, use the native `password="True"` attribute so the frontend masks it: + + .. code-block:: xml + + + +Internal Python code can access `record.api_secret` normally and will receive the +decrypted plaintext value. The web client will only receive `********`. diff --git a/base_field_encrypted/security/ir.model.access.csv b/base_field_encrypted/security/ir.model.access.csv new file mode 100644 index 0000000000..ecd8213747 --- /dev/null +++ b/base_field_encrypted/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_generate_encryption_key_wizard,generate.encryption.key.wizard,model_generate_encryption_key_wizard,base.group_system,1,1,1,1 diff --git a/base_field_encrypted/static/description/index.html b/base_field_encrypted/static/description/index.html new file mode 100644 index 0000000000..04200bb700 --- /dev/null +++ b/base_field_encrypted/static/description/index.html @@ -0,0 +1,478 @@ + + + + + +Base Field Encrypted + + + +
+

Base Field Encrypted

+ + +

Beta License: AGPL-3 OCA/server-auth Translate me on Weblate Try me on Runboat

+

This module provides a generic mixin to symmetrically encrypt data in the database +while maintaining a standard Python workflow for developers.

+

Odoo natively handles password=”True” on views by sending plaintext data +to the client, where the browser masks it. This module intercepts reads and writes +to implement actual “data at rest” encryption using the cryptography library.

+

Table of contents

+ +
+

Configuration

+

To use this module, you need to configure a master encryption key in your +odoo.conf file:

+
    +
  1. Generate a URL-safe base64-encoded 32-byte key. You have two options:

    +

    Option A (Recommended - UI Wizard): +- Log in as an Administrator (with “Settings” access). +- Go to Settings > Technical > Security > Generate Encryption Key (Fernet). +- Copy the generated key.

    +

    Option B (Terminal): +.. code-block:: python

    +
    +

    from cryptography.fernet import Fernet +print(Fernet.generate_key().decode())

    +
    +
  2. +
  3. Add the copied key to your configuration file under the [options] section:

    +
    +[options]
    +encryption_key = <YOUR_GENERATED_KEY>
    +
    +
  4. +
  5. Restart your Odoo server.

    +
  6. +
+

If no key is configured, or the key is invalid, the module will log a warning +and fallback to storing data in plaintext to prevent data loss.

+

WARNING: The encryption key is NOT stored in the database. If you lose +the key, all previously encrypted fields will become permanently unreadable. +Keep your odoo.conf safe.

+
+
+

Usage

+

To use the encryption capabilities in your own custom models:

+
    +
  1. Inherit the mixin in your model:

    +
    +class MyIntegration(models.Model):
    +    _name = 'my.integration'
    +    _inherit = ['encryption.mixin']
    +
    +    api_secret = fields.Char(string="API Secret", encrypted=True)
    +
    +
  2. +
  3. In your XML view, use the native password=”True” attribute so the frontend masks it:

    +
    +<field name="api_secret" password="True" />
    +
    +
  4. +
+

Internal Python code can access record.api_secret normally and will receive the +decrypted plaintext value. The web client will only receive ********.

+
+
+

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

+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

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.

+

Current maintainer:

+

antoniodavid

+

This module is part of the OCA/server-auth project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/base_field_encrypted/tests/__init__.py b/base_field_encrypted/tests/__init__.py new file mode 100644 index 0000000000..1775650791 --- /dev/null +++ b/base_field_encrypted/tests/__init__.py @@ -0,0 +1 @@ +from . import test_encryption diff --git a/base_field_encrypted/tests/test_encryption.py b/base_field_encrypted/tests/test_encryption.py new file mode 100644 index 0000000000..7799f5f8be --- /dev/null +++ b/base_field_encrypted/tests/test_encryption.py @@ -0,0 +1,162 @@ +from cryptography.fernet import Fernet + +from odoo import fields, models +from odoo.tests.common import TransactionCase +from odoo.tools import config + + +class TestEncryptionModel(models.Model): + _name = "test.encryption.model" + _description = "Test Encryption Model" + _inherit = ["encryption.mixin"] + + name = fields.Char() + secret_token = fields.Char(encrypted=True) + normal_field = fields.Char() + + +class TestBaseEncryptedField(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env.registry.registry_invalidated = True + + TestEncryptionModel._build_model(cls.env.registry, cls.env.cr) + cls.env.registry.setup_models(cls.env.cr) + cls.env.registry.init_models( + cls.env.cr, ["test.encryption.model"], dict(cls.env.context) + ) + + # Manually create table for the test model + if not cls.env.cr._obj.closed: + cls.env.cr.execute( + """ + CREATE TABLE IF NOT EXISTS test_encryption_model ( + id serial PRIMARY KEY, + name varchar, + secret_token varchar, + normal_field varchar, + create_uid int, + create_date timestamp, + write_uid int, + write_date timestamp + ) + """ + ) + + def setUp(self): + super().setUp() + self.TestModel = self.env["test.encryption.model"] + # Reset the cached Fernet instance before each test + from ..tools import crypto + + crypto._fernet = None + crypto._fernet_initialized = False + + # Keep original config to restore later + self.original_key = config.get("encryption_key") + + def tearDown(self): + # Restore original config + config.options["encryption_key"] = self.original_key + # Clean up the test model table + from ..tools import crypto + + crypto._fernet = None + crypto._fernet_initialized = False + super().tearDown() + + def test_01_fallback_plaintext(self): + """Test that without a key, data is saved and read in plaintext.""" + config.options["encryption_key"] = False + + record = self.TestModel.create( + {"name": "Fallback Test", "secret_token": "my_plaintext_secret"} + ) + + # Read from ORM + self.assertEqual(record.secret_token, "my_plaintext_secret") + + # Verify in DB directly + self.env.cr.execute( + "SELECT secret_token FROM test_encryption_model WHERE id=%s", [record.id] + ) + db_val = self.env.cr.fetchone()[0] + self.assertEqual( + db_val, + "my_plaintext_secret", + "Should be saved as plaintext in DB without a key", + ) + + def test_02_encryption_and_decryption(self): + """Test encryption logic with a valid key.""" + key = Fernet.generate_key().decode() + config.options["encryption_key"] = key + + record = self.TestModel.create( + {"name": "Secure Test", "secret_token": "super_secret_123"} + ) + + # Read from ORM (should automatically decrypt via cache convert) + self.assertEqual(record.secret_token, "super_secret_123") + + # Verify in DB directly (should be encrypted, starts with gAAAA) + self.env.cr.execute( + "SELECT secret_token FROM test_encryption_model WHERE id=%s", [record.id] + ) + db_val = self.env.cr.fetchone()[0] + self.assertNotEqual(db_val, "super_secret_123") + self.assertTrue(db_val.startswith("gAAAA")) + + # Verify decryption works correctly outside ORM + f = Fernet(key.encode()) + decrypted = f.decrypt(db_val.encode()).decode() + self.assertEqual(decrypted, "super_secret_123") + + def test_03_rpc_read_masking(self): + """Test that calling .read() masks the encrypted field with ********.""" + config.options["encryption_key"] = Fernet.generate_key().decode() + + record = self.TestModel.create( + {"name": "RPC Test", "secret_token": "top_secret"} + ) + + # Standard ORM access is clean + self.assertEqual(record.secret_token, "top_secret") + + # RPC/Web client .read() masks it + res = record.read(["name", "secret_token"])[0] + self.assertEqual(res["name"], "RPC Test") + self.assertEqual(res["secret_token"], "********") + + # Context bypass allows reading the real dictionary if specifically requested + res_bypassed = record.with_context(decrypt_fields=True).read(["secret_token"])[ + 0 + ] + self.assertEqual(res_bypassed["secret_token"], "top_secret") + + def test_04_dummy_write_ignored(self): + """Test that writing the dummy value '********' does not overwrite the real secret.""" + config.options["encryption_key"] = Fernet.generate_key().decode() + + record = self.TestModel.create( + {"name": "Dummy Ignore Test", "secret_token": "real_password"} + ) + + # Simulate frontend sending the masked password back unchanged during a write + record.write({"name": "Updated Name", "secret_token": "********"}) + + # The name should change, but the secret_token should remain 'real_password' + self.assertEqual(record.name, "Updated Name") + self.assertEqual(record.secret_token, "real_password") + + def test_05_dummy_create_ignored(self): + """Test that creating with the dummy value '********' skips it.""" + config.options["encryption_key"] = Fernet.generate_key().decode() + + record = self.TestModel.create( + {"name": "Dummy Create Test", "secret_token": "********"} + ) + + # Since it was ********, the mixin should have removed it, saving it as empty/False + self.assertFalse(record.secret_token) diff --git a/base_field_encrypted/tools/__init__.py b/base_field_encrypted/tools/__init__.py new file mode 100644 index 0000000000..a2795d21e8 --- /dev/null +++ b/base_field_encrypted/tools/__init__.py @@ -0,0 +1 @@ +from . import crypto diff --git a/base_field_encrypted/tools/crypto.py b/base_field_encrypted/tools/crypto.py new file mode 100644 index 0000000000..f47d3ed69d --- /dev/null +++ b/base_field_encrypted/tools/crypto.py @@ -0,0 +1,90 @@ +# Copyright 2026 Antonio Ruban +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import logging + +from cryptography.fernet import Fernet, InvalidToken + +from odoo.tools import config + +_logger = logging.getLogger(__name__) + +# Cache the Fernet instance to avoid recreating it +_fernet = None +_fernet_initialized = False + + +def get_fernet(): + global _fernet, _fernet_initialized + if _fernet_initialized: + return _fernet + + _fernet_initialized = True + key = config.get("encryption_key") + + if not key: + _logger.warning( + "No 'encryption_key' found in odoo.conf. " + "Encrypted fields will be stored in plaintext!" + ) + return None + + try: + # Validate the key is a valid Fernet key (32 url-safe base64-encoded bytes) + # Fernet will raise ValueError if invalid + _fernet = Fernet(key.encode("utf-8")) + except Exception as e: + _logger.error( + "Invalid 'encryption_key' in odoo.conf. Encryption disabled! Error: %s", e + ) + _fernet = None + + return _fernet + + +def encrypt(value): + """Encrypt a string value using Fernet.""" + if not value: + return value + + f = get_fernet() + if not f: + return value # Fallback: plaintext + + try: + if isinstance(value, str): + value = value.encode("utf-8") + return f.encrypt(value).decode("utf-8") + except Exception as e: + _logger.error("Encryption failed: %s", e) + return value + + +def decrypt(value): + """Decrypt a Fernet encrypted string.""" + if not value: + return value + + f = get_fernet() + if not f: + return value # Fallback: plaintext + + try: + if isinstance(value, str): + # Check if it looks like a Fernet token (usually starts with gAAAAAB...) + if not value.startswith("gAAAAAB"): + return value + + value = value.encode("utf-8") + + return f.decrypt(value).decode("utf-8") + except InvalidToken: + # It wasn't encrypted with this key, or wasn't encrypted at all + return ( + value if isinstance(value, str) else value.decode("utf-8", errors="ignore") + ) + except Exception as e: + _logger.error("Decryption failed: %s", e) + return ( + value if isinstance(value, str) else value.decode("utf-8", errors="ignore") + ) diff --git a/base_field_encrypted/wizards/__init__.py b/base_field_encrypted/wizards/__init__.py new file mode 100644 index 0000000000..0f2e55870d --- /dev/null +++ b/base_field_encrypted/wizards/__init__.py @@ -0,0 +1 @@ +from . import generate_encryption_key_wizard diff --git a/base_field_encrypted/wizards/generate_encryption_key_wizard.py b/base_field_encrypted/wizards/generate_encryption_key_wizard.py new file mode 100644 index 0000000000..123c6ecd3b --- /dev/null +++ b/base_field_encrypted/wizards/generate_encryption_key_wizard.py @@ -0,0 +1,54 @@ +# Copyright 2026 Antonio Ruban +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from cryptography.fernet import Fernet + +from odoo import fields, models + + +class GenerateEncryptionKeyWizard(models.TransientModel): + _name = "generate.encryption.key.wizard" + _description = "Generate Encryption Key Wizard" + + # The key is computed dynamically and never stored in the database + key = fields.Char( + string="New Encryption Key", + compute="_compute_key", + store=False, + readonly=True, + help=( + "Copy this key and paste it into your odoo.conf file. " + "It will never be saved in the database." + ), + ) + + message = fields.Text( + string="Instructions", + default=( + "1. Copy the generated key above.\n" + "2. Paste it into your odoo.conf file under the [options] section:\n\n" + "[options]\n" + "encryption_key = PASTE_THE_KEY_HERE\n\n" + "3. Restart your Odoo server.\n\n" + "⚠️ IMPORTANT: This key is dynamically generated in memory and " + "NEVER touches the database. If you lose it and had encrypted data, " + "that data will be lost forever!" + ), + readonly=True, + store=False, + ) + + def _compute_key(self): + for record in self: + # Generate a new key on the fly every time the record is read + record.key = Fernet.generate_key().decode() + + def action_regenerate(self): + """Forces a refresh of the wizard to compute a new key.""" + return { + "type": "ir.actions.act_window", + "res_model": self._name, + "res_id": self.id, + "view_mode": "form", + "target": "new", + } diff --git a/base_field_encrypted/wizards/generate_encryption_key_wizard_views.xml b/base_field_encrypted/wizards/generate_encryption_key_wizard_views.xml new file mode 100644 index 0000000000..cc2826a527 --- /dev/null +++ b/base_field_encrypted/wizards/generate_encryption_key_wizard_views.xml @@ -0,0 +1,69 @@ + + + + generate.encryption.key.wizard.form + generate.encryption.key.wizard + +
+ + + + + + + + +
+
+
+
+
+ + + Generate Encryption Key + generate.encryption.key.wizard + form + new + + + + + +