From b813360bf6fe928b6c39c5367f62018000ffaba9 Mon Sep 17 00:00:00 2001
From: Alex Kavanagh <alex@ajkavanagh.co.uk>
Date: Sun, 5 Aug 2018 17:21:49 +0100
Subject: [PATCH] Keystone Fernet Token implementation

This patchset adds more Fernet token implementation:

1. Adds a cron job to rotate / sync keys to other units.
2. Adds additional tests around gating on config.
3. Adds rotation / syncing with more robust key handling.

Change-Id: Ied021ad83c241f241dbb5f9acdede9045e43a8a3
---
 config.yaml                                   |  17 +-
 hooks/keystone_context.py                     |  40 +++-
 hooks/keystone_hooks.py                       |  15 +-
 hooks/keystone_utils.py                       | 194 ++++++++++++++----
 scripts/fernet_rotate_and_sync.py             |  49 +++++
 templates/keystone-fernet-rotate-sync         |   9 +
 templates/ocata/keystone.conf                 |   5 +
 templates/rocky/keystone.conf                 |   3 +
 unit_tests/__init__.py                        |   1 +
 unit_tests/test_keystone_contexts.py          |  69 ++++++-
 unit_tests/test_keystone_hooks.py             |   6 +-
 unit_tests/test_keystone_utils.py             | 156 +++++++++-----
 .../test_scripts_fernet_rotate_and_sync.py    |  42 ++++
 13 files changed, 505 insertions(+), 101 deletions(-)
 create mode 100755 scripts/fernet_rotate_and_sync.py
 create mode 100644 templates/keystone-fernet-rotate-sync
 create mode 100644 unit_tests/test_scripts_fernet_rotate_and_sync.py

diff --git a/config.yaml b/config.yaml
index cabd4577..2585a805 100644
--- a/config.yaml
+++ b/config.yaml
@@ -98,7 +98,7 @@ options:
     description: Admin role to be associated with admin and service users.
   token-provider:
     type: string
-    default: 'uuid'
+    default:
     description: |
       Transitional configuration option to enable migration to Fernet tokens
       prior to upgrade to OpenStack Rocky.
@@ -112,6 +112,21 @@ options:
     type: int
     default: 3600
     description: Amount of time (in seconds) a token should remain valid.
+  fernet-max-active-keys:
+    type: int
+    default: 3
+    description: |
+      This is the maximum number of active keys when `token-provider` is set to
+      "fernet".  If has a minimum of 3, which includes the spare and staging
+      keys.  When set to 3, the rotation time for the keys is the same as the
+      token expiration time.  When set to a higher value, the rotation time is
+      less than the `token-expiration` time as calculated by:
+      .
+      rotation-time = token-expiration / (fernet-max-active-keys - 2)
+      .
+      Please see the charm documentation for further details about how to use
+      the Fernet token parameters to achieve a key strategy appropriate for the
+      system in question.
   service-tenant:
     type: string
     default: "services"
diff --git a/hooks/keystone_context.py b/hooks/keystone_context.py
index c4595f2c..5287a8aa 100644
--- a/hooks/keystone_context.py
+++ b/hooks/keystone_context.py
@@ -26,14 +26,21 @@ from charmhelpers.contrib.hahelpers.cluster import (
 )
 
 from charmhelpers.core.hookenv import (
+    charm_dir,
     config,
     log,
     leader_get,
+    local_unit,
     related_units,
     relation_ids,
     relation_get,
 )
 
+from charmhelpers.contrib.openstack.utils import (
+    CompareOpenStackReleases,
+    os_release,
+)
+
 
 class ApacheSSLContext(context.ApacheSSLContext):
     interfaces = ['https']
@@ -175,6 +182,7 @@ class KeystoneContext(context.OSContextGenerator):
         ctxt['identity_backend'] = config('identity-backend')
         ctxt['assignment_backend'] = config('assignment-backend')
         ctxt['token_provider'] = config('token-provider')
+        ctxt['fernet_max_active_keys'] = config('fernet-max-active-keys')
         if config('identity-backend') == 'ldap':
             ctxt['ldap_server'] = config('ldap-server')
             ctxt['ldap_user'] = config('ldap-user')
@@ -242,11 +250,41 @@ class TokenFlushContext(context.OSContextGenerator):
 
     def __call__(self):
         ctxt = {
-            'token_flush': is_elected_leader(DC_RESOURCE_NAME)
+            'token_flush': (not fernet_enabled() and
+                            is_elected_leader(DC_RESOURCE_NAME))
         }
         return ctxt
 
 
+class FernetCronContext(context.OSContextGenerator):
+
+    def __call__(self):
+        token_expiration = int(config('token-expiration'))
+        ctxt = {
+            'enabled': (fernet_enabled() and
+                        is_elected_leader(DC_RESOURCE_NAME)),
+            'unit_name': local_unit(),
+            'charm_dir': charm_dir(),
+            'minute': ('*/5' if token_expiration > 300 else '*')
+        }
+        return ctxt
+
+
+def fernet_enabled():
+    """Helper function for determinining whether Fernet tokens are enabled.
+
+    :returns: True if the fernet keys should be configured.
+    :rtype: bool
+    """
+    cmp_release = CompareOpenStackReleases(os_release('keystone'))
+    if cmp_release < 'ocata':
+        return False
+    elif 'ocata' >= cmp_release < 'rocky':
+        return config('token-provider') == 'fernet'
+    else:
+        return True
+
+
 class KeystoneFIDServiceProviderContext(context.OSContextGenerator):
     interfaces = ['keystone-fid-service-provider']
 
diff --git a/hooks/keystone_hooks.py b/hooks/keystone_hooks.py
index 1eef9ba6..f16a9874 100755
--- a/hooks/keystone_hooks.py
+++ b/hooks/keystone_hooks.py
@@ -66,6 +66,8 @@ from charmhelpers.contrib.openstack.utils import (
     enable_memcache,
 )
 
+from keystone_context import fernet_enabled
+
 from keystone_utils import (
     add_service_to_keystone,
     add_credentials_to_keystone,
@@ -101,10 +103,9 @@ from keystone_utils import (
     ADMIN_PROJECT,
     create_or_show_domain,
     restart_keystone,
-    fernet_enabled,
-    fernet_leader_set,
-    fernet_setup,
-    fernet_write_keys,
+    key_leader_set,
+    key_setup,
+    key_write,
 )
 
 from charmhelpers.contrib.hahelpers.cluster import (
@@ -227,8 +228,8 @@ def config_changed_postupgrade():
         apt_install(filter_installed_packages(determine_packages()))
 
     if is_leader() and fernet_enabled():
-        fernet_setup()
-        fernet_leader_set()
+        key_setup()
+        key_leader_set()
 
     configure_https()
     open_port(config('service-port'))
@@ -502,7 +503,7 @@ def leader_settings_changed():
         CONFIGS.write(POLICY_JSON)
 
     if fernet_enabled():
-        fernet_write_keys()
+        key_write()
 
     update_all_identity_relation_units()
 
diff --git a/hooks/keystone_utils.py b/hooks/keystone_utils.py
index 34672425..10244649 100644
--- a/hooks/keystone_utils.py
+++ b/hooks/keystone_utils.py
@@ -84,6 +84,8 @@ from charmhelpers.core.hookenv import (
     related_units,
     DEBUG,
     INFO,
+    ERROR,
+    WARNING,
 )
 
 from charmhelpers.fetch import (
@@ -191,6 +193,10 @@ ADMIN_PROJECT = 'admin'
 DEFAULT_DOMAIN = 'default'
 SERVICE_DOMAIN = 'service_domain'
 TOKEN_FLUSH_CRON_FILE = '/etc/cron.d/keystone-token-flush'
+KEY_SETUP_FILE = '/etc/keystone/key-setup'
+CREDENTIAL_KEY_REPOSITORY = '/etc/keystone/credential-keys/'
+FERNET_KEY_REPOSITORY = '/etc/keystone/fernet-keys/'
+FERNET_KEY_ROTATE_SYNC_CRON_FILE = '/etc/cron.d/keystone-fernet-rotate-sync'
 WSGI_KEYSTONE_API_CONF = '/etc/apache2/sites-enabled/wsgi-openstack-api.conf'
 UNUSED_APACHE_SITE_FILES = ['/etc/apache2/sites-enabled/keystone.conf',
                             '/etc/apache2/sites-enabled/wsgi-keystone.conf']
@@ -254,6 +260,11 @@ BASE_RESOURCE_MAP = OrderedDict([
                      context.SyslogContext()],
         'services': [],
     }),
+    (FERNET_KEY_ROTATE_SYNC_CRON_FILE, {
+        'contexts': [keystone_context.FernetCronContext(),
+                     context.SyslogContext()],
+        'services': [],
+    }),
 ])
 
 valid_services = {
@@ -1904,54 +1915,161 @@ def restart_keystone():
             service_restart(keystone_service())
 
 
-def fernet_enabled():
-    """Helper function for determinining whether Fernet tokens are enabled"""
-    cmp_release = CompareOpenStackReleases(os_release('keystone'))
-    if cmp_release < 'ocata':
-        return False
-    elif 'ocata' >= cmp_release < 'rocky':
-        return config('token-provider') == 'fernet'
-    else:
-        return True
+def key_setup():
+    """Initialize Fernet and Credential encryption key repositories
 
+    To setup the key repositories, calls (as user "keystone"):
 
-def fernet_setup():
-    """Initialize Fernet key repositories"""
+        keystone-manage fernet_setup
+        keystone-manage credential_setup
+
+    In addition we migrate any credentials currently stored in database using
+    the null key to be encrypted by the new credential key:
+
+        keystone-manage credential_migrate
+
+    Note that we only want to do this once, so we store success in the leader
+    settings (which we should be).
+
+    :raises: `:class:subprocess.CallProcessError` if either of the commands
+        fails.
+    """
+    if os.path.exists(KEY_SETUP_FILE) or not is_leader():
+        return
     base_cmd = ['sudo', '-u', 'keystone', 'keystone-manage']
-    subprocess.check_output(base_cmd + ['fernet_setup'])
-    subprocess.check_output(base_cmd + ['credential_setup'])
+    try:
+        log("Setting up key repositories for Fernet tokens and Credential "
+            "encryption", level=DEBUG)
+        subprocess.check_call(base_cmd + ['fernet_setup'])
+        subprocess.check_call(base_cmd + ['credential_setup'])
+        subprocess.check_call(base_cmd + ['credential_migrate'])
+        # touch the file to create
+        open(KEY_SETUP_FILE, "w").close()
+    except subprocess.CalledProcessError as e:
+        log("Key repository setup failed, will retry in config-changed hook: "
+            "{}".format(e), level=ERROR)
 
 
 def fernet_rotate():
-    """Rotate Fernet keys"""
+    """Rotate Fernet keys
+
+    To rotate the Fernet tokens, and create a new staging key, it calls (as the
+    "keystone" user):
+
+        keystone-manage fernet_rotate
+
+    Note that we do not rotate the Credential encryption keys.
+
+    Note that this does NOT synchronise the keys between the units.  This is
+    performed in `:function:`hooks.keystone_utils.fernet_leader_set`
+
+    :raises: `:class:subprocess.CallProcessError` if the command fails.
+    """
+    log("Rotating Fernet tokens", level=DEBUG)
     cmd = ['sudo', '-u', 'keystone', 'keystone-manage', 'fernet_rotate']
-    subprocess.check_output(cmd)
+    subprocess.check_call(cmd)
 
 
-def fernet_leader_set():
-    """Read current key set and update leader storage if necessary"""
-    key_repository = '/etc/keystone/fernet-keys/'
-    disk_keys = []
-    for key in os.listdir(key_repository):
-        with open(os.path.join(key_repository, str(key)), 'r') as f:
-            disk_keys.append(f.read())
-    leader_set({'fernet_keys': json.dumps(disk_keys)})
+def key_leader_set():
+    """Read current key sets and update leader storage
+
+    The keys are read from the `FERNET_KEY_REPOSITORY` and
+    `CREDENTIAL_KEY_REPOSITORY` directories.  Note that this function will fail
+    if it is called on the unit that is not the leader.
+
+    :raises: :class:`subprocess.CalledProcessError` if the leader_set fails.
+    """
+    disk_keys = {}
+    for key_repository in [FERNET_KEY_REPOSITORY, CREDENTIAL_KEY_REPOSITORY]:
+        disk_keys[key_repository] = {}
+        for key_number in os.listdir(key_repository):
+            with open(os.path.join(key_repository, key_number),
+                      'r') as f:
+                disk_keys[key_repository][key_number] = f.read()
+    leader_set({'key_repository': json.dumps(disk_keys)})
 
 
-def fernet_write_keys():
-    """Get keys from leader storage and write out to disk"""
-    key_repository = '/etc/keystone/fernet-keys/'
-    leader_keys = leader_get('fernet_keys')
+def key_write():
+    """Get keys from leader storage and write out to disk
+
+    The keys are written to the `FERNET_KEY_REPOSITORY` and
+    `CREDENTIAL_KEY_REPOSITORY` directories.  Note that the keys are first
+    written to a tmp file and then moved to the key to avoid any races.  Any
+    'excess' keys are deleted, which may occur if the "number of keys" has been
+    reduced on the leader.
+    """
+    leader_keys = leader_get('key_repository')
     if not leader_keys:
-        log('"fernet_keys" not in leader settings yet...', level=DEBUG)
+        log('"key_repository" not in leader settings yet...', level=DEBUG)
         return
-    mkdir(key_repository, owner=KEYSTONE_USER, group=KEYSTONE_USER,
-          perms=0o700)
-    for idx, key in enumerate(json.loads(leader_keys)):
-        tmp_filename = os.path.join(key_repository, "."+str(idx))
-        key_filename = os.path.join(key_repository, str(idx))
-        # write to tmp file first, move the key into place in an atomic
-        # operation avoiding any races with consumers of the key files
-        write_file(tmp_filename, key, owner=KEYSTONE_USER, group=KEYSTONE_USER,
-                   perms=0o600)
-        os.rename(tmp_filename, key_filename)
+    leader_keys = json.loads(leader_keys)
+    for key_repository in [FERNET_KEY_REPOSITORY, CREDENTIAL_KEY_REPOSITORY]:
+        mkdir(key_repository,
+              owner=KEYSTONE_USER,
+              group=KEYSTONE_USER,
+              perms=0o700)
+        for key_number, key in leader_keys[key_repository].items():
+            tmp_filename = os.path.join(key_repository,
+                                        ".{}".format(key_number))
+            key_filename = os.path.join(key_repository, key_number)
+            # write to tmp file first, move the key into place in an atomic
+            # operation avoiding any races with consumers of the key files
+            write_file(tmp_filename,
+                       key,
+                       owner=KEYSTONE_USER,
+                       group=KEYSTONE_USER,
+                       perms=0o600)
+            os.rename(tmp_filename, key_filename)
+        # now delete any keys that shouldn't be there
+        for key_number in os.listdir(key_repository):
+            if key_number not in leader_keys[key_repository].keys():
+                os.remove(os.path.join(key_repository, key_number))
+        # also say that keys have been setup for this system.
+        open(KEY_SETUP_FILE, "w").close()
+
+
+def fernet_keys_rotate_and_sync(log_func=log):
+    """Rotate and sync the keys if the unit is the leader and the primary key
+    has expired.
+
+    The modification time of the staging key (key with index '0') is used,
+    along with the config setting "token_expiration" to determine whether to
+    rotate the keys, along with the function `fernet_enabled()` to test
+    whether to do it at all.
+
+    Note that the reason for using modification time and not change time is
+    that the former can be set by the operator as part of restoring the key
+    from backup.
+
+    The rotation time = token-expiration / (max-active-keys - 2)
+
+    where max-active-keys has a minumum of 3.
+
+    :param log_func: Function to use for logging
+    :type log_func: func
+    """
+    if not keystone_context.fernet_enabled() or not is_leader():
+        return
+    # now see if the keys need to be rotated
+    try:
+        last_rotation = os.stat(
+            os.path.join(FERNET_KEY_REPOSITORY, '0')).st_mtime
+    except OSError:
+        log_func("Fernet key rotation requested but key repository not "
+                 "initialized yet", level=WARNING)
+        return
+    max_keys = max(config('fernet-max-active-keys'), 3)
+    expiration = config('token-expiration')
+    rotation_time = expiration // (max_keys - 2)
+    now = time.time()
+    if last_rotation + rotation_time > now:
+        # Nothing to do as not reached rotation time
+        log_func("No rotation until at least {}"
+                 .format(time.ctime(last_rotation + rotation_time)),
+                 level=DEBUG)
+        return
+    # now rotate the keys and sync them
+    fernet_rotate()
+    key_leader_set()
+    log_func("Rotated and started sync (via leader settings) of fernet keys",
+             level=INFO)
diff --git a/scripts/fernet_rotate_and_sync.py b/scripts/fernet_rotate_and_sync.py
new file mode 100755
index 00000000..7fe814d1
--- /dev/null
+++ b/scripts/fernet_rotate_and_sync.py
@@ -0,0 +1,49 @@
+#!/usr/bin/env python
+# Copyright 2018 Canonical Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+from __future__ import print_function
+
+import os
+import sys
+import time
+
+dir_path = os.path.dirname(os.path.realpath(__file__))
+hooks_path = os.path.abspath(os.path.join(dir_path, "..", "hooks"))
+
+if hooks_path not in sys.path:
+    sys.path.append(hooks_path)
+
+# now we can import charm related items
+import charmhelpers.core.hookenv
+
+import keystone_utils
+
+
+def cli_log(msg, level=charmhelpers.core.hookenv.INFO):
+    """Helper function to write log message to stdout/stderr for CLI usage."""
+    if level == charmhelpers.core.hookenv.DEBUG:
+        return charmhelpers.core.hookenv.log(msg, level=level)
+    elif level in [charmhelpers.core.hookenv.ERROR,
+                   charmhelpers.core.hookenv.WARNING]:
+        output = sys.stderr
+    else:
+        output = sys.stdout
+
+    print('{}: {}'.format(time.ctime(), msg), file=output)
+
+
+# the rotate_and_sync_keys() function checks for leadership AND whether to
+# rotate the keys or not.
+if __name__ == "__main__":
+    keystone_utils.fernet_keys_rotate_and_sync(log_func=cli_log)
diff --git a/templates/keystone-fernet-rotate-sync b/templates/keystone-fernet-rotate-sync
new file mode 100644
index 00000000..184c0e88
--- /dev/null
+++ b/templates/keystone-fernet-rotate-sync
@@ -0,0 +1,9 @@
+# call the rotate and sync function at 5 min intervals.  The actual function
+# works out when to do the rotate and sync of the keys.
+{% if enabled -%}
+{% if use_syslog -%}
+{{ minute }} * * * * root /usr/bin/juju-run {{ unit_name }} {{ charm_dir }}/scripts/fernet_rotate_and_sync.py 2>&1 | logger -t keystone-fernet-rotate-sync
+{% else -%}
+{{ minute }} * * * * root /usr/bin/juju-run {{ unit_name }} {{ charm_dir }}/scripts/fernet_rotate_and_sync.py >> /var/log/keystone/keystone-fernet-rotate-sync.log 2>&1
+{% endif -%}
+{% endif -%}
diff --git a/templates/ocata/keystone.conf b/templates/ocata/keystone.conf
index 22ecf7c2..a0d335bc 100644
--- a/templates/ocata/keystone.conf
+++ b/templates/ocata/keystone.conf
@@ -52,6 +52,11 @@ provider = uuid
 {% endif -%}
 expiration = {{ token_expiration }}
 
+{% if token_provider == 'fernet' -%}
+[fernet_tokens]
+max_active_keys = {{ fernet_max_active_keys }}
+{% endif -%}
+
 {% include "parts/section-signing" %}
 
 {% include "section-oslo-cache" %}
diff --git a/templates/rocky/keystone.conf b/templates/rocky/keystone.conf
index 3bf5b409..1072a558 100644
--- a/templates/rocky/keystone.conf
+++ b/templates/rocky/keystone.conf
@@ -44,6 +44,9 @@ driver = sql
 [token]
 expiration = {{ token_expiration }}
 
+[fernet_tokens]
+max_active_keys = {{ fernet_max_active_keys }}
+
 {% include "parts/section-signing" %}
 
 {% include "section-oslo-cache" %}
diff --git a/unit_tests/__init__.py b/unit_tests/__init__.py
index 184cf3d8..7ab7abaf 100644
--- a/unit_tests/__init__.py
+++ b/unit_tests/__init__.py
@@ -16,3 +16,4 @@ import sys
 
 sys.path.append('actions/')
 sys.path.append('hooks/')
+sys.path.append('scripts/')
diff --git a/unit_tests/test_keystone_contexts.py b/unit_tests/test_keystone_contexts.py
index ba09b17d..372a3750 100644
--- a/unit_tests/test_keystone_contexts.py
+++ b/unit_tests/test_keystone_contexts.py
@@ -29,6 +29,7 @@ TO_PATCH = [
     'config',
     'determine_apache_port',
     'determine_api_port',
+    'os_release',
 ]
 
 
@@ -36,6 +37,7 @@ class TestKeystoneContexts(CharmTestCase):
 
     def setUp(self):
         super(TestKeystoneContexts, self).setUp(context, TO_PATCH)
+        self.config.side_effect = self.test_config.get
 
     @patch('charmhelpers.contrib.hahelpers.cluster.relation_ids')
     @patch('charmhelpers.contrib.openstack.ip.unit_get')
@@ -152,15 +154,80 @@ class TestKeystoneContexts(CharmTestCase):
                          ctxt())
 
     @patch.object(context, 'is_elected_leader')
-    def test_token_flush_context(self, mock_is_elected_leader):
+    @patch.object(context, 'fernet_enabled')
+    def test_token_flush_context(
+            self, mock_fernet_enabled, mock_is_elected_leader):
         ctxt = context.TokenFlushContext()
 
+        mock_fernet_enabled.return_value = False
         mock_is_elected_leader.return_value = False
         self.assertEqual({'token_flush': False}, ctxt())
 
         mock_is_elected_leader.return_value = True
         self.assertEqual({'token_flush': True}, ctxt())
 
+        mock_fernet_enabled.return_value = True
+        self.assertEqual({'token_flush': False}, ctxt())
+
+    @patch.object(context, 'charm_dir')
+    @patch.object(context, 'local_unit')
+    @patch.object(context, 'is_elected_leader')
+    @patch.object(context, 'fernet_enabled')
+    def test_fernet_cron_context(
+            self, mock_fernet_enabled, mock_is_elected_leader, mock_local_unit,
+            mock_charm_dir):
+        ctxt = context.FernetCronContext()
+
+        mock_charm_dir.return_value = "my-dir"
+        mock_local_unit.return_value = "the-local-unit"
+
+        expected = {
+            'enabled': False,
+            'unit_name': 'the-local-unit',
+            'charm_dir': 'my-dir',
+            'minute': '*/5',
+        }
+
+        mock_fernet_enabled.return_value = False
+        mock_is_elected_leader.return_value = False
+        self.assertEqual(expected, ctxt())
+
+        mock_is_elected_leader.return_value = True
+        self.assertEqual(expected, ctxt())
+
+        mock_fernet_enabled.return_value = True
+        expected['enabled'] = True
+        self.assertEqual(expected, ctxt())
+
+    def test_fernet_enabled_no_config(self):
+        self.os_release.return_value = 'ocata'
+        self.test_config.set('token-provider', 'uuid')
+        result = context.fernet_enabled()
+        self.assertFalse(result)
+
+    def test_fernet_enabled_yes_config(self):
+        self.os_release.return_value = 'ocata'
+        self.test_config.set('token-provider', 'fernet')
+        result = context.fernet_enabled()
+        self.assertTrue(result)
+
+    def test_fernet_enabled_no_release_override_config(self):
+        self.os_release.return_value = 'mitaka'
+        self.test_config.set('token-provider', 'fernet')
+        result = context.fernet_enabled()
+        self.assertFalse(result)
+
+    def test_fernet_enabled_yes_release(self):
+        self.os_release.return_value = 'rocky'
+        result = context.fernet_enabled()
+        self.assertTrue(result)
+
+    def test_fernet_enabled_yes_release_override_config(self):
+        self.os_release.return_value = 'rocky'
+        self.test_config.set('token-provider', 'uuid')
+        result = context.fernet_enabled()
+        self.assertTrue(result)
+
     @patch.object(context, 'relation_ids')
     @patch.object(context, 'related_units')
     @patch.object(context, 'relation_get')
diff --git a/unit_tests/test_keystone_hooks.py b/unit_tests/test_keystone_hooks.py
index 720c37d4..12a6e31c 100644
--- a/unit_tests/test_keystone_hooks.py
+++ b/unit_tests/test_keystone_hooks.py
@@ -90,9 +90,9 @@ TO_PATCH = [
     'create_or_show_domain',
     'get_api_version',
     'fernet_enabled',
-    'fernet_leader_set',
-    'fernet_setup',
-    'fernet_write_keys',
+    'key_leader_set',
+    'key_setup',
+    'key_write',
     # other
     'check_call',
     'execd_preinstall',
diff --git a/unit_tests/test_keystone_utils.py b/unit_tests/test_keystone_utils.py
index 86b0e346..7bd82398 100644
--- a/unit_tests/test_keystone_utils.py
+++ b/unit_tests/test_keystone_utils.py
@@ -16,6 +16,7 @@ import json
 import os
 import subprocess
 import sys
+import time
 
 from mock import MagicMock, call, mock_open, patch
 from test_utils import CharmTestCase
@@ -1142,43 +1143,23 @@ class TestKeystoneUtils(CharmTestCase):
         with self.assertRaises(ValueError):
             utils.get_api_version()
 
-    def test_fernet_enabled_no_config(self):
-        self.os_release.return_value = 'ocata'
-        self.test_config.set('token-provider', 'uuid')
-        result = utils.fernet_enabled()
-        self.assertFalse(result)
-
-    def test_fernet_enabled_yes_config(self):
-        self.os_release.return_value = 'ocata'
-        self.test_config.set('token-provider', 'fernet')
-        result = utils.fernet_enabled()
-        self.assertTrue(result)
-
-    def test_fernet_enabled_no_release_override_config(self):
-        self.os_release.return_value = 'mitaka'
-        self.test_config.set('token-provider', 'fernet')
-        result = utils.fernet_enabled()
-        self.assertFalse(result)
-
-    def test_fernet_enabled_yes_release(self):
-        self.os_release.return_value = 'rocky'
-        result = utils.fernet_enabled()
-        self.assertTrue(result)
-
-    def test_fernet_enabled_yes_release_override_config(self):
-        self.os_release.return_value = 'rocky'
-        self.test_config.set('token-provider', 'uuid')
-        result = utils.fernet_enabled()
-        self.assertTrue(result)
-
-    def test_fernet_setup(self):
+    @patch.object(utils, 'is_leader')
+    @patch('os.path.exists')
+    def test_key_setup(self, mock_path_exists, mock_is_leader):
         base_cmd = ['sudo', '-u', 'keystone', 'keystone-manage']
-        utils.fernet_setup()
+        mock_is_leader.return_value = True
+        mock_path_exists.return_value = False
+        with patch.object(builtins, 'open', mock_open()) as m:
+            utils.key_setup()
+            m.assert_called_once_with(utils.KEY_SETUP_FILE, "w")
         self.subprocess.check_output.has_calls(
             [
                 base_cmd + ['fernet_setup'],
                 base_cmd + ['credential_setup'],
+                base_cmd + ['credential_migrate'],
             ])
+        mock_path_exists.assert_called_once_with(utils.KEY_SETUP_FILE)
+        mock_is_leader.assert_called_once_with()
 
     def test_fernet_rotate(self):
         cmd = ['sudo', '-u', 'keystone', 'keystone-manage', 'fernet_rotate']
@@ -1187,34 +1168,109 @@ class TestKeystoneUtils(CharmTestCase):
 
     @patch.object(utils, 'leader_set')
     @patch('os.listdir')
-    def test_fernet_leader_set(self, listdir, leader_set):
-        listdir.return_value = [0, 1]
+    def test_key_leader_set(self, listdir, leader_set):
+        listdir.return_value = ['0', '1']
+        self.time.time.return_value = "the-time"
         with patch.object(builtins, 'open', mock_open(
                 read_data="some_data")):
-            utils.fernet_leader_set()
-        listdir.assert_called_with('/etc/keystone/fernet-keys/')
+            utils.key_leader_set()
+        listdir.has_calls([
+            call(utils.FERNET_KEY_REPOSITORY),
+            call(utils.CREDENTIAL_KEY_REPOSITORY)])
         leader_set.assert_called_with(
-            {'fernet_keys': json.dumps(['some_data', 'some_data'])})
+            {'key_repository': json.dumps(
+                {utils.FERNET_KEY_REPOSITORY:
+                    {'0': 'some_data', '1': 'some_data'},
+                 utils.CREDENTIAL_KEY_REPOSITORY:
+                    {'0': 'some_data', '1': 'some_data'}})
+             })
 
     @patch('os.rename')
     @patch.object(utils, 'leader_get')
-    def test_fernet_write_keys(self, leader_get, rename):
-        key_repository = '/etc/keystone/fernet-keys/'
-        leader_get.return_value = json.dumps(['key0', 'key1'])
-        utils.fernet_write_keys()
-        self.mkdir.assert_called_with(key_repository, owner='keystone',
-                                      group='keystone', perms=0o700)
+    @patch('os.listdir')
+    @patch('os.remove')
+    def test_key_write(self, remove, listdir, leader_get, rename):
+        leader_get.return_value = json.dumps(
+            {utils.FERNET_KEY_REPOSITORY:
+                {'0': 'key0', '1': 'key1'},
+             utils.CREDENTIAL_KEY_REPOSITORY:
+                {'0': 'key0', '1': 'key1'}})
+        listdir.return_value = ['0', '1', '2']
+        with patch.object(builtins, 'open', mock_open()) as m:
+            utils.key_write()
+            m.assert_called_with(utils.KEY_SETUP_FILE, "w")
+        self.mkdir.has_calls([call(utils.CREDENTIAL_KEY_REPOSITORY,
+                                   owner='keystone', group='keystone',
+                                   perms=0o700),
+                              call(utils.FERNET_KEY_REPOSITORY,
+                                   owner='keystone', group='keystone',
+                                   perms=0o700)])
+        # note 'any_order=True' as we are dealing with dictionaries in Py27
         self.write_file.assert_has_calls(
             [
-                call(os.path.join(key_repository, '.0'), u'key0',
+                call(os.path.join(utils.CREDENTIAL_KEY_REPOSITORY, '.0'),
+                     u'key0', owner='keystone', group='keystone', perms=0o600),
+                call(os.path.join(utils.CREDENTIAL_KEY_REPOSITORY, '.1'),
+                     u'key1', owner='keystone', group='keystone', perms=0o600),
+                call(os.path.join(utils.FERNET_KEY_REPOSITORY, '.0'), u'key0',
                      owner='keystone', group='keystone', perms=0o600),
-                call(os.path.join(key_repository, '.1'), u'key1',
+                call(os.path.join(utils.FERNET_KEY_REPOSITORY, '.1'), u'key1',
                      owner='keystone', group='keystone', perms=0o600),
-            ])
+            ], any_order=True)
         rename.assert_has_calls(
             [
-                call(os.path.join(key_repository, '.0'),
-                     os.path.join(key_repository, '0')),
-                call(os.path.join(key_repository, '.1'),
-                     os.path.join(key_repository, '1')),
-            ])
+                call(os.path.join(utils.CREDENTIAL_KEY_REPOSITORY, '.0'),
+                     os.path.join(utils.CREDENTIAL_KEY_REPOSITORY, '0')),
+                call(os.path.join(utils.CREDENTIAL_KEY_REPOSITORY, '.1'),
+                     os.path.join(utils.CREDENTIAL_KEY_REPOSITORY, '1')),
+                call(os.path.join(utils.FERNET_KEY_REPOSITORY, '.0'),
+                     os.path.join(utils.FERNET_KEY_REPOSITORY, '0')),
+                call(os.path.join(utils.FERNET_KEY_REPOSITORY, '.1'),
+                     os.path.join(utils.FERNET_KEY_REPOSITORY, '1')),
+            ], any_order=True)
+
+    @patch.object(utils, 'keystone_context')
+    @patch.object(utils, 'fernet_rotate')
+    @patch.object(utils, 'key_leader_set')
+    @patch.object(utils, 'os')
+    @patch.object(utils, 'is_leader')
+    def test_fernet_keys_rotate_and_sync(self, mock_is_leader, mock_os,
+                                         mock_key_leader_set,
+                                         mock_fernet_rotate,
+                                         mock_keystone_context):
+        self.test_config.set('fernet-max-active-keys', 3)
+        self.test_config.set('token-expiration', 60)
+        self.time.time.return_value = 0
+
+        # if not leader shouldn't do anything
+        mock_is_leader.return_value = False
+        utils.fernet_keys_rotate_and_sync()
+        mock_os.stat.assert_not_called()
+        # shouldn't do anything as the token provider is wrong
+        mock_keystone_context.fernet_enabled.return_value = False
+        mock_is_leader.return_value = True
+        utils.fernet_keys_rotate_and_sync()
+        mock_os.stat.assert_not_called()
+        # fail gracefully if key repository is not initialized
+        mock_keystone_context.fernet_enabled.return_value = True
+        mock_os.stat.side_effect = Exception()
+        with self.assertRaises(Exception):
+            utils.fernet_keys_rotate_and_sync()
+        self.time.time.assert_not_called()
+        mock_os.stat.side_effect = None
+        # now set up the times, so that it still shouldn't be called.
+        self.time.time.return_value = 30
+        self.time.ctime = time.ctime
+        _stat = MagicMock()
+        _stat.st_mtime = 10
+        mock_os.stat.return_value = _stat
+        utils.fernet_keys_rotate_and_sync(log_func=self.log)
+        self.log.assert_called_once_with(
+            'No rotation until at least Thu Jan  1 00:01:10 1970',
+            level='DEBUG')
+        mock_key_leader_set.assert_not_called()
+        # finally, set it up so that the rotation and sync occur
+        self.time.time.return_value = 71
+        utils.fernet_keys_rotate_and_sync()
+        mock_fernet_rotate.assert_called_once_with()
+        mock_key_leader_set.assert_called_once_with()
diff --git a/unit_tests/test_scripts_fernet_rotate_and_sync.py b/unit_tests/test_scripts_fernet_rotate_and_sync.py
new file mode 100644
index 00000000..d91c12aa
--- /dev/null
+++ b/unit_tests/test_scripts_fernet_rotate_and_sync.py
@@ -0,0 +1,42 @@
+# Copyright 2018 Canonical Ltd
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#  http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import sys
+
+from mock import patch
+
+from test_utils import CharmTestCase
+
+import fernet_rotate_and_sync as script
+
+
+class FernetRotateAndSync(CharmTestCase):
+
+    def setUp(self):
+        super(FernetRotateAndSync, self).setUp(
+            script, [])
+
+    @patch('charmhelpers.core.hookenv.log')
+    @patch('time.ctime')
+    @patch('__builtin__.print')
+    def test_cli_log(self, mock_print, mock_ctime, mock_ch_log):
+        mock_ctime.return_value = 'FAKE_TIMESTAMP'
+        script.cli_log('message', level='DEBUG')
+        mock_ch_log.assert_called_with('message', level='DEBUG')
+        script.cli_log('message', level='WARNING')
+        mock_print.assert_called_with('FAKE_TIMESTAMP: message',
+                                      file=sys.stderr)
+        script.cli_log('message', level='INFO')
+        mock_print.assert_called_with('FAKE_TIMESTAMP: message',
+                                      file=sys.stdout)