The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
CONTRIBUTING 2878
Changes 030
MANIFEST 15
META.json 1125
META.yml 3335
Makefile.PL 2936
README 2363
cpanfile 050
dist.ini 31
lib/Session/Storage/Secure.pm 55234
perlcritic.rc 04
t/00-compile.t 740
t/00-report-prereqs.t 38152
t/basic.t 89122
t/encoding.t 0101
t/errors.t 1920
tidyall.ini 05
xt/author/00-compile.t 053
xt/author/pod-spell.t 26
xt/release/distmeta.t 32
xt/release/pod-coverage.t 93
xt/release/pod-syntax.t 32
22 files changed (This is a version diff) 4201027
@@ -1,50 +1,100 @@
-CONTRIBUTING
+## HOW TO CONTRIBUTE
 
 Thank you for considering contributing to this distribution.  This file
 contains instructions that will help you work with the source code.
 
 The distribution is managed with Dist::Zilla.  This means than many of the
-usual files you might expect are not in the repository, but are generated
-at release time (e.g. Makefile.PL).
+usual files you might expect are not in the repository, but are generated at
+release time (e.g. Makefile.PL).
 
-However, you can run tests directly using the 'prove' tool:
+Generally, **you do not need Dist::Zilla to contribute patches**.  You do need
+Dist::Zilla to create a tarball and/or install from the repository.  See below
+for guidance.
 
-  $ prove -l
-  $ prove -lv t/some_test_file.t
+### Getting dependencies
 
-For most distributions, 'prove' is entirely sufficent for you to test any
-patches you have.
+See the included `cpanfile` file for a list of dependencies.  If you have
+App::cpanminus 1.6 or later installed, you can use `cpanm` to satisfy
+dependencies like this:
 
-You may need to satisfy some dependencies.  See the included META.json
-file for a list.  If you install App::mymeta_requires from CPAN, it's easy
-to satisfy any that you are missing by piping the output to your favorite
-CPAN client:
+    $ cpanm --installdeps .
 
-  $ mymeta-requires | cpanm
-  $ cpan `mymeta-requires`
+Otherwise, you can install Module::CPANfile 1.0002 or later and then satisfy
+dependencies with the regular `cpan` client and `cpanfile-dump`:
 
-Likewise, much of the documentation Pod is generated at release time.
-Depending on the distribution, some documentation may be written in a Pod
-dialect called WikiDoc. (See Pod::WikiDoc on CPAN.) If you would like to
-submit a documentation edit, please limit yourself to the documentation you
-see.
+    $ cpan `cpanfile-dump`
+
+### Running tests
+
+You can run tests directly using the `prove` tool:
+
+    $ prove -l
+    $ prove -lv t/some_test_file.t
+
+For most of my distributions, `prove` is entirely sufficient for you to test any
+patches you have. I use `prove` for 99% of my testing during development.
+
+### Code style and tidying
+
+Please try to match any existing coding style.  If there is a `.perltidyrc`
+file, please install Perl::Tidy and use perltidy before submitting patches.
+
+If there is a `tidyall.ini` file, you can also install Code::TidyAll and run
+`tidyall` on a file or `tidyall -a` to tidy all files.
+
+### Patching documentation
+
+Much of the documentation Pod is generated at release time.  Depending on the
+distribution, some of my documentation may be written in a Pod dialect called
+WikiDoc. (See Pod::WikiDoc on CPAN.)
+
+If you would like to submit a documentation edit, please limit yourself to the
+documentation you see.
 
 If you see typos or documentation issues in the generated docs, please
 email or open a bug ticket instead of patching.
 
-Dist::Zilla is a very powerful authoring tool, but requires a number of
-author-specific plugins.  If you would like to use it for contributing,
-install it from CPAN, then run one of the following commands, depending on
-your CPAN client:
+### Installing from the repository
 
-  $ cpan `dzil authordeps`
-  $ dzil authordeps | cpanm
+If you want to install directly from the repository, you need to have
+Dist::Zilla installed (see below).  If this is a burden to you, I welcome
+patches against a CPAN tarball instead of the repository.
+
+### Installing and using Dist::Zilla
+
+Dist::Zilla is a very powerful authoring tool, optimized for maintaining a
+large number of distributions with a high degree of automation, but it has a
+large dependency chain, a bit of a learning curve and requires a number of
+author-specific plugins.
+
+To install it from CPAN, I recommend one of the following approaches for
+the quickest installation:
+
+    # using CPAN.pm, but bypassing non-functional pod tests
+    $ cpan TAP::Harness::Restricted
+    $ PERL_MM_USE_DEFAULT=1 HARNESS_CLASS=TAP::Harness::Restricted cpan Dist::Zilla
+
+    # using cpanm, bypassing *all* tests
+    $ cpanm -n Dist::Zilla
+
+In either case, it's probably going to take about 10 minutes.  Go for a walk,
+go get a cup of your favorite beverage, take a bathroom break, or whatever.
+When you get back, Dist::Zilla should be ready for you.
+
+Then you need to install any plugins specific to this distribution:
+
+    $ cpan `dzil authordeps`
+    $ dzil authordeps | cpanm
 
 Once installed, here are some dzil commands you might try:
 
-  $ dzil build
-  $ dzil test
-  $ dzil xtest
+    $ dzil build
+    $ dzil test
+    $ dzil xtest
+
+To install from the repository, use:
+
+    $ dzil install
 
 You can learn more about Dist::Zilla at http://dzil.org/
 
@@ -1,5 +1,35 @@
 Revision history for Session-Storage-Secure
 
+0.010     2014-05-04 13:52:13-04:00 America/New_York
+
+    [ADDED]
+
+    - Added support for customizing options to Sereal encoder and decoder,
+      i.e. to allow object serialization for those willing to accept the
+      risks of doing so.  (Thanks to Breno de Oliveira for inspiration to
+      do this.)
+
+0.009     2014-04-17 17:15:25-04:00 America/New_York
+
+    [FIXED]
+
+    - Fixed bug that would cause custom encoding tests to fail
+      intermittently
+
+0.008     2014-04-17 16:29:50-04:00 America/New_York
+
+    [ADDED]
+
+    - Added support for keeping an array of old keys for decryption
+      (Tom Hukins)
+
+    - Added support for replacing MIME::Base64 encoding with user-specified
+      transport encoding/decoding, possibly with a custom separator
+
+    [INTERNAL]
+
+    - Update repository support and meta files
+
 0.007     2013-05-31 23:30:44 America/New_York
 
     [FIXED]
@@ -1,3 +1,4 @@
+# This file was automatically generated by Dist::Zilla::Plugin::Manifest v5.015.
 CONTRIBUTING
 Changes
 LICENSE
@@ -6,13 +7,16 @@ META.json
 META.yml
 Makefile.PL
 README
+cpanfile
 dist.ini
 lib/Session/Storage/Secure.pm
 perlcritic.rc
-t/00-compile.t
 t/00-report-prereqs.t
 t/basic.t
+t/encoding.t
 t/errors.t
+tidyall.ini
+xt/author/00-compile.t
 xt/author/critic.t
 xt/author/pod-spell.t
 xt/release/distmeta.t
@@ -4,7 +4,7 @@
       "David Golden <dagolden@cpan.org>"
    ],
    "dynamic_config" : 0,
-   "generated_by" : "Dist::Zilla version 4.300034, CPAN::Meta::Converter version 2.131490",
+   "generated_by" : "Dist::Zilla version 5.015, CPAN::Meta::Converter version 2.141170",
    "license" : [
       "apache_2_0"
    ],
@@ -27,13 +27,20 @@
    "prereqs" : {
       "configure" : {
          "requires" : {
-            "ExtUtils::MakeMaker" : "6.30"
+            "ExtUtils::MakeMaker" : "6.17"
          }
       },
       "develop" : {
          "requires" : {
+            "Dist::Zilla" : "5.015",
+            "Dist::Zilla::PluginBundle::DAGOLDEN" : "0.060",
+            "File::Spec" : "0",
+            "File::Temp" : "0",
+            "IO::Handle" : "0",
+            "IPC::Open3" : "0",
             "Pod::Coverage::TrustPod" : "0",
             "Test::CPAN::Meta" : "0",
+            "Test::More" : "0",
             "Test::Pod" : "1.41",
             "Test::Pod::Coverage" : "1.08"
          }
@@ -59,37 +66,44 @@
          }
       },
       "test" : {
+         "recommends" : {
+            "CPAN::Meta" : "0",
+            "CPAN::Meta::Requirements" : "2.120900"
+         },
          "requires" : {
             "ExtUtils::MakeMaker" : "0",
-            "File::Find" : "0",
             "File::Spec::Functions" : "0",
-            "File::Temp" : "0",
             "List::Util" : "0",
             "Test::Deep" : "0",
             "Test::Fatal" : "0",
             "Test::More" : "0.96",
-            "Test::Tolerant" : "0"
+            "Test::Tolerant" : "0",
+            "version" : "0"
          }
       }
    },
    "provides" : {
       "Session::Storage::Secure" : {
          "file" : "lib/Session/Storage/Secure.pm",
-         "version" : "0.007"
+         "version" : "0.010"
       }
    },
    "release_status" : "stable",
    "resources" : {
       "bugtracker" : {
-         "web" : "https://github.com/dagolden/session-storage-secure/issues"
+         "web" : "https://github.com/dagolden/Session-Storage-Secure/issues"
       },
-      "homepage" : "https://metacpan.org/release/Session-Storage-Secure",
+      "homepage" : "https://github.com/dagolden/Session-Storage-Secure",
       "repository" : {
          "type" : "git",
-         "url" : "git://github.com/dagolden/session-storage-secure.git",
-         "web" : "https://github.com/dagolden/session-storage-secure"
+         "url" : "https://github.com/dagolden/Session-Storage-Secure.git",
+         "web" : "https://github.com/dagolden/Session-Storage-Secure"
       }
    },
-   "version" : "0.007"
+   "version" : "0.010",
+   "x_authority" : "cpan:DAGOLDEN",
+   "x_contributors" : [
+      "Tom Hukins <tom@eborcom.com>"
+   ]
 }
 
@@ -3,23 +3,22 @@ abstract: 'Encrypted, expiring, compressed, serialized session data with integri
 author:
   - 'David Golden <dagolden@cpan.org>'
 build_requires:
-  ExtUtils::MakeMaker: 0
-  File::Find: 0
-  File::Spec::Functions: 0
-  File::Temp: 0
-  List::Util: 0
-  Test::Deep: 0
-  Test::Fatal: 0
-  Test::More: 0.96
-  Test::Tolerant: 0
+  ExtUtils::MakeMaker: '0'
+  File::Spec::Functions: '0'
+  List::Util: '0'
+  Test::Deep: '0'
+  Test::Fatal: '0'
+  Test::More: '0.96'
+  Test::Tolerant: '0'
+  version: '0'
 configure_requires:
-  ExtUtils::MakeMaker: 6.30
+  ExtUtils::MakeMaker: '6.17'
 dynamic_config: 0
-generated_by: 'Dist::Zilla version 4.300034, CPAN::Meta::Converter version 2.131490'
+generated_by: 'Dist::Zilla version 5.015, CPAN::Meta::Converter version 2.141170'
 license: apache
 meta-spec:
   url: http://module-build.sourceforge.net/META-spec-v1.4.html
-  version: 1.4
+  version: '1.4'
 name: Session-Storage-Secure
 no_index:
   directory:
@@ -32,26 +31,29 @@ no_index:
 provides:
   Session::Storage::Secure:
     file: lib/Session/Storage/Secure.pm
-    version: 0.007
+    version: '0.010'
 requires:
-  Carp: 0
-  Crypt::CBC: 0
-  Crypt::Rijndael: 0
-  Crypt::URandom: 0
-  Digest::SHA: 0
-  MIME::Base64: 3.12
-  Math::Random::ISAAC::XS: 0
-  Moo: 0
-  MooX::Types::MooseLike::Base: 0.16
-  Sereal::Decoder: 0
-  Sereal::Encoder: 0
-  String::Compare::ConstantTime: 0
-  namespace::clean: 0
-  perl: 5.008001
-  strict: 0
-  warnings: 0
+  Carp: '0'
+  Crypt::CBC: '0'
+  Crypt::Rijndael: '0'
+  Crypt::URandom: '0'
+  Digest::SHA: '0'
+  MIME::Base64: '3.12'
+  Math::Random::ISAAC::XS: '0'
+  Moo: '0'
+  MooX::Types::MooseLike::Base: '0.16'
+  Sereal::Decoder: '0'
+  Sereal::Encoder: '0'
+  String::Compare::ConstantTime: '0'
+  namespace::clean: '0'
+  perl: '5.008001'
+  strict: '0'
+  warnings: '0'
 resources:
-  bugtracker: https://github.com/dagolden/session-storage-secure/issues
-  homepage: https://metacpan.org/release/Session-Storage-Secure
-  repository: git://github.com/dagolden/session-storage-secure.git
-version: 0.007
+  bugtracker: https://github.com/dagolden/Session-Storage-Secure/issues
+  homepage: https://github.com/dagolden/Session-Storage-Secure
+  repository: https://github.com/dagolden/Session-Storage-Secure.git
+version: '0.010'
+x_authority: cpan:DAGOLDEN
+x_contributors:
+  - 'Tom Hukins <tom@eborcom.com>'
@@ -1,10 +1,11 @@
 
+# This file was automatically generated by Dist::Zilla::Plugin::MakeMaker v5.015.
 use strict;
 use warnings;
 
 use 5.008001;
 
-use ExtUtils::MakeMaker 6.30;
+use ExtUtils::MakeMaker 6.17;
 
 
 
@@ -13,7 +14,7 @@ my %WriteMakefileArgs = (
   "AUTHOR" => "David Golden <dagolden\@cpan.org>",
   "BUILD_REQUIRES" => {},
   "CONFIGURE_REQUIRES" => {
-    "ExtUtils::MakeMaker" => "6.30"
+    "ExtUtils::MakeMaker" => "6.17"
   },
   "DISTNAME" => "Session-Storage-Secure",
   "EXE_FILES" => [],
@@ -38,46 +39,52 @@ my %WriteMakefileArgs = (
   },
   "TEST_REQUIRES" => {
     "ExtUtils::MakeMaker" => 0,
-    "File::Find" => 0,
     "File::Spec::Functions" => 0,
-    "File::Temp" => 0,
     "List::Util" => 0,
     "Test::Deep" => 0,
     "Test::Fatal" => 0,
     "Test::More" => "0.96",
-    "Test::Tolerant" => 0
+    "Test::Tolerant" => 0,
+    "version" => 0
   },
-  "VERSION" => "0.007",
+  "VERSION" => "0.010",
   "test" => {
     "TESTS" => "t/*.t"
   }
 );
 
 
-unless ( eval { ExtUtils::MakeMaker->VERSION(6.63_03) } ) {
-  my $tr = delete $WriteMakefileArgs{TEST_REQUIRES};
-  my $br = $WriteMakefileArgs{BUILD_REQUIRES};
-  for my $mod ( keys %$tr ) {
-    if ( exists $br->{$mod} ) {
-      $br->{$mod} = $tr->{$mod} if $tr->{$mod} > $br->{$mod};
-    }
-    else {
-      $br->{$mod} = $tr->{$mod};
-    }
-  }
-}
+my %FallbackPrereqs = (
+  "Carp" => 0,
+  "Crypt::CBC" => 0,
+  "Crypt::Rijndael" => 0,
+  "Crypt::URandom" => 0,
+  "Digest::SHA" => 0,
+  "ExtUtils::MakeMaker" => 0,
+  "File::Spec::Functions" => 0,
+  "List::Util" => 0,
+  "MIME::Base64" => "3.12",
+  "Math::Random::ISAAC::XS" => 0,
+  "Moo" => 0,
+  "MooX::Types::MooseLike::Base" => "0.16",
+  "Sereal::Decoder" => 0,
+  "Sereal::Encoder" => 0,
+  "String::Compare::ConstantTime" => 0,
+  "Test::Deep" => 0,
+  "Test::Fatal" => 0,
+  "Test::More" => "0.96",
+  "Test::Tolerant" => 0,
+  "namespace::clean" => 0,
+  "strict" => 0,
+  "version" => 0,
+  "warnings" => 0
+);
 
-unless ( eval { ExtUtils::MakeMaker->VERSION(6.56) } ) {
-  my $br = delete $WriteMakefileArgs{BUILD_REQUIRES};
-  my $pp = $WriteMakefileArgs{PREREQ_PM};
-  for my $mod ( keys %$br ) {
-    if ( exists $pp->{$mod} ) {
-      $pp->{$mod} = $br->{$mod} if $br->{$mod} > $pp->{$mod};
-    }
-    else {
-      $pp->{$mod} = $br->{$mod};
-    }
-  }
+
+unless ( eval { ExtUtils::MakeMaker->VERSION(6.63_03) } ) {
+  delete $WriteMakefileArgs{TEST_REQUIRES};
+  delete $WriteMakefileArgs{BUILD_REQUIRES};
+  $WriteMakefileArgs{PREREQ_PM} = \%FallbackPrereqs;
 }
 
 delete $WriteMakefileArgs{CONFIGURE_REQUIRES}
@@ -3,7 +3,7 @@ NAME
     session data with integrity
 
 VERSION
-    version 0.007
+    version 0.010
 
 SYNOPSIS
       my $store = Session::Storage::Secure->new(
@@ -55,14 +55,15 @@ DESCRIPTION
 
     Using "user" and "expiration" to generate the encryption and MAC keys
     was a method proposed to ensure unique keys to defeat volume attacks
-    against the secret key. Rather than rely on those for uniqueness, which
-    also reveals user name and prohibits anonymous sessions, we replace
-    "user" with a cryptographically-strong random salt value.
+    against the secret key. Rather than rely on those for uniqueness (with
+    the unfortunate side effect of revealing user names and prohibiting
+    anonymous sessions), we replace "user" with a cryptographically-strong
+    random salt value.
 
     The original proposal also calculates a MAC based on unencrypted data.
-    We instead calculate the MAC based on the encrypted data. This avoids
-    the extra step of decrypting invalid messages. Because the salt is
-    already encoded into the key, we omit it from the MAC input.
+    We instead calculate the MAC based on the encrypted data. This avoids an
+    extra step decrypting invalid messages. Because the salt is already
+    encoded into the key, we omit it from the MAC input.
 
     Therefore, the session storage protocol used by this module is as
     follows:
@@ -82,7 +83,7 @@ DESCRIPTION
 
     The HMAC algorithm is "hmac_sha256" from Digest::SHA. Encryption is done
     by Crypt::CBC using Crypt::Rijndael (AES). The ciphertext and MAC's in
-    the cookie are Base64 encoded by MIME::Base64.
+    the cookie are Base64 encoded by MIME::Base64 by default.
 
     During session retrieval, if the MAC does not authenticate or if the
     expiration is set and in the past, the session will be discarded.
@@ -97,15 +98,43 @@ ATTRIBUTES
     Number of seconds for which the session may be considered valid. If an
     expiration is not provided to "encode", this is used instead to expire
     the session after a period of time. It is unset by default, meaning that
-    sessions expiration is not capped.
+    session expiration is not capped.
+
+  old_secrets
+    An optional array reference of strings containing old secret keys no
+    longer used for encryption but still supported for decrypting session
+    data.
+
+  separator
+    A character used to separate fields. It defaults to "~".
+
+  sereal_encoder_options
+    A hash reference with constructor arguments for Sereal::Encoder.
+    Defaults to "{ snappy => 1, croak_on_bless => 1 }".
+
+  sereal_decoder_options
+    A hash reference with constructor arguments for Sereal::Decoder.
+    Defaults to "{ refuse_objects => 1, validate_utf8 => 1 }".
+
+  transport_encoder
+    A code reference to convert binary data elements (the encrypted data and
+    the MAC) into a transport-safe form. Defaults to
+    MIME::Base64::encode_base64url. The output must not include the
+    "separator" attribute used to delimit fields.
+
+  transport_decoder
+    A code reference to extract binary data (the encrypted data and the MAC)
+    from a transport-safe form. It must be the complement to "encode".
+    Defaults to MIME::Base64::decode_base64url.
 
 METHODS
   encode
       my $string = $store->encode( $data, $expires );
 
-    The $data argument should be a reference to a data structure. It must
-    not contain objects. If it is undefined, an empty hash reference will be
-    encoded instead.
+    The $data argument should be a reference to a data structure. By
+    default, it must not contain objects. (See "Objects not stored by
+    default" for rationale and alternatives.) If it is undefined, an empty
+    hash reference will be encoded instead.
 
     The optional $expires argument should be the session expiration time
     expressed as epoch seconds. If the $expires time is in the past, the
@@ -114,7 +143,7 @@ METHODS
     attribute is set, it will be used to calculate an expiration time.
 
     The method returns a string that securely encodes the session data. All
-    binary components are base64 encoded.
+    binary components are protected via the "transport_encoder" attribute.
 
     An exception is thrown on any errors.
 
@@ -132,8 +161,10 @@ METHODS
 LIMITATIONS
   Secret key
     You must protect the secret key, of course. Rekeying periodically would
-    improve security. Rekeying also invalidates all existing sessions. In a
-    multi-node application, all nodes must share the same secret key.
+    improve security. Rekeying also invalidates all existing sessions unless
+    the "old_secrets" attribute contains old encryption keys still used for
+    decryption. In a multi-node application, all nodes must share the same
+    secret key.
 
   Session size
     If storing the encoded session in a cookie, keep in mind that cookies
@@ -147,11 +178,17 @@ LIMITATIONS
     Applications must check for this condition and handle it appropriately
     with an error or by splitting the value across multiple cookies.
 
-  Objects not stored
-    Session data may not include objects. Sereal is configured to die if
-    objects are encountered because object serialization/deserialiation can
-    have undesirable side effects. Applications should take steps to
-    deflate/inflate objects before storing them in session data.
+  Objects not stored by default
+    The default Sereal options do not allow storing objects because object
+    deserialization can have undesirable side effects, including potentially
+    fatal errors if a class is not available at deserialization time or if
+    internal class structures changed from when the session data was
+    serialized to when it was deserialized. Applications should take steps
+    to deflate/inflate objects before storing them in session data.
+
+    Alternatively, applications can change "sereal_encoder_options" and
+    "sereal_decoder_options" to allow object serialization or other object
+    transformations and accept the risks of doing so.
 
 SECURITY
     Storing encrypted session data within a browser cookie avoids latency
@@ -272,20 +309,23 @@ SEE ALSO
 SUPPORT
   Bugs / Feature Requests
     Please report any bugs or feature requests through the issue tracker at
-    <https://github.com/dagolden/session-storage-secure/issues>. You will be
+    <https://github.com/dagolden/Session-Storage-Secure/issues>. You will be
     notified automatically of any progress on your issue.
 
   Source Code
     This is open source software. The code repository is available for
     public review and contribution under the terms of the license.
 
-    <https://github.com/dagolden/session-storage-secure>
+    <https://github.com/dagolden/Session-Storage-Secure>
 
-      git clone git://github.com/dagolden/session-storage-secure.git
+      git clone https://github.com/dagolden/Session-Storage-Secure.git
 
 AUTHOR
     David Golden <dagolden@cpan.org>
 
+CONTRIBUTOR
+    Tom Hukins <tom@eborcom.com>
+
 COPYRIGHT AND LICENSE
     This software is Copyright (c) 2013 by David Golden.
 
@@ -0,0 +1,50 @@
+requires "Carp" => "0";
+requires "Crypt::CBC" => "0";
+requires "Crypt::Rijndael" => "0";
+requires "Crypt::URandom" => "0";
+requires "Digest::SHA" => "0";
+requires "MIME::Base64" => "3.12";
+requires "Math::Random::ISAAC::XS" => "0";
+requires "Moo" => "0";
+requires "MooX::Types::MooseLike::Base" => "0.16";
+requires "Sereal::Decoder" => "0";
+requires "Sereal::Encoder" => "0";
+requires "String::Compare::ConstantTime" => "0";
+requires "namespace::clean" => "0";
+requires "perl" => "5.008001";
+requires "strict" => "0";
+requires "warnings" => "0";
+
+on 'test' => sub {
+  requires "ExtUtils::MakeMaker" => "0";
+  requires "File::Spec::Functions" => "0";
+  requires "List::Util" => "0";
+  requires "Test::Deep" => "0";
+  requires "Test::Fatal" => "0";
+  requires "Test::More" => "0.96";
+  requires "Test::Tolerant" => "0";
+  requires "version" => "0";
+};
+
+on 'test' => sub {
+  recommends "CPAN::Meta" => "0";
+  recommends "CPAN::Meta::Requirements" => "2.120900";
+};
+
+on 'configure' => sub {
+  requires "ExtUtils::MakeMaker" => "6.17";
+};
+
+on 'develop' => sub {
+  requires "Dist::Zilla" => "5.015";
+  requires "Dist::Zilla::PluginBundle::DAGOLDEN" => "0.060";
+  requires "File::Spec" => "0";
+  requires "File::Temp" => "0";
+  requires "IO::Handle" => "0";
+  requires "IPC::Open3" => "0";
+  requires "Pod::Coverage::TrustPod" => "0";
+  requires "Test::CPAN::Meta" => "0";
+  requires "Test::More" => "0";
+  requires "Test::Pod" => "1.41";
+  requires "Test::Pod::Coverage" => "1.08";
+};
@@ -5,9 +5,7 @@ copyright_holder = David Golden
 copyright_year   = 2013
 
 [@DAGOLDEN]
-:version = 0.032
-AutoMetaResources.bugtracker.rt = 0
-AutoMetaResources.bugtracker.github = user:dagolden
+:version = 0.060
 stopwords = AES
 stopwords = Don'ts
 stopwords = Fu
@@ -4,7 +4,7 @@ use warnings;
 
 package Session::Storage::Secure;
 # ABSTRACT: Encrypted, expiring, compressed, serialized session data with integrity
-our $VERSION = '0.007'; # VERSION
+our $VERSION = '0.010'; # VERSION
 
 use Carp                    (qw/croak/);
 use Crypt::CBC              ();
@@ -12,7 +12,7 @@ use Crypt::Rijndael         ();
 use Crypt::URandom          (qw/urandom/);
 use Digest::SHA             (qw/hmac_sha256/);
 use Math::Random::ISAAC::XS ();
-use MIME::Base64 3.12 (qw/encode_base64url decode_base64url/);
+use MIME::Base64 3.12 ();
 use Sereal::Encoder ();
 use Sereal::Decoder ();
 use String::Compare::ConstantTime qw/equals/;
@@ -25,6 +25,13 @@ use MooX::Types::MooseLike::Base 0.16 qw(:all);
 # Attributes
 #--------------------------------------------------------------------------#
 
+#pod =attr secret_key (required)
+#pod
+#pod This is used to secure the session data.  The encryption and message
+#pod authentication key is derived from this using a one-way function.  Changing it
+#pod will invalidate all sessions.
+#pod
+#pod =cut
 
 has secret_key => (
     is       => 'ro',
@@ -32,6 +39,14 @@ has secret_key => (
     required => 1,
 );
 
+#pod =attr default_duration
+#pod
+#pod Number of seconds for which the session may be considered valid.  If an
+#pod expiration is not provided to C<encode>, this is used instead to expire the
+#pod session after a period of time.  It is unset by default, meaning that session
+#pod expiration is not capped.
+#pod
+#pod =cut
 
 has default_duration => (
     is        => 'ro',
@@ -39,6 +54,85 @@ has default_duration => (
     predicate => 1,
 );
 
+#pod =attr old_secrets
+#pod
+#pod An optional array reference of strings containing old secret keys no longer
+#pod used for encryption but still supported for decrypting session data.
+#pod
+#pod =cut
+
+has old_secrets => (
+    is  => 'ro',
+    isa => ArrayRef [Str],
+);
+
+#pod =attr separator
+#pod
+#pod A character used to separate fields.  It defaults to C<~>.
+#pod
+#pod =cut
+
+has separator => (
+    is      => 'ro',
+    isa     => Str,
+    default => '~',
+);
+
+#pod =attr sereal_encoder_options
+#pod
+#pod A hash reference with constructor arguments for L<Sereal::Encoder>. Defaults
+#pod to C<< { snappy => 1, croak_on_bless => 1 } >>.
+#pod
+#pod =cut
+
+has sereal_encoder_options => (
+    is      => 'ro',
+    isa     => HashRef,
+    default => sub { { snappy => 1, croak_on_bless => 1 } },
+);
+
+#pod =attr sereal_decoder_options
+#pod
+#pod A hash reference with constructor arguments for L<Sereal::Decoder>. Defaults
+#pod to C<< { refuse_objects => 1, validate_utf8  => 1 } >>.
+#pod
+#pod =cut
+
+has sereal_decoder_options => (
+    is      => 'ro',
+    isa     => HashRef,
+    default => sub { { refuse_objects => 1, validate_utf8 => 1 } },
+);
+
+#pod =attr transport_encoder
+#pod
+#pod A code reference to convert binary data elements (the encrypted data and the
+#pod MAC) into a transport-safe form.  Defaults to
+#pod L<MIME::Base64::encode_base64url|MIME::Base64>.  The output must not include
+#pod the C<separator> attribute used to delimit fields.
+#pod
+#pod =cut
+
+has transport_encoder => (
+    is      => 'ro',
+    isa     => CodeRef,
+    default => sub { \&MIME::Base64::encode_base64url },
+);
+
+#pod =attr transport_decoder
+#pod
+#pod A code reference to extract binary data (the encrypted data and the
+#pod MAC) from a transport-safe form.  It must be the complement to C<encode>.
+#pod Defaults to L<MIME::Base64::decode_base64url|MIME::Base64>.
+#pod
+#pod =cut
+
+has transport_decoder => (
+    is      => 'ro',
+    isa     => CodeRef,
+    default => sub { \&MIME::Base64::decode_base64url },
+);
+
 has _encoder => (
     is      => 'lazy',
     isa     => InstanceOf ['Sereal::Encoder'],
@@ -47,12 +141,7 @@ has _encoder => (
 
 sub _build__encoder {
     my ($self) = @_;
-    return Sereal::Encoder->new(
-        {
-            snappy         => 1,
-            croak_on_bless => 1,
-        }
-    );
+    return Sereal::Encoder->new( $self->sereal_encoder_options );
 }
 
 has _decoder => (
@@ -63,12 +152,7 @@ has _decoder => (
 
 sub _build__decoder {
     my ($self) = @_;
-    return Sereal::Decoder->new(
-        {
-            refuse_objects => 1,
-            validate_utf8  => 1,
-        }
-    );
+    return Sereal::Decoder->new( $self->sereal_decoder_options );
 }
 
 has _rng => (
@@ -82,10 +166,32 @@ sub _build__rng {
     return Math::Random::ISAAC::XS->new( map { unpack( "N", urandom(4) ) } 1 .. 256 );
 }
 
+#pod =method encode
+#pod
+#pod   my $string = $store->encode( $data, $expires );
+#pod
+#pod The C<$data> argument should be a reference to a data structure.  By default,
+#pod it must not contain objects.  (See L</Objects not stored by default> for
+#pod rationale and alternatives.) If it is undefined, an empty hash reference will
+#pod be encoded instead.
+#pod
+#pod The optional C<$expires> argument should be the session expiration time
+#pod expressed as epoch seconds.  If the C<$expires> time is in the past, the
+#pod C<$data> argument is cleared and an empty hash reference is encoded and returned.
+#pod If no C<$expires> is given, then if the C<default_duration> attribute is set, it
+#pod will be used to calculate an expiration time.
+#pod
+#pod The method returns a string that securely encodes the session data.  All binary
+#pod components are protected via the L</transport_encoder> attribute.
+#pod
+#pod An exception is thrown on any errors.
+#pod
+#pod =cut
 
 sub encode {
     my ( $self, $data, $expires ) = @_;
     $data = {} unless defined $data;
+    my $sep = $self->separator;
 
     # If expiration is set, we want to check it and possibly clear data;
     # if not set, we might add an expiration based on default_duration
@@ -103,38 +209,66 @@ sub encode {
     my $cbc = Crypt::CBC->new( -key => $key, -cipher => 'Rijndael' );
     my ( $ciphertext, $mac );
     eval {
-        $ciphertext = encode_base64url( $cbc->encrypt( $self->_freeze($data) ) );
-        $mac = encode_base64url( hmac_sha256( "$expires~$ciphertext", $key ) );
+        $ciphertext = $self->transport_encoder->( $cbc->encrypt( $self->_freeze($data) ) );
+        $mac = $self->transport_encoder->( hmac_sha256( "$expires$sep$ciphertext", $key ) );
     };
     croak "Encoding error: $@" if $@;
 
-    return join( "~", $salt, $expires, $ciphertext, $mac );
+    return join( $sep, $salt, $expires, $ciphertext, $mac );
 }
 
+#pod =method decode
+#pod
+#pod   my $data = $store->decode( $string );
+#pod
+#pod The C<$string> argument must be the output of C<encode>.
+#pod
+#pod If the message integrity check fails or if expiration exists and is in
+#pod the past, the method returns undef or an empty list (depending on context).
+#pod
+#pod An exception is thrown on any errors.
+#pod
+#pod =cut
 
 sub decode {
     my ( $self, $string ) = @_;
     return unless length $string;
 
     # Having a string implies at least salt; expires is optional; rest required
-    my ( $salt, $expires, $ciphertext, $mac ) = split qr/~/, $string;
+    my $sep = $self->separator;
+    my ( $salt, $expires, $ciphertext, $mac ) = split qr/\Q$sep\E/, $string;
     return unless defined($ciphertext) && length($ciphertext);
     return unless defined($mac)        && length($mac);
 
-    # Check MAC integrity and expiration
-    my $key = hmac_sha256( $salt, $self->secret_key );
-    my $check_mac =
-      eval { encode_base64url( hmac_sha256( "$expires~$ciphertext", $key ) ) };
-    return
-         unless defined($check_mac)
-      && length($check_mac)
-      && equals( $check_mac, $mac ); # constant time comparision
+    # Try to decode against all known secret keys
+    my @secrets = ( $self->secret_key, @{ $self->old_secrets || [] } );
+    my $key;
+    CHECK: foreach my $secret (@secrets) {
+        $key = hmac_sha256( $salt, $secret );
+        my $check_mac = eval {
+            $self->transport_encoder->( hmac_sha256( "$expires$sep$ciphertext", $key ) );
+        };
+        last CHECK
+          if (
+               defined($check_mac)
+            && length($check_mac)
+            && equals( $check_mac, $mac ) # constant time comparison
+          );
+        undef $key;
+    }
+
+    # Check MAC integrity
+    return unless defined($key);
+
+    # Check expiration
     return if length($expires) && $expires < time;
 
     # Decrypt and deserialize the data
     my $cbc = Crypt::CBC->new( -key => $key, -cipher => 'Rijndael' );
     my $data;
-    eval { $self->_thaw( $cbc->decrypt( decode_base64url($ciphertext) ), $data ) };
+    eval {
+        $self->_thaw( $cbc->decrypt( $self->transport_decoder->($ciphertext) ), $data );
+    };
     croak "Decoding error: $@" if $@;
 
     return $data;
@@ -149,7 +283,7 @@ __END__
 
 =pod
 
-=encoding utf-8
+=encoding UTF-8
 
 =head1 NAME
 
@@ -157,7 +291,7 @@ Session::Storage::Secure - Encrypted, expiring, compressed, serialized session d
 
 =head1 VERSION
 
-version 0.007
+version 0.010
 
 =head1 SYNOPSIS
 
@@ -218,16 +352,16 @@ may happen prior to the application server), we omit C<ssl-key>.  This
 weakens protection against replay attacks if an attacker can break
 the SSL session key and intercept messages.
 
-Using C<user> and C<expiration> to generate the encryption and MAC keys
-was a method proposed to ensure unique keys to defeat volume attacks
-against the secret key.  Rather than rely on those for uniqueness, which
-also reveals user name and prohibits anonymous sessions, we replace
-C<user> with a cryptographically-strong random salt value.
+Using C<user> and C<expiration> to generate the encryption and MAC keys was a
+method proposed to ensure unique keys to defeat volume attacks against the
+secret key.  Rather than rely on those for uniqueness (with the unfortunate
+side effect of revealing user names and prohibiting anonymous sessions), we
+replace C<user> with a cryptographically-strong random salt value.
 
-The original proposal also calculates a MAC based on unencrypted
-data.  We instead calculate the MAC based on the encrypted data.  This
-avoids the extra step of decrypting invalid messages.  Because the
-salt is already encoded into the key, we omit it from the MAC input.
+The original proposal also calculates a MAC based on unencrypted data.  We
+instead calculate the MAC based on the encrypted data.  This avoids an extra
+step decrypting invalid messages.  Because the salt is already encoded into the
+key, we omit it from the MAC input.
 
 Therefore, the session storage protocol used by this module is as follows:
 
@@ -246,7 +380,7 @@ L<Crypt::URandom>.
 
 The HMAC algorithm is C<hmac_sha256> from L<Digest::SHA>.  Encryption
 is done by L<Crypt::CBC> using L<Crypt::Rijndael> (AES).  The ciphertext and
-MAC's in the cookie are Base64 encoded by L<MIME::Base64>.
+MAC's in the cookie are Base64 encoded by L<MIME::Base64> by default.
 
 During session retrieval, if the MAC does not authenticate or if the expiration
 is set and in the past, the session will be discarded.
@@ -263,18 +397,51 @@ will invalidate all sessions.
 
 Number of seconds for which the session may be considered valid.  If an
 expiration is not provided to C<encode>, this is used instead to expire the
-session after a period of time.  It is unset by default, meaning that sessions
+session after a period of time.  It is unset by default, meaning that session
 expiration is not capped.
 
+=head2 old_secrets
+
+An optional array reference of strings containing old secret keys no longer
+used for encryption but still supported for decrypting session data.
+
+=head2 separator
+
+A character used to separate fields.  It defaults to C<~>.
+
+=head2 sereal_encoder_options
+
+A hash reference with constructor arguments for L<Sereal::Encoder>. Defaults
+to C<< { snappy => 1, croak_on_bless => 1 } >>.
+
+=head2 sereal_decoder_options
+
+A hash reference with constructor arguments for L<Sereal::Decoder>. Defaults
+to C<< { refuse_objects => 1, validate_utf8  => 1 } >>.
+
+=head2 transport_encoder
+
+A code reference to convert binary data elements (the encrypted data and the
+MAC) into a transport-safe form.  Defaults to
+L<MIME::Base64::encode_base64url|MIME::Base64>.  The output must not include
+the C<separator> attribute used to delimit fields.
+
+=head2 transport_decoder
+
+A code reference to extract binary data (the encrypted data and the
+MAC) from a transport-safe form.  It must be the complement to C<encode>.
+Defaults to L<MIME::Base64::decode_base64url|MIME::Base64>.
+
 =head1 METHODS
 
 =head2 encode
 
   my $string = $store->encode( $data, $expires );
 
-The C<$data> argument should be a reference to a data structure.  It must not
-contain objects. If it is undefined, an empty hash reference will be encoded
-instead.
+The C<$data> argument should be a reference to a data structure.  By default,
+it must not contain objects.  (See L</Objects not stored by default> for
+rationale and alternatives.) If it is undefined, an empty hash reference will
+be encoded instead.
 
 The optional C<$expires> argument should be the session expiration time
 expressed as epoch seconds.  If the C<$expires> time is in the past, the
@@ -283,7 +450,7 @@ If no C<$expires> is given, then if the C<default_duration> attribute is set, it
 will be used to calculate an expiration time.
 
 The method returns a string that securely encodes the session data.  All binary
-components are base64 encoded.
+components are protected via the L</transport_encoder> attribute.
 
 An exception is thrown on any errors.
 
@@ -298,15 +465,17 @@ the past, the method returns undef or an empty list (depending on context).
 
 An exception is thrown on any errors.
 
-=for Pod::Coverage method_names_here
+=for Pod::Coverage has_default_duration
 
 =head1 LIMITATIONS
 
 =head2 Secret key
 
 You must protect the secret key, of course.  Rekeying periodically would
-improve security.  Rekeying also invalidates all existing sessions.  In a
-multi-node application, all nodes must share the same secret key.
+improve security.  Rekeying also invalidates all existing sessions unless the
+C<old_secrets> attribute contains old encryption keys still used for
+decryption.  In a multi-node application, all nodes must share the same secret
+key.
 
 =head2 Session size
 
@@ -322,12 +491,18 @@ However, nothing prevents the encoded output from exceeding 4k.  Applications
 must check for this condition and handle it appropriately with an error or
 by splitting the value across multiple cookies.
 
-=head2 Objects not stored
+=head2 Objects not stored by default
+
+The default Sereal options do not allow storing objects because object
+deserialization can have undesirable side effects, including potentially fatal
+errors if a class is not available at deserialization time or if internal class
+structures changed from when the session data was serialized to when it was
+deserialized.  Applications should take steps to deflate/inflate objects before
+storing them in session data.
 
-Session data may not include objects.  Sereal is configured to die if objects
-are encountered because object serialization/deserialiation can have
-undesirable side effects.  Applications should take steps to deflate/inflate
-objects before storing them in session data.
+Alternatively, applications can change L</sereal_encoder_options> and
+L</sereal_decoder_options> to allow object serialization or other object
+transformations and accept the risks of doing so.
 
 =head1 SECURITY
 
@@ -481,7 +656,7 @@ L<Data::Serializer>
 =head2 Bugs / Feature Requests
 
 Please report any bugs or feature requests through the issue tracker
-at L<https://github.com/dagolden/session-storage-secure/issues>.
+at L<https://github.com/dagolden/Session-Storage-Secure/issues>.
 You will be notified automatically of any progress on your issue.
 
 =head2 Source Code
@@ -489,14 +664,18 @@ You will be notified automatically of any progress on your issue.
 This is open source software.  The code repository is available for
 public review and contribution under the terms of the license.
 
-L<https://github.com/dagolden/session-storage-secure>
+L<https://github.com/dagolden/Session-Storage-Secure>
 
-  git clone git://github.com/dagolden/session-storage-secure.git
+  git clone https://github.com/dagolden/Session-Storage-Secure.git
 
 =head1 AUTHOR
 
 David Golden <dagolden@cpan.org>
 
+=head1 CONTRIBUTOR
+
+Tom Hukins <tom@eborcom.com>
+
 =head1 COPYRIGHT AND LICENSE
 
 This software is Copyright (c) 2013 by David Golden.
@@ -7,6 +7,9 @@ allow = $@ $!
 [TestingAndDebugging::ProhibitNoStrict]
 allow = refs
 
+[Variables::ProhibitEvilVariables]
+variables = $DB::single
+
 # Turn these off
 [-BuiltinFunctions::ProhibitStringyEval]
 [-ControlStructures::ProhibitPostfixControls]
@@ -16,6 +19,7 @@ allow = refs
 [-References::ProhibitDoubleSigils]
 [-RegularExpressions::RequireExtendedFormatting]
 [-InputOutput::ProhibitTwoArgOpen]
+[-Modules::ProhibitEvilModules]
 
 # Turn this on
 [Lax::ProhibitStringyEval::ExceptForRequire]
@@ -1,74 +0,0 @@
-#!perl
-
-use strict;
-use warnings;
-
-use Test::More;
-
-
-
-use File::Find;
-use File::Temp qw{ tempdir };
-
-my @modules;
-find(
-  sub {
-    return if $File::Find::name !~ /\.pm\z/;
-    my $found = $File::Find::name;
-    $found =~ s{^lib/}{};
-    $found =~ s{[/\\]}{::}g;
-    $found =~ s/\.pm$//;
-    # nothing to skip
-    push @modules, $found;
-  },
-  'lib',
-);
-
-sub _find_scripts {
-    my $dir = shift @_;
-
-    my @found_scripts = ();
-    find(
-      sub {
-        return unless -f;
-        my $found = $File::Find::name;
-        # nothing to skip
-        open my $FH, '<', $_ or do {
-          note( "Unable to open $found in ( $! ), skipping" );
-          return;
-        };
-        my $shebang = <$FH>;
-        return unless $shebang =~ /^#!.*?\bperl\b\s*$/;
-        push @found_scripts, $found;
-      },
-      $dir,
-    );
-
-    return @found_scripts;
-}
-
-my @scripts;
-do { push @scripts, _find_scripts($_) if -d $_ }
-    for qw{ bin script scripts };
-
-my $plan = scalar(@modules) + scalar(@scripts);
-$plan ? (plan tests => $plan) : (plan skip_all => "no tests to run");
-
-{
-    # fake home for cpan-testers
-     local $ENV{HOME} = tempdir( CLEANUP => 1 );
-
-    like( qx{ $^X -Ilib -e "require $_; print '$_ ok'" }, qr/^\s*$_ ok/s, "$_ loaded ok" )
-        for sort @modules;
-
-    SKIP: {
-        eval "use Test::Script 1.05; 1;";
-        skip "Test::Script needed to test script compilation", scalar(@scripts) if $@;
-        foreach my $file ( @scripts ) {
-            my $script = $file;
-            $script =~ s!.*/!!;
-            script_compiles( $file, "$script script compiles" );
-        }
-    }
-
-}
@@ -3,54 +3,138 @@
 use strict;
 use warnings;
 
+# This test was generated by Dist::Zilla::Plugin::Test::ReportPrereqs 0.013
+
 use Test::More tests => 1;
 
 use ExtUtils::MakeMaker;
 use File::Spec::Functions;
 use List::Util qw/max/;
+use version;
+
+# hide optional CPAN::Meta modules from prereq scanner
+# and check if they are available
+my $cpan_meta = "CPAN::Meta";
+my $cpan_meta_req = "CPAN::Meta::Requirements";
+my $HAS_CPAN_META = eval "require $cpan_meta"; ## no critic
+my $HAS_CPAN_META_REQ = eval "require $cpan_meta_req; $cpan_meta_req->VERSION('2.120900')";
+
+# Verify requirements?
+my $DO_VERIFY_PREREQS = 1;
+
+sub _merge_requires {
+    my ($collector, $prereqs) = @_;
+    for my $phase ( qw/configure build test runtime develop/ ) {
+        next unless exists $prereqs->{$phase};
+        if ( my $req = $prereqs->{$phase}{'requires'} ) {
+            my $cmr = CPAN::Meta::Requirements->from_string_hash( $req );
+            $collector->add_requirements( $cmr );
+        }
+    }
+}
+
+my %include = map {; $_ => 1 } qw(
 
-my @modules = qw(
-  Carp
-  Crypt::CBC
-  Crypt::Rijndael
-  Crypt::URandom
-  Digest::SHA
-  ExtUtils::MakeMaker
-  File::Find
-  File::Spec::Functions
-  File::Temp
-  List::Util
-  MIME::Base64
-  Math::Random::ISAAC::XS
-  Moo
-  MooX::Types::MooseLike::Base
-  Sereal::Decoder
-  Sereal::Encoder
-  String::Compare::ConstantTime
-  Test::Deep
-  Test::Fatal
-  Test::More
-  Test::Tolerant
-  namespace::clean
-  perl
-  strict
-  warnings
 );
 
-# replace modules with dynamic results from MYMETA.json if we can
-# (hide CPAN::Meta from prereq scanner)
-my $cpan_meta = "CPAN::Meta";
-if ( -f "MYMETA.json" && eval "require $cpan_meta" ) { ## no critic
-  if ( my $meta = eval { CPAN::Meta->load_file("MYMETA.json") } ) {
-    my $prereqs = $meta->prereqs;
-    delete $prereqs->{develop};
-    my %uniq = map {$_ => 1} map { keys %$_ } map { values %$_ } values %$prereqs;
-    $uniq{$_} = 1 for @modules; # don't lose any static ones
-    @modules = sort keys %uniq;
+my %exclude = map {; $_ => 1 } qw(
+
+);
+
+# Add static prereqs to the included modules list
+my $static_prereqs = do { my $x = {
+       'configure' => {
+                        'requires' => {
+                                        'ExtUtils::MakeMaker' => '6.17'
+                                      }
+                      },
+       'develop' => {
+                      'requires' => {
+                                      'Dist::Zilla' => '5.015',
+                                      'Dist::Zilla::PluginBundle::DAGOLDEN' => '0.060',
+                                      'File::Spec' => '0',
+                                      'File::Temp' => '0',
+                                      'IO::Handle' => '0',
+                                      'IPC::Open3' => '0',
+                                      'Pod::Coverage::TrustPod' => '0',
+                                      'Test::CPAN::Meta' => '0',
+                                      'Test::More' => '0',
+                                      'Test::Pod' => '1.41',
+                                      'Test::Pod::Coverage' => '1.08'
+                                    }
+                    },
+       'runtime' => {
+                      'requires' => {
+                                      'Carp' => '0',
+                                      'Crypt::CBC' => '0',
+                                      'Crypt::Rijndael' => '0',
+                                      'Crypt::URandom' => '0',
+                                      'Digest::SHA' => '0',
+                                      'MIME::Base64' => '3.12',
+                                      'Math::Random::ISAAC::XS' => '0',
+                                      'Moo' => '0',
+                                      'MooX::Types::MooseLike::Base' => '0.16',
+                                      'Sereal::Decoder' => '0',
+                                      'Sereal::Encoder' => '0',
+                                      'String::Compare::ConstantTime' => '0',
+                                      'namespace::clean' => '0',
+                                      'perl' => '5.008001',
+                                      'strict' => '0',
+                                      'warnings' => '0'
+                                    }
+                    },
+       'test' => {
+                   'recommends' => {
+                                     'CPAN::Meta' => '0',
+                                     'CPAN::Meta::Requirements' => '2.120900'
+                                   },
+                   'requires' => {
+                                   'ExtUtils::MakeMaker' => '0',
+                                   'File::Spec::Functions' => '0',
+                                   'List::Util' => '0',
+                                   'Test::Deep' => '0',
+                                   'Test::Fatal' => '0',
+                                   'Test::More' => '0.96',
+                                   'Test::Tolerant' => '0',
+                                   'version' => '0'
+                                 }
+                 }
+     };
+  $x;
+ };
+
+delete $static_prereqs->{develop} if not $ENV{AUTHOR_TESTING};
+$include{$_} = 1 for map { keys %$_ } map { values %$_ } values %$static_prereqs;
+
+# Merge requirements for major phases (if we can)
+my $all_requires;
+if ( $DO_VERIFY_PREREQS && $HAS_CPAN_META_REQ ) {
+    $all_requires = $cpan_meta_req->new;
+    _merge_requires($all_requires, $static_prereqs);
+}
+
+
+# Add dynamic prereqs to the included modules list (if we can)
+my ($source) = grep { -f } 'MYMETA.json', 'MYMETA.yml';
+if ( $source && $HAS_CPAN_META ) {
+  if ( my $meta = eval { CPAN::Meta->load_file($source) } ) {
+    my $dynamic_prereqs = $meta->prereqs;
+    delete $dynamic_prereqs->{develop} if not $ENV{AUTHOR_TESTING};
+    $include{$_} = 1 for map { keys %$_ } map { values %$_ } values %$dynamic_prereqs;
+
+    if ( $DO_VERIFY_PREREQS && $HAS_CPAN_META_REQ ) {
+        _merge_requires($all_requires, $dynamic_prereqs);
+    }
   }
 }
+else {
+  $source = 'static metadata';
+}
 
+my @modules = sort grep { ! $exclude{$_} } keys %include;
 my @reports = [qw/Version Module/];
+my @dep_errors;
+my $req_hash = defined($all_requires) ? $all_requires->as_string_hash : {};
 
 for my $mod ( @modules ) {
   next if $mod eq 'perl';
@@ -62,9 +146,29 @@ for my $mod ( @modules ) {
     my $ver = MM->parse_version( catfile($prefix, $file) );
     $ver = "undef" unless defined $ver; # Newer MM should do this anyway
     push @reports, [$ver, $mod];
+
+    if ( $DO_VERIFY_PREREQS && $all_requires ) {
+      my $req = $req_hash->{$mod};
+      if ( defined $req && length $req ) {
+        if ( ! defined eval { version->parse($ver) } ) {
+          push @dep_errors, "$mod version '$ver' cannot be parsed (version '$req' required)";
+        }
+        elsif ( ! $all_requires->accepts_module( $mod => $ver ) ) {
+          push @dep_errors, "$mod version '$ver' is not in required range '$req'";
+        }
+      }
+    }
+
   }
   else {
     push @reports, ["missing", $mod];
+
+    if ( $DO_VERIFY_PREREQS && $all_requires ) {
+      my $req = $req_hash->{$mod};
+      if ( defined $req && length $req ) {
+        push @dep_errors, "$mod is not installed (version '$req' required)";
+      }
+    }
   }
 }
 
@@ -72,9 +176,19 @@ if ( @reports ) {
   my $vl = max map { length $_->[0] } @reports;
   my $ml = max map { length $_->[1] } @reports;
   splice @reports, 1, 0, ["-" x $vl, "-" x $ml];
-  diag "Prerequisite Report:\n", map {sprintf("  %*s %*s\n",$vl,$_->[0],-$ml,$_->[1])} @reports;
+  diag "\nVersions for all modules listed in $source (including optional ones):\n",
+    map {sprintf("  %*s %*s\n",$vl,$_->[0],-$ml,$_->[1])} @reports;
+}
+
+if ( @dep_errors ) {
+  diag join("\n",
+    "\n*** WARNING WARNING WARNING WARNING WARNING WARNING WARNING WARNING ***\n",
+    "The following REQUIRED prerequisites were not satisfied:\n",
+    @dep_errors,
+    "\n"
+  );
 }
 
 pass;
 
-# vim: ts=2 sts=2 sw=2 et:
+# vim: ts=4 sts=4 sw=4 et:
@@ -4,174 +4,207 @@ use warnings;
 use Test::More 0.96;
 use Test::Deep qw/!blessed/;
 use Test::Tolerant;
-use MIME::Base64 qw/encode_base64url/;
+use MIME::Base64 qw/encode_base64url decode_base64url/;
 
 use Session::Storage::Secure;
 
 my $data = {
-  foo => 'bar',
-  baz => 'bam',
+    foo => 'bar',
+    baz => 'bam',
 };
 
 my $secret = "serenade viscount secretary frail";
 
 sub _gen_store {
-  my ($config) = @_;
-  local $Test::Builder::Level = $Test::Builder::Level + 1;
-  my $store = Session::Storage::Secure->new(
-    secret_key => $secret,
-    %{ $config || {} },
-  );
-  ok( $store, "created a storage object" );
-  return $store;
+    my ($config) = @_;
+    local $Test::Builder::Level = $Test::Builder::Level + 1;
+    my $store = Session::Storage::Secure->new(
+        secret_key => $secret,
+        %{ $config || {} },
+    );
+    ok( $store, "created a storage object" );
+    return $store;
 }
 
 sub _replace {
-  my ( $string, $index, $value ) = @_;
-  my @parts = split qr/~/, $string;
-  $parts[$index] = $value;
-  return join "~", @parts;
+    my ( $string, $index, $value ) = @_;
+    my @parts = split qr/~/, $string;
+    $parts[$index] = $value;
+    return join "~", @parts;
 }
 
 subtest "defaults" => sub {
-  my $store = _gen_store;
-
-  my $encoded = $store->encode($data);
-  like( $encoded, qr/^\d+~~/, "no expiration set" );
-
-  my $decoded = $store->decode($encoded);
-  cmp_deeply( $decoded, $data, "roundtrip" );
+    my $store = _gen_store;
+
+    my $encoded = $store->encode($data);
+    like( $encoded, qr/^\d+~~/, "no expiration set" );
+
+    my $decoded = $store->decode($encoded);
+    cmp_deeply( $decoded, $data, "roundtrip" );
+
+    my $store2 = _gen_store(
+        {
+            secret_key  => "second secret",
+            old_secrets => [$secret],
+        }
+    );
+    my $decoded2 = $store2->decode($encoded);
+    cmp_deeply( $decoded2, $data, "roundtrip with old secret" );
+
+    my $store3 = _gen_store(
+        {
+            secret_key  => "second secret",
+            old_secrets => [ "another secret", $secret ],
+        }
+    );
+    my $decoded3 = $store3->decode($encoded);
+    cmp_deeply( $decoded3, $data, "roundtrip with old secret" );
+
+    my $store4 = _gen_store(
+        {
+            secret_key  => "second secret",
+            old_secrets => [ $secret, "another secret" ],
+        }
+    );
+    my $decoded4 = $store4->decode($encoded);
+    cmp_deeply( $decoded4, $data, "roundtrip with old secret" );
 };
 
 subtest "no data" => sub {
-  my $store = _gen_store;
+    my $store = _gen_store;
 
-  my $encoded = $store->encode();
-  like( $encoded, qr/^\d+~~/, "no expiration set" );
+    my $encoded = $store->encode();
+    like( $encoded, qr/^\d+~~/, "no expiration set" );
 
-  my $decoded = $store->decode($encoded);
-  cmp_deeply( $decoded, {}, "undefined data treated as empty hashref" );
+    my $decoded = $store->decode($encoded);
+    cmp_deeply( $decoded, {}, "undefined data treated as empty hashref" );
 };
 
 subtest "future expiration" => sub {
-  my $store   = _gen_store;
-  my $expires = time + 3600;
+    my $store   = _gen_store;
+    my $expires = time + 3600;
 
-  my $encoded = $store->encode( $data, $expires );
-  my ($got) = $encoded =~ m/~(\d+)~/;
-  is( $got, $expires, "expiration timestamp correct" );
+    my $encoded = $store->encode( $data, $expires );
+    my ($got) = $encoded =~ m/~(\d+)~/;
+    is( $got, $expires, "expiration timestamp correct" );
 
-  my $decoded = $store->decode($encoded);
-  cmp_deeply( $decoded, $data, "roundtrip" );
+    my $decoded = $store->decode($encoded);
+    cmp_deeply( $decoded, $data, "roundtrip" );
 };
 
 subtest "past expiration" => sub {
-  my $store   = _gen_store;
-  my $expires = time - 3600;
+    my $store   = _gen_store;
+    my $expires = time - 3600;
 
-  my $encoded = $store->encode( $data, $expires );
-  my ($got) = $encoded =~ m/~(\d+)~/;
-  is( $got, $expires, "expiration timestamp correct" );
+    my $encoded = $store->encode( $data, $expires );
+    my ($got) = $encoded =~ m/~(\d+)~/;
+    is( $got, $expires, "expiration timestamp correct" );
 
-  my $decoded = $store->decode($encoded);
-  is( $decoded, undef, "expired data decodes to undef" );
+    my $decoded = $store->decode($encoded);
+    is( $decoded, undef, "expired data decodes to undef" );
 };
 
 subtest "future default duration" => sub {
-  my $store = _gen_store( { default_duration => 3600 } );
+    my $store = _gen_store( { default_duration => 3600 } );
 
-  my $encoded = $store->encode($data);
-  my ($got) = $encoded =~ m/~(\d+)~/;
-  is_tol( $got - time, [qw/3550 to 3605/], "expiration in correct range" );
+    my $encoded = $store->encode($data);
+    my ($got) = $encoded =~ m/~(\d+)~/;
+    is_tol( $got - time, [qw/3550 to 3605/], "expiration in correct range" );
 
-  my $decoded = $store->decode($encoded);
-  cmp_deeply( $decoded, $data, "roundtrip" );
+    my $decoded = $store->decode($encoded);
+    cmp_deeply( $decoded, $data, "roundtrip" );
 };
 
 subtest "past default duration" => sub {
-  my $store = _gen_store( { default_duration => -3600 } );
+    my $store = _gen_store( { default_duration => -3600 } );
 
-  my $encoded = $store->encode($data);
-  my ($got) = $encoded =~ m/~(\d+)~/;
-  is_tol( $got - time, [qw/-3605 to -3550/], "expiration in correct range" );
+    my $encoded = $store->encode($data);
+    my ($got) = $encoded =~ m/~(\d+)~/;
+    is_tol( $got - time, [qw/-3605 to -3550/], "expiration in correct range" );
 
-  my $decoded = $store->decode($encoded);
-  is( $decoded, undef, "expired data decodes to undef" );
+    my $decoded = $store->decode($encoded);
+    is( $decoded, undef, "expired data decodes to undef" );
 };
 
 subtest "changed secret key" => sub {
-  my $store = _gen_store;
+    my $store = _gen_store;
+
+    my $encoded = $store->encode($data);
 
-  my $encoded = $store->encode($data);
+    my $store2 = _gen_store( { secret_key => "unpopular deface inflamed belay" } );
+    my $decoded = $store2->decode($encoded);
+    is( $decoded, undef, "changed key decodes to undef" );
 
-  my $store2 = _gen_store( { secret_key => "unpopular deface inflamed belay" } );
-  my $decoded = $store2->decode($encoded);
-  is( $decoded, undef, "changed key decodes to undef" );
+    my $store3 = _gen_store(
+        {
+            secret_key  => "second secret key",
+            old_secrets => [ "something else", "another secret" ],
+        }
+    );
+    is( $store3->decode($encoded), undef, "No matching keys decodes to undef" );
 };
 
 subtest "modified salt" => sub {
-  my $store = _gen_store( { default_duration => 3600 } );
+    my $store = _gen_store( { default_duration => 3600 } );
 
-  my $encoded = _replace( $store->encode($data), 0, int( rand() * 2**31 ) );
+    my $encoded = _replace( $store->encode($data), 0, int( rand() * 2**31 ) );
 
-  my $decoded = $store->decode($encoded);
-  is( $decoded, undef, "changed salt decodes to undef" );
+    my $decoded = $store->decode($encoded);
+    is( $decoded, undef, "changed salt decodes to undef" );
 };
 
 subtest "modified expiration" => sub {
-  my $store = _gen_store( { default_duration => 3600 } );
+    my $store = _gen_store( { default_duration => 3600 } );
 
-  my $encoded = _replace( $store->encode($data), 1, time + 86400 );
+    my $encoded = _replace( $store->encode($data), 1, time + 86400 );
 
-  my $decoded = $store->decode($encoded);
-  is( $decoded, undef, "changed expiration decodes to undef" );
+    my $decoded = $store->decode($encoded);
+    is( $decoded, undef, "changed expiration decodes to undef" );
 };
 
 subtest "modified ciphertext" => sub {
-  my $store = _gen_store( { default_duration => 3600 } );
+    my $store = _gen_store( { default_duration => 3600 } );
 
-  my $encoded =
-    _replace( $store->encode($data), 2,
-    encode_base64url( pack( "l*", rand, rand, rand, rand ) ) );
+    my $encoded = _replace( $store->encode($data),
+        2, encode_base64url( pack( "l*", rand, rand, rand, rand ) ) );
 
-  my $decoded = $store->decode($encoded);
-  is( $decoded, undef, "changed ciphertext decodes to undef" );
+    my $decoded = $store->decode($encoded);
+    is( $decoded, undef, "changed ciphertext decodes to undef" );
 };
 
 subtest "modified mac" => sub {
-  my $store = _gen_store( { default_duration => 3600 } );
+    my $store = _gen_store( { default_duration => 3600 } );
 
-  my $encoded =
-    _replace( $store->encode($data), 3,
-    encode_base64url( pack( "l*", rand, rand, rand, rand ) ) );
+    my $encoded = _replace( $store->encode($data),
+        3, encode_base64url( pack( "l*", rand, rand, rand, rand ) ) );
 
-  my $decoded = $store->decode($encoded);
-  is( $decoded, undef, "changed mac decodes to undef" );
+    my $decoded = $store->decode($encoded);
+    is( $decoded, undef, "changed mac decodes to undef" );
 };
 
 subtest "truncated mac" => sub {
-  my $store = _gen_store( { default_duration => 3600 } );
+    my $store = _gen_store( { default_duration => 3600 } );
 
-  my $encoded = _replace( $store->encode($data), 3, "" );
+    my $encoded = _replace( $store->encode($data), 3, "" );
 
-  my $decoded = $store->decode($encoded);
-  is( $decoded, undef, "truncated mac decodes to undef" );
+    my $decoded = $store->decode($encoded);
+    is( $decoded, undef, "truncated mac decodes to undef" );
 };
 
 subtest "garbage encoded" => sub {
-  my $store = _gen_store( { default_duration => 3600 } );
+    my $store = _gen_store( { default_duration => 3600 } );
 
-  my $encoded = encode_base64url( pack( "l*", rand, rand, rand, rand ) );
+    my $encoded = encode_base64url( pack( "l*", rand, rand, rand, rand ) );
 
-  my $decoded = $store->decode($encoded);
-  is( $decoded, undef, "garbage decodes to undef" );
+    my $decoded = $store->decode($encoded);
+    is( $decoded, undef, "garbage decodes to undef" );
 };
 
 subtest "empty encoded" => sub {
-  my $store = _gen_store( { default_duration => 3600 } );
+    my $store = _gen_store( { default_duration => 3600 } );
 
-  my $decoded = $store->decode('');
-  is( $decoded, undef, "empty string decodes to undef" );
+    my $decoded = $store->decode('');
+    is( $decoded, undef, "empty string decodes to undef" );
 };
 
 done_testing;
@@ -0,0 +1,101 @@
+use 5.008001;
+use strict;
+use warnings;
+use Test::More 0.96;
+use Test::Deep qw/!blessed/;
+use Test::Tolerant;
+use MIME::Base64 qw/encode_base64url decode_base64url/;
+
+use Session::Storage::Secure;
+
+my $data = {
+    foo => 'bar',
+    baz => 'bam',
+};
+
+my $secret = "serenade viscount secretary frail";
+
+my $custom_enc = sub {
+    return "~" . reverse encode_base64url( $_[0] );
+};
+
+my $custom_dec = sub {
+    my $string = shift;
+    substr( $string, 0, 1, '' );
+    return decode_base64url( scalar reverse $string );
+};
+
+sub _gen_store {
+    my ($config) = @_;
+    local $Test::Builder::Level = $Test::Builder::Level + 1;
+    my $store = Session::Storage::Secure->new(
+        secret_key => $secret,
+        %{ $config || {} },
+    );
+    ok( $store, "created a storage object" );
+    return $store;
+}
+
+subtest "custom separator" => sub {
+    my $store = _gen_store( { separator => ":", } );
+
+    my $encoded = $store->encode($data);
+    my $decoded = eval { $store->decode($encoded) };
+    is( $@, '', "no error decoding custom separator" );
+    cmp_deeply( $decoded, $data, "custom separator works" );
+};
+
+subtest "custom transfer encoding" => sub {
+    my $store = _gen_store(
+        {
+            transport_encoder => $custom_enc,
+            transport_decoder => sub { return "" }, # intentionally broken
+            separator         => ':',
+        }
+    );
+
+    my $encoded = $store->encode($data);
+
+    my $decoded = eval { $store->decode($encoded) };
+    is( $decoded, undef, "non-symmtric custom codec throws error" );
+
+    $store = _gen_store(
+        {
+            transport_encoder => $custom_enc,
+            transport_decoder => $custom_dec,
+            separator         => ':',
+        }
+    );
+
+    $decoded = eval { $store->decode($encoded) };
+    is( $@, '', "no error decoding custom codec" );
+    cmp_deeply( $decoded, $data, "custom codec works" );
+};
+
+subtest "custom sereal options" => sub {
+    my $store = _gen_store(
+        {
+            sereal_encoder_options => {}, # i.e. allow objects
+            sereal_decoder_options => {},
+        }
+    );
+
+    my $object = bless { %$data }, "Fake::Class";
+
+    my $encoded = $store->encode({ object => $object});
+
+    my $decoded = eval { $store->decode($encoded) };
+    isa_ok( $decoded->{object}, "Fake::Class", "decoded session element" );
+    is_deeply( $decoded->{object}, $object, "object decoded correctly" );
+};
+
+done_testing;
+#
+# This file is part of Session-Storage-Secure
+#
+# This software is Copyright (c) 2013 by David Golden.
+#
+# This is free software, licensed under:
+#
+#   The Apache License, Version 2.0, January 2004
+#
@@ -7,36 +7,37 @@ use Test::Fatal;
 use Session::Storage::Secure;
 
 my $data = {
-  foo => 'bar',
-  baz => 'bam',
+    foo => 'bar',
+    baz => 'bam',
 };
 
 my $secret = "serenade viscount secretary frail";
 
 sub _gen_store {
-  my ($config) = @_;
-  local $Test::Builder::Level = $Test::Builder::Level + 1;
-  my $store = Session::Storage::Secure->new(
-    secret_key => $secret,
-    %{ $config || {} },
-  );
-  ok( $store, "created a storage object" );
-  return $store;
+    my ($config) = @_;
+    local $Test::Builder::Level = $Test::Builder::Level + 1;
+    my $store = Session::Storage::Secure->new(
+        secret_key => $secret,
+        %{ $config || {} },
+    );
+    ok( $store, "created a storage object" );
+    return $store;
 }
 
 sub _replace {
-  my ( $string, $index, $value ) = @_;
-  my @parts = split qr/~/, $string;
-  $parts[$index] = $value;
-  return join "~", @parts;
+    my ( $string, $index, $value ) = @_;
+    my @parts = split qr/~/, $string;
+    $parts[$index] = $value;
+    return join "~", @parts;
 }
 
 subtest "bad data" => sub {
-  my $store = _gen_store;
-  like(
-    exception { $store->encode( { foo => bless {} } ) },
-    qr/Encoding error/, "Invalid data throws encoding error",
-  );
+    my $store = _gen_store;
+    like(
+        exception { $store->encode( { foo => bless {} } ) },
+        qr/Encoding error/,
+        "Invalid data throws encoding error",
+    );
 };
 
 done_testing;
@@ -0,0 +1,5 @@
+; Install Code::TidyAll
+; run "tidyall -a" to tidy all files
+; run "tidyall -g" to tidy only files modified from git
+[PerlTidy]
+select = {lib,t}/**/*.{pl,pm,t}
@@ -0,0 +1,53 @@
+use 5.006;
+use strict;
+use warnings;
+
+# this test was generated with Dist::Zilla::Plugin::Test::Compile 2.040
+
+use Test::More  tests => 1 + ($ENV{AUTHOR_TESTING} ? 1 : 0);
+
+
+
+my @module_files = (
+    'Session/Storage/Secure.pm'
+);
+
+
+
+# fake home for cpan-testers
+use File::Temp;
+local $ENV{HOME} = File::Temp::tempdir( CLEANUP => 1 );
+
+
+my $inc_switch = -d 'blib' ? '-Mblib' : '-Ilib';
+
+use File::Spec;
+use IPC::Open3;
+use IO::Handle;
+
+open my $stdin, '<', File::Spec->devnull or die "can't open devnull: $!";
+
+my @warnings;
+for my $lib (@module_files)
+{
+    # see L<perlfaq8/How can I capture STDERR from an external command?>
+    my $stderr = IO::Handle->new;
+
+    my $pid = open3($stdin, '>&STDERR', $stderr, $^X, $inc_switch, '-e', "require q[$lib]");
+    binmode $stderr, ':crlf' if $^O eq 'MSWin32';
+    my @_warnings = <$stderr>;
+    waitpid($pid, 0);
+    is($?, 0, "$lib loaded ok");
+
+    if (@_warnings)
+    {
+        warn @_warnings;
+        push @warnings, @_warnings;
+    }
+}
+
+
+
+is(scalar(@warnings), 0, 'no warnings found') if $ENV{AUTHOR_TESTING};
+
+
@@ -2,8 +2,9 @@ use strict;
 use warnings;
 use Test::More;
 
-# generated by Dist::Zilla::Plugin::Test::PodSpelling 2.006000
-eval "use Test::Spelling 0.12; use Pod::Wordlist::hanekomu; 1" or die $@;
+# generated by Dist::Zilla::Plugin::Test::PodSpelling 2.006007
+use Test::Spelling 0.12;
+use Pod::Wordlist;
 
 
 add_stopwords(<DATA>);
@@ -29,6 +30,9 @@ unencrypted
 David
 Golden
 dagolden
+Tom
+Hukins
+tom
 lib
 Session
 Storage
@@ -1,7 +1,6 @@
 #!perl
+# This file was automatically generated by Dist::Zilla::Plugin::MetaTests.
 
-use Test::More;
+use Test::CPAN::Meta;
 
-eval "use Test::CPAN::Meta";
-plan skip_all => "Test::CPAN::Meta required for testing META.yml" if $@;
 meta_yaml_ok();
@@ -1,13 +1,7 @@
 #!perl
+# This file was automatically generated by Dist::Zilla::Plugin::PodCoverageTests.
 
-use Test::More;
-
-eval "use Test::Pod::Coverage 1.08";
-plan skip_all => "Test::Pod::Coverage 1.08 required for testing POD coverage"
-  if $@;
-
-eval "use Pod::Coverage::TrustPod";
-plan skip_all => "Pod::Coverage::TrustPod required for testing POD coverage"
-  if $@;
+use Test::Pod::Coverage 1.08;
+use Pod::Coverage::TrustPod;
 
 all_pod_coverage_ok({ coverage_class => 'Pod::Coverage::TrustPod' });
@@ -1,7 +1,6 @@
 #!perl
+# This file was automatically generated by Dist::Zilla::Plugin::PodSyntaxTests.
 use Test::More;
-
-eval "use Test::Pod 1.41";
-plan skip_all => "Test::Pod 1.41 required for testing POD" if $@;
+use Test::Pod 1.41;
 
 all_pod_files_ok();