diff --git a/keychain/README.rst b/keychain/README.rst
new file mode 100644
index 000000000..bd25adcfe
--- /dev/null
+++ b/keychain/README.rst
@@ -0,0 +1,240 @@
+.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg
+ :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
+ :alt: License: AGPL-3
+
+================
+Keychain Account
+================
+
+This module allows you to store credentials of external systems.
+
+* All the crendentials are stored in one place: easier to manage and to audit.
+* Multi-account made possible without effort.
+* Store additionnal data for each account.
+* Validation rules for additional data.
+* Have different account for different environments (prod / test / env / etc).
+
+
+By default, passwords are encrypted with a key stored in Odoo config.
+It's far from an ideal password storage setup, but it's way better
+than password in clear text in the database.
+It can be easily replaced by another system. See "Security" chapter below.
+
+Accounts may be: market places (Amazon, Cdiscount, ...), carriers (Laposte, UPS, ...)
+or any third party system called from Odoo.
+
+This module is aimed for developers.
+The logic to choose between accounts will be achieved in dependent modules.
+
+
+==========
+Uses cases
+==========
+
+Possible use case for deliveries: you need multiple accounts for the same carrier.
+It can be for instance due to carrier restrictions (immutable sender address),
+or business rules (each warehouse use a different account).
+
+
+Configuration
+=============
+
+After the installation of this module, you need to add some entries
+in Odoo's config file: (etc/openerp.cfg)
+
+> keychain_key = fyeMIx9XVPBBky5XZeLDxVc9dFKy7Uzas3AoyMarHPA=
+
+You can generate keys with `python keychain/bin/generate_key.py`.
+
+This key is used to encrypt account passwords.
+
+If you plan to use environments, you should add a key per environment:
+
+> keychain_key_dev = 8H_qFvwhxv6EeO9bZ8ww7BUymNt3xtQKYEq9rjAPtrc=
+
+> keychain_key_prod = y5z-ETtXkVI_ADoFEZ5CHLvrNjwOPxsx-htSVbDbmRc=
+
+keychain_key is used for encryption when no environment is set.
+
+
+Usage (for module dev)
+======================
+
+
+* Add this keychain as a dependency in __openerp__.py
+* Subclass `keychain.account` and add your module in namespaces: `(see after for the name of namespace )`
+
+.. code:: python
+
+ class LaposteAccount(models.Model):
+ _inherit = 'keychain.account'
+
+ namespace = fields.Selection(
+ selection_add=[('roulier_laposte', 'Laposte')])
+
+* Add the default data (as dict):
+
+.. code:: python
+
+ class LaposteAccount(models.Model):
+ # ...
+ def _roulier_laposte_init_data(self):
+ return {
+ "agencyCode": "",
+ "recommandationLevel": "R1"
+ }
+
+* Implement validation of user entered data:
+
+.. code:: python
+
+ class LaposteAccount(models.Model):
+ # ...
+ def _roulier_laposte_validate_data(self, data):
+ return len(data.get("agencyCode") > 3)
+
+* In your code, fetch the account:
+
+.. code:: python
+
+ import random
+
+ def get_auth(self):
+ keychain = self.env['keychain.account']
+ if self.env.user.has_group('stock.group_stock_user'):
+ retrieve = keychain.suspend_security().retrieve
+ else:
+ retrieve = keychain.retrieve
+
+ accounts = retrieve(
+ [['namespace', '=', 'roulier_laposte']])
+ account = random.choice(accounts)
+ return {
+ 'login': account.login,
+ 'password': account.get_password()
+ }
+
+
+In this example, an account is randomly picked. Usually this is set according
+to rules specific for each client.
+
+You have to restrict user access of your methods with suspend_security().
+
+Warning: _init_data and _validate_data should be prefixed with your namespace!
+Choose python naming function compatible name.
+
+Switching from prod to dev
+==========================
+
+You may adopt one of the following strategies:
+
+* store your dev accounts in production db using the dev key
+* import your dev accounts with Odoo builtin methods like a data.xml (in a dedicated module).
+* import your dev accounts with your own migration/cleanup script
+* etc.
+
+Note: only the password field is unreadable without the proper key, login and data fields
+are available on all environments.
+
+You may also use a same `technical_name` and different `environment` for choosing at runtime
+between accounts.
+
+Usage (for user)
+================
+
+Go to *settings / keychain*, create a record with the following
+
+* Namespace: type of account (ie: Laposte)
+* Name: human readable label "Warehouse 1"
+* Technical Name: name used by a consumer module (like "warehouse_1")
+* Login: login of the account
+* Password_clear: For entering the password in clear text (not stored unencrypted)
+* Password: password encrypted, unreadable without the key (in config)
+* Data: a JSON string for additionnal values (additionnal config for the account, like: `{"agencyCode": "Lyon", "insuranceLevel": "R1"})`
+* Environment: usually prod or dev or blank (for all)
+
+
+
+.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas
+ :alt: Try me on Runbot
+ :target: https://runbot.odoo-community.org/runbot/server-tools/9.0
+
+
+Known issues / Roadmap
+======================
+- Account inheritence is not supported out-of-the-box (like defining common settings for all environments)
+- Adapted to work with `server_environnement` modules
+- Key expiration or rotation should be done manually
+- Import passwords from data.xml
+
+Security
+========
+
+This discussion: https://github.com/OCA/server-tools/pull/644 may help you decide if this module is suitable for your needs or not.
+
+Common sense: Odoo is not a safe place for storing sensitive data.
+But sometimes you don't have any other possibilities.
+This module is designed to store credentials of data like carrier account, smtp, api keys...
+but definitively not for credits cards number, medical records, etc.
+
+
+By default, passwords are stored encrypted in the db using
+symetric encryption `Fernet `_.
+The encryption key is stored in openerp.tools.config.
+
+Threats even with this module installed:
+
+- unauthorized Odoo user want to access data: access is rejected by Odoo security rules
+- authorized Odoo user try to access data with rpc api: he gets the passwords encrypted, he can't recover because the key and the decrypted password are not exposed through rpc
+- db is stolen: without the key it's currently pretty hard to recover the passwords
+- Odoo is compromised (malicious module or vulnerability): hacker has access to python and can do what he wants with Odoo: passwords of the current env can be easily decrypted
+- server is compromised: idem
+
+If your dev server is compromised, hacker can't decrypt your prod passwords
+since you have different keys between dev and prod.
+
+If you want something more secure: don't store any sensitive data in Odoo,
+use an external system as a proxy, you can still use this module
+for storing all other data related to your accounts.
+
+
+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 smashing it by providing a detailed and welcomed feedback.
+
+Credits
+=======
+
+`Akretion `_
+
+
+Contributors
+------------
+
+* Raphaël Reverdy
+
+Funders
+-------
+
+The development of this module has been financially supported by:
+
+* `Akretion `_
+
+Maintainer
+----------
+
+.. image:: https://odoo-community.org/logo.png
+ :alt: Odoo Community Association
+ :target: https://odoo-community.org
+
+This module is maintained by the OCA.
+
+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.
+
+To contribute to this module, please visit https://odoo-community.org.
diff --git a/keychain/__init__.py b/keychain/__init__.py
new file mode 100644
index 000000000..0650744f6
--- /dev/null
+++ b/keychain/__init__.py
@@ -0,0 +1 @@
+from . import models
diff --git a/keychain/__openerp__.py b/keychain/__openerp__.py
new file mode 100644
index 000000000..a43cbe292
--- /dev/null
+++ b/keychain/__openerp__.py
@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+# Copyright <2016> Akretion
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+{
+ "name": "Keychain",
+ "summary": "Store accounts and credentials",
+ "version": "9.0.1.0.0",
+ "category": "Uncategorized",
+ "website": "https://akretion.com/",
+ "author": "Akretion, Odoo Community Association (OCA)",
+ "license": "AGPL-3",
+ "application": False,
+ "installable": True,
+ "external_dependencies": {
+ "python": [
+ 'cryptography'],
+ },
+ "depends": [
+ "base",
+ ],
+ "data": [
+ "security/ir.model.access.csv",
+ 'views/keychain_view.xml'
+ ],
+}
diff --git a/keychain/models/__init__.py b/keychain/models/__init__.py
new file mode 100644
index 000000000..b84bb9112
--- /dev/null
+++ b/keychain/models/__init__.py
@@ -0,0 +1 @@
+from . import keychain
\ No newline at end of file
diff --git a/keychain/models/keychain.py b/keychain/models/keychain.py
new file mode 100644
index 000000000..0008adb3c
--- /dev/null
+++ b/keychain/models/keychain.py
@@ -0,0 +1,200 @@
+# -*- coding: utf-8 -*-
+# © 2016 Akretion Mourad EL HADJ MIMOUNE, David BEAL, Raphaël REVERDY
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+from functools import wraps
+
+import logging
+import json
+
+from openerp import models, fields, api
+from openerp.exceptions import ValidationError
+from openerp.tools.config import config
+from openerp.tools.translate import _
+
+_logger = logging.getLogger(__name__)
+
+try:
+ from cryptography.fernet import Fernet, MultiFernet, InvalidToken
+except ImportError as err:
+ _logger.debug(err)
+
+
+def implemented_by_keychain(func):
+ """Call a prefixed function based on 'namespace'."""
+ @wraps(func)
+ def wrapper(cls, *args, **kwargs):
+ fun_name = func.__name__
+ fun = '_%s%s' % (cls.namespace, fun_name)
+ if not hasattr(cls, fun):
+ fun = '_default%s' % (fun_name)
+ return getattr(cls, fun)(*args, **kwargs)
+ return wrapper
+
+
+class KeychainAccount(models.Model):
+ """Manage all accounts of external systems in one place."""
+
+ _name = 'keychain.account'
+
+ name = fields.Char(required=True, help="Humain readable label")
+ technical_name = fields.Char(
+ required=True,
+ help="Technical name. Must be unique")
+ namespace = fields.Selection([], help="Type of account", required=True)
+ environment = fields.Char(
+ required=False,
+ help="'prod', 'dev', etc. or empty (for all)"
+ )
+ login = fields.Char(help="Login")
+ clear_password = fields.Char(
+ help="Password. Leave empty if no changes",
+ inverse='_inverse_set_password',
+ compute='_compute_password',
+ store=False)
+ password = fields.Char(
+ help="Password is derived from clear_password",
+ readonly=True)
+ data = fields.Text(help="Additionnal data as json")
+
+ def _compute_password(self):
+ # Only needed in v8 for _description_searchable issues
+ return True
+
+ def get_password(self):
+ """Password in clear text."""
+ try:
+ return self._decode_password(self.password)
+ except Warning as warn:
+ raise Warning(_(
+ "%s \n"
+ "Account: %s %s %s " % (
+ warn,
+ self.login, self.name, self.technical_name
+ )
+ ))
+
+ def get_data(self):
+ """Data in dict form."""
+ return self._parse_data(self.data)
+
+ @api.constrains('data')
+ def _check_data(self):
+ """Ensure valid input in data field."""
+ for account in self:
+ if account.data:
+ parsed = account._parse_data(account.data)
+ if not account._validate_data(parsed):
+ raise ValidationError(_("Data not valid"))
+
+ def _inverse_set_password(self):
+ """Encode password from clear text."""
+ # inverse function
+ for rec in self:
+ rec.password = rec._encode_password(
+ rec.clear_password, rec.environment)
+
+ @api.model
+ def retrieve(self, domain):
+ """Search accounts for a given domain.
+
+ Environment is added by this function.
+ Use this instead of search() to benefit from environment filtering.
+ Use user.has_group() and suspend_security() before
+ calling this method.
+ """
+ domain.append(['environment', 'in', self._retrieve_env()])
+ return self.search(domain)
+
+ @api.multi
+ def write(self, vals):
+ """At this time there is no namespace set."""
+ if not vals.get('data') and not self.data:
+ vals['data'] = self._serialize_data(self._init_data())
+ return super(KeychainAccount, self).write(vals)
+
+ @implemented_by_keychain
+ def _validate_data(self, data):
+ pass
+
+ @implemented_by_keychain
+ def _init_data(self):
+ pass
+
+ @staticmethod
+ def _retrieve_env():
+ """Return the current environments.
+
+ You may override this function to fit your needs.
+
+ returns: a tuple like:
+ ('dev', 'test', False)
+ Which means accounts for dev, test and blank (not set)
+ Order is important: the first one is used for encryption.
+ """
+ current = config.get('running_env') or False
+ envs = [current]
+ if False not in envs:
+ envs.append(False)
+ return envs
+
+ @staticmethod
+ def _serialize_data(data):
+ return json.dumps(data)
+
+ @staticmethod
+ def _parse_data(data):
+ try:
+ return json.loads(data)
+ except ValueError:
+ raise ValidationError(_("Data should be a valid JSON"))
+
+ @classmethod
+ def _encode_password(cls, data, env):
+ cipher = cls._get_cipher(env)
+ return cipher.encrypt(str((data or '').encode('UTF-8')))
+
+ @classmethod
+ def _decode_password(cls, data):
+ cipher = cls._get_cipher()
+ try:
+ return unicode(cipher.decrypt(str(data)), 'UTF-8')
+ except InvalidToken:
+ raise Warning(_(
+ "Password has been encrypted with a different "
+ "key. Unless you can recover the previous key, "
+ "this password is unreadable."
+ ))
+
+ @classmethod
+ def _get_cipher(cls, force_env=None):
+ """Return a cipher using the keys of environments.
+
+ force_env = name of the env key.
+ Useful for encoding against one precise env
+ """
+ def _get_keys(envs):
+ suffixes = [
+ '_%s' % env if env else ''
+ for env in envs] # ('_dev', '')
+ keys_name = [
+ 'keychain_key%s' % suf
+ for suf in suffixes] # prefix it
+ keys_str = [
+ config.get(key)
+ for key in keys_name] # fetch from config
+ return [
+ Fernet(key) for key in keys_str # build Fernet object
+ if key and len(key) > 0 # remove False values
+ ]
+
+ if force_env:
+ envs = [force_env]
+ else:
+ envs = cls._retrieve_env() # ex: ('dev', False)
+ keys = _get_keys(envs)
+ if len(keys) == 0:
+ raise Warning(_(
+ "No 'keychain_key_%s' entries found in config file. "
+ "Use a key similar to: %s" % (envs[0], Fernet.generate_key())
+ ))
+ return MultiFernet(keys)
diff --git a/keychain/security/ir.model.access.csv b/keychain/security/ir.model.access.csv
new file mode 100644
index 000000000..5b3de013f
--- /dev/null
+++ b/keychain/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_keychain_account,access_keychain_account,model_keychain_account,,0,0,0,0
diff --git a/keychain/tests/__init__.py b/keychain/tests/__init__.py
new file mode 100644
index 000000000..779cc403e
--- /dev/null
+++ b/keychain/tests/__init__.py
@@ -0,0 +1 @@
+from . import test_keychain
\ No newline at end of file
diff --git a/keychain/tests/test_keychain.py b/keychain/tests/test_keychain.py
new file mode 100644
index 000000000..ef82d6db9
--- /dev/null
+++ b/keychain/tests/test_keychain.py
@@ -0,0 +1,220 @@
+# -*- coding: utf-8 -*-
+from openerp.tests.common import TransactionCase
+from openerp.tools.config import config
+from openerp.exceptions import ValidationError
+
+
+import logging
+
+_logger = logging.getLogger(__name__)
+
+try:
+ from cryptography.fernet import Fernet
+except ImportError as err:
+ _logger.debug(err)
+
+
+class TestKeychain(TransactionCase):
+
+ def setUp(self):
+ super(TestKeychain, self).setUp()
+
+ self.keychain = self.env['keychain.account']
+ config['keychain_key'] = Fernet.generate_key()
+
+ self.old_running_env = config['running_env']
+ config['running_env'] = None
+
+ def _init_data(self):
+ return {
+ "c": True,
+ "a": "b",
+ "d": "",
+ }
+
+ def _validate_data(self, data):
+ return 'c' in data
+
+ keychain_clss = self.keychain.__class__
+ keychain_clss._keychain_test_init_data = _init_data
+ keychain_clss._keychain_test_validate_data = _validate_data
+
+ self.keychain._fields['namespace'].selection.append(
+ ('keychain_test', 'test')
+ )
+
+ def tearDown(self):
+ config['running_env'] = self.old_running_env
+ return super(TestKeychain, self).tearDown()
+
+ def _create_account(self):
+ vals = {
+ "name": "test",
+ "namespace": "keychain_test",
+ "login": "test",
+ "technical_name": "keychain.test"
+ }
+ return self.keychain.create(vals)
+
+ def test_password(self):
+ """It should encrypt passwords."""
+ account = self._create_account()
+ passwords = ('', '12345', 'djkqfljfqm', u"""&é"'(§è!ç""")
+
+ for password in passwords:
+ account.clear_password = password
+ account._inverse_set_password()
+ self.assertTrue(account.clear_password != account.password)
+ self.assertEqual(account.get_password(), password)
+
+ def test_wrong_key(self):
+ """It should raise an exception when encoded key != decoded."""
+ account = self._create_account()
+ password = 'urieapocq'
+ account.clear_password = password
+ account._inverse_set_password()
+ config['keychain_key'] = Fernet.generate_key()
+ try:
+ account.get_password()
+ self.assertTrue(False, 'It should not work with another key')
+ except Warning as err:
+ self.assertTrue(True, 'It should raise a Warning')
+ self.assertTrue(
+ 'has been encrypted with a diff' in str(err),
+ 'It should display the right msg')
+ else:
+ self.assertTrue(False, 'It should raise a Warning')
+
+ def test_no_key(self):
+ """It should raise an exception when no key is set."""
+ account = self._create_account()
+ del config.options['keychain_key']
+
+ with self.assertRaises(Warning) as err:
+ account.clear_password = 'aiuepr'
+ account._inverse_set_password()
+ self.assertTrue(False, 'It should not work without key')
+ self.assertTrue(
+ 'Use a key similar to' in str(err.exception),
+ 'It should display the right msg')
+
+ def test_badly_formatted_key(self):
+ """It should raise an exception when key is not acceptable format."""
+ account = self._create_account()
+
+ config['keychain_key'] = ""
+ with self.assertRaises(Warning):
+ account.clear_password = 'aiuepr'
+ account._inverse_set_password()
+ self.assertTrue(False, 'It should not work missing formated key')
+
+ self.assertTrue(True, 'It shoud raise a ValueError')
+
+ def test_retrieve_env(self):
+ """Retrieve env should always return False at the end"""
+ config['running_env'] = False
+ self.assertListEqual(self.keychain._retrieve_env(), [False])
+
+ config['running_env'] = 'dev'
+ self.assertListEqual(self.keychain._retrieve_env(), ['dev', False])
+
+ config['running_env'] = 'prod'
+ self.assertListEqual(self.keychain._retrieve_env(), ['prod', False])
+
+ def test_multienv(self):
+ """Encrypt with dev, decrypt with dev."""
+ account = self._create_account()
+ config['keychain_key_dev'] = Fernet.generate_key()
+ config['keychain_key_prod'] = Fernet.generate_key()
+ config['running_env'] = 'dev'
+
+ account.clear_password = 'abc'
+ account._inverse_set_password()
+ self.assertEqual(
+ account.get_password(),
+ 'abc', 'Should work with dev')
+
+ config['running_env'] = 'prod'
+ with self.assertRaises(Warning):
+ self.assertEqual(
+ account.get_password(),
+ 'abc', 'Should not work with prod key')
+
+ def test_multienv_blank(self):
+ """Encrypt with blank, decrypt for all."""
+ account = self._create_account()
+ config['keychain_key'] = Fernet.generate_key()
+ config['keychain_key_dev'] = Fernet.generate_key()
+ config['keychain_key_prod'] = Fernet.generate_key()
+ config['running_env'] = ''
+
+ account.clear_password = 'abc'
+ account._inverse_set_password()
+ self.assertEqual(
+ account.get_password(),
+ 'abc', 'Should work with dev')
+
+ config['running_env'] = 'prod'
+ self.assertEqual(
+ account.get_password(),
+ 'abc', 'Should work with prod')
+
+ def test_multienv_force(self):
+ """Set the env on the record"""
+
+ account = self._create_account()
+ account.environment = 'prod'
+
+ config['keychain_key'] = Fernet.generate_key()
+ config['keychain_key_dev'] = Fernet.generate_key()
+ config['keychain_key_prod'] = Fernet.generate_key()
+ config['running_env'] = ''
+
+ account.clear_password = 'abc'
+ account._inverse_set_password()
+
+ with self.assertRaises(Warning):
+ self.assertEqual(
+ account.get_password(),
+ 'abc', 'Should not work with dev')
+
+ config['running_env'] = 'prod'
+ self.assertEqual(
+ account.get_password(),
+ 'abc', 'Should work with prod')
+
+ def test_wrong_json(self):
+ """It should raise an exception when data is not valid json."""
+ account = self._create_account()
+ wrong_jsons = ("{'hi':'o'}", "{'oq", '[>}')
+ for json in wrong_jsons:
+ with self.assertRaises(ValidationError) as err:
+ account.write({"data": json})
+ self.assertTrue(
+ False,
+ 'Should not validate baddly formatted json')
+ self.assertTrue(
+ 'Data should be a valid JSON' in str(err.exception),
+ 'It should raise a ValidationError')
+
+ def test_invalid_json(self):
+ """It should raise an exception when data don't pass _validate_data."""
+ account = self._create_account()
+ invalid_jsons = ('{}', '{"hi": 1}')
+ for json in invalid_jsons:
+ with self.assertRaises(ValidationError) as err:
+ account.write({"data": json})
+ self.assertTrue(
+ 'Data not valid' in str(err.exception),
+ 'It should raise a ValidationError')
+
+ def test_valid_json(self):
+ """It should work with valid data."""
+ account = self._create_account()
+ valid_jsons = ('{"c": true}', '{"c": 1}', '{"a": "o", "c": "b"}')
+ for json in valid_jsons:
+ try:
+ account.write({"data": json})
+ self.assertTrue(True, 'Should validate json')
+ except:
+ self.assertTrue(False, 'It should validate a good json')
diff --git a/keychain/views/keychain_view.xml b/keychain/views/keychain_view.xml
new file mode 100644
index 000000000..223f84899
--- /dev/null
+++ b/keychain/views/keychain_view.xml
@@ -0,0 +1,50 @@
+
+
+
+
+ keychain.account
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ keychain.account
+
+
+
+
+
+
+ ir.actions.act_window
+
+ Accounts
+ keychain.account
+ form
+ tree,form
+
+
+
+
+