diff --git a/core/core.services.yml b/core/core.services.yml
index 06981a0326e506709f0db25b4ac94a57e4c4bd51..24fa98eaaaaa638d888a3562ef4fd937643f6c46 100644
--- a/core/core.services.yml
+++ b/core/core.services.yml
@@ -33,6 +33,12 @@ services:
     class: Drupal\Core\Cache\TimeZoneCacheContext
     tags:
       - { name: cache.context}
+  cache_context.menu.active_trail:
+    class: Drupal\Core\Cache\MenuActiveTrailCacheContext
+    calls:
+      - [setContainer, ['@service_container']]
+    tags:
+      - { name: cache.context}
   cache_tags.invalidator:
     parent: container.trait
     class: Drupal\Core\Cache\CacheTagsInvalidator
diff --git a/core/lib/Drupal/Core/Block/BlockBase.php b/core/lib/Drupal/Core/Block/BlockBase.php
index e0c6a12200fd858db905a6cd0f8d886032b8aa59..8a92e2a0de150f19d8a300aa038d75fc54fea28b 100644
--- a/core/lib/Drupal/Core/Block/BlockBase.php
+++ b/core/lib/Drupal/Core/Block/BlockBase.php
@@ -9,6 +9,7 @@
 
 use Drupal\block\BlockInterface;
 use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Cache\CacheContexts;
 use Drupal\Core\Form\FormStateInterface;
 use Drupal\Core\Plugin\ContextAwarePluginBase;
 use Drupal\Component\Utility\Unicode;
@@ -223,8 +224,9 @@ public function buildConfigurationForm(array $form, FormStateInterface $form_sta
       // Remove the required cache contexts from the list of contexts a user can
       // choose to modify by: they must always be applied.
       $context_labels = array();
-      foreach ($this->getRequiredCacheContexts() as $context) {
-        $context_labels[] = $form['cache']['contexts']['#options'][$context];
+      $all_contexts = \Drupal::service("cache_contexts")->getLabels(TRUE);
+      foreach (array_keys(CacheContexts::parseTokens($this->getRequiredCacheContexts())) as $context) {
+        $context_labels[] = $all_contexts[$context];
         unset($form['cache']['contexts']['#options'][$context]);
       }
       $required_context_list = implode(', ', $context_labels);
diff --git a/core/lib/Drupal/Core/Cache/CacheContexts.php b/core/lib/Drupal/Core/Cache/CacheContexts.php
index a2d8bdb6ea0f239e45f8aec073efefd1629dba01..7e70915bb6a48d93ede9a960ca8d4f6641e07c4d 100644
--- a/core/lib/Drupal/Core/Cache/CacheContexts.php
+++ b/core/lib/Drupal/Core/Cache/CacheContexts.php
@@ -8,14 +8,22 @@
 namespace Drupal\Core\Cache;
 
 use Drupal\Component\Utility\String;
-use Drupal\Core\Database\Query\SelectInterface;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 
 /**
- * Defines the CacheContexts service.
+ * Converts cache context tokens into cache keys.
  *
- * Converts cache context IDs into their final string values, to be used as
- * cache keys.
+ * Uses cache context services (services tagged with 'cache.context', and whose
+ * service ID has the 'cache_context.' prefix) to dynamically generate cache
+ * keys based on the request context, thus allowing developers to express the
+ * state by which should varied (the current URL, language, and so on).
+ *
+ * Note that this maps exactly to HTTP's Vary header semantics:
+ * @link http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.44
+ *
+ * @see \Drupal\Core\Cache\CacheContextInterface
+ * @see \Drupal\Core\Cache\CalculatedCacheContextInterface
+ * @see \Drupal\Core\Cache\CacheContextsPass
  */
 class CacheContexts {
 
@@ -61,64 +69,88 @@ public function getAll() {
    *
    * To be used in cache configuration forms.
    *
+   * @param bool $include_calculated_cache_contexts
+   *   Whether to also return calculated cache contexts. Default to FALSE.
+   *
    * @return array
    *   An array of available cache contexts and corresponding labels.
    */
-  public function getLabels() {
+  public function getLabels($include_calculated_cache_contexts = FALSE) {
     $with_labels = array();
     foreach ($this->contexts as $context) {
-      $with_labels[$context] = $this->getService($context)->getLabel();
+      $service = $this->getService($context);
+      if (!$include_calculated_cache_contexts && $service instanceof CalculatedCacheContextInterface) {
+        continue;
+      }
+      $with_labels[$context] = $service->getLabel();
     }
     return $with_labels;
   }
 
   /**
-   * Converts cache context tokens to string representations of the context.
+   * Converts cache context tokens to cache keys.
    *
-   * @param string[] $contexts
-   *   An array of cache context IDs.
+   * A cache context token is either:
+   * - a cache context ID (if the service ID is 'cache_context.foo', then 'foo'
+   *   is a cache context ID), e.g. 'foo'
+   * - a calculated cache context ID, followed by a double colon, followed by
+   *   the parameter for the calculated cache context, e.g. 'bar:some_parameter'
+   *
+   * @param string[] $context_tokens
+   *   An array of cache context tokens.
    *
    * @return string[]
-   *   A copy of the input, with cache context tokens converted.
+   *   The array of corresponding cache keys.
    *
    * @throws \InvalidArgumentException
    */
-  public function convertTokensToKeys(array $contexts) {
-    $materialized_contexts = [];
-    foreach ($contexts as $context) {
-      if (!in_array($context, $this->contexts)) {
-        throw new \InvalidArgumentException(String::format('"@context" is not a valid cache context ID.', ['@context' => $context]));
+  public function convertTokensToKeys(array $context_tokens) {
+    $keys = [];
+    foreach (static::parseTokens($context_tokens) as $context_id => $parameter) {
+      if (!in_array($context_id, $this->contexts)) {
+        throw new \InvalidArgumentException(String::format('"@context" is not a valid cache context ID.', ['@context' => $context_id]));
       }
-      $materialized_contexts[] = $this->getContext($context);
+      $keys[] = $this->getService($context_id)->getContext($parameter);
     }
-    return $materialized_contexts;
+    return $keys;
   }
 
   /**
-   * Provides the string representation of a cache context.
+   * Retrieves a cache context service from the container.
    *
-   * @param string $context
-   *   A cache context ID of an available cache context service.
+   * @param string $context_id
+   *   The context ID, which together with the service ID prefix allows the
+   *   corresponding cache context service to be retrieved.
    *
-   * @return string
-   *   The string representation of a cache context.
+   * @return \Drupal\Core\Cache\CacheContextInterface
+   *   The requested cache context service.
    */
-  protected function getContext($context) {
-    return $this->getService($context)->getContext();
+  protected function getService($context_id) {
+    return $this->container->get('cache_context.' . $context_id);
   }
 
   /**
-   * Retrieves a cache context service from the container.
+   * Parses cache context tokens into context IDs and optional parameters.
    *
-   * @param string $context
-   *   The context ID, which together with the service ID prefix allows the
-   *   corresponding cache context service to be retrieved.
+   * @param string[] $context_tokens
+   *   An array of cache context tokens.
    *
-   * @return \Drupal\Core\Cache\CacheContextInterface
-   *   The requested cache context service.
+   * @return array
+   *   An array with the parsed results, with the cache context IDs as keys, and
+   *   the associated parameter as value (for a calculated cache context), or
+   *   NULL if there is no parameter.
    */
-  protected function getService($context) {
-    return $this->container->get('cache_context.' . $context);
+  public static function parseTokens(array $context_tokens) {
+    $contexts_with_parameters = [];
+    foreach ($context_tokens as $context) {
+      $context_id = $context;
+      $parameter = NULL;
+      if (strpos($context, ':') !== FALSE) {
+        list($context_id, $parameter) = explode(':', $context, 2);
+      }
+      $contexts_with_parameters[$context_id] = $parameter;
+    }
+    return $contexts_with_parameters;
   }
 
 }
diff --git a/core/lib/Drupal/Core/Cache/CacheableInterface.php b/core/lib/Drupal/Core/Cache/CacheableInterface.php
index 4af960ac65025fca77c60231c5b0d8e4906ed831..70b0a8733b71b78fd6382cd9092205ab4ff7189a 100644
--- a/core/lib/Drupal/Core/Cache/CacheableInterface.php
+++ b/core/lib/Drupal/Core/Cache/CacheableInterface.php
@@ -25,6 +25,8 @@ interface CacheableInterface {
   /**
    * The cache keys associated with this potentially cacheable object.
    *
+   * These identify the object.
+   *
    * @return string[]
    *   An array of strings, used to generate a cache ID.
    */
@@ -33,6 +35,8 @@ public function getCacheKeys();
   /**
    * The cache contexts associated with this potentially cacheable object.
    *
+   * These identify a specific variation/representation of the object.
+   *
    * Cache contexts are tokens: placeholders that are converted to cache keys by
    * the @cache_contexts service. The replacement value depends on the request
    * context (the current URL, language, and so on). They're converted before
diff --git a/core/lib/Drupal/Core/Cache/CalculatedCacheContextInterface.php b/core/lib/Drupal/Core/Cache/CalculatedCacheContextInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..795a512e72f993a03931573389ed49b0cd323065
--- /dev/null
+++ b/core/lib/Drupal/Core/Cache/CalculatedCacheContextInterface.php
@@ -0,0 +1,39 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\Core\Cache\CacheContextInterface.
+ */
+
+namespace Drupal\Core\Cache;
+
+/**
+ * Provides an interface for defining a calculated cache context service.
+ */
+interface CalculatedCacheContextInterface {
+
+  /**
+   * Returns the label of the cache context.
+   *
+   * @return string
+   *   The label of the cache context.
+   *
+   * @see Cache
+   */
+  public static function getLabel();
+
+  /**
+   * Returns the string representation of the cache context.
+   *
+   * A cache context service's name is used as a token (placeholder) cache key,
+   * and is then replaced with the string returned by this method.
+   *
+   * @param string $parameter
+   *   The parameter.
+   *
+   * @return string
+   *   The string representation of the cache context.
+   */
+  public function getContext($parameter);
+
+}
diff --git a/core/lib/Drupal/Core/Cache/MenuActiveTrailCacheContext.php b/core/lib/Drupal/Core/Cache/MenuActiveTrailCacheContext.php
new file mode 100644
index 0000000000000000000000000000000000000000..7c41bee2a8e00922eb4bece18a28e9ce8bdc6daa
--- /dev/null
+++ b/core/lib/Drupal/Core/Cache/MenuActiveTrailCacheContext.php
@@ -0,0 +1,36 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\Core\Cache\MenuActiveTrailCacheContext.
+ */
+
+namespace Drupal\Core\Cache;
+
+use Symfony\Component\DependencyInjection\ContainerAware;
+
+/**
+ * Defines the MenuActiveTrailCacheContext service.
+ *
+ * This class is container-aware to avoid initializing the 'menu.active_trail'
+ * service (and its dependencies) when it is not necessary.
+ */
+class MenuActiveTrailCacheContext extends ContainerAware implements CalculatedCacheContextInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getLabel() {
+    return t("Active menu trail");
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getContext($menu_name) {
+    $active_trail = $this->container->get('menu.active_trail')
+      ->getActiveTrailIds($menu_name);
+    return 'menu_trail.' . $menu_name . '|' . implode('|', $active_trail);
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Menu/MenuActiveTrail.php b/core/lib/Drupal/Core/Menu/MenuActiveTrail.php
index 4732c5cb99c4654db128db4863d0c1fd7a239972..6247b29ee13fc749b801bdf11861ddb3b8752e9d 100644
--- a/core/lib/Drupal/Core/Menu/MenuActiveTrail.php
+++ b/core/lib/Drupal/Core/Menu/MenuActiveTrail.php
@@ -63,13 +63,6 @@ public function getActiveTrailIds($menu_name) {
     return $active_trail;
   }
 
-  /**
-   * {@inheritdoc}
-   */
-  public function getActiveTrailCacheKey($menu_name) {
-    return 'menu_trail.' . implode('|', $this->getActiveTrailIds($menu_name));
-  }
-
   /**
    * {@inheritdoc}
    */
diff --git a/core/lib/Drupal/Core/Menu/MenuActiveTrailInterface.php b/core/lib/Drupal/Core/Menu/MenuActiveTrailInterface.php
index db485c43544a1e396a1b703356b9a5c60880a0d8..49f4e99d5b6337ecd0b417dafca5a52348bb0eef 100644
--- a/core/lib/Drupal/Core/Menu/MenuActiveTrailInterface.php
+++ b/core/lib/Drupal/Core/Menu/MenuActiveTrailInterface.php
@@ -26,17 +26,6 @@ interface MenuActiveTrailInterface {
    */
   public function getActiveTrailIds($menu_name);
 
-  /**
-   * Gets the active trail cache key of the specified menu tree.
-   *
-   * @param string $menu_name
-   *   The menu name of the requested tree.
-   *
-   * @return string
-   *   The cache key that uniquely identifies the active trail of the menu tree.
-   */
-  public function getActiveTrailCacheKey($menu_name);
-
   /**
    * Fetches a menu link which matches the route name, parameters and menu name.
    *
diff --git a/core/modules/system/src/Plugin/Block/SystemMenuBlock.php b/core/modules/system/src/Plugin/Block/SystemMenuBlock.php
index 8ddcc46508ef19970b8b2320e8cc4ddf9d672034..93aefef8ebccad9b3a8cf6e311f2ddae440f2f09 100644
--- a/core/modules/system/src/Plugin/Block/SystemMenuBlock.php
+++ b/core/modules/system/src/Plugin/Block/SystemMenuBlock.php
@@ -180,15 +180,6 @@ public function defaultConfiguration() {
     ];
   }
 
-  /**
-   * {@inheritdoc}
-   */
-  public function getCacheKeys() {
-    // Add a key for the active menu trail.
-    $menu = $this->getDerivativeId();
-    return array_merge(parent::getCacheKeys(), array($this->menuActiveTrail->getActiveTrailCacheKey($menu)));
-  }
-
   /**
    * {@inheritdoc}
    */
@@ -206,9 +197,12 @@ public function getCacheTags() {
    * {@inheritdoc}
    */
   protected function getRequiredCacheContexts() {
-    // Menu blocks must be cached per role: different roles may have access to
-    // different menu links.
-    return array('user.roles');
+    // Menu blocks must be cached per role and per active trail.
+    $menu_name = $this->getDerivativeId();
+    return [
+      'user.roles',
+      'menu.active_trail:' . $menu_name,
+    ];
   }
 
 }
diff --git a/core/tests/Drupal/Tests/Core/Cache/CacheContextsTest.php b/core/tests/Drupal/Tests/Core/Cache/CacheContextsTest.php
index fff9df226adba13d056e08b8b4eb3bcc0095f152..cd749683833803cab5784dfe970fe6d68c406499 100644
--- a/core/tests/Drupal/Tests/Core/Cache/CacheContextsTest.php
+++ b/core/tests/Drupal/Tests/Core/Cache/CacheContextsTest.php
@@ -9,7 +9,9 @@
 
 use Drupal\Core\Cache\CacheContexts;
 use Drupal\Core\Cache\CacheContextInterface;
+use Drupal\Core\Cache\CalculatedCacheContextInterface;
 use Drupal\Tests\UnitTestCase;
+use Symfony\Component\DependencyInjection\Container;
 
 /**
  * @coversDefaultClass \Drupal\Core\Cache\CacheContexts
@@ -17,20 +19,19 @@
  */
 class CacheContextsTest extends UnitTestCase {
 
-  public function testContextPlaceholdersAreReplaced() {
+  /**
+   * @covers ::convertTokensToKeys
+   */
+  public function testConvertTokensToKeys() {
     $container = $this->getMockContainer();
-    $container->expects($this->once())
-              ->method("get")
-              ->with("cache_context.foo")
-              ->will($this->returnValue(new FooCacheContext()));
-
     $cache_contexts = new CacheContexts($container, $this->getContextsFixture());
 
-    $new_keys = $cache_contexts->convertTokensToKeys(
-      ['foo']
-    );
+    $new_keys = $cache_contexts->convertTokensToKeys([
+      'foo',
+      'baz:parameter',
+    ]);
 
-    $expected = ['bar'];
+    $expected = ['bar', 'baz.cnenzrgre'];
     $this->assertEquals($expected, $new_keys);
   }
 
@@ -44,24 +45,41 @@ public function testInvalidContext() {
     $container = $this->getMockContainer();
     $cache_contexts = new CacheContexts($container, $this->getContextsFixture());
 
-    $cache_contexts->convertTokensToKeys(
-      ["non-cache-context"]
-    );
+    $cache_contexts->convertTokensToKeys(["non-cache-context"]);
+  }
+
+  /**
+   * @covers ::convertTokensToKeys
+   *
+   * @expectedException \Exception
+   *
+   * @dataProvider providerTestInvalidCalculatedContext
+   */
+  public function testInvalidCalculatedContext($context_token) {
+    $container = $this->getMockContainer();
+    $cache_contexts = new CacheContexts($container, $this->getContextsFixture());
+
+    $cache_contexts->convertTokensToKeys([$context_token]);
+  }
+
+  /**
+   * Provides a list of invalid 'baz' cache contexts: the parameter is missing.
+   */
+  public function providerTestInvalidCalculatedContext() {
+    return [
+      ['baz'],
+      ['baz:'],
+    ];
   }
 
   public function testAvailableContextStrings() {
     $cache_contexts = new CacheContexts($this->getMockContainer(), $this->getContextsFixture());
     $contexts = $cache_contexts->getAll();
-    $this->assertEquals(array("foo"), $contexts);
+    $this->assertEquals(array("foo", "baz"), $contexts);
   }
 
   public function testAvailableContextLabels() {
     $container = $this->getMockContainer();
-    $container->expects($this->once())
-              ->method("get")
-              ->with("cache_context.foo")
-              ->will($this->returnValue(new FooCacheContext()));
-
     $cache_contexts = new CacheContexts($container, $this->getContextsFixture());
     $labels = $cache_contexts->getLabels();
     $expected = array("foo" => "Foo");
@@ -69,14 +87,22 @@ public function testAvailableContextLabels() {
   }
 
   protected function getContextsFixture() {
-    return array('foo');
+    return array('foo', 'baz');
   }
 
   protected function getMockContainer() {
-    return $this->getMockBuilder('Drupal\Core\DependencyInjection\Container')
-                ->disableOriginalConstructor()
-                ->getMock();
+    $container = $this->getMockBuilder('Drupal\Core\DependencyInjection\Container')
+      ->disableOriginalConstructor()
+      ->getMock();
+    $container->expects($this->any())
+      ->method('get')
+      ->will($this->returnValueMap([
+        ['cache_context.foo', Container::EXCEPTION_ON_INVALID_REFERENCE, new FooCacheContext()],
+        ['cache_context.baz', Container::EXCEPTION_ON_INVALID_REFERENCE, new BazCacheContext()],
+      ]));
+    return $container;
   }
+
 }
 
 /**
@@ -100,3 +126,26 @@ public function getContext() {
 
 }
 
+/**
+ * Fake calculated cache context class.
+ */
+class BazCacheContext implements CalculatedCacheContextInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getLabel() {
+    return 'Baz';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getContext($parameter) {
+    if (!is_string($parameter) || strlen($parameter) ===  0) {
+      throw new \Exception();
+    }
+    return 'baz.' . str_rot13($parameter);
+  }
+
+}
diff --git a/core/tests/Drupal/Tests/Core/Menu/MenuActiveTrailTest.php b/core/tests/Drupal/Tests/Core/Menu/MenuActiveTrailTest.php
index fc99cc3d8c21bf7b906bb1b53da61da183768f67..2ea72ff6ace33f61ccb1ac81c991f2c49cab326a 100644
--- a/core/tests/Drupal/Tests/Core/Menu/MenuActiveTrailTest.php
+++ b/core/tests/Drupal/Tests/Core/Menu/MenuActiveTrailTest.php
@@ -94,28 +94,25 @@ public function provider() {
     $link_1_parent_ids = array('baby_llama_link_1', 'mama_llama_link', '');
     $empty_active_trail = array('');
 
-    $link_1__active_trail_cache_key = 'menu_trail.baby_llama_link_1|mama_llama_link|';
-    $empty_active_trail_cache_key = 'menu_trail.';
-
     // No active link is returned when zero links match the current route.
-    $data[] = array($request, array(), $this->randomMachineName(), NULL, $empty_active_trail, $empty_active_trail_cache_key);
+    $data[] = array($request, array(), $this->randomMachineName(), NULL, $empty_active_trail);
 
     // The first (and only) matching link is returned when one link matches the
     // current route.
-    $data[] = array($request, array('baby_llama_link_1' => $link_1), $this->randomMachineName(), $link_1, $link_1_parent_ids, $link_1__active_trail_cache_key);
+    $data[] = array($request, array('baby_llama_link_1' => $link_1), $this->randomMachineName(), $link_1, $link_1_parent_ids);
 
     // The first of multiple matching links is returned when multiple links
     // match the current route, where "first" is determined by sorting by key.
-    $data[] = array($request, array('baby_llama_link_1' => $link_1, 'baby_llama_link_2' => $link_2), $this->randomMachineName(), $link_1, $link_1_parent_ids, $link_1__active_trail_cache_key);
+    $data[] = array($request, array('baby_llama_link_1' => $link_1, 'baby_llama_link_2' => $link_2), $this->randomMachineName(), $link_1, $link_1_parent_ids);
 
     // No active link is returned in case of a 403.
     $request = new Request();
     $request->attributes->set('_exception_statuscode', 403);
-    $data[] = array($request, FALSE, $this->randomMachineName(), NULL, $empty_active_trail, $empty_active_trail_cache_key);
+    $data[] = array($request, FALSE, $this->randomMachineName(), NULL, $empty_active_trail);
 
     // No active link is returned when the route name is missing.
     $request = new Request();
-    $data[] = array($request, FALSE, $this->randomMachineName(), NULL, $empty_active_trail, $empty_active_trail_cache_key);
+    $data[] = array($request, FALSE, $this->randomMachineName(), NULL, $empty_active_trail);
 
     return $data;
   }
@@ -144,20 +141,19 @@ public function testGetActiveLink(Request $request, $links, $menu_name, $expecte
    * Tests getActiveTrailIds().
    *
    * @covers ::getActiveTrailIds
-   * @covers ::getActiveTrailCacheKey
    * @dataProvider provider
    */
-  public function testGetActiveTrailIds(Request $request, $links, $menu_name, $expected_link, $expected_trail, $expected_cache_key) {
+  public function testGetActiveTrailIds(Request $request, $links, $menu_name, $expected_link, $expected_trail) {
     $expected_trail_ids = array_combine($expected_trail, $expected_trail);
 
     $this->requestStack->push($request);
     if ($links !== FALSE) {
-      $this->menuLinkManager->expects($this->exactly(2))
+      $this->menuLinkManager->expects($this->once())
         ->method('loadLinksbyRoute')
         ->with('baby_llama')
         ->will($this->returnValue($links));
       if ($expected_link !== NULL) {
-        $this->menuLinkManager->expects($this->exactly(2))
+        $this->menuLinkManager->expects($this->once())
           ->method('getParentIds')
           ->will($this->returnValueMap(array(
             array($expected_link->getPluginId(), $expected_trail_ids),
@@ -166,7 +162,6 @@ public function testGetActiveTrailIds(Request $request, $links, $menu_name, $exp
     }
 
     $this->assertSame($expected_trail_ids, $this->menuActiveTrail->getActiveTrailIds($menu_name));
-    $this->assertSame($expected_cache_key, $this->menuActiveTrail->getActiveTrailCacheKey($menu_name));
   }
 
 }