From 4d719b75c1bfc494a0cbde79a813acd803eb9577 Mon Sep 17 00:00:00 2001
From: Spomky <Spomky@users.noreply.github.com>
Date: Thu, 22 Sep 2016 13:00:08 +0200
Subject: [PATCH] Storable JWKSet (#126)

* Storable and Rotatable JWKSet added
* Tests added
* Documentation updated
---
 .travis.yml                                   |  14 +-
 composer.json                                 |   6 +-
 doc/object/jwk.md                             |  32 ++
 src/Algorithm/ContentEncryption/AESCBCHS.php  |   2 +-
 src/Algorithm/ContentEncryption/AESGCM.php    |   2 +-
 src/Algorithm/KeyEncryption/AESGCMKW.php      |   2 +-
 src/Factory/JWKFactory.php                    |  35 +-
 src/Factory/JWKFactoryInterface.php           |  34 ++
 src/Object/RotatableJWK.php                   |  48 +++
 src/Object/RotatableJWKInterface.php          |  19 ++
 src/Object/RotatableJWKSet.php                |  63 ++++
 src/Object/RotatableJWKSetInterface.php       |  19 ++
 src/Object/StorableJWK.php                    |  30 +-
 src/Object/StorableJWKInterface.php           |   2 +
 src/Object/StorableJWKSet.php                 | 311 ++++++++++++++++++
 src/Object/StorableJWKSetInterface.php        |  19 ++
 tests/Unit/Objects/RotatableJWKSetTest.php    |  67 ++++
 ...orableJWKTest.php => RotatableJWKTest.php} |  34 +-
 tests/ci/install_php_ext.sh                   |  20 ++
 19 files changed, 704 insertions(+), 55 deletions(-)
 create mode 100644 src/Object/RotatableJWK.php
 create mode 100644 src/Object/RotatableJWKInterface.php
 create mode 100644 src/Object/RotatableJWKSet.php
 create mode 100644 src/Object/RotatableJWKSetInterface.php
 create mode 100644 src/Object/StorableJWKSet.php
 create mode 100644 src/Object/StorableJWKSetInterface.php
 create mode 100644 tests/Unit/Objects/RotatableJWKSetTest.php
 rename tests/Unit/Objects/{StorableJWKTest.php => RotatableJWKTest.php} (60%)
 create mode 100644 tests/ci/install_php_ext.sh

diff --git a/.travis.yml b/.travis.yml
index ac88d7fe..124d8d9b 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,6 +1,6 @@
 language: php
 
-sudo: false
+sudo: true
 
 matrix:
     fast_finish: true
@@ -22,15 +22,14 @@ matrix:
 before_script:
     - composer self-update
     - sh -c 'if [ "$WITH_CRYPTO" != "" ]; then pecl install crypto-0.2.2; fi;'
+    - mkdir -p build/logs
+    - chmod +x tests/ci/install_php_ext.sh
+    - if [ "$TRAVIS_PHP_VERSION" != 'hhvm' ]; then ./tests/ci/install_php_ext.sh; fi
     - if [[ $deps = low ]]; then composer update --no-interaction --prefer-lowest ; fi
     - if [[ !$deps ]]; then composer install --no-interaction ; fi
-    - mkdir -p build/logs
 
 script:
-    - composer test-with-coverage
-
-after_script:
-    - vendor/bin/coveralls --no-interaction
+    - ./vendor/bin/phpunit --coverage-clover build/logs/clover.xml
     - php ./examples/Encrypt1.php
     - php ./examples/Encrypt2.php
     - php ./examples/Signature1.php
@@ -39,3 +38,6 @@ after_script:
     - php ./examples/Load2.php
     - php ./examples/Load3.php
     - php ./examples/Load4.php
+
+after_success:
+    - vendor/bin/coveralls --no-interaction
diff --git a/composer.json b/composer.json
index ee0c5c52..554a514b 100644
--- a/composer.json
+++ b/composer.json
@@ -47,13 +47,9 @@
         "ext-ed25519": "For EdDSA with Ed25519 curves support.",
         "ext-curve25519": "For EdDSA with X25519 curves support."
    },
-  "scripts":{
-    "test": "phpunit",
-    "test-with-coverage": "phpunit --verbose --coverage-clover build/logs/clover.xml"
-  },
     "extra": {
         "branch-alias": {
-            "dev-master": "5.1.x-dev"
+            "dev-master": "5.2.x-dev"
         }
     }
 }
diff --git a/doc/object/jwk.md b/doc/object/jwk.md
index 038850f4..426a6361 100644
--- a/doc/object/jwk.md
+++ b/doc/object/jwk.md
@@ -396,3 +396,35 @@ $rotatable_key = JWKFactory::createRotatableKey(
 
 The key can be used like any other keys. After 3600 seconds, the values of that key will be updated.
 If the key exists in the storage and is not expired then it is loaded.
+
+
+## Create a Rotatable Key Set
+
+Some applications may require a key set with keys that are updated after a period of time.
+To continue to validate JWS or decrypt JWE, the old keys should be able for another period of time.
+
+That is the purpose of the Rotatable Key Set.
+
+You have to define which type of key you want to have (onlly one type per JWKSet allowed), how many keys in the key set and a period of time.
+Keys are automatically created and rotation is performed after the period of time.
+
+You can manipulate that key set as any other key sets, however we recommend you to never add or remove keys. All changes will be erased we keys are rotated.
+We also recommend you to use the first key of that key set to perform your signature/encryption operations.
+
+Except when the key set is created, all keys will be available at least during `number of key * period of time`.
+
+```php
+use Jose\Factory\JWKFactory;
+
+$rotatable_key_set = JWKFactory::createRotatableKeySet(
+    '/path/to/the/storage/file.keyset', // The file which will contain the key set
+    [
+        'kty' => 'OKP',
+        'crv' => 'X25519',
+        'alg' => 'ECDH-ES',
+        'use' => 'enc',
+    ],
+    3,                      // Number of keys in that key set
+    3600                    // This key set will rotate all keys after 3600 seconds (1 hour)
+);
+```
diff --git a/src/Algorithm/ContentEncryption/AESCBCHS.php b/src/Algorithm/ContentEncryption/AESCBCHS.php
index 7195b8be..c37fa3c5 100644
--- a/src/Algorithm/ContentEncryption/AESCBCHS.php
+++ b/src/Algorithm/ContentEncryption/AESCBCHS.php
@@ -117,6 +117,6 @@ public function getIVSize()
      */
     private function getMode($k)
     {
-        return 'aes-'.(8 *  mb_strlen($k, '8bit')).'-cbc';
+        return 'aes-'.(8 * mb_strlen($k, '8bit')).'-cbc';
     }
 }
diff --git a/src/Algorithm/ContentEncryption/AESGCM.php b/src/Algorithm/ContentEncryption/AESGCM.php
index 6ea42c09..2a2334f8 100644
--- a/src/Algorithm/ContentEncryption/AESGCM.php
+++ b/src/Algorithm/ContentEncryption/AESGCM.php
@@ -80,7 +80,7 @@ public function decryptContent($data, $cek, $iv, $aad, $encoded_protected_header
      */
     private function getMode($k)
     {
-        return 'aes-'.(8 *  mb_strlen($k, '8bit')).'-gcm';
+        return 'aes-'.(8 * mb_strlen($k, '8bit')).'-gcm';
     }
 
     /**
diff --git a/src/Algorithm/KeyEncryption/AESGCMKW.php b/src/Algorithm/KeyEncryption/AESGCMKW.php
index 6a0ba949..672ba0d4 100644
--- a/src/Algorithm/KeyEncryption/AESGCMKW.php
+++ b/src/Algorithm/KeyEncryption/AESGCMKW.php
@@ -84,7 +84,7 @@ public function unwrapKey(JWKInterface $key, $encrypted_cek, array $header)
      */
     private function getMode($k)
     {
-        return 'aes-'.(8 *  mb_strlen($k, '8bit')).'-gcm';
+        return 'aes-'.(8 * mb_strlen($k, '8bit')).'-gcm';
     }
 
     /**
diff --git a/src/Factory/JWKFactory.php b/src/Factory/JWKFactory.php
index a59ac8f1..38489048 100644
--- a/src/Factory/JWKFactory.php
+++ b/src/Factory/JWKFactory.php
@@ -17,7 +17,10 @@
 use Jose\KeyConverter\RSAKey;
 use Jose\Object\JWK;
 use Jose\Object\JWKSet;
+use Jose\Object\RotatableJWK;
+use Jose\Object\RotatableJWKSet;
 use Jose\Object\StorableJWK;
+use Jose\Object\StorableJWKSet;
 use Mdanter\Ecc\Curves\CurveFactory;
 use Mdanter\Ecc\Curves\NistCurve;
 use Mdanter\Ecc\EccFactory;
@@ -33,6 +36,30 @@ public static function createStorableKey($filename, array $parameters)
         return new StorableJWK($filename, $parameters);
     }
 
+    /**
+     * {@inheritdoc}
+     */
+    public static function createRotatableKey($filename, array $parameters, $ttl)
+    {
+        return new RotatableJWK($filename, $parameters, $ttl);
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public static function createRotatableKeySet($filename, array $parameters, $nb_keys, $ttl)
+    {
+        return new RotatableJWKSet($filename, $parameters, $nb_keys, $ttl);
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public static function createStorableKeySet($filename, array $parameters, $nb_keys)
+    {
+        return new StorableJWKSet($filename, $parameters, $nb_keys);
+    }
+
     /**
      * {@inheritdoc}
      */
@@ -318,17 +345,19 @@ public static function createFromX5U($x5u, $allow_unsecured_connection = false,
      * @param string                                 $url
      * @param bool                                   $allow_unsecured_connection
      * @param \Psr\Cache\CacheItemPoolInterface|null $cache
+     * @param int                                    $ttl
      *
      * @return array
      */
-    private static function getContent($url, $allow_unsecured_connection, CacheItemPoolInterface $cache = null)
+    private static function getContent($url, $allow_unsecured_connection, CacheItemPoolInterface $cache = null, $ttl = 300)
     {
-        $cache_key = sprintf('%s-%s', 'JWKFactory-Content', hash('sha512', $url));
+        $cache_key = sprintf('JWKFactory-Content-%s', hash('sha512', $url));
         if (null !== $cache) {
             $item = $cache->getItem($cache_key);
             if (!$item->isHit()) {
                 $content = self::downloadContent($url, $allow_unsecured_connection);
                 $item->set($content);
+                $item->expiresAfter($ttl);
                 $cache->save($item);
 
                 return $content;
@@ -367,7 +396,7 @@ private static function downloadContent($url, $allow_unsecured_connection)
             'Invalid URL.'
         );
         Assertion::false(
-            false === $allow_unsecured_connection && 'https://' !==  mb_substr($url, 0, 8, '8bit'),
+            false === $allow_unsecured_connection && 'https://' !== mb_substr($url, 0, 8, '8bit'),
             'Unsecured connection.'
         );
 
diff --git a/src/Factory/JWKFactoryInterface.php b/src/Factory/JWKFactoryInterface.php
index 85951b81..f6f85de5 100644
--- a/src/Factory/JWKFactoryInterface.php
+++ b/src/Factory/JWKFactoryInterface.php
@@ -15,6 +15,40 @@
 
 interface JWKFactoryInterface
 {
+    /**
+     * RotatableJWK constructor.
+     *
+     * @param string $filename
+     * @param array  $parameters
+     * @param int    $nb_keys
+     *
+     * @return \Jose\Object\JWKSetInterface
+     */
+    public static function createStorableKeySet($filename, array $parameters, $nb_keys);
+
+    /**
+     * RotatableJWK constructor.
+     *
+     * @param string $filename
+     * @param array  $parameters
+     * @param int    $nb_keys
+     * @param int    $ttl
+     *
+     * @return \Jose\Object\JWKSetInterface
+     */
+    public static function createRotatableKeySet($filename, array $parameters, $nb_keys, $ttl);
+
+    /**
+     * RotatableJWK constructor.
+     *
+     * @param string $filename
+     * @param array  $parameters
+     * @param int    $ttl
+     *
+     * @return \Jose\Object\JWKInterface
+     */
+    public static function createRotatableKey($filename, array $parameters, $ttl);
+
     /**
      * RotatableJWK constructor.
      *
diff --git a/src/Object/RotatableJWK.php b/src/Object/RotatableJWK.php
new file mode 100644
index 00000000..8e59f617
--- /dev/null
+++ b/src/Object/RotatableJWK.php
@@ -0,0 +1,48 @@
+<?php
+
+/*
+ * The MIT License (MIT)
+ *
+ * Copyright (c) 2014-2016 Spomky-Labs
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license.  See the LICENSE file for details.
+ */
+
+namespace Jose\Object;
+
+use Assert\Assertion;
+
+/**
+ * Class RotatableJWK.
+ */
+final class RotatableJWK extends StorableJWK implements RotatableJWKInterface
+{
+    /**
+     * @var int
+     */
+    protected $ttl;
+
+    public function __construct($filename, array $parameters, $ttl)
+    {
+        Assertion::integer($ttl);
+        Assertion::greaterThan($ttl, 0, 'The parameter TTL must be at least 0.');
+        $this->ttl = $ttl;
+        parent::__construct($filename, $parameters);
+    }
+
+    /**
+     * @return \Jose\Object\JWKInterface
+     */
+    protected function getJWK()
+    {
+        if (file_exists($this->getFilename())) {
+            $mtime = filemtime($this->getFilename());
+            if ($mtime + $this->ttl <= time()) {
+                unlink($this->getFilename());
+            }
+        }
+
+        return parent::getJWK();
+    }
+}
diff --git a/src/Object/RotatableJWKInterface.php b/src/Object/RotatableJWKInterface.php
new file mode 100644
index 00000000..bdcecd1e
--- /dev/null
+++ b/src/Object/RotatableJWKInterface.php
@@ -0,0 +1,19 @@
+<?php
+
+/*
+ * The MIT License (MIT)
+ *
+ * Copyright (c) 2014-2016 Spomky-Labs
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license.  See the LICENSE file for details.
+ */
+
+namespace Jose\Object;
+
+/**
+ * Interface RotatableJWKInterface.
+ */
+interface RotatableJWKInterface extends StorableJWKInterface
+{
+}
diff --git a/src/Object/RotatableJWKSet.php b/src/Object/RotatableJWKSet.php
new file mode 100644
index 00000000..f990bcf8
--- /dev/null
+++ b/src/Object/RotatableJWKSet.php
@@ -0,0 +1,63 @@
+<?php
+
+/*
+ * The MIT License (MIT)
+ *
+ * Copyright (c) 2014-2016 Spomky-Labs
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license.  See the LICENSE file for details.
+ */
+
+namespace Jose\Object;
+
+use Assert\Assertion;
+
+/**
+ * Class RotatableJWKSet.
+ */
+final class RotatableJWKSet extends StorableJWKSet implements RotatableJWKSetInterface
+{
+    /**
+     * @var int
+     */
+    protected $ttl;
+
+    /**
+     * RotatableJWKSet constructor.
+     *
+     * @param string $filename
+     * @param array  $parameters
+     * @param int    $nb_keys
+     * @param int    $ttl
+     */
+    public function __construct($filename, array $parameters, $nb_keys, $ttl)
+    {
+        Assertion::integer($ttl);
+        Assertion::greaterThan($ttl, 0, 'The parameter TTL must be at least 0.');
+        $this->ttl = $ttl;
+        parent::__construct($filename, $parameters, $nb_keys);
+    }
+
+    /**
+     * @return \Jose\Object\JWKSetInterface
+     */
+    protected function getJWKSet()
+    {
+        $mtime = $this->getLastModificationTime();
+        if (null !== $mtime) {
+            if ($mtime + $this->ttl <= time()) {
+                $keys = $this->jwkset->getKeys();
+                unset($keys[count($keys) - 1]);
+                $this->jwkset = new JWKSet();
+                $this->jwkset->addKey($this->createJWK());
+                foreach ($keys as $key) {
+                    $this->jwkset->addKey($key);
+                }
+                $this->save();
+            }
+        }
+
+        return parent::getJWKSet();
+    }
+}
diff --git a/src/Object/RotatableJWKSetInterface.php b/src/Object/RotatableJWKSetInterface.php
new file mode 100644
index 00000000..79cad1e5
--- /dev/null
+++ b/src/Object/RotatableJWKSetInterface.php
@@ -0,0 +1,19 @@
+<?php
+
+/*
+ * The MIT License (MIT)
+ *
+ * Copyright (c) 2014-2016 Spomky-Labs
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license.  See the LICENSE file for details.
+ */
+
+namespace Jose\Object;
+
+/**
+ * Interface RotatableJWKSetInterface.
+ */
+interface RotatableJWKSetInterface extends StorableJWKSetInterface
+{
+}
diff --git a/src/Object/StorableJWK.php b/src/Object/StorableJWK.php
index e2f5902b..ed00c3c3 100644
--- a/src/Object/StorableJWK.php
+++ b/src/Object/StorableJWK.php
@@ -18,22 +18,22 @@
 /**
  * Class StorableJWK.
  */
-final class StorableJWK implements StorableJWKInterface
+class StorableJWK implements StorableJWKInterface
 {
     /**
      * @var \Jose\Object\JWKInterface
      */
-    private $jwk;
+    protected $jwk;
 
     /**
      * @var string
      */
-    private $filename;
+    protected $filename;
 
     /**
      * @var array
      */
-    private $parameters;
+    protected $parameters;
 
     /**
      * RotatableJWK constructor.
@@ -50,7 +50,7 @@ public function __construct($filename, array $parameters)
     }
 
     /**
-     * {@inheritdoc}
+     * @return string
      */
     public function getFilename()
     {
@@ -108,16 +108,14 @@ public function jsonSerialize()
     /**
      * @return \Jose\Object\JWKInterface
      */
-    private function getJWK()
+    protected function getJWK()
     {
-        if (null === $this->jwk) {
-            $this->loadJWK();
-        }
+        $this->loadJWK();
 
         return $this->jwk;
     }
 
-    private function loadJWK()
+    protected function loadJWK()
     {
         if (file_exists($this->filename)) {
             $content = file_get_contents($this->filename);
@@ -134,15 +132,17 @@ private function loadJWK()
         }
     }
 
-    private function createJWK()
+    protected function createJWK()
     {
         $data = JWKFactory::createKey($this->parameters)->getAll();
         $data['kid'] = Base64Url::encode(random_bytes(64));
         $this->jwk = JWKFactory::createFromValues($data);
 
-        file_put_contents(
-            $this->filename,
-            json_encode($this->jwk)
-        );
+        $this->save();
+    }
+
+    protected function save()
+    {
+        file_put_contents($this->getFilename(), json_encode($this->jwk));
     }
 }
diff --git a/src/Object/StorableJWKInterface.php b/src/Object/StorableJWKInterface.php
index 522d2e87..21f2ed70 100644
--- a/src/Object/StorableJWKInterface.php
+++ b/src/Object/StorableJWKInterface.php
@@ -17,6 +17,8 @@
 interface StorableJWKInterface extends JWKInterface
 {
     /**
+     * @deprecated This method will be removed in v6.x
+     *
      * @return string
      */
     public function getFilename();
diff --git a/src/Object/StorableJWKSet.php b/src/Object/StorableJWKSet.php
new file mode 100644
index 00000000..0bf6d32a
--- /dev/null
+++ b/src/Object/StorableJWKSet.php
@@ -0,0 +1,311 @@
+<?php
+
+/*
+ * The MIT License (MIT)
+ *
+ * Copyright (c) 2014-2016 Spomky-Labs
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license.  See the LICENSE file for details.
+ */
+
+namespace Jose\Object;
+
+use Assert\Assertion;
+use Base64Url\Base64Url;
+use Jose\Factory\JWKFactory;
+
+/**
+ * Class StorableJWKSet.
+ */
+class StorableJWKSet implements StorableJWKSetInterface
+{
+    /**
+     * @var \Jose\Object\JWKSetInterface
+     */
+    protected $jwkset;
+
+    /**
+     * @var string
+     */
+    protected $filename;
+
+    /**
+     * @var int
+     */
+    protected $last_modification_time = null;
+
+    /**
+     * @var array
+     */
+    protected $parameters;
+
+    /**
+     * @var array
+     */
+    protected $nb_keys;
+
+    /**
+     * StorableJWKSet constructor.
+     *
+     * @param string $filename
+     * @param array  $parameters
+     * @param int    $nb_keys
+     */
+    public function __construct($filename, array $parameters, $nb_keys)
+    {
+        Assertion::directory(dirname($filename), 'The selected directory does not exist.');
+        Assertion::writeable(dirname($filename), 'The selected directory is not writable.');
+        Assertion::integer($nb_keys, 'The key set must contain at least one key.');
+        Assertion::greaterThan($nb_keys, 0, 'The key set must contain at least one key.');
+        $this->filename = $filename;
+        $this->parameters = $parameters;
+        $this->nb_keys = $nb_keys;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function current()
+    {
+        return $this->getJWKSet()->current();
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function next()
+    {
+        $this->getJWKSet()->next();
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function key()
+    {
+        return $this->getJWKSet()->key();
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function valid()
+    {
+        return $this->getJWKSet()->valid();
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function rewind()
+    {
+        $this->getJWKSet()->rewind();
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function offsetExists($offset)
+    {
+        return $this->getJWKSet()->offsetExists($offset);
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function offsetGet($offset)
+    {
+        return $this->getJWKSet()->offsetGet($offset);
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function offsetSet($offset, $value)
+    {
+        return $this->getJWKSet()->offsetSet($offset, $value);
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function offsetUnset($offset)
+    {
+        return $this->getJWKSet()->offsetUnset($offset);
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getKey($index)
+    {
+        return $this->getJWKSet()->getKey($index);
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function hasKey($index)
+    {
+        return $this->getJWKSet()->hasKey($index);
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getKeys()
+    {
+        return $this->getJWKSet()->getKeys();
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function addKey(JWKInterface $key)
+    {
+        return $this->getJWKSet()->addKey($key);
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function removeKey($index)
+    {
+        return $this->getJWKSet()->removeKey($index);
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function countKeys()
+    {
+        return $this->getJWKSet()->countKeys();
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function selectKey($type, $algorithm = null, array $restrictions = [])
+    {
+        return $this->getJWKSet()->selectKey($type, $algorithm, $restrictions);
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function count()
+    {
+        return $this->getJWKSet()->count();
+    }
+
+    /**
+     * @return string
+     */
+    protected function getFilename()
+    {
+        return $this->filename;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function jsonSerialize()
+    {
+        return $this->getJWKSet()->jsonSerialize();
+    }
+
+    /**
+     * @return \Jose\Object\JWKSetInterface
+     */
+    protected function getJWKSet()
+    {
+        $this->loadJWKSet();
+
+        return $this->jwkset;
+    }
+
+    protected function loadJWKSet()
+    {
+        if (false === $this->hasJWKSetBeenUpdated()) {
+            return;
+        }
+        $content = $this->getFileContent();
+        if (null === $content) {
+            $this->createJWKSet();
+        } else {
+            $this->jwkset = new JWKSet($content);
+        }
+    }
+
+    /**
+     * @return null|string
+     */
+    protected function getFileContent()
+    {
+        if (!file_exists($this->getFilename())) {
+            return;
+        }
+        $content = file_get_contents($this->getFilename());
+        if (false === $content) {
+            return;
+        }
+        $content = json_decode($content, true);
+        if (!is_array($content)) {
+            return;
+        }
+
+        return $content;
+    }
+
+    /**
+     * @return bool
+     */
+    protected function hasJWKSetBeenUpdated()
+    {
+        if (null !== $this->last_modification_time) {
+            return $this->last_modification_time !== $this->getLastModificationTime();
+        }
+
+        return true;
+    }
+
+
+    protected function createJWKSet()
+    {
+        $this->jwkset = new JWKSet();
+        for ($i = 0; $i < $this->nb_keys; $i++) {
+            $key = $this->createJWK();
+            $this->jwkset->addKey($key);
+        }
+
+        $this->save();
+    }
+
+    /**
+     * @return \Jose\Object\JWKInterface
+     */
+    protected function createJWK()
+    {
+        $data = JWKFactory::createKey($this->parameters)->getAll();
+        $data['kid'] = Base64Url::encode(random_bytes(64));
+
+        return JWKFactory::createFromValues($data);
+    }
+
+    /**
+     * @return int|null
+     */
+    protected function getLastModificationTime()
+    {
+        if (file_exists($this->getFilename())) {
+            return filemtime($this->getFilename());
+        }
+    }
+
+    protected function save()
+    {
+        file_put_contents($this->getFilename(), json_encode($this->jwkset));
+        $this->last_modification_time = filemtime($this->getFilename());
+    }
+}
diff --git a/src/Object/StorableJWKSetInterface.php b/src/Object/StorableJWKSetInterface.php
new file mode 100644
index 00000000..d2fd093f
--- /dev/null
+++ b/src/Object/StorableJWKSetInterface.php
@@ -0,0 +1,19 @@
+<?php
+
+/*
+ * The MIT License (MIT)
+ *
+ * Copyright (c) 2014-2016 Spomky-Labs
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license.  See the LICENSE file for details.
+ */
+
+namespace Jose\Object;
+
+/**
+ * Interface StorableJWKSetInterface.
+ */
+interface StorableJWKSetInterface extends JWKSetInterface
+{
+}
diff --git a/tests/Unit/Objects/RotatableJWKSetTest.php b/tests/Unit/Objects/RotatableJWKSetTest.php
new file mode 100644
index 00000000..ef6802ea
--- /dev/null
+++ b/tests/Unit/Objects/RotatableJWKSetTest.php
@@ -0,0 +1,67 @@
+<?php
+
+/*
+ * The MIT License (MIT)
+ *
+ * Copyright (c) 2014-2016 Spomky-Labs
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license.  See the LICENSE file for details.
+ */
+
+use Jose\Factory\JWKFactory;
+use Jose\Object\JWKInterface;
+
+/**
+ * Class RotatableJWKSetTest.
+ *
+ * @group Unit
+ * @group RotatableJWKSet
+ */
+class RotatableJWKSetTest extends \PHPUnit_Framework_TestCase
+{
+    public function testKey()
+    {
+        @unlink(sys_get_temp_dir().'/JWKSet.key');
+        $jwkset = JWKFactory::createRotatableKeySet(
+            sys_get_temp_dir().'/JWKSet.key',
+            [
+                'kty'   => 'EC',
+                'crv'   => 'P-256',
+            ],
+            3,
+            10
+        );
+
+        $this->assertEquals(3, $jwkset->count());
+        $this->assertEquals(3, $jwkset->countKeys());
+
+        $this->assertInstanceOf(JWKInterface::class, $jwkset[0]);
+        $this->assertInstanceOf(JWKInterface::class, $jwkset[1]);
+        $this->assertInstanceOf(JWKInterface::class, $jwkset[2]);
+        $this->assertFalse(isset($jwkset[3]));
+        $this->assertTrue($jwkset->hasKey(0));
+        $this->assertEquals($jwkset->getKey(0), $jwkset[0]);
+        foreach ($jwkset->getKeys() as $key) {
+            $this->assertInstanceOf(JWKInterface::class, $key);
+        }
+        foreach ($jwkset as $key) {
+            $this->assertInstanceOf(JWKInterface::class, $key);
+        }
+
+        $actual_content = json_encode($jwkset);
+
+        sleep(5);
+
+        $this->assertEquals($actual_content, json_encode($jwkset));
+
+        sleep(6);
+
+        $this->assertNotEquals($actual_content, json_encode($jwkset));
+
+        $jwkset[] = JWKFactory::createKey(['kty' => 'EC', 'crv' => 'P-521']);
+        unset($jwkset[count($jwkset) - 1]);
+        $jwkset->addKey(JWKFactory::createKey(['kty' => 'EC', 'crv' => 'P-521']));
+        $jwkset->removeKey(count($jwkset) - 1);
+    }
+}
diff --git a/tests/Unit/Objects/StorableJWKTest.php b/tests/Unit/Objects/RotatableJWKTest.php
similarity index 60%
rename from tests/Unit/Objects/StorableJWKTest.php
rename to tests/Unit/Objects/RotatableJWKTest.php
index e8cdc3a6..7445e0d7 100644
--- a/tests/Unit/Objects/StorableJWKTest.php
+++ b/tests/Unit/Objects/RotatableJWKTest.php
@@ -9,28 +9,28 @@
  * of the MIT license.  See the LICENSE file for details.
  */
 
-use Jose\Object\StorableJWK;
+use Jose\Factory\JWKFactory;
 
 /**
- * Class StorableJWKTest.
+ * Class RotatableJWKTest.
  *
  * @group Unit
- * @group StorableJWK
+ * @group RotatableJWK
  */
-class StorableJWKTest extends \PHPUnit_Framework_TestCase
+class RotatableJWKTest extends \PHPUnit_Framework_TestCase
 {
     public function testKey()
     {
         @unlink(sys_get_temp_dir().'/JWK.key');
-        $jwk = new StorableJWK(
+        $jwk = JWKFactory::createRotatableKey(
             sys_get_temp_dir().'/JWK.key',
             [
                 'kty'   => 'EC',
                 'crv'   => 'P-256',
-            ]
+            ],
+            10
         );
 
-        $this->assertEquals(sys_get_temp_dir().'/JWK.key', $jwk->getFilename());
         $all = $jwk->getAll();
         $this->assertEquals($all, $jwk->getAll());
         $this->assertTrue($jwk->has('kty'));
@@ -41,24 +41,12 @@ public function testKey()
         $this->assertTrue(is_string(json_encode($jwk)));
         $this->assertInstanceOf(\Jose\Object\JWKInterface::class, $jwk->toPublic());
 
-        $jwk = new StorableJWK(
-            sys_get_temp_dir().'/JWK.key',
-            [
-                'kty'   => 'EC',
-                'crv'   => 'P-256',
-            ]
-        );
+        sleep(5);
+
         $this->assertEquals($all, $jwk->getAll());
 
-        // We remove the file to force to creation of a new key
-        @unlink(sys_get_temp_dir().'/JWK.key');
-        $jwk = new StorableJWK(
-            sys_get_temp_dir().'/JWK.key',
-            [
-                'kty'   => 'EC',
-                'crv'   => 'P-256',
-            ]
-        );
+        sleep(6);
+
         $this->assertNotEquals($all, $jwk->getAll());
         $all = $jwk->getAll();
         $this->assertEquals($all, $jwk->getAll());
diff --git a/tests/ci/install_php_ext.sh b/tests/ci/install_php_ext.sh
new file mode 100644
index 00000000..05dfba8f
--- /dev/null
+++ b/tests/ci/install_php_ext.sh
@@ -0,0 +1,20 @@
+#!/usr/bin/env bash
+
+git clone git://github.com/lt/php-curve25519-ext.git
+cd php-curve25519-ext
+phpize
+./configure
+make
+sudo make install
+cd ..
+rm -rf php-curve25519-ext
+echo "extension = curve25519.so" >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini
+git clone git://github.com/encedo/php-ed25519-ext.git
+cd php-ed25519-ext
+phpize
+./configure
+make
+sudo make install
+cd ..
+rm -rf php-ed25519-ext
+echo "extension = ed25519.so" >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini