Copybara import of the project:

  - 42ec5f97f6af3f8c91822fcc0da1f19bc9b5b9b1 Initial commit. by Marvin S. Addison <marvin.addison@gmail.com>
  - 9504c0d6bdd17b8834f66e37547c5bd5f870a7e4 Test coverage for CipherUtil class. by Marvin S. Addison <marvin.addison@gmail.com>
  - 716bbb13d9b158b4ee66809e4b374569032ebb8d Add javadocs. by Marvin S. Addison <marvin.addison@gmail.com>
  - 6deadce5cc7f2a9aff748d2d8347cc70d7553f0d Test coverage for HashUtil. by Marvin S. Addison <marvin.addison@gmail.com>
  - 1afd88805535fb50ff722bfea618a17cea30ff8e Add support for symmetric encryption of streams. by Marvin S. Addison <marvin.addison@gmail.com>
  - 05b880fbb0b443fde86678777d341d5aa6099596 Add support for hashing streams. by Marvin S. Addison <marvin.addison@gmail.com>
  - 99d9a61c747f871b6c40cdf077652f18489c66f7 Create encoding components that support chunked IO. by Marvin S. Addison <marvin.addison@gmail.com>
  - 9c897d95863b2f33855f29b9793314e7052d8e2a Reorganize utility components in util package. by Marvin S. Addison <marvin.addison@gmail.com>
  - 04f8093f2aa29105bdd58fcfacc6cdd673be1229 Port vt-crypt key pair generation and utils. by Marvin S. Addison <marvin.addison@gmail.com>
  - 814deb93446382133fc8a191f733ba3d77c451b1 Add support for colon-delimited hex strings. by Marvin S. Addison <marvin.addison@gmail.com>
  - 60bc9af5b4ab0a64213feb80e43dc1b8504fc586 Add X.509 utilities. by Marvin S. Addison <marvin.addison@gmail.com>
  - c2c93e69e32770ad151486cb1c186d553b57b981 Fix base64 encoding bugs. by Marvin S. Addison <marvin.addison@gmail.com>
  - 9e78590891bb436e9f5bd88e3d4a397953e70c8c Preliminary port of private key reading/decoding. by Marvin S. Addison <marvin.addison@gmail.com>
  - e18447085bb287965abd3bbdfa12eb01352d58a8 Fix invalid data passed to overridden constructor. by Marvin S. Addison <marvin.addison@gmail.com>
  - a1f8aecb963f6e3b70e6a5aea406bc3937d32f77 Add CodecSpec for declarative creation of encoders/decode... by Marvin S. Addison <marvin.addison@gmail.com>
  - 1d4d51114c0a705127d69311684ed2f8d97d381c Complete private key read/asn.1 decode support. by Marvin S. Addison <marvin.addison@gmail.com>
  - 2aefc3d59cc4207471c0d3374b744402eb80ae2c Refactor priv key decoding, add pub key reading. by Marvin S. Addison <marvin.addison@gmail.com>
  - b23ac7f50bb9bde959fe79139d3847ea6f47a3ff Disable base64 encoding line breaks by default. by Marvin S. Addison <marvin.addison@gmail.com>
  - 884a80deb3cd9c669f5bedd7985e47f9af48eba2 Platform-specific line breaks in base64 encoding. by Marvin S. Addison <marvin.addison@gmail.com>
  - 9c09ff03a11bd4d16ae0f5f92e3632a8052ca345 Add nonce generator classes. by Marvin S. Addison <marvin.addison@gmail.com>
  - 6e59f80d8b9acd4038ea729afd442d8abace1231 Add convenience constructors to CounterNonce. by Marvin S. Addison <marvin.addison@gmail.com>
  - ca677a69f3ac7a0b04bab7b3a3bc4303b1812c36 Add field getters to DigestSpec. by Marvin S. Addison <marvin.addison@gmail.com>
  - 8a2f8f08d197d76fafe495178fe28a34a71ec062 Convert CodecSpec to class for consistency. by Marvin S. Addison <marvin.addison@gmail.com>
  - 26c57aaeef7cd036290e405a16a056d60dc84001 Add Nonce parameter to CipherUtil#encrypt() methods. by Marvin S. Addison <marvin.addison@gmail.com>
  - deb97ef4b9de39f476d46757d0422ad2ffad80f5 Use RBGNonce in implementation of NonceUtil#nist80038d(). by Marvin S. Addison <marvin.addison@gmail.com>
  - 9d3def585fe35cd4284f9b3ac7456513e2c56dc4 Complete test coverage for CertUtil. by Marvin S. Addison <marvin.addison@gmail.com>
  - 92ef86eebe66e13b16a336187b80dc87b53afa47 Add encrypted nonce generation strategy. by Marvin S. Addison <marvin.addison@gmail.com>
  - 8ab1893ce74e444e35db76b05582b35a1853133f Refactor crypotographic specification components. by Marvin S. Addison <marvin.addison@gmail.com>
  - 39b3f3488ede456c7674d92e4c29e6e399f5ef77 Use Spec interface where possible. by Marvin S. Addison <marvin.addison@gmail.com>
  - d3cc9cbf7b0d4d2fec57d201900ae2c3378cce2f Add facility to specify block cipher as algorith/mode/pad... by Marvin S. Addison <marvin.addison@gmail.com>
  - d70b5e4255fdf4fd56734a62b82cc8e097ab316f Move key generators to generator package. by Marvin S. Addison <marvin.addison@gmail.com>
  - c89e2fb88a0339b06295c120dbd0cfd3f33d016c Start work on bean components. by Marvin S. Addison <marvin.addison@gmail.com>
  - e8ddbd0c4f701db6a5163354550ddfda1b4de762 Add secret key factory beans. by Marvin S. Addison <marvin.addison@gmail.com>
  - 455d6cd7d2122de0b52537f3391d64167a670894 Update HashUtil to apply _salt_ before hashing data. by Marvin S. Addison <marvin.addison@gmail.com>
  - 4d771b244c721be431c48077beaf0d48601168fe Add hash bean components. by Marvin S. Addison <marvin.addison@gmail.com>
  - c82c38dd2a2586e091b1ee53e5e80a8e07d8617e Use Spec interface for greater extensibility. by Marvin S. Addison <marvin.addison@gmail.com>
  - 3174291dc84afff81ec2b9c8b655f6ccf600499b Fix case sensitivity of KeyStoreFactoryBean source file. by Marvin S. Addison <marvin.addison@gmail.com>
  - 3be2316a14fe2dd0bb7568336a30035f35800c2e Implement cipher beans. by Marvin S. Addison <marvin.addison@gmail.com>
  - 5baf0e79b462d5fa588f1410053d82fba323a341 Refactor packaging of adapter classes. by Marvin S. Addison <marvin.addison@gmail.com>
  - 272a3dd24f406a3626a4d28bc68d34f421e0f1ae Remove PKCS#12 PBE encryption scheme -- no use case. by Marvin S. Addison <marvin.addison@gmail.com>
  - cf6ce5bd350817a8687dc8dd2d7910b95d4295cc Replace inner classes with new BlockCipherAdapter compone... by Marvin S. Addison <marvin.addison@gmail.com>
  - 696e9d57d9fb6420bf8d090faf9563e831404961 Refactor nonces to support more use cases. by Marvin S. Addison <marvin.addison@gmail.com>
  - 2dc499d5f7039c7966236eaaaf48857479eac4db Implement checkstyle rules in build and format accordingly. by Marvin S. Addison <marvin.addison@gmail.com>
  - 78df5f1dea7460f079a9c34b8c32c690343e52de Javadoc fix. by Marvin S. Addison <marvin.addison@gmail.com>
  - 3f4cfbce99fb9120f57d235308e7fb6d7e3ad62b Add hash comparison methods to HashUtil. by Marvin S. Addison <marvin.addison@gmail.com>
  - 91c722d07050eb075ece3728d51302f8254ad655 Add HashBean#compare() for hash comparisons. by Marvin S. Addison <marvin.addison@gmail.com>
  - a8d18859b20fb54502c516a7cf6e96760d208303 Add getters for bean fields. by Marvin S. Addison <marvin.addison@gmail.com>
  - 6f27ee5078c9377fb0e38e21c7a7f0bbdec46f8f Rename project to cryptacular. by Marvin S. Addison <marvin.addison@gmail.com>
  - 58f8a0d56a879d46095bf04a9fcf394f1afb6e32 Update README for cryptacular. by Marvin S. Addison <marvin.addison@gmail.com>
  - 917cd45b05427ee8cb564745b50e8b2bf9b9927a Refactor salted hashing strategy. by Marvin S. Addison <marvin.addison@gmail.com>
  - 0e581bd99939ee30dd0679efc4d2cc3f57df7bbe Merge branch 'master' of github.com:vt-middleware/cryptac... by Marvin S. Addison <marvin.addison@gmail.com>
  - a38881d1ea08fe8df00acf44c236a37afa35d0d8 Restore compare method on HashBean. by Marvin S. Addison <marvin.addison@gmail.com>
  - 284ce4cbabb755acbe7980d4962d026253ec507f Add all-in-one constructors to beans. by Marvin S. Addison <marvin.addison@gmail.com>
  - 3bbec1a0bbc67433c5c9cbc226a5279e1e36997a More methods for reading certs. by Marvin S. Addison <marvin.addison@gmail.com>
  - c8f9799c5dd4caeca410220e4adc229584af7996 Add missing test certificate chain files. by Marvin S. Addison <marvin.addison@gmail.com>
  - af2e6b108c99cf4a13fdf79f5760750f83d1bc24 Add IdGenerator components for random string ID generation. by Marvin S. Addison <marvin.addison@gmail.com>
  - 2bf17a2cce6116ae9c4385f702e5acc04d320e63 Conformity changes. by Daniel Fisher <dfisher@vt.edu>
  - 862be57c4cb9e45905713ff8ee427ae55ab3e43c Remove some usages of this. by Daniel Fisher <dfisher@vt.edu>
  - b44b6f458d8b6b99cc8fc9e2ce62fe387af47214 Intellij source cleanup. by Daniel Fisher <dfisher@vt.edu>
  - 35a5e2562045d58406374b0e31d7ebacde1d4e2b Jalopy source code cleanup. by Daniel Fisher <dfisher@vt.edu>
  - de6b5a56b145765d7eb9d08eea8457d669c7f2ed Update BC version to 1.50. by Daniel Fisher <dfisher@vt.edu>
  - dd503a1f0e898ec2c0871ce87ff56dd624d0d0f6 Must use a header module or header is ignored. by Daniel Fisher <dfisher@vt.edu>
  - 0768c5d88be374811ef21bf83d0c6083189563cf Add public/private key factory beans. by Marvin S. Addison <marvin.addison@gmail.com>
  - 352588aff48c0358bd9fcbe95cd506c7377067ad Add maven release plugin config. by Marvin S. Addison <marvin.addison@gmail.com>
  - 39c5be22a5b9f094531c590fd16bf1736ea58e85 [maven-release-plugin] prepare release v1.0-RC1 by Marvin S. Addison <marvin.addison@gmail.com>
  - 216d22be9eb7ac65013d99ef7f1fdadc6bc6daa2 [maven-release-plugin] prepare for next development itera... by Marvin S. Addison <marvin.addison@gmail.com>
  - 06fde2b5350439e03dc048e3acee911a4c770b50 Merge branch 'otp' by Marvin S. Addison <marvin.addison@gmail.com>
  - 10045a7f8712d3c9f6a446b667cae7ee18ff1ae8 [maven-release-plugin] prepare release v1.0-RC2 by Marvin S. Addison <marvin.addison@gmail.com>
  - 8714f65702b915726ffee4cc83d7fcd8731c4c3a [maven-release-plugin] prepare for next development itera... by Marvin S. Addison <marvin.addison@gmail.com>
  - 4579f12e1ad3503eb588478e7effa31316a2ada7 Expose getter for numberOfDigits. by Marvin S. Addison <marvin.addison@gmail.com>
  - 22cc3c13d29b4e3ce9435d0c2d74ce459dc5321b Add base 32 encoding/decoding support. by Marvin S. Addison <marvin.addison@gmail.com>
  - 50560a286719bc3420d891ab2443c8f454e82666 [maven-release-plugin] prepare release v1.0-RC3 by Marvin S. Addison <marvin.addison@gmail.com>
  - c0f65941dbd068f81ae2c6f42328fa55cfb59b1d [maven-release-plugin] prepare for next development itera... by Marvin S. Addison <marvin.addison@gmail.com>
  - d2a0a9957b166a3e95cb8cb44f493987a667c0a9 Add a null check when reading subject alt names. by Daniel Fisher <dfisher@gmail.com>
  - 6c15305e06756c2299a37e8fbce14a7f9ee0f849 Merge pull request #2 from serac/issue-1 by Daniel Fisher <dfisher@gmail.com>
  - 296a6926aa715e80573fcdce5c6438fdb4229d3e Merge pull request #4 from serac/issue-3 by Daniel Fisher <dfisher@gmail.com>
  - a3fa4b9a64c419c986b778367c4f99d3d6c1363e Fix version -- never released RC4. by Marvin S. Addison <marvin.addison@gmail.com>
  - 4983df707bc7b6d0f4afdd75e89d6dce5f286417 [maven-release-plugin] prepare release v1.0-RC4 by Marvin S. Addison <marvin.addison@gmail.com>
  - 8fdf26670c457b0b2a44e4e7b92123dd4af93a7f [maven-release-plugin] prepare for next development itera... by Marvin S. Addison <marvin.addison@gmail.com>
  - bc73d5491da4e4346dc0be940a5210aa88e30c84 Update plugin and dependency versions. by Daniel Fisher <dfisher@vt.edu>
  - 1b739f9b30992c8b5dadf2828a1692ba2d57175b Don't attach the assembly to the project. by Daniel Fisher <dfisher@vt.edu>
  - faca124004da3994b0af9ebfabc139753f5387b7 Add multi-value RDN test vectors. by Marvin S. Addison <marvin.addison@gmail.com>
  - 59824a405a58b9c31a2facbbe28a64071fbb8b79 Add LdapNameFormatterTest multi-value RDN test cases. by Marvin S. Addison <marvin.addison@gmail.com>
  - be723161bb8ce44706348d8ead20dedd77fff2b2 Merge pull request #9 from vt-middleware/multi-value-rdn by Daniel Fisher <dfisher@gmail.com>
  - 2abfc83720749ea5ebc6c8f42f716a32a24de2e7 Merge pull request #8 from vt-middleware/generalize-ksbskfb by Daniel Fisher <dfisher@gmail.com>
  - 3f290eacf0ed0b2b291fd51b984ff3d4426e5498 Merge pull request #11 from vt-middleware/rfc2253-escape by Daniel Fisher <dfisher@gmail.com>
  - 85ba61657a6de75e6eca3c56496e77e1ddde5d7f Fix issue URL. by Daniel Fisher <dfisher@vt.edu>
  - 8ad916cbdfae9ff87c3df38b1ae2cb90e186a81a Intellij cleanup. by Daniel Fisher <dfisher@vt.edu>
  - 5795da7b9daad0086e72f1db8276b15e73bf4d32 Merge pull request #13 from vt-middleware/non-standard-dn... by Daniel Fisher <dfisher@gmail.com>
  - caeef021fb83f53a844bd1f439c02a313976dd60 Merge pull request #14 from vt-middleware/jalopy-cleanup by Marvin S. Addison <marvin.addison@gmail.com>
  - 1563671d0f7da76ad6927057fc2d2bfe64d3d96d Update version for release. by Daniel Fisher <dfisher@vt.edu>
  - 6fb9b7c6c4602e6ed7e87b745ed94d07c11c4f57 Set master back to snapshot. by Daniel Fisher <dfisher@vt.edu>
  - 5503c5b34c048916de0e7e938f1b2a895862d138 Tagging 1.0 release. by Daniel Fisher <dfisher@vt.edu>
  - c0b84c9aa8e2035001af25f540794600ea105a0d Bump version for next snapshot. by Daniel Fisher <dfisher@vt.edu>
  - 2eeafd1b65e8b0574985b4567ce04546ca610e80 Update plugin and dependency versions. by Daniel Fisher <dfisher@vt.edu>
  - 604272b06700664cf80a278200a9f29f8ce8a1a3 Remove inheritDoc for @Override methods. by Daniel Fisher <dfisher@vt.edu>
  - 215259028f85816845e2919cc87942ef834cba0c Update copyright year. by Daniel Fisher <dfisher@vt.edu>
  - b112a9b302a543cd3b5ab63ad1655e204747fdee Issue #18 Fix thread safety bug. by Marvin S. Addison <marvin.addison@gmail.com>
  - f61d0d1539f7374e6298700ef47a75ca79e72414 Merge pull request #21 from vt-middleware/salted-encoding... by Marvin S. Addison <marvin.addison@gmail.com>
  - 37fbcef6c7b6e8177caf69f8391c04a6374fec37 Update dependency and plugin versions. by Daniel Fisher <dfisher@vt.edu>
  (And 106 more changes)

GitOrigin-RevId: a070bf4723761dac9c72c0f0333336b829fef525
Change-Id: I69f14464e5a5999125a71501793f1ed95ac644c0
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..9d08a1a
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,9 @@
+root = true
+
+[*]
+charset = utf-8
+indent_style = space
+indent_size = 2
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..8905eb7
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+src/test/resources/plaintexts/*.txt text eol=lf
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..6240411
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+*.iml
+.idea
+target
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..7213ae0
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,2 @@
+This product is dual licensed under a choice of either the Apache-2.0 or the LGPL-3.0 license.
+See LICENSE-apache2 and LICENSE-lgpl for the full text of the licenses.
diff --git a/LICENSE-apache2 b/LICENSE-apache2
new file mode 100644
index 0000000..6b0b127
--- /dev/null
+++ b/LICENSE-apache2
@@ -0,0 +1,203 @@
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+
diff --git a/LICENSE-lgpl b/LICENSE-lgpl
new file mode 100644
index 0000000..cca7fc2
--- /dev/null
+++ b/LICENSE-lgpl
@@ -0,0 +1,165 @@
+		   GNU LESSER GENERAL PUBLIC LICENSE
+                       Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+
+  This version of the GNU Lesser General Public License incorporates
+the terms and conditions of version 3 of the GNU General Public
+License, supplemented by the additional permissions listed below.
+
+  0. Additional Definitions.
+
+  As used herein, "this License" refers to version 3 of the GNU Lesser
+General Public License, and the "GNU GPL" refers to version 3 of the GNU
+General Public License.
+
+  "The Library" refers to a covered work governed by this License,
+other than an Application or a Combined Work as defined below.
+
+  An "Application" is any work that makes use of an interface provided
+by the Library, but which is not otherwise based on the Library.
+Defining a subclass of a class defined by the Library is deemed a mode
+of using an interface provided by the Library.
+
+  A "Combined Work" is a work produced by combining or linking an
+Application with the Library.  The particular version of the Library
+with which the Combined Work was made is also called the "Linked
+Version".
+
+  The "Minimal Corresponding Source" for a Combined Work means the
+Corresponding Source for the Combined Work, excluding any source code
+for portions of the Combined Work that, considered in isolation, are
+based on the Application, and not on the Linked Version.
+
+  The "Corresponding Application Code" for a Combined Work means the
+object code and/or source code for the Application, including any data
+and utility programs needed for reproducing the Combined Work from the
+Application, but excluding the System Libraries of the Combined Work.
+
+  1. Exception to Section 3 of the GNU GPL.
+
+  You may convey a covered work under sections 3 and 4 of this License
+without being bound by section 3 of the GNU GPL.
+
+  2. Conveying Modified Versions.
+
+  If you modify a copy of the Library, and, in your modifications, a
+facility refers to a function or data to be supplied by an Application
+that uses the facility (other than as an argument passed when the
+facility is invoked), then you may convey a copy of the modified
+version:
+
+   a) under this License, provided that you make a good faith effort to
+   ensure that, in the event an Application does not supply the
+   function or data, the facility still operates, and performs
+   whatever part of its purpose remains meaningful, or
+
+   b) under the GNU GPL, with none of the additional permissions of
+   this License applicable to that copy.
+
+  3. Object Code Incorporating Material from Library Header Files.
+
+  The object code form of an Application may incorporate material from
+a header file that is part of the Library.  You may convey such object
+code under terms of your choice, provided that, if the incorporated
+material is not limited to numerical parameters, data structure
+layouts and accessors, or small macros, inline functions and templates
+(ten or fewer lines in length), you do both of the following:
+
+   a) Give prominent notice with each copy of the object code that the
+   Library is used in it and that the Library and its use are
+   covered by this License.
+
+   b) Accompany the object code with a copy of the GNU GPL and this license
+   document.
+
+  4. Combined Works.
+
+  You may convey a Combined Work under terms of your choice that,
+taken together, effectively do not restrict modification of the
+portions of the Library contained in the Combined Work and reverse
+engineering for debugging such modifications, if you also do each of
+the following:
+
+   a) Give prominent notice with each copy of the Combined Work that
+   the Library is used in it and that the Library and its use are
+   covered by this License.
+
+   b) Accompany the Combined Work with a copy of the GNU GPL and this license
+   document.
+
+   c) For a Combined Work that displays copyright notices during
+   execution, include the copyright notice for the Library among
+   these notices, as well as a reference directing the user to the
+   copies of the GNU GPL and this license document.
+
+   d) Do one of the following:
+
+       0) Convey the Minimal Corresponding Source under the terms of this
+       License, and the Corresponding Application Code in a form
+       suitable for, and under terms that permit, the user to
+       recombine or relink the Application with a modified version of
+       the Linked Version to produce a modified Combined Work, in the
+       manner specified by section 6 of the GNU GPL for conveying
+       Corresponding Source.
+
+       1) Use a suitable shared library mechanism for linking with the
+       Library.  A suitable mechanism is one that (a) uses at run time
+       a copy of the Library already present on the user's computer
+       system, and (b) will operate properly with a modified version
+       of the Library that is interface-compatible with the Linked
+       Version.
+
+   e) Provide Installation Information, but only if you would otherwise
+   be required to provide such information under section 6 of the
+   GNU GPL, and only to the extent that such information is
+   necessary to install and execute a modified version of the
+   Combined Work produced by recombining or relinking the
+   Application with a modified version of the Linked Version. (If
+   you use option 4d0, the Installation Information must accompany
+   the Minimal Corresponding Source and Corresponding Application
+   Code. If you use option 4d1, you must provide the Installation
+   Information in the manner specified by section 6 of the GNU GPL
+   for conveying Corresponding Source.)
+
+  5. Combined Libraries.
+
+  You may place library facilities that are a work based on the
+Library side by side in a single library together with other library
+facilities that are not Applications and are not covered by this
+License, and convey such a combined library under terms of your
+choice, if you do both of the following:
+
+   a) Accompany the combined library with a copy of the same work based
+   on the Library, uncombined with any other library facilities,
+   conveyed under the terms of this License.
+
+   b) Give prominent notice with the combined library that part of it
+   is a work based on the Library, and explaining where to find the
+   accompanying uncombined form of the same work.
+
+  6. Revised Versions of the GNU Lesser General Public License.
+
+  The Free Software Foundation may publish revised and/or new versions
+of the GNU Lesser General Public License from time to time. Such new
+versions will be similar in spirit to the present version, but may
+differ in detail to address new problems or concerns.
+
+  Each version is given a distinguishing version number. If the
+Library as you received it specifies that a certain numbered version
+of the GNU Lesser General Public License "or any later version"
+applies to it, you have the option of following the terms and
+conditions either of that published version or of any later version
+published by the Free Software Foundation. If the Library as you
+received it does not specify a version number of the GNU Lesser
+General Public License, you may choose any version of the GNU Lesser
+General Public License ever published by the Free Software Foundation.
+
+  If the Library as you received it specifies that a proxy can decide
+whether future versions of the GNU Lesser General Public License shall
+apply, that proxy's public statement of acceptance of any version is
+permanent authorization for you to choose that version for the
+Library.
diff --git a/NOTICE b/NOTICE
new file mode 100644
index 0000000..c261dd6
--- /dev/null
+++ b/NOTICE
@@ -0,0 +1,6 @@
+Cryptacular Java Library
+Copyright (C) 2003-2024 Virginia Tech.
+All rights reserved.
+
+This product includes software developed at
+Virginia Tech (http://www.vt.edu).
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..2e205aa
--- /dev/null
+++ b/README.md
@@ -0,0 +1,11 @@
+# Cryptacular [![Maven Central](https://maven-badges.herokuapp.com/maven-central/org.cryptacular/cryptacular/badge.svg?style=flat)](https://maven-badges.herokuapp.com/maven-central/org.cryptacular/cryptacular)
+
+The spectacular complement to the Bouncy Castle crypto API for Java.
+
+Cryptacular in a nutshell:
+
+* Utilities to perform common crypto operations (hash, encrypt, encode).
+* Stateful, thread-safe bean components.
+* Components to facilitate strict adherence to standards.
+* Comprehensive documentation and examples.
+
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..58fe26e
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,417 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+  <modelVersion>4.0.0</modelVersion>
+  <groupId>org.cryptacular</groupId>
+  <artifactId>cryptacular</artifactId>
+  <packaging>jar</packaging>
+  <version>1.2.7</version>
+  <name>Cryptacular Library</name>
+  <description>The spectacular complement to the Bouncy Castle crypto API for Java.</description>
+  <url>https://www.cryptacular.org</url>
+  <issueManagement>
+    <system>GitHub</system>
+    <url>https://github.com/vt-middleware/cryptacular/issues</url>
+  </issueManagement>
+  <licenses>
+    <license>
+      <name>Apache 2</name>
+      <url>https://www.apache.org/licenses/LICENSE-2.0.txt</url>
+    </license>
+    <license>
+      <name>GNU Lesser General Public License</name>
+      <url>https://www.gnu.org/licenses/lgpl-3.0.txt</url>
+    </license>
+  </licenses>
+  <scm>
+    <connection>scm:git:git@github.com:vt-middleware/cryptacular.git</connection>
+    <url>scm:git:git@github.com:vt-middleware/cryptacular.git</url>
+    <tag>HEAD</tag>
+  </scm>
+  <developers>
+    <developer>
+      <id>dfisher</id>
+      <name>Daniel Fisher</name>
+      <email>dfisher@vt.edu</email>
+      <organization>Virginia Tech</organization>
+      <organizationUrl>https://www.vt.edu</organizationUrl>
+      <roles>
+        <role>developer</role>
+      </roles>
+    </developer>
+    <developer>
+      <id>serac</id>
+      <name>Marvin S. Addison</name>
+      <email>serac@vt.edu</email>
+      <organization>Virginia Tech</organization>
+      <organizationUrl>https://www.vt.edu</organizationUrl>
+      <roles>
+        <role>developer</role>
+      </roles>
+    </developer>
+  </developers>
+
+  <properties>
+    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+    <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
+    <checkstyle.dir>${basedir}/src/main/checkstyle</checkstyle.dir>
+    <spotbugs.dir>${basedir}/src/main/spotbugs</spotbugs.dir>
+    <assembly.dir>${basedir}/src/main/assembly</assembly.dir>
+    <testng.verbosity>0</testng.verbosity>
+    <japicmp.enabled>true</japicmp.enabled>
+    <japicmp.oldVersion>1.2.0</japicmp.oldVersion>
+    <bc.version>1.78.1</bc.version>
+  </properties>
+
+  <dependencies>
+    <dependency>
+      <groupId>org.bouncycastle</groupId>
+      <artifactId>bcprov-jdk18on</artifactId>
+      <version>${bc.version}</version>
+    </dependency>
+    <dependency>
+      <groupId>org.bouncycastle</groupId>
+      <artifactId>bcpkix-jdk18on</artifactId>
+      <version>${bc.version}</version>
+    </dependency>
+    <!-- Remain on version 7.5 until source moves to JDK 11 -->
+    <dependency>
+      <groupId>org.testng</groupId>
+      <artifactId>testng</artifactId>
+      <version>7.5.1</version>
+      <scope>test</scope>
+    </dependency>
+  </dependencies>
+
+  <build>
+    <plugins>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-enforcer-plugin</artifactId>
+        <version>3.5.0</version>
+        <executions>
+          <execution>
+            <id>enforce-maven</id>
+            <goals>
+              <goal>enforce</goal>
+            </goals>
+            <configuration>
+              <rules>
+                <requireMavenVersion>
+                  <version>[3.8.0,)</version>
+                </requireMavenVersion>
+              </rules>
+            </configuration>
+          </execution>
+        </executions>
+      </plugin>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-resources-plugin</artifactId>
+        <version>3.3.1</version>
+        <executions>
+          <execution>
+            <id>copy-info</id>
+            <phase>validate</phase>
+            <goals>
+              <goal>copy-resources</goal>
+            </goals>
+            <configuration>
+              <outputDirectory>${basedir}/target/package-info</outputDirectory>
+              <resources>
+                <resource>
+                  <directory>${basedir}</directory>
+                  <filtering>false</filtering>
+                  <includes>
+                    <include>README*</include>
+                    <include>LICENSE*</include>
+                    <include>NOTICE*</include>
+                    <include>pom.xml</include>
+                  </includes>
+                </resource>
+              </resources>
+            </configuration>
+          </execution>
+          <execution>
+            <id>copy-scripts</id>
+            <phase>validate</phase>
+            <goals>
+              <goal>copy-resources</goal>
+            </goals>
+            <configuration>
+              <outputDirectory>${basedir}/target/bin</outputDirectory>
+              <resources>
+                <resource>
+                  <directory>bin</directory>
+                  <filtering>true</filtering>
+                </resource>
+              </resources>
+            </configuration>
+          </execution>
+        </executions>
+      </plugin>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-clean-plugin</artifactId>
+        <version>3.4.0</version>
+      </plugin>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-deploy-plugin</artifactId>
+        <version>3.1.2</version>
+      </plugin>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-install-plugin</artifactId>
+        <version>3.1.2</version>
+      </plugin>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-site-plugin</artifactId>
+        <version>3.12.1</version>
+      </plugin>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-compiler-plugin</artifactId>
+        <version>3.13.0</version>
+        <configuration>
+          <fork>true</fork>
+          <debug>true</debug>
+          <showDeprecation>true</showDeprecation>
+          <showWarnings>true</showWarnings>
+          <compilerArgument>-Xlint:unchecked</compilerArgument>
+          <source>1.8</source>
+          <target>1.8</target>
+        </configuration>
+      </plugin>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-checkstyle-plugin</artifactId>
+        <version>3.4.0</version>
+        <dependencies>
+          <dependency>
+            <groupId>com.puppycrawl.tools</groupId>
+            <artifactId>checkstyle</artifactId>
+            <!-- version 10 requires >= java 11 -->
+            <version>9.3</version>
+          </dependency>
+        </dependencies>
+        <configuration>
+          <configLocation>${checkstyle.dir}/checks.xml</configLocation>
+          <headerLocation>${checkstyle.dir}/header.txt</headerLocation>
+          <suppressionsLocation>${checkstyle.dir}/suppressions.xml</suppressionsLocation>
+          <includeTestSourceDirectory>true</includeTestSourceDirectory>
+          <failsOnError>true</failsOnError>
+          <outputFileFormat>plain</outputFileFormat>
+          <outputFile>${project.build.directory}/checkstyle-result.txt</outputFile>
+        </configuration>
+        <executions>
+          <execution>
+            <id>checkstyle</id>
+            <phase>compile</phase>
+            <goals>
+              <goal>checkstyle</goal>
+            </goals>
+          </execution>
+        </executions>
+      </plugin>
+      <plugin>
+        <groupId>com.github.spotbugs</groupId>
+        <artifactId>spotbugs-maven-plugin</artifactId>
+        <version>4.8.6.2</version>
+        <configuration>
+          <effort>Max</effort>
+          <threshold>Medium</threshold>
+          <xmlOutput>true</xmlOutput>
+          <xmlOutputDirectory>${project.build.directory}/spotbugs</xmlOutputDirectory>
+          <excludeFilterFile>${spotbugs.dir}/exclude.xml</excludeFilterFile>
+        </configuration>
+        <executions>
+          <execution>
+            <goals>
+              <goal>check</goal>
+            </goals>
+          </execution>
+        </executions>
+      </plugin>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-surefire-plugin</artifactId>
+        <version>3.3.1</version>
+        <configuration>
+          <threadCount>10</threadCount>
+          <properties>
+            <property>
+              <name>surefire.testng.verbose</name>
+              <value>${testng.verbosity}</value>
+            </property>
+          </properties>
+        </configuration>
+      </plugin>
+      <plugin>
+        <groupId>com.github.siom79.japicmp</groupId>
+        <artifactId>japicmp-maven-plugin</artifactId>
+        <version>0.22.0</version>
+        <configuration>
+          <oldVersion>
+            <dependency>
+              <groupId>${project.groupId}</groupId>
+              <artifactId>${project.artifactId}</artifactId>
+              <version>${japicmp.oldVersion}</version>
+              <type>jar</type>
+            </dependency>
+          </oldVersion>
+          <newVersion>
+            <file>
+              <path>${project.build.directory}/${project.artifactId}-${project.version}.${project.packaging}</path>
+            </file>
+          </newVersion>
+          <parameter>
+            <ignoreNonResolvableArtifacts>false</ignoreNonResolvableArtifacts>
+            <onlyBinaryIncompatible>true</onlyBinaryIncompatible>
+            <breakBuildOnBinaryIncompatibleModifications>${japicmp.enabled}</breakBuildOnBinaryIncompatibleModifications>
+            <packagingSupporteds>
+              <packagingSupported>jar</packagingSupported>
+            </packagingSupporteds>
+          </parameter>
+        </configuration>
+        <executions>
+          <execution>
+            <phase>verify</phase>
+            <goals>
+              <goal>cmp</goal>
+            </goals>
+          </execution>
+        </executions>
+      </plugin>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-javadoc-plugin</artifactId>
+        <version>3.8.0</version>
+        <configuration>
+          <source>8</source>
+          <links>
+            <link>https://download.oracle.com/javase/8/docs/api</link>
+          </links>
+          <bottom><![CDATA[<i>Copyright &#169; 2003-2024 Virginia Tech. All Rights Reserved.</i>]]></bottom>
+        </configuration>
+        <executions>
+          <execution>
+            <id>javadoc</id>
+            <phase>package</phase>
+            <goals>
+              <goal>jar</goal>
+            </goals>
+          </execution>
+        </executions>
+      </plugin>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-source-plugin</artifactId>
+        <version>3.3.1</version>
+        <executions>
+          <execution>
+            <id>source</id>
+            <phase>package</phase>
+            <goals>
+              <goal>jar</goal>
+            </goals>
+          </execution>
+        </executions>
+      </plugin>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-jar-plugin</artifactId>
+        <version>3.4.2</version>
+        <configuration>
+          <archive>
+            <manifestFile>${project.build.outputDirectory}/META-INF/MANIFEST.MF</manifestFile>
+          </archive>
+        </configuration>
+        <executions>
+          <execution>
+            <id>jar</id>
+            <phase>package</phase>
+            <goals>
+              <goal>test-jar</goal>
+            </goals>
+          </execution>
+        </executions>
+      </plugin>
+      <plugin>
+        <groupId>org.apache.felix</groupId>
+        <artifactId>maven-bundle-plugin</artifactId>
+        <version>5.1.9</version>
+        <executions>
+          <execution>
+            <id>bundle-manifest</id>
+            <phase>process-classes</phase>
+            <goals>
+              <goal>manifest</goal>
+            </goals>
+          </execution>
+        </executions>
+      </plugin>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-assembly-plugin</artifactId>
+        <version>3.7.1</version>
+        <configuration>
+          <appendAssemblyId>true</appendAssemblyId>
+          <attach>false</attach>
+          <descriptors>
+            <descriptor>${assembly.dir}/cryptacular.xml</descriptor>
+          </descriptors>
+          <archiverConfig>
+            <defaultDirectoryMode>0755</defaultDirectoryMode>
+          </archiverConfig>
+        </configuration>
+        <executions>
+          <execution>
+            <id>assembly</id>
+            <phase>package</phase>
+            <goals>
+              <goal>single</goal>
+            </goals>
+          </execution>
+        </executions>
+      </plugin>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-release-plugin</artifactId>
+        <version>3.1.1</version>
+        <configuration>
+          <tagNameFormat>v@{project.version}</tagNameFormat>
+        </configuration>
+      </plugin>
+    </plugins>
+  </build>
+  <profiles>
+    <profile>
+      <id>sign-artifacts</id>
+      <activation>
+        <property>
+          <name>sign</name>
+          <value>true</value>
+        </property>
+      </activation>
+      <build>
+        <plugins>
+          <plugin>
+            <groupId>org.apache.maven.plugins</groupId>
+            <artifactId>maven-gpg-plugin</artifactId>
+            <version>3.2.5</version>
+            <executions>
+              <execution>
+                <id>sign-artifacts</id>
+                <phase>package</phase>
+                <goals>
+                  <goal>sign</goal>
+                </goals>
+              </execution>
+            </executions>
+          </plugin>
+        </plugins>
+      </build>
+    </profile>
+  </profiles>
+</project>
diff --git a/publish-snapshot b/publish-snapshot
new file mode 100755
index 0000000..710ca19
--- /dev/null
+++ b/publish-snapshot
@@ -0,0 +1,45 @@
+#!/bin/bash
+
+function user_continue() {
+  read -p "Do you want to continue? [y/n]" -n 1 -r
+  echo
+  if [[ ! $REPLY =~ ^[Yy]$ ]]; then
+    exit 1
+  fi
+}
+
+if [ "$#" -ne 1 ]; then
+  echo "USAGE: `basename $0` <repo-path>"
+  exit
+fi
+
+REPO_PATH="${1}"
+SHA=`git rev-parse --verify HEAD`
+echo "================================================================="
+echo "BEGIN PUBLISH SNAPSHOT for revision ${SHA} at ${REPO_PATH}"
+echo "================================================================="
+user_continue
+
+# update pom to release version
+if ! mvn clean; then
+  echo "maven clean command failed, check your environment"
+  exit
+fi
+mvn package -Dmaven.javadoc.skip=true -B -V
+
+# Clone the maven repo
+pushd ${REPO_PATH}
+git pull
+popd
+
+# Deploy the artifact to the maven repo
+mvn deploy -DskipTests -DaltDeploymentRepository=snapshot::file://${REPO_PATH}
+
+# Push changes to the maven repo
+pushd ${REPO_PATH}
+git add .
+git commit -a -m "Publish snapshot: ${SHA}"
+git push origin master
+popd
+
+echo "Successfully published SNAPSHOT artifacts for ${SHA}"
diff --git a/release b/release
new file mode 100755
index 0000000..26ee1cd
--- /dev/null
+++ b/release
@@ -0,0 +1,138 @@
+#!/bin/bash
+
+set -e
+
+function user_continue() {
+  read -p "Do you want to continue? [y/n]" -n 1 -r
+  echo
+  if [[ ! $REPLY =~ ^[Yy]$ ]]; then
+    exit 1
+  fi
+}
+
+if [ "$#" -ne 5 ]; then
+  echo "USAGE: `basename $0` <branch> <release-version> <next-version> <sonatype-user> <sonatype-passwd>"
+  exit
+fi
+
+PROJECT="cryptacular"
+BRANCH="${1}"
+if [ ! $(git rev-parse --abbrev-ref HEAD) = "${BRANCH}" ]; then
+  echo "The current branch must be ${BRANCH}"
+  exit
+fi
+RELEASE_VERSION="${2}"
+if [[ ! "${RELEASE_VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+  echo "<release-version> must be of the form 'MAJOR.MINOR.REVISION'"
+  exit
+fi
+NEXT_VERSION="${3}"
+if [[ ! "${NEXT_VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-SNAPSHOT$ ]]; then
+  echo "<next-version> must be of the form 'MAJOR.MINOR.REVISION-SNAPSHOT'"
+  exit
+fi
+SONATYPE_USER="${4}"
+SONATYPE_PASSWORD="${5}"
+
+if [ -z $(git config --get user.signingkey) ]; then
+  echo "Git signing must be enabled. Add user.signingkey to ~/.gitconfig"
+  exit
+fi
+
+if [ $(git tag -l | grep "$RELEASE_VERSION") ]; then
+  echo "Tag ${RELEASE_VERSION} already exists"
+  exit
+fi
+
+echo "================================================================="
+echo "BEGIN RELEASE"
+echo "PROJECT:         ${PROJECT}"
+echo "BRANCH TO TAG:   ${BRANCH}"
+echo "RELEASE VERSION: ${RELEASE_VERSION}"
+echo "NEXT VERSION:    ${NEXT_VERSION}"
+echo "================================================================="
+user_continue
+
+# update pom to release version
+if ! mvn clean; then
+  echo "maven clean command failed, check your environment"
+  exit
+fi
+mvn versions:set -DnewVersion=${RELEASE_VERSION} -DgenerateBackupPoms=false
+echo "Updated pom to release version ${RELEASE_VERSION}"
+
+POM_VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout)
+if [ "${POM_VERSION}" != "${RELEASE_VERSION}" ]; then
+  echo "POM version ${POM_VERSION} does not equal ${RELEASE_VERSION}"
+  exit
+fi
+user_continue
+
+# commit pom changes
+git commit pom.xml -m "Update version for ${RELEASE_VERSION} release."
+
+# tag the release version
+git tag -s v${RELEASE_VERSION} -m "Tagging ${RELEASE_VERSION} release."
+echo "Tagged release ${RELEASE_VERSION}"
+
+# update pom to the next version
+mvn versions:set -DnewVersion=${NEXT_VERSION} -DgenerateBackupPoms=false
+echo "Updated pom to next version ${NEXT_VERSION}"
+
+# commit pom changes
+git commit pom.xml -m "Bump version to ${NEXT_VERSION}."
+
+# push commits
+git push origin ${BRANCH}
+
+# push release tag
+git push origin v${RELEASE_VERSION}
+
+# checkout the release tag
+git checkout v${RELEASE_VERSION}
+echo "Switched to the tag version ${RELEASE_VERSION}"
+
+# build the release distribution
+mvn -Dsign=true repository:bundle-create
+gpg --armor --detach-sign target/${PROJECT}-${RELEASE_VERSION}-dist.tar.gz
+gpg --armor --detach-sign target/${PROJECT}-${RELEASE_VERSION}-dist.zip
+
+# update the javadocs
+echo "Updating javadocs"
+user_continue
+
+git checkout gh-pages
+git pull origin gh-pages
+# remove root directory javadocs
+git rm -r javadocs/org javadocs/*.html javadocs/*.css javadocs/*.js javadocs/package-list
+# add new javadocs to root directory
+cp -Rp target/apidocs/ javadocs
+# add new javadocs to release version directory
+cp -Rp target/apidocs/ javadocs/${RELEASE_VERSION}
+git add javadocs
+git commit -a -m "Updated javadocs for ${RELEASE_VERSION} release."
+echo "Committed new javadocs"
+
+# add new binaries
+echo "Adding release binaries"
+user_continue
+
+mkdir downloads/${RELEASE_VERSION}
+cp target/*-dist* downloads/${RELEASE_VERSION}
+git add downloads/${RELEASE_VERSION}
+git commit -a -m "Added binaries for ${RELEASE_VERSION} release."
+echo "Committed new release binaries"
+
+# push changes to the server
+git push origin gh-pages
+
+# upload bundle jar to sonatype
+echo "Uploading bundle jar to sonatype"
+user_continue
+
+curl -i -u ${SONATYPE_USER}:${SONATYPE_PASSWORD} \
+  -F "file=@target/${PROJECT}-"${RELEASE_VERSION}"-bundle.jar" \
+  "https://oss.sonatype.org/service/local/staging/bundle_upload"
+
+echo "Finished release ${RELEASE_VERSION} for ${PROJECT}"
+
diff --git a/src/main/assembly/cryptacular.xml b/src/main/assembly/cryptacular.xml
new file mode 100644
index 0000000..43486c8
--- /dev/null
+++ b/src/main/assembly/cryptacular.xml
@@ -0,0 +1,64 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<assembly>
+  <id>dist</id>
+  <formats>
+    <format>tar.gz</format>
+    <format>zip</format>
+  </formats>
+  <dependencySets>
+    <dependencySet>
+      <useProjectArtifact>false</useProjectArtifact>
+      <outputDirectory>lib</outputDirectory>
+      <scope>runtime</scope>
+      <includes>
+        <include>*:jar</include>
+      </includes>
+    </dependencySet>
+  </dependencySets>
+  <fileSets>
+    <fileSet>
+      <directory>target/package-info</directory>
+      <outputDirectory>/</outputDirectory>
+      <includes>
+        <include>**</include>
+      </includes>
+      <useDefaultExcludes>true</useDefaultExcludes>
+    </fileSet>
+    <fileSet>
+      <directory>src</directory>
+      <useDefaultExcludes>true</useDefaultExcludes>
+    </fileSet>
+    <fileSet>
+      <directory>target</directory>
+      <outputDirectory>jars</outputDirectory>
+      <includes>
+        <include>${project.build.finalName}*.jar</include>
+      </includes>
+      <useDefaultExcludes>true</useDefaultExcludes>
+    </fileSet>
+    <fileSet>
+      <directory>target/site/apidocs</directory>
+      <outputDirectory>docs/apidocs</outputDirectory>
+      <useDefaultExcludes>true</useDefaultExcludes>
+    </fileSet>
+    <fileSet>
+      <directory>target/bin</directory>
+      <outputDirectory>bin</outputDirectory>
+      <useDefaultExcludes>true</useDefaultExcludes>
+      <includes>
+        <include>*.bat</include>
+        <include>*.config</include>
+      </includes>
+    </fileSet>
+    <fileSet>
+      <directory>target/bin</directory>
+      <outputDirectory>bin</outputDirectory>
+      <fileMode>0755</fileMode>
+      <useDefaultExcludes>true</useDefaultExcludes>
+      <excludes>
+        <exclude>*.bat</exclude>
+        <exclude>*.config</exclude>
+      </excludes>
+    </fileSet>
+  </fileSets>
+</assembly>
diff --git a/src/main/checkstyle/checks.xml b/src/main/checkstyle/checks.xml
new file mode 100644
index 0000000..59bb109
--- /dev/null
+++ b/src/main/checkstyle/checks.xml
@@ -0,0 +1,222 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE module PUBLIC
+    "-//Puppy Crawl//DTD Check Configuration 1.2//EN"
+    "http://www.puppycrawl.com/dtds/configuration_1_2.dtd">
+
+<module name="Checker">
+
+  <!-- limit source file size -->
+  <module name="FileLength">
+    <property name="max" value="2000"/>
+  </module>
+
+  <!-- require a header -->
+  <module name="RegexpHeader">
+    <property name="headerFile" value="${checkstyle.header.file}"/>
+    <property name="fileExtensions" value="java"/>
+  </module>
+
+  <!-- forbid tab characters in source files -->
+  <module name="FileTabCharacter"/>
+
+  <module name="NewlineAtEndOfFile">
+    <property name="fileExtensions" value="java"/>
+  </module>
+
+  <module name="LineLength">
+    <property name="ignorePattern" value="(^.*\$.*\$.*$)|(^import .*)"/>
+    <property name="max" value="120"/>
+  </module>
+
+  <module name="TreeWalker">
+    <property name="tabWidth" value="2"/>
+
+    <module name="SuppressionCommentFilter">
+      <property name="offCommentFormat" value="CheckStyle\:([\w\|]+) OFF"/>
+      <property name="onCommentFormat" value="CheckStyle\:([\w\|]+) ON"/>
+      <property name="checkFormat" value="$1"/>
+    </module>
+
+    <!--  annotations -->
+    <module name="AnnotationUseStyle"/>
+    <module name="MissingDeprecated"/>
+    <module name="MissingOverride"/>
+    <module name="SuppressWarnings"/>
+
+    <!-- javadocs -->
+    <module name="JavadocType">
+      <property name="authorFormat" value="\S+"/>
+    </module>
+    <module name="JavadocMethod"/>
+    <module name="JavadocVariable"/>
+    <module name="JavadocStyle">
+      <property name="checkFirstSentence" value="false"/>
+    </module>
+
+    <!-- naming -->
+    <module name="AbstractClassName">
+      <property name="format" value="^Abstract.*$"/>
+    </module>
+    <module name="ClassTypeParameterName"/>
+    <module name="ConstantName"/>
+    <module name="LocalFinalVariableName"/>
+    <module name="LocalVariableName"/>
+    <module name="MemberName"/>
+    <module name="MethodName"/>
+    <module name="MethodTypeParameterName"/>
+    <module name="PackageName"/>
+    <module name="ParameterName"/>
+    <module name="StaticVariableName"/>
+    <module name="TypeName"/>
+
+    <!-- imports -->
+    <module name="AvoidStarImport"/>
+    <module name="AvoidStaticImport"/>
+    <module name="IllegalImport"/>
+    <module name="RedundantImport"/>
+    <module name="UnusedImports"/>
+    <module name="ImportOrder">
+      <property name="groups" value="java,javax"/>
+      <property name="ordered" value="true"/>
+      <property name="option" value="bottom"/>
+    </module>
+
+    <!-- sizes -->
+    <module name="MethodLength">
+      <property name="max" value="300"/>
+    </module>
+    <module name="AnonInnerLength">
+      <property name="max" value="30"/>
+    </module>
+    <module name="ParameterNumber"/>
+    <module name="OuterTypeNumber"/>
+
+    <!-- whitespace -->
+    <module name="GenericWhitespace"/>
+    <module name="EmptyForInitializerPad"/>
+    <module name="EmptyForIteratorPad"/>
+    <module name="MethodParamPad"/>
+    <module name="NoWhitespaceAfter"/>
+    <module name="NoWhitespaceBefore">
+      <property name="allowLineBreaks" value="true"/>
+      <property name="tokens"
+                value="SEMI,DOT,POST_DEC,POST_INC"/>
+    </module>
+    <module name="OperatorWrap">
+      <property name="option" value="eol"/>
+      <property name="tokens"
+                value="BAND,BOR,BSR,BXOR,DIV,EQUAL,GE,GT,LAND,LE,
+                       LITERAL_INSTANCEOF,LOR,LT,MINUS,MOD,NOT_EQUAL,PLUS,
+                       SL,SR,STAR"/>
+    </module>
+    <module name="ParenPad"/>
+    <module name="TypecastParenPad"/>
+    <module name="WhitespaceAfter"/>
+    <module name="WhitespaceAround">
+      <property name="tokens"
+                value="ASSIGN,BAND,BAND_ASSIGN,BOR,BOR_ASSIGN,BSR,BSR_ASSIGN,
+                       BXOR,BXOR_ASSIGN,COLON,DIV,DIV_ASSIGN,EQUAL,GE,GT,LAND,
+                       LE,LITERAL_ASSERT,LITERAL_CATCH,LITERAL_DO,
+                       LITERAL_ELSE,LITERAL_FINALLY,LITERAL_FOR,LITERAL_IF,
+                       LITERAL_RETURN,LITERAL_SYNCHRONIZED,LITERAL_TRY,
+                       LITERAL_WHILE,LOR,LT,MINUS,MINUS_ASSIGN,MOD,MOD_ASSIGN,
+                       NOT_EQUAL,PLUS_ASSIGN,QUESTION,SL,
+                       SL_ASSIGN,SR,SR_ASSIGN,STAR,STAR_ASSIGN"/>
+    </module>
+
+    <!-- modifiers -->
+    <module name="ModifierOrder"/>
+    <module name="RedundantModifier"/>
+
+    <!-- blocks -->
+    <module name="EmptyBlock"/>
+    <module name="LeftCurly">
+      <property name="option" value="nl"/>
+      <property name="tokens"
+                value="CLASS_DEF,CTOR_DEF,INTERFACE_DEF,METHOD_DEF"/>
+    </module>
+    <module name="NeedBraces"/>
+    <module name="RightCurly"/>
+    <module name="AvoidNestedBlocks"/>
+
+    <!-- coding -->
+    <module name="ArrayTrailingComma"/>
+    <module name="CovariantEquals"/>
+    <module name="EmptyStatement"/>
+    <module name="EqualsAvoidNull"/>
+    <module name="EqualsHashCode"/>
+    <module name="FinalLocalVariable"/>
+    <module name="IllegalInstantiation">
+      <property name="classes" value="java.lang.Boolean"/>
+    </module>
+    <module name="InnerAssignment"/>
+    <module name="MissingSwitchDefault"/>
+    <module name="SimplifyBooleanExpression"/>
+    <module name="SimplifyBooleanReturn"/>
+    <module name="StringLiteralEquality"/>
+    <module name="NestedIfDepth">
+      <property name="max" value="5"/>
+    </module>
+    <module name="NestedTryDepth">
+      <property name="max" value="5"/>
+    </module>
+    <module name="SuperClone"/>
+    <module name="SuperFinalize"/>
+    <module name="PackageDeclaration"/>
+    <module name="ReturnCount">
+      <property name="max" value="3"/>
+    </module>
+    <module name="IllegalType">
+      <property name="illegalClassNames"
+                value="java.util.GregorianCalendar, java.util.Hashtable,
+                       java.util.HashSet, java.util.HashMap,
+                       java.util.ArrayList, java.util.LinkedList,
+                       java.util.LinkedHashMap, java.util.LinkedHashSet,
+                       java.util.TreeSet, java.util.TreeMap, java.util.Vector"/>
+    </module>
+    <module name="DeclarationOrder"/>
+    <module name="ParameterAssignment"/>
+    <module name="ExplicitInitialization"/>
+    <module name="DefaultComesLast"/>
+    <module name="FallThrough"/>
+    <module name="MultipleVariableDeclarations"/>
+    <module name="RequireThis">
+      <property name="checkFields" value="false"/>
+      <property name="checkMethods" value="false"/>
+    </module>
+    <module name="UnnecessaryParentheses"/>
+
+    <!-- design -->
+    <module name="VisibilityModifier">
+      <property name="protectedAllowed" value="true"/>
+    </module>
+    <module name="FinalClass"/>
+    <module name="InterfaceIsType"/>
+    <module name="HideUtilityClassConstructor"/>
+    <module name="MutableException"/>
+    <module name="ThrowsCount">
+      <property name="max" value="4"/>
+    </module>
+
+    <!-- prevent trailing whitespace -->
+    <module name="Regexp">
+      <property name="format" value="[ \t]+$"/>
+      <property name="illegalPattern" value="true"/>
+      <property name="message" value="Trailing whitespace"/>
+    </module>
+
+    <!-- misc -->
+    <module name="UpperEll"/>
+    <module name="ArrayTypeStyle"/>
+    <module name="FinalParameters"/>
+    <module name="Indentation">
+      <property name="basicOffset" value="2"/>
+      <property name="caseIndent" value="0"/>
+      <property name="throwsIndent" value="2"/>
+      <property name="arrayInitIndent" value="2"/>
+      <property name="lineWrappingIndentation" value="2"/>
+    </module>
+    <module name="TrailingComment"/>
+
+  </module>
+</module>
diff --git a/src/main/checkstyle/header.txt b/src/main/checkstyle/header.txt
new file mode 100644
index 0000000..0ce0ff7
--- /dev/null
+++ b/src/main/checkstyle/header.txt
@@ -0,0 +1 @@
+/\* See LICENSE for licensing and NOTICE for copyright. \*/
diff --git a/src/main/checkstyle/suppressions.xml b/src/main/checkstyle/suppressions.xml
new file mode 100644
index 0000000..5eaf9df
--- /dev/null
+++ b/src/main/checkstyle/suppressions.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE suppressions PUBLIC
+    "-//Puppy Crawl//DTD Suppressions 1.1//EN"
+    "http://www.puppycrawl.com/dtds/suppressions_1_1.dtd">
+
+<suppressions>
+  <suppress checks="AvoidStaticImport|Javadoc.*"
+            files=".*Test\.java" />
+</suppressions>
diff --git a/src/main/java/org/cryptacular/CiphertextHeader.java b/src/main/java/org/cryptacular/CiphertextHeader.java
new file mode 100644
index 0000000..9b18454
--- /dev/null
+++ b/src/main/java/org/cryptacular/CiphertextHeader.java
@@ -0,0 +1,263 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.BufferUnderflowException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import org.cryptacular.util.ByteUtil;
+
+/**
+ * Cleartext header prepended to ciphertext providing data required for decryption.
+ *
+ * <p>Data format:</p>
+ *
+ * <pre>
+     +-----+----------+-------+------------+---------+
+     | Len | NonceLen | Nonce | KeyNameLen | KeyName |
+     +-----+----------+-------+------------+---------+
+ * </pre>
+ *
+ * <p>Where fields are defined as follows:</p>
+ *
+ * <ul>
+ *   <li>Len - Total header length in bytes (4-byte integer)</li>
+ *   <li>NonceLen - Nonce length in bytes (4-byte integer)</li>
+ *   <li>Nonce - Nonce bytes (variable length)</li>
+ *   <li>KeyNameLen (OPTIONAL) - Key name length in bytes (4-byte integer)</li>
+ *   <li>KeyName (OPTIONAL) - Key name encoded as bytes in platform-specific encoding (variable length)</li>
+ * </ul>
+ *
+ * <p>The last two fields are optional and provide support for multiple keys at the encryption provider. A common case
+ * for multiple keys is key rotation; by tagging encrypted data with a key name, an old key may be retrieved by name to
+ * decrypt outstanding data which will be subsequently re-encrypted with a new key.</p>
+ *
+ * @author  Middleware Services
+ *
+ * @deprecated Superseded by {@link CiphertextHeaderV2}
+ */
+@Deprecated
+public class CiphertextHeader
+{
+  /** Maximum nonce length in bytes. */
+  protected static final int MAX_NONCE_LEN = 255;
+
+  /** Maximum key name length in bytes. */
+  protected static final int MAX_KEYNAME_LEN = 500;
+
+  /** Header nonce field value. */
+  protected final byte[] nonce;
+
+  /** Header key name field value. */
+  protected String keyName;
+
+  /** Header length in bytes. */
+  protected int length;
+
+
+  /**
+   * Creates a new instance with only a nonce.
+   *
+   * @param  nonce  Nonce bytes.
+   */
+  public CiphertextHeader(final byte[] nonce)
+  {
+    this(nonce, null);
+  }
+
+
+  /**
+   * Creates a new instance with a nonce and named key.
+   *
+   * @param  nonce  Nonce bytes.
+   * @param  keyName  Key name.
+   */
+  public CiphertextHeader(final byte[] nonce, final String keyName)
+  {
+    if (nonce.length > MAX_NONCE_LEN) {
+      throw new IllegalArgumentException("Nonce exceeds size limit in bytes (" + MAX_NONCE_LEN + ")");
+    }
+    if (keyName != null) {
+      if (ByteUtil.toBytes(keyName).length > MAX_KEYNAME_LEN) {
+        throw new IllegalArgumentException("Key name exceeds size limit in bytes (" + MAX_KEYNAME_LEN + ")");
+      }
+    }
+    this.nonce = nonce;
+    this.keyName = keyName;
+    length = computeLength();
+  }
+
+  /**
+   * Gets the header length in bytes.
+   *
+   * @return  Header length in bytes.
+   */
+  public int getLength()
+  {
+    return this.length;
+  }
+
+  /**
+   * Gets the bytes of the nonce/IV.
+   *
+   * @return  Nonce bytes.
+   */
+  public byte[] getNonce()
+  {
+    return this.nonce;
+  }
+
+  /**
+   * Gets the encryption key name stored in the header.
+   *
+   * @return  Encryption key name.
+   */
+  public String getKeyName()
+  {
+    return this.keyName;
+  }
+
+
+  /**
+   * Encodes the header into bytes.
+   *
+   * @return  Byte representation of header.
+   */
+  public byte[] encode()
+  {
+    final ByteBuffer bb = ByteBuffer.allocate(length);
+    bb.order(ByteOrder.BIG_ENDIAN);
+    bb.putInt(length);
+    bb.putInt(nonce.length);
+    bb.put(nonce);
+    if (keyName != null) {
+      final byte[] b = keyName.getBytes();
+      bb.putInt(b.length);
+      bb.put(b);
+    }
+    return bb.array();
+  }
+
+
+  /**
+   * @return  Length of this header encoded as bytes.
+   */
+  protected int computeLength()
+  {
+    int len = 8 + nonce.length;
+    if (keyName != null) {
+      len += 4 + keyName.getBytes().length;
+    }
+    return len;
+  }
+
+
+  /**
+   * Creates a header from encrypted data containing a cleartext header prepended to the start.
+   *
+   * @param  data  Encrypted data with prepended header data.
+   *
+   * @return  Decoded header.
+   *
+   * @throws  EncodingException  when ciphertext header cannot be decoded.
+   */
+  public static CiphertextHeader decode(final byte[] data) throws EncodingException
+  {
+    final ByteBuffer bb = ByteBuffer.wrap(data);
+    bb.order(ByteOrder.BIG_ENDIAN);
+
+    final int length = bb.getInt();
+    if (length < 0) {
+      throw new EncodingException("Bad ciphertext header");
+    }
+
+    final byte[] nonce;
+    final int nonceLen;
+    try {
+      nonceLen = bb.getInt();
+      if (nonceLen > MAX_NONCE_LEN) {
+        throw new EncodingException("Bad ciphertext header: maximum nonce length exceeded");
+      }
+      nonce = new byte[nonceLen];
+      bb.get(nonce);
+    } catch (IndexOutOfBoundsException | BufferUnderflowException e) {
+      throw new EncodingException("Bad ciphertext header");
+    }
+
+    String keyName = null;
+    if (length > nonce.length + 8) {
+      final byte[] b;
+      final int keyLen;
+      try {
+        keyLen = bb.getInt();
+        if (keyLen > MAX_KEYNAME_LEN) {
+          throw new EncodingException("Bad ciphertext header: maximum key length exceeded");
+        }
+        b = new byte[keyLen];
+        bb.get(b);
+        keyName = new String(b);
+      } catch (IndexOutOfBoundsException | BufferUnderflowException e) {
+        throw new EncodingException("Bad ciphertext header");
+      }
+    }
+
+    return new CiphertextHeader(nonce, keyName);
+  }
+
+
+  /**
+   * Creates a header from encrypted data containing a cleartext header prepended to the start.
+   *
+   * @param  input  Input stream that is positioned at the start of ciphertext header data.
+   *
+   * @return  Decoded header.
+   *
+   * @throws  EncodingException  when ciphertext header cannot be decoded.
+   * @throws  StreamException  on stream IO errors.
+   */
+  public static CiphertextHeader decode(final InputStream input) throws EncodingException, StreamException
+  {
+    final int length = ByteUtil.readInt(input);
+    if (length < 0) {
+      throw new EncodingException("Bad ciphertext header");
+    }
+
+    final byte[] nonce;
+    final int nonceLen;
+    try {
+      nonceLen = ByteUtil.readInt(input);
+      if (nonceLen > MAX_NONCE_LEN) {
+        throw new EncodingException("Bad ciphertext header: maximum nonce size exceeded");
+      }
+      nonce = new byte[nonceLen];
+      input.read(nonce);
+    } catch (ArrayIndexOutOfBoundsException e) {
+      throw new EncodingException("Bad ciphertext header");
+    } catch (IOException e) {
+      throw new StreamException(e);
+    }
+
+    String keyName = null;
+    if (length > nonce.length + 8) {
+      final byte[] b;
+      final int keyLen;
+      try {
+        keyLen = ByteUtil.readInt(input);
+        if (keyLen > MAX_KEYNAME_LEN) {
+          throw new EncodingException("Bad ciphertext header: maximum key length exceeded");
+        }
+        b = new byte[keyLen];
+        input.read(b);
+      } catch (ArrayIndexOutOfBoundsException e) {
+        throw new EncodingException("Bad ciphertext header");
+      } catch (IOException e) {
+        throw new StreamException(e);
+      }
+      keyName = new String(b);
+    }
+
+    return new CiphertextHeader(nonce, keyName);
+  }
+
+}
diff --git a/src/main/java/org/cryptacular/CiphertextHeaderV2.java b/src/main/java/org/cryptacular/CiphertextHeaderV2.java
new file mode 100644
index 0000000..bb9d566
--- /dev/null
+++ b/src/main/java/org/cryptacular/CiphertextHeaderV2.java
@@ -0,0 +1,311 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.BufferUnderflowException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.function.BiConsumer;
+import java.util.function.Function;
+import javax.crypto.SecretKey;
+import org.bouncycastle.crypto.digests.SHA256Digest;
+import org.bouncycastle.crypto.macs.HMac;
+import org.cryptacular.util.ByteUtil;
+
+/**
+ * Cleartext header prepended to ciphertext providing data required for decryption.
+ *
+ * <p>Data format:</p>
+ *
+ * <pre>
+     +---------+---------+---+----------+-------+------+
+     | Version | KeyName | 0 | NonceLen | Nonce | HMAC |
+     +---------+---------+---+----------+-------+------+
+     |                                                 |
+     +--- 4 ---+--- x ---+ 1 +--- 1 ----+-- y --+- 32 -+
+ * </pre>
+ *
+ * <p>Where fields are defined as follows:</p>
+ *
+ * <ul>
+ *   <li>Version - Header version format as a negative number (4-byte integer). Current version is -2.</li>
+ *   <li>KeyName - Symbolic key name encoded as UTF-8 bytes (variable length)</li>
+ *   <li>0 - Null byte signifying the end of the symbolic key name</li>
+ *   <li>NonceLen - Nonce length in bytes (1-byte unsigned integer)</li>
+ *   <li>Nonce - Nonce bytes (variable length)</li>
+ *   <li>HMAC - HMAC-256 over preceding fields (32 bytes)</li>
+ * </ul>
+ *
+ * <p>The last two fields provide support for multiple keys at the encryption provider. A common case for multiple
+ * keys is key rotation; by tagging encrypted data with a key name, an old key may be retrieved by name to decrypt
+ * outstanding data which will be subsequently re-encrypted with a new key.</p>
+ *
+ * @author  Middleware Services
+ */
+public class CiphertextHeaderV2 extends CiphertextHeader
+{
+  /** Header version format. */
+  private static final int VERSION = -2;
+
+  /** Size of HMAC algorithm output in bytes. */
+  private static final int HMAC_SIZE = 32;
+
+  /** Function to resolve a key from a symbolic key name. */
+  private Function<String, SecretKey> keyLookup;
+
+
+  /**
+   * Creates a new instance with a nonce and named key.
+   *
+   * @param  nonce  Nonce bytes.
+   * @param  keyName  Key name.
+   */
+  public CiphertextHeaderV2(final byte[] nonce, final String keyName)
+  {
+    super(nonce, keyName);
+    if (keyName == null || keyName.isEmpty()) {
+      throw new IllegalArgumentException("Key name is required");
+    }
+  }
+
+
+  /**
+   * Sets the function to resolve keys from {@link #keyName}.
+   *
+   * @param  keyLookup  Key lookup function.
+   */
+  public void setKeyLookup(final Function<String, SecretKey> keyLookup)
+  {
+    this.keyLookup = keyLookup;
+  }
+
+
+  @Override
+  public byte[] encode()
+  {
+    final SecretKey key = keyLookup != null ? keyLookup.apply(keyName) : null;
+    if (key == null) {
+      throw new IllegalStateException("Could not resolve secret key to generate header HMAC");
+    }
+    return encode(key);
+  }
+
+
+  /**
+   * Encodes the header into bytes.
+   *
+   * @param  hmacKey  Key used to generate header HMAC.
+   *
+   * @return  Byte representation of header.
+   */
+  public byte[] encode(final SecretKey hmacKey)
+  {
+    if (hmacKey == null) {
+      throw new IllegalArgumentException("Secret key cannot be null");
+    }
+    final ByteBuffer bb = ByteBuffer.allocate(length);
+    bb.order(ByteOrder.BIG_ENDIAN);
+    bb.putInt(VERSION);
+    bb.put(ByteUtil.toBytes(keyName));
+    bb.put((byte) 0);
+    bb.put(ByteUtil.toUnsignedByte(nonce.length));
+    bb.put(nonce);
+    bb.put(hmac(bb.array(), 0, bb.limit() - HMAC_SIZE));
+    return bb.array();
+  }
+
+
+  /**
+   * @return  Length of this header encoded as bytes.
+   */
+  protected int computeLength()
+  {
+    return 4 + ByteUtil.toBytes(keyName).length + 2 + nonce.length + HMAC_SIZE;
+  }
+
+
+  /**
+   * Creates a header from encrypted data containing a cleartext header prepended to the start.
+   *
+   * @param  data  Encrypted data with prepended header data.
+   * @param  keyLookup  Function used to look up the secret key from the symbolic key name in the header.
+   *
+   * @return  Decoded header.
+   *
+   * @throws  EncodingException  when ciphertext header cannot be decoded.
+   */
+  public static CiphertextHeaderV2 decode(final byte[] data, final Function<String, SecretKey> keyLookup)
+      throws EncodingException
+  {
+    final ByteBuffer bb = ByteBuffer.wrap(data).order(ByteOrder.BIG_ENDIAN);
+    return decodeInternal(
+      ByteBuffer.wrap(data).order(ByteOrder.BIG_ENDIAN),
+      keyLookup,
+      ByteBuffer -> bb.getInt(),
+      ByteBuffer -> bb.get(),
+      (ByteBuffer, output) -> bb.get(output));
+  }
+
+
+  /**
+   * Creates a header from encrypted data containing a cleartext header prepended to the start.
+   *
+   * @param  input  Input stream that is positioned at the start of ciphertext header data.
+   * @param  keyLookup  Function used to look up the secret key from the symbolic key name in the header.
+   *
+   * @return  Decoded header.
+   *
+   * @throws  EncodingException  when ciphertext header cannot be decoded.
+   * @throws  StreamException  on stream IO errors.
+   */
+  public static CiphertextHeaderV2 decode(final InputStream input, final Function<String, SecretKey> keyLookup)
+      throws EncodingException, StreamException
+  {
+    return decodeInternal(
+      input, keyLookup, ByteUtil::readInt, CiphertextHeaderV2::readByte, CiphertextHeaderV2::readInto);
+  }
+
+
+  /**
+   * Internal header decoding routine.
+   *
+   * @param  <T>  Type of input source.
+   * @param  source  Source of header data (input stream or byte buffer).
+   * @param  keyLookup  Function to look up key from symbolic key name in header.
+   * @param  readIntFn  Function that produces a 4-byte integer from the input source.
+   * @param  readByteFn  Function that produces a byte from the input source.
+   * @param  readBytesConsumer  Function that fills a byte array from the input source.
+   *
+   * @return  Decoded header.
+   */
+  private static <T> CiphertextHeaderV2 decodeInternal(
+      final T source,
+      final Function<String, SecretKey> keyLookup,
+      final Function<T, Integer> readIntFn,
+      final Function<T, Byte> readByteFn,
+      final BiConsumer<T, byte[]> readBytesConsumer)
+  {
+    final SecretKey key;
+    final String keyName;
+    final byte[] nonce;
+    final byte[] hmac;
+    try {
+      final int version = readIntFn.apply(source);
+      if (version != VERSION) {
+        throw new EncodingException("Unsupported ciphertext header version");
+      }
+      final ByteArrayOutputStream out = new ByteArrayOutputStream(100);
+      byte b;
+      int count = 0;
+      while ((b = readByteFn.apply(source)) != 0) {
+        out.write(b);
+        if (out.size() > MAX_KEYNAME_LEN) {
+          throw new EncodingException("Bad ciphertext header: maximum nonce length exceeded");
+        }
+        count++;
+      }
+      keyName = ByteUtil.toString(out.toByteArray(), 0, count);
+      key = keyLookup.apply(keyName);
+      if (key == null) {
+        throw new IllegalStateException("Symbolic key name mentioned in header was not found");
+      }
+      final int nonceLen = ByteUtil.toInt(readByteFn.apply(source));
+      nonce = new byte[nonceLen];
+      readBytesConsumer.accept(source, nonce);
+      hmac = new byte[HMAC_SIZE];
+      readBytesConsumer.accept(source, hmac);
+    } catch (IndexOutOfBoundsException | BufferUnderflowException e) {
+      throw new EncodingException("Bad ciphertext header");
+    }
+    final CiphertextHeaderV2 header = new CiphertextHeaderV2(nonce, keyName);
+    final byte[] encoded = header.encode(key);
+    if (!arraysEqual(hmac, 0, encoded, encoded.length - HMAC_SIZE, HMAC_SIZE)) {
+      throw new EncodingException("Ciphertext header HMAC verification failed");
+    }
+    header.setKeyLookup(keyLookup);
+    return header;
+  }
+
+
+  /**
+   * Generates an HMAC-256 over the given input byte array.
+   *
+   * @param  input  Input bytes.
+   * @param  offset  Starting position in input byte array.
+   * @param  length  Number of bytes in input to consume.
+   *
+   * @return  HMAC as byte array.
+   */
+  private static byte[] hmac(final byte[] input, final int offset, final int length)
+  {
+    final HMac hmac = new HMac(new SHA256Digest());
+    final byte[] output = new byte[HMAC_SIZE];
+    hmac.update(input, offset, length);
+    hmac.doFinal(output, 0);
+    return output;
+  }
+
+
+  /**
+   * Read <code>output.length</code> bytes from the input stream into the output buffer.
+   *
+   * @param  input  Input stream.
+   * @param  output  Output buffer.
+   *
+   * @return  number of bytes read
+   *
+   * @throws  StreamException  on stream IO errors.
+   */
+  private static int readInto(final InputStream input, final byte[] output)
+  {
+    try {
+      return input.read(output);
+    } catch (IOException e) {
+      throw new StreamException(e);
+    }
+  }
+
+
+  /**
+   * Read a single byte from the input stream.
+   *
+   * @param  input  Input stream.
+   *
+   * @return  Byte read from input stream.
+   */
+  private static byte readByte(final InputStream input)
+  {
+    try {
+      return (byte) input.read();
+    } catch (IOException e) {
+      throw new StreamException(e);
+    }
+  }
+
+
+  /**
+   * Determines if two byte array ranges are equal bytewise.
+   *
+   * @param  a  First array to compare.
+   * @param  aOff  Offset into first array.
+   * @param  b  Second array to compare.
+   * @param  bOff  Offset into second array.
+   * @param  length  Number of bytes to compare.
+   *
+   * @return  True if every byte in the given range is equal, false otherwise.
+   */
+  private static boolean arraysEqual(final byte[] a, final int aOff, final byte[] b, final int bOff, final int length)
+  {
+    if (length + aOff > a.length || length + bOff > b.length) {
+      return false;
+    }
+    for (int i = 0; i < length; i++) {
+      if (a[i + aOff] != b[i + bOff]) {
+        return false;
+      }
+    }
+    return true;
+  }
+}
diff --git a/src/main/java/org/cryptacular/CryptoException.java b/src/main/java/org/cryptacular/CryptoException.java
new file mode 100644
index 0000000..94756b2
--- /dev/null
+++ b/src/main/java/org/cryptacular/CryptoException.java
@@ -0,0 +1,32 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular;
+
+/**
+ * Runtime error describing a generic cryptographic problem (e.g. bad padding, unsupported cipher).
+ *
+ * @author  Middleware Services
+ */
+public class CryptoException extends RuntimeException
+{
+  /**
+   * Creates a new instance with the given error message.
+   *
+   * @param  message  Error message.
+   */
+  public CryptoException(final String message)
+  {
+    super(message);
+  }
+
+
+  /**
+   * Creates a new instance with the given error message and cause.
+   *
+   * @param  message  Error message.
+   * @param  cause  Error cause.
+   */
+  public CryptoException(final String message, final Throwable cause)
+  {
+    super(message, cause);
+  }
+}
diff --git a/src/main/java/org/cryptacular/EncodingException.java b/src/main/java/org/cryptacular/EncodingException.java
new file mode 100644
index 0000000..641cf81
--- /dev/null
+++ b/src/main/java/org/cryptacular/EncodingException.java
@@ -0,0 +1,32 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular;
+
+/**
+ * Runtime error describing an encoding problem of a cryptographic primitive (e.g. private key, X.509 certificate).
+ *
+ * @author  Middleware Services
+ */
+public class EncodingException extends RuntimeException
+{
+  /**
+   * Creates a new instance with the given error message.
+   *
+   * @param  message  Error message.
+   */
+  public EncodingException(final String message)
+  {
+    super(message);
+  }
+
+
+  /**
+   * Creates a new instance with the given error message and cause.
+   *
+   * @param  message  Error message.
+   * @param  cause  Error cause.
+   */
+  public EncodingException(final String message, final Throwable cause)
+  {
+    super(message, cause);
+  }
+}
diff --git a/src/main/java/org/cryptacular/SaltedHash.java b/src/main/java/org/cryptacular/SaltedHash.java
new file mode 100644
index 0000000..b09d78c
--- /dev/null
+++ b/src/main/java/org/cryptacular/SaltedHash.java
@@ -0,0 +1,120 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular;
+
+import org.cryptacular.codec.Encoder;
+import org.cryptacular.util.CodecUtil;
+
+/**
+ * Container for the output of a salted hash operation that includes both the digest output and salt value.
+ *
+ * @author  Middleware Services
+ */
+public class SaltedHash
+{
+
+  /** Digest output. */
+  private final byte[] hash;
+
+  /** Salt value. */
+  private final byte[] salt;
+
+
+  /**
+   * Creates a new instance with digest and salt data.
+   *
+   * @param  hash  Digest output.
+   * @param  salt  Salt value used to compute salt.
+   */
+  public SaltedHash(final byte[] hash, final byte[] salt)
+  {
+    this.hash = hash;
+    this.salt = salt;
+  }
+
+
+  /**
+   * Creates a new instance from byte input that contains the concatenation of digest output and salt.
+   *
+   * @param  hashWithSalt  Concatenation of hash and salt.
+   * @param  digestLength  Number of bytes in digest output.
+   * @param  toEnd  True if salt is appended to end of hash, false if salt is prepended to hash.
+   */
+  public SaltedHash(final byte[] hashWithSalt, final int digestLength, final boolean toEnd)
+  {
+    this.hash = new byte[digestLength];
+    this.salt = new byte[hashWithSalt.length - digestLength];
+    if (toEnd) {
+      System.arraycopy(hashWithSalt, 0, hash, 0, hash.length);
+      System.arraycopy(hashWithSalt, hash.length, salt, 0, salt.length);
+    } else {
+      System.arraycopy(hashWithSalt, 0, salt, 0, salt.length);
+      System.arraycopy(hashWithSalt, salt.length, hash, 0, hash.length);
+    }
+  }
+
+
+  /** @return  Digest output. */
+  public byte[] getHash()
+  {
+    return hash;
+  }
+
+
+  /** @return  Salt value. */
+  public byte[] getSalt()
+  {
+    return salt;
+  }
+
+  /**
+   * Gets N bytes of salt.
+   *
+   * @param n Number of bytes of salt; must be less than or equal to salt size.
+   *
+   * @return First N bytes of salt.
+   */
+  public byte[] getSalt(final int n)
+  {
+    if (n > salt.length) {
+      throw new IllegalArgumentException("Requested size exceeded length: " + n + ">" + salt.length);
+    }
+    final byte[] bytes = new byte[n];
+    System.arraycopy(salt, 0, bytes, 0, n);
+    return bytes;
+  }
+
+
+  /**
+   * Gets an encoded string of the concatenation of digest output and salt.
+   *
+   * @param  toEnd  True to append salt to end of hash, false to prefix hash with salt.
+   * @param  encoder  Encodes concatenated bytes to a string.
+   *
+   * @return  Salt concatenated to hash encoded as a string.
+   */
+  public String concatenateSalt(final boolean toEnd, final Encoder encoder)
+  {
+    return CodecUtil.encode(encoder, concatenateSalt(toEnd));
+  }
+
+
+  /**
+   * Gets a byte array containing the concatenation of digest output and salt.
+   *
+   * @param  toEnd  True to append salt to end of hash, false to prefix hash with salt.
+   *
+   * @return  Salt concatenated to hash.
+   */
+  public byte[] concatenateSalt(final boolean toEnd)
+  {
+    final byte[] output = new byte[hash.length + salt.length];
+    if (toEnd) {
+      System.arraycopy(hash, 0, output, 0, hash.length);
+      System.arraycopy(salt, 0, output, hash.length, salt.length);
+    } else {
+      System.arraycopy(salt, 0, output, 0, salt.length);
+      System.arraycopy(hash, 0, output, salt.length, hash.length);
+    }
+    return output;
+  }
+}
diff --git a/src/main/java/org/cryptacular/StreamException.java b/src/main/java/org/cryptacular/StreamException.java
new file mode 100644
index 0000000..9aabea7
--- /dev/null
+++ b/src/main/java/org/cryptacular/StreamException.java
@@ -0,0 +1,33 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular;
+
+import java.io.IOException;
+
+/**
+ * Runtime exception thrown on stream IO errors. Effectively a runtime equivalent of {@link java.io.IOException}.
+ *
+ * @author  Middleware Services
+ */
+public class StreamException extends RuntimeException
+{
+  /**
+   * Creates a new instance with the given error message.
+   *
+   * @param  message  Error message.
+   */
+  public StreamException(final String message)
+  {
+    super(message);
+  }
+
+
+  /**
+   * Creates a new instance with causing IO exception.
+   *
+   * @param  cause  IO exception to wrap.
+   */
+  public StreamException(final IOException cause)
+  {
+    super("IO error", cause);
+  }
+}
diff --git a/src/main/java/org/cryptacular/adapter/AEADBlockCipherAdapter.java b/src/main/java/org/cryptacular/adapter/AEADBlockCipherAdapter.java
new file mode 100644
index 0000000..27bc4aa
--- /dev/null
+++ b/src/main/java/org/cryptacular/adapter/AEADBlockCipherAdapter.java
@@ -0,0 +1,78 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.adapter;
+
+import org.bouncycastle.crypto.CipherParameters;
+import org.bouncycastle.crypto.InvalidCipherTextException;
+import org.bouncycastle.crypto.modes.AEADBlockCipher;
+import org.cryptacular.CryptoException;
+
+/**
+ * Adapts a {@link AEADBlockCipherAdapter}.
+ *
+ * @author  Middleware Services
+ */
+public class AEADBlockCipherAdapter implements BlockCipherAdapter
+{
+
+  /** All methods delegate to this instance. */
+  private final AEADBlockCipher cipherDelegate;
+
+
+  /**
+   * Creates a new instance that delegates to the given cipher.
+   *
+   * @param  delegate  Adapted cipher.
+   */
+  public AEADBlockCipherAdapter(final AEADBlockCipher delegate)
+  {
+    cipherDelegate = delegate;
+  }
+
+
+  @Override
+  public int getOutputSize(final int len)
+  {
+    return cipherDelegate.getOutputSize(len);
+  }
+
+
+  @Override
+  public void init(final boolean forEncryption, final CipherParameters params) throws CryptoException
+  {
+    try {
+      cipherDelegate.init(forEncryption, params);
+    } catch (RuntimeException e) {
+      throw new CryptoException("Cipher initialization error", e);
+    }
+  }
+
+
+  @Override
+  public int processBytes(final byte[] in, final int inOff, final int len, final byte[] out, final int outOff)
+      throws CryptoException
+  {
+    try {
+      return cipherDelegate.processBytes(in, inOff, len, out, outOff);
+    } catch (RuntimeException e) {
+      throw new CryptoException("Cipher processing error", e);
+    }
+  }
+
+
+  @Override
+  public int doFinal(final byte[] out, final int outOff) throws CryptoException
+  {
+    try {
+      return cipherDelegate.doFinal(out, outOff);
+    } catch (InvalidCipherTextException e) {
+      throw new CryptoException("Error finalizing cipher", e);
+    }
+  }
+
+
+  @Override
+  public void reset()
+  {
+    cipherDelegate.reset();
+  }
+}
diff --git a/src/main/java/org/cryptacular/adapter/AbstractWrappedDSAKey.java b/src/main/java/org/cryptacular/adapter/AbstractWrappedDSAKey.java
new file mode 100644
index 0000000..e10ba48
--- /dev/null
+++ b/src/main/java/org/cryptacular/adapter/AbstractWrappedDSAKey.java
@@ -0,0 +1,63 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.adapter;
+
+import java.math.BigInteger;
+import java.security.interfaces.DSAParams;
+import org.bouncycastle.crypto.params.DSAKeyParameters;
+
+/**
+ * Base class for DSA wrapped keys.
+ *
+ * @param  <T>  DSA key parameters type.
+ *
+ * @author  Middleware Services
+ */
+public abstract class AbstractWrappedDSAKey<T extends DSAKeyParameters> extends AbstractWrappedKey<T>
+{
+
+  /** DSA algorithm name. */
+  private static final String ALGORITHM = "DSA";
+
+
+  /**
+   * Creates a new instance that wraps the given key.
+   *
+   * @param  wrappedKey  Key to wrap.
+   */
+  public AbstractWrappedDSAKey(final T wrappedKey)
+  {
+    super(wrappedKey);
+  }
+
+
+  /** @return  DSA key parameters. */
+  public DSAParams getParams()
+  {
+    return new DSAParams() {
+      @Override
+      public BigInteger getP()
+      {
+        return delegate.getParameters().getP();
+      }
+
+      @Override
+      public BigInteger getQ()
+      {
+        return delegate.getParameters().getQ();
+      }
+
+      @Override
+      public BigInteger getG()
+      {
+        return delegate.getParameters().getG();
+      }
+    };
+  }
+
+
+  @Override
+  public String getAlgorithm()
+  {
+    return ALGORITHM;
+  }
+}
diff --git a/src/main/java/org/cryptacular/adapter/AbstractWrappedECKey.java b/src/main/java/org/cryptacular/adapter/AbstractWrappedECKey.java
new file mode 100644
index 0000000..46d0c96
--- /dev/null
+++ b/src/main/java/org/cryptacular/adapter/AbstractWrappedECKey.java
@@ -0,0 +1,55 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.adapter;
+
+import java.security.spec.ECParameterSpec;
+import java.security.spec.ECPoint;
+import org.bouncycastle.crypto.params.ECDomainParameters;
+import org.bouncycastle.crypto.params.ECKeyParameters;
+import org.bouncycastle.jcajce.provider.asymmetric.util.EC5Util;
+
+/**
+ * Base class for wrapped EC keys.
+ *
+ * @param  <T>  EC key parameters type.
+ *
+ * @author  Middleware Services
+ */
+public abstract class AbstractWrappedECKey<T extends ECKeyParameters> extends AbstractWrappedKey<T>
+{
+
+  /** Elliptic curve algorithm name. */
+  private static final String ALGORITHM = "EC";
+
+
+  /**
+   * Creates a new instance that wraps the given key.
+   *
+   * @param  wrappedKey  Key to wrap.
+   */
+  public AbstractWrappedECKey(final T wrappedKey)
+  {
+    super(wrappedKey);
+  }
+
+
+  /** @return  EC domain parameters. */
+  public ECParameterSpec getParams()
+  {
+    final ECDomainParameters params = delegate.getParameters();
+    return
+      new ECParameterSpec(
+        EC5Util.convertCurve(params.getCurve(), params.getSeed()),
+        new ECPoint(
+          params.getG().normalize().getXCoord().toBigInteger(),
+          params.getG().normalize().getYCoord().toBigInteger()),
+        params.getN(),
+        params.getH().intValue());
+  }
+
+
+  @Override
+  public String getAlgorithm()
+  {
+    return ALGORITHM;
+  }
+}
diff --git a/src/main/java/org/cryptacular/adapter/AbstractWrappedKey.java b/src/main/java/org/cryptacular/adapter/AbstractWrappedKey.java
new file mode 100644
index 0000000..15d65bd
--- /dev/null
+++ b/src/main/java/org/cryptacular/adapter/AbstractWrappedKey.java
@@ -0,0 +1,73 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.adapter;
+
+import java.io.IOException;
+import java.security.Key;
+import org.bouncycastle.crypto.params.AsymmetricKeyParameter;
+import org.bouncycastle.crypto.util.PrivateKeyInfoFactory;
+import org.bouncycastle.crypto.util.SubjectPublicKeyInfoFactory;
+import org.cryptacular.EncodingException;
+
+/**
+ * JCE/JDK key base class that wraps a BC native private key.
+ *
+ * @param  <T>  Asymmetric key parameters type wrapped by this class.
+ *
+ * @author  Middleware Services
+ */
+public abstract class AbstractWrappedKey<T extends AsymmetricKeyParameter> implements Key
+{
+
+  /** PKCS#8 format identifier used with private keys. */
+  public static final String PKCS8_FORMAT = "PKCS#8";
+
+  /** X.509 format identifier used with private keys. */
+  public static final String X509_FORMAT = "X.509";
+
+
+  /** Wrapped key. */
+  protected final T delegate;
+
+
+  /**
+   * Creates a new instance that wraps the given BC key.
+   *
+   * @param  wrappedKey  BC key to wrap.
+   */
+  public AbstractWrappedKey(final T wrappedKey)
+  {
+    if (wrappedKey == null) {
+      throw new IllegalArgumentException("Wrapped key cannot be null.");
+    }
+    delegate = wrappedKey;
+  }
+
+
+  /** @return  {@value #PKCS8_FORMAT} in the case of a private key, otherwise {@link #X509_FORMAT}. */
+  @Override
+  public String getFormat()
+  {
+    if (delegate.isPrivate()) {
+      return PKCS8_FORMAT;
+    }
+    return X509_FORMAT;
+  }
+
+
+  /**
+   * @return  Encoded PrivateKeyInfo structure in the case of a private key, otherwise an encoded SubjectPublicKeyInfo
+   *          structure.
+   */
+  @Override
+  public byte[] getEncoded()
+  {
+    try {
+      if (delegate.isPrivate()) {
+        return PrivateKeyInfoFactory.createPrivateKeyInfo(delegate).getEncoded();
+      }
+      return SubjectPublicKeyInfoFactory.createSubjectPublicKeyInfo(delegate).getEncoded();
+    } catch (IOException e) {
+      throw new EncodingException("Key encoding error", e);
+    }
+  }
+}
diff --git a/src/main/java/org/cryptacular/adapter/AbstractWrappedRSAKey.java b/src/main/java/org/cryptacular/adapter/AbstractWrappedRSAKey.java
new file mode 100644
index 0000000..4d50a55
--- /dev/null
+++ b/src/main/java/org/cryptacular/adapter/AbstractWrappedRSAKey.java
@@ -0,0 +1,44 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.adapter;
+
+import java.math.BigInteger;
+import org.bouncycastle.crypto.params.RSAKeyParameters;
+
+/**
+ * Base class for RSA wrapped keys.
+ *
+ * @param  <T>  RSA key parameters type handled by this class.
+ *
+ * @author  Middleware Services
+ */
+public abstract class AbstractWrappedRSAKey<T extends RSAKeyParameters> extends AbstractWrappedKey<T>
+{
+
+  /** RSA algorithm name. */
+  private static final String ALGORITHM = "RSA";
+
+
+  /**
+   * Creates a new instance that wraps the given key.
+   *
+   * @param  wrappedKey  Key to wrap.
+   */
+  public AbstractWrappedRSAKey(final T wrappedKey)
+  {
+    super(wrappedKey);
+  }
+
+
+  /** @return  Gets the RSA modulus. */
+  public BigInteger getModulus()
+  {
+    return delegate.getModulus();
+  }
+
+
+  @Override
+  public String getAlgorithm()
+  {
+    return ALGORITHM;
+  }
+}
diff --git a/src/main/java/org/cryptacular/adapter/BlockCipherAdapter.java b/src/main/java/org/cryptacular/adapter/BlockCipherAdapter.java
new file mode 100644
index 0000000..c24a718
--- /dev/null
+++ b/src/main/java/org/cryptacular/adapter/BlockCipherAdapter.java
@@ -0,0 +1,35 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.adapter;
+
+import org.cryptacular.CryptoException;
+
+/**
+ * Adapter for all block cipher types.
+ *
+ * @author  Middleware Services
+ */
+public interface BlockCipherAdapter extends CipherAdapter
+{
+
+  /**
+   * Gets the size of the output buffer required to hold the output of an input buffer of the given size.
+   *
+   * @param  len  Length of input buffer.
+   *
+   * @return  Size of output buffer.
+   */
+  int getOutputSize(int len);
+
+
+  /**
+   * Finish the encryption/decryption operation (e.g. apply padding).
+   *
+   * @param  out  Output buffer to receive final processing output.
+   * @param  outOff  Offset into output buffer where processed data should start.
+   *
+   * @return  Number of bytes written to output buffer.
+   *
+   * @throws  CryptoException  on underlying cipher finalization errors.
+   */
+  int doFinal(byte[] out, int outOff) throws CryptoException;
+}
diff --git a/src/main/java/org/cryptacular/adapter/BufferedBlockCipherAdapter.java b/src/main/java/org/cryptacular/adapter/BufferedBlockCipherAdapter.java
new file mode 100644
index 0000000..2877ac1
--- /dev/null
+++ b/src/main/java/org/cryptacular/adapter/BufferedBlockCipherAdapter.java
@@ -0,0 +1,78 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.adapter;
+
+import org.bouncycastle.crypto.BufferedBlockCipher;
+import org.bouncycastle.crypto.CipherParameters;
+import org.bouncycastle.crypto.InvalidCipherTextException;
+import org.cryptacular.CryptoException;
+
+/**
+ * Adapts a {@link BufferedBlockCipher}.
+ *
+ * @author  Middleware Services
+ */
+public class BufferedBlockCipherAdapter implements BlockCipherAdapter
+{
+
+  /** All methods delegate to this instance. */
+  private final BufferedBlockCipher cipherDelegate;
+
+
+  /**
+   * Creates a new instance that delegates to the given cipher.
+   *
+   * @param  delegate  Adapted cipher.
+   */
+  public BufferedBlockCipherAdapter(final BufferedBlockCipher delegate)
+  {
+    cipherDelegate = delegate;
+  }
+
+
+  @Override
+  public int getOutputSize(final int len)
+  {
+    return cipherDelegate.getOutputSize(len);
+  }
+
+
+  @Override
+  public void init(final boolean forEncryption, final CipherParameters params) throws CryptoException
+  {
+    try {
+      cipherDelegate.init(forEncryption, params);
+    } catch (RuntimeException e) {
+      throw new CryptoException("Cipher initialization error", e);
+    }
+  }
+
+
+  @Override
+  public int processBytes(final byte[] in, final int inOff, final int len, final byte[] out, final int outOff)
+      throws CryptoException
+  {
+    try {
+      return cipherDelegate.processBytes(in, inOff, len, out, outOff);
+    } catch (RuntimeException e) {
+      throw new CryptoException("Cipher processing error", e);
+    }
+  }
+
+
+  @Override
+  public int doFinal(final byte[] out, final int outOff) throws CryptoException
+  {
+    try {
+      return cipherDelegate.doFinal(out, outOff);
+    } catch (InvalidCipherTextException e) {
+      throw new CryptoException("Error finalizing cipher", e);
+    }
+  }
+
+
+  @Override
+  public void reset()
+  {
+    cipherDelegate.reset();
+  }
+}
diff --git a/src/main/java/org/cryptacular/adapter/CipherAdapter.java b/src/main/java/org/cryptacular/adapter/CipherAdapter.java
new file mode 100644
index 0000000..34a6217
--- /dev/null
+++ b/src/main/java/org/cryptacular/adapter/CipherAdapter.java
@@ -0,0 +1,46 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.adapter;
+
+import org.bouncycastle.crypto.CipherParameters;
+import org.cryptacular.CryptoException;
+
+/**
+ * Provides a consistent interface for cipher operations against dissimilar BC cipher types.
+ *
+ * @author  Middleware Services
+ */
+public interface CipherAdapter
+{
+
+  /**
+   * Initialize the underlying cipher.
+   *
+   * @param  forEncryption  True for encryption mode, false for decryption mode.
+   * @param  params  Cipher initialization parameters.
+   *
+   * @throws  CryptoException  on underlying cipher initialization errors.
+   */
+  void init(boolean forEncryption, CipherParameters params) throws CryptoException;
+
+
+  /**
+   * Process an array of bytes, producing output if necessary.
+   *
+   * @param  in  Input data.
+   * @param  inOff  Offset at which the input data starts.
+   * @param  len  The number of bytes in the input data to process.
+   * @param  out  Array to receive any data produced by cipher.
+   * @param  outOff  Offset into output array.
+   *
+   * @return  The number of bytes produced by the cipher.
+   *
+   * @throws  CryptoException  on underlying cipher data handling errors.
+   */
+  int processBytes(byte[] in, int inOff, int len, byte[] out, int outOff) throws CryptoException;
+
+
+  /**
+   * Reset the cipher. After resetting the cipher is in the same state as it was after the last init (if there was one).
+   */
+  void reset();
+}
diff --git a/src/main/java/org/cryptacular/adapter/Converter.java b/src/main/java/org/cryptacular/adapter/Converter.java
new file mode 100644
index 0000000..d096ddc
--- /dev/null
+++ b/src/main/java/org/cryptacular/adapter/Converter.java
@@ -0,0 +1,78 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.adapter;
+
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import org.bouncycastle.crypto.params.AsymmetricKeyParameter;
+import org.bouncycastle.crypto.params.DSAPrivateKeyParameters;
+import org.bouncycastle.crypto.params.DSAPublicKeyParameters;
+import org.bouncycastle.crypto.params.ECPrivateKeyParameters;
+import org.bouncycastle.crypto.params.ECPublicKeyParameters;
+import org.bouncycastle.crypto.params.RSAKeyParameters;
+import org.bouncycastle.crypto.params.RSAPrivateCrtKeyParameters;
+
+/**
+ * Static factory with methods to convert from BC type to the corresponding JCE type.
+ *
+ * @author  Middleware Services
+ */
+public final class Converter
+{
+
+  /** Private constructor of utility class. */
+  private Converter() {}
+
+
+  /**
+   * Produces a {@link PrivateKey} from a BC private key type.
+   *
+   * @param  bcKey  BC private key.
+   *
+   * @return  JCE private key.
+   */
+  public static PrivateKey convertPrivateKey(final AsymmetricKeyParameter bcKey)
+  {
+    if (!bcKey.isPrivate()) {
+      throw new IllegalArgumentException("AsymmetricKeyParameter is not a private key: " + bcKey);
+    }
+
+    final PrivateKey key;
+    if (bcKey instanceof DSAPrivateKeyParameters) {
+      key = new WrappedDSAPrivateKey((DSAPrivateKeyParameters) bcKey);
+    } else if (bcKey instanceof ECPrivateKeyParameters) {
+      key = new WrappedECPrivateKey((ECPrivateKeyParameters) bcKey);
+    } else if (bcKey instanceof RSAPrivateCrtKeyParameters) {
+      key = new WrappedRSAPrivateCrtKey((RSAPrivateCrtKeyParameters) bcKey);
+    } else {
+      throw new IllegalArgumentException("Unsupported private key " + bcKey);
+    }
+    return key;
+  }
+
+
+  /**
+   * Produces a {@link PublicKey} from a BC public key type.
+   *
+   * @param  bcKey  BC public key.
+   *
+   * @return  JCE public key.
+   */
+  public static PublicKey convertPublicKey(final AsymmetricKeyParameter bcKey)
+  {
+    if (bcKey.isPrivate()) {
+      throw new IllegalArgumentException("AsymmetricKeyParameter is not a public key: " + bcKey);
+    }
+
+    final PublicKey key;
+    if (bcKey instanceof DSAPublicKeyParameters) {
+      key = new WrappedDSAPublicKey((DSAPublicKeyParameters) bcKey);
+    } else if (bcKey instanceof ECPublicKeyParameters) {
+      key = new WrappedECPublicKey((ECPublicKeyParameters) bcKey);
+    } else if (bcKey instanceof RSAKeyParameters) {
+      key = new WrappedRSAPublicKey((RSAKeyParameters) bcKey);
+    } else {
+      throw new IllegalArgumentException("Unsupported public key " + bcKey);
+    }
+    return key;
+  }
+}
diff --git a/src/main/java/org/cryptacular/adapter/WrappedDSAPrivateKey.java b/src/main/java/org/cryptacular/adapter/WrappedDSAPrivateKey.java
new file mode 100644
index 0000000..16b9950
--- /dev/null
+++ b/src/main/java/org/cryptacular/adapter/WrappedDSAPrivateKey.java
@@ -0,0 +1,35 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.adapter;
+
+import java.math.BigInteger;
+import java.security.interfaces.DSAPrivateKey;
+import org.bouncycastle.crypto.params.DSAPrivateKeyParameters;
+
+/**
+ * JCE/JDK DSA private key that wraps the corresponding BC DSA private key type, {@link DSAPrivateKeyParameters}.
+ *
+ * @author  Middleware Services
+ */
+public class WrappedDSAPrivateKey extends AbstractWrappedDSAKey<DSAPrivateKeyParameters> implements DSAPrivateKey
+{
+
+  /** serialVersionUID. */
+  private static final long serialVersionUID = 8393283358287883368L;
+
+  /**
+   * Creates a new instance that wraps the given BC DSA private key.
+   *
+   * @param  parameters  BC DSA private key.
+   */
+  public WrappedDSAPrivateKey(final DSAPrivateKeyParameters parameters)
+  {
+    super(parameters);
+  }
+
+
+  @Override
+  public BigInteger getX()
+  {
+    return delegate.getX();
+  }
+}
diff --git a/src/main/java/org/cryptacular/adapter/WrappedDSAPublicKey.java b/src/main/java/org/cryptacular/adapter/WrappedDSAPublicKey.java
new file mode 100644
index 0000000..dbf3522
--- /dev/null
+++ b/src/main/java/org/cryptacular/adapter/WrappedDSAPublicKey.java
@@ -0,0 +1,36 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.adapter;
+
+import java.math.BigInteger;
+import java.security.interfaces.DSAPublicKey;
+import org.bouncycastle.crypto.params.DSAPublicKeyParameters;
+
+/**
+ * JCE/JDK DSA public key that wraps the corresponding BC DSA public key type, {@link DSAPublicKeyParameters}.
+ *
+ * @author  Middleware Services
+ */
+public class WrappedDSAPublicKey extends AbstractWrappedDSAKey<DSAPublicKeyParameters> implements DSAPublicKey
+{
+
+  /** serialVersionUID. */
+  private static final long serialVersionUID = -3349509056520420431L;
+
+  /**
+   * Creates a new instance that wraps the given key.
+   *
+   * @param  wrappedKey  DSA key to wrap.
+   */
+  public WrappedDSAPublicKey(final DSAPublicKeyParameters wrappedKey)
+  {
+    super(wrappedKey);
+  }
+
+
+  @Override
+  public BigInteger getY()
+  {
+    return delegate.getY();
+  }
+
+}
diff --git a/src/main/java/org/cryptacular/adapter/WrappedECPrivateKey.java b/src/main/java/org/cryptacular/adapter/WrappedECPrivateKey.java
new file mode 100644
index 0000000..e0ac1d1
--- /dev/null
+++ b/src/main/java/org/cryptacular/adapter/WrappedECPrivateKey.java
@@ -0,0 +1,35 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.adapter;
+
+import java.math.BigInteger;
+import java.security.interfaces.ECPrivateKey;
+import org.bouncycastle.crypto.params.ECPrivateKeyParameters;
+
+/**
+ * JCE/JDK EC private key that wraps the corresponding BC EC private key type, {@link ECPrivateKeyParameters}.
+ *
+ * @author  Middleware Services
+ */
+public class WrappedECPrivateKey extends AbstractWrappedECKey<ECPrivateKeyParameters> implements ECPrivateKey
+{
+
+  /** serialVersionUID. */
+  private static final long serialVersionUID = -2383997830074646642L;
+
+  /**
+   * Creates a new instance that wraps the given key.
+   *
+   * @param  wrappedKey  EC key to wrap.
+   */
+  public WrappedECPrivateKey(final ECPrivateKeyParameters wrappedKey)
+  {
+    super(wrappedKey);
+  }
+
+
+  @Override
+  public BigInteger getS()
+  {
+    return delegate.getD();
+  }
+}
diff --git a/src/main/java/org/cryptacular/adapter/WrappedECPublicKey.java b/src/main/java/org/cryptacular/adapter/WrappedECPublicKey.java
new file mode 100644
index 0000000..a49fa73
--- /dev/null
+++ b/src/main/java/org/cryptacular/adapter/WrappedECPublicKey.java
@@ -0,0 +1,39 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.adapter;
+
+import java.security.interfaces.ECPublicKey;
+import java.security.spec.ECPoint;
+import org.bouncycastle.crypto.params.ECPublicKeyParameters;
+
+/**
+ * JCE/JDK EC public key that wraps the corresponding BC EC public key type, {@link ECPublicKeyParameters}.
+ *
+ * @author  Middleware Services
+ */
+public class WrappedECPublicKey extends AbstractWrappedECKey<ECPublicKeyParameters> implements ECPublicKey
+{
+
+  /** serialVersionUID. */
+  private static final long serialVersionUID = -8218654577692012657L;
+
+  /**
+   * Creates a new instance that wraps the given key.
+   *
+   * @param  wrappedKey  EC key to wrap.
+   */
+  public WrappedECPublicKey(final ECPublicKeyParameters wrappedKey)
+  {
+    super(wrappedKey);
+  }
+
+
+  @Override
+  public ECPoint getW()
+  {
+    return
+      new ECPoint(
+        delegate.getQ().normalize().getXCoord().toBigInteger(),
+        delegate.getQ().normalize().getYCoord().toBigInteger());
+  }
+
+}
diff --git a/src/main/java/org/cryptacular/adapter/WrappedRSAPrivateCrtKey.java b/src/main/java/org/cryptacular/adapter/WrappedRSAPrivateCrtKey.java
new file mode 100644
index 0000000..4dc9274
--- /dev/null
+++ b/src/main/java/org/cryptacular/adapter/WrappedRSAPrivateCrtKey.java
@@ -0,0 +1,80 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.adapter;
+
+import java.math.BigInteger;
+import java.security.interfaces.RSAPrivateCrtKey;
+import org.bouncycastle.crypto.params.RSAPrivateCrtKeyParameters;
+
+/**
+ * JCE/JDK RSA private key that wraps the corresponding BC RSA private key type, {@link RSAPrivateCrtKeyParameters}.
+ *
+ * @author  Middleware Services
+ */
+public class WrappedRSAPrivateCrtKey extends AbstractWrappedRSAKey<RSAPrivateCrtKeyParameters>
+  implements RSAPrivateCrtKey
+{
+
+  /** serialVersionUID. */
+  private static final long serialVersionUID = 99555083744578278L;
+
+  /**
+   * Creates a new instance that wraps the given BC RSA private key.
+   *
+   * @param  parameters  BC RSA private (certificate) key.
+   */
+  public WrappedRSAPrivateCrtKey(final RSAPrivateCrtKeyParameters parameters)
+  {
+    super(parameters);
+  }
+
+
+  @Override
+  public BigInteger getPublicExponent()
+  {
+    return delegate.getPublicExponent();
+  }
+
+
+  @Override
+  public BigInteger getPrimeP()
+  {
+    return delegate.getP();
+  }
+
+
+  @Override
+  public BigInteger getPrimeQ()
+  {
+    return delegate.getQ();
+  }
+
+
+  @Override
+  public BigInteger getPrimeExponentP()
+  {
+    return delegate.getDP();
+  }
+
+
+  @Override
+  public BigInteger getPrimeExponentQ()
+  {
+    return delegate.getDQ();
+  }
+
+
+  @Override
+  public BigInteger getCrtCoefficient()
+  {
+    return delegate.getQInv();
+  }
+
+
+  @Override
+  public BigInteger getPrivateExponent()
+  {
+    return delegate.getExponent();
+  }
+
+
+}
diff --git a/src/main/java/org/cryptacular/adapter/WrappedRSAPublicKey.java b/src/main/java/org/cryptacular/adapter/WrappedRSAPublicKey.java
new file mode 100644
index 0000000..f9becd7
--- /dev/null
+++ b/src/main/java/org/cryptacular/adapter/WrappedRSAPublicKey.java
@@ -0,0 +1,35 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.adapter;
+
+import java.math.BigInteger;
+import java.security.interfaces.RSAPublicKey;
+import org.bouncycastle.crypto.params.RSAKeyParameters;
+
+/**
+ * JCE/JDK RSA public key that wraps the corresponding BC RSA public key type, {@link RSAKeyParameters}.
+ *
+ * @author  Middleware Services
+ */
+public class WrappedRSAPublicKey extends AbstractWrappedRSAKey<RSAKeyParameters> implements RSAPublicKey
+{
+
+  /** serialVersionUID. */
+  private static final long serialVersionUID = -5733201361124222309L;
+
+  /**
+   * Creates a new instance that wraps the given key.
+   *
+   * @param  wrappedKey  RSA key to wrap.
+   */
+  public WrappedRSAPublicKey(final RSAKeyParameters wrappedKey)
+  {
+    super(wrappedKey);
+  }
+
+
+  @Override
+  public BigInteger getPublicExponent()
+  {
+    return delegate.getExponent();
+  }
+}
diff --git a/src/main/java/org/cryptacular/asn/ASN1Decoder.java b/src/main/java/org/cryptacular/asn/ASN1Decoder.java
new file mode 100644
index 0000000..b2a8f2b
--- /dev/null
+++ b/src/main/java/org/cryptacular/asn/ASN1Decoder.java
@@ -0,0 +1,27 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.asn;
+
+import org.cryptacular.EncodingException;
+
+/**
+ * Strategy interface for converting encoded ASN.1 bytes to an object.
+ *
+ * @param  <T>  Type of object to produce on decode.
+ *
+ * @author  Middleware Services
+ */
+public interface ASN1Decoder<T>
+{
+
+  /**
+   * Produces an object from an encoded representation.
+   *
+   * @param  encoded  ASN.1 encoded data.
+   * @param  args  Additional data required to perform decoding.
+   *
+   * @return  Decoded object.
+   *
+   * @throws  EncodingException  on encoding errors.
+   */
+  T decode(byte[] encoded, Object... args) throws EncodingException;
+}
diff --git a/src/main/java/org/cryptacular/asn/AbstractPrivateKeyDecoder.java b/src/main/java/org/cryptacular/asn/AbstractPrivateKeyDecoder.java
new file mode 100644
index 0000000..0e4d376
--- /dev/null
+++ b/src/main/java/org/cryptacular/asn/AbstractPrivateKeyDecoder.java
@@ -0,0 +1,73 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.asn;
+
+import org.cryptacular.EncodingException;
+import org.cryptacular.util.PemUtil;
+
+/**
+ * Base class for all private key decoders.
+ *
+ * @param  <T>  Type produced by decode operation.
+ *
+ * @author  Middleware Services
+ */
+public abstract class AbstractPrivateKeyDecoder<T> implements ASN1Decoder<T>
+{
+
+  @Override
+  public T decode(final byte[] encoded, final Object... args) throws EncodingException
+  {
+    try {
+      final byte[] asn1Bytes;
+      if (args != null && args.length > 0 && args[0] instanceof char[]) {
+        asn1Bytes = decryptKey(encoded, (char[]) args[0]);
+      } else {
+        asn1Bytes = tryConvertPem(encoded);
+      }
+      return decodeASN1(asn1Bytes);
+    } catch (EncodingException e) {
+      throw e;
+    } catch (RuntimeException e) {
+      throw new EncodingException("Key encoding error", e);
+    }
+  }
+
+
+  /**
+   * Tests the given encoded input and converts it to PEM if it is detected, stripping out any header/footer data in the
+   * process.
+   *
+   * @param  input  Encoded data that may be PEM encoded.
+   *
+   * @return  Decoded data if PEM encoding detected, otherwise original data.
+   */
+  protected byte[] tryConvertPem(final byte[] input)
+  {
+    if (PemUtil.isPem(input)) {
+      return PemUtil.decode(input);
+    }
+    return input;
+  }
+
+
+  /**
+   * Decrypts an encrypted key in either PKCS#8 or OpenSSL "traditional" format. Both PEM and DER encodings are
+   * supported.
+   *
+   * @param  encrypted  Encoded encrypted key data.
+   * @param  password  Password to decrypt key.
+   *
+   * @return  Decrypted key.
+   */
+  protected abstract byte[] decryptKey(byte[] encrypted, char[] password);
+
+
+  /**
+   * Decodes the given raw ASN.1 encoded data into a private key of the type supported by this class.
+   *
+   * @param  encoded  Encoded ASN.1 data.
+   *
+   * @return  Private key object.
+   */
+  protected abstract T decodeASN1(byte[] encoded);
+}
diff --git a/src/main/java/org/cryptacular/asn/OpenSSLPrivateKeyDecoder.java b/src/main/java/org/cryptacular/asn/OpenSSLPrivateKeyDecoder.java
new file mode 100644
index 0000000..5934d85
--- /dev/null
+++ b/src/main/java/org/cryptacular/asn/OpenSSLPrivateKeyDecoder.java
@@ -0,0 +1,135 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.asn;
+
+import java.math.BigInteger;
+import org.bouncycastle.asn1.ASN1InputStream;
+import org.bouncycastle.asn1.ASN1Integer;
+import org.bouncycastle.asn1.ASN1Object;
+import org.bouncycastle.asn1.ASN1ObjectIdentifier;
+import org.bouncycastle.asn1.ASN1OctetString;
+import org.bouncycastle.asn1.ASN1Sequence;
+import org.bouncycastle.asn1.ASN1TaggedObject;
+import org.bouncycastle.asn1.x9.X9ECParameters;
+import org.bouncycastle.crypto.params.AsymmetricKeyParameter;
+import org.bouncycastle.crypto.params.DSAParameters;
+import org.bouncycastle.crypto.params.DSAPrivateKeyParameters;
+import org.bouncycastle.crypto.params.ECDomainParameters;
+import org.bouncycastle.crypto.params.ECPrivateKeyParameters;
+import org.bouncycastle.crypto.params.RSAPrivateCrtKeyParameters;
+import org.bouncycastle.jcajce.provider.asymmetric.util.ECUtil;
+import org.cryptacular.EncodingException;
+import org.cryptacular.pbe.OpenSSLAlgorithm;
+import org.cryptacular.pbe.OpenSSLEncryptionScheme;
+import org.cryptacular.util.ByteUtil;
+import org.cryptacular.util.CodecUtil;
+import org.cryptacular.util.PemUtil;
+
+/**
+ * Decrypts PEM-encoded OpenSSL "traditional" format private keys.
+ *
+ * @author  Middleware Services
+ */
+public class OpenSSLPrivateKeyDecoder extends AbstractPrivateKeyDecoder<AsymmetricKeyParameter>
+{
+
+  @Override
+  protected byte[] decryptKey(final byte[] encrypted, final char[] password)
+  {
+    final String pem = new String(encrypted, ByteUtil.ASCII_CHARSET);
+    final int start = pem.indexOf(PemUtil.DEK_INFO);
+    final int eol = pem.indexOf('\n', start);
+    final String[] dekInfo = pem.substring(start + 10, eol).split(",");
+    final String alg = dekInfo[0];
+    final byte[] iv = CodecUtil.hex(dekInfo[1]);
+    final byte[] bytes = PemUtil.decode(encrypted);
+    return new OpenSSLEncryptionScheme(OpenSSLAlgorithm.fromAlgorithmId(alg), iv, password).decrypt(bytes);
+  }
+
+
+  @Override
+  protected AsymmetricKeyParameter decodeASN1(final byte[] encoded)
+  {
+    final ASN1InputStream stream = new ASN1InputStream(encoded);
+    final ASN1Object o;
+    try {
+      o = stream.readObject();
+    } catch (Exception e) {
+      throw new EncodingException("Invalid encoded key", e);
+    }
+
+    final AsymmetricKeyParameter key;
+    if (o instanceof ASN1ObjectIdentifier) {
+      // EC private key with named curve in the default OpenSSL format emitted
+      // by openssl ecparam -name xxxx -genkey
+      try {
+        key = parseECPrivateKey(ASN1Sequence.getInstance(stream.readObject()));
+      } catch (Exception e) {
+        throw new EncodingException("Invalid encoded key", e);
+      }
+    } else {
+      // OpenSSL "traditional" format is an ASN.1 sequence of key parameters
+
+      // Detect key type based on number and types of parameters:
+      // RSA -> {version, mod, pubExp, privExp, prime1, prime2, exp1, exp2, c}
+      // DSA -> {version, p, q, g, pubExp, privExp}
+      // EC ->  {version, privateKey, parameters, publicKey}
+      final ASN1Sequence sequence = ASN1Sequence.getInstance(o);
+      if (sequence.size() == 9) {
+        // RSA private certificate key
+        key = new RSAPrivateCrtKeyParameters(
+          ASN1Integer.getInstance(sequence.getObjectAt(1)).getValue(),
+          ASN1Integer.getInstance(sequence.getObjectAt(2)).getValue(),
+          ASN1Integer.getInstance(sequence.getObjectAt(3)).getValue(),
+          ASN1Integer.getInstance(sequence.getObjectAt(4)).getValue(),
+          ASN1Integer.getInstance(sequence.getObjectAt(5)).getValue(),
+          ASN1Integer.getInstance(sequence.getObjectAt(6)).getValue(),
+          ASN1Integer.getInstance(sequence.getObjectAt(7)).getValue(),
+          ASN1Integer.getInstance(sequence.getObjectAt(8)).getValue());
+      } else if (sequence.size() == 6) {
+        // DSA private key
+        key = new DSAPrivateKeyParameters(
+          ASN1Integer.getInstance(sequence.getObjectAt(5)).getValue(),
+          new DSAParameters(
+            ASN1Integer.getInstance(sequence.getObjectAt(1)).getValue(),
+            ASN1Integer.getInstance(sequence.getObjectAt(2)).getValue(),
+            ASN1Integer.getInstance(sequence.getObjectAt(3)).getValue()));
+      } else if (sequence.size() == 4) {
+        // EC private key with explicit curve
+        key = parseECPrivateKey(sequence);
+      } else {
+        throw new EncodingException("Invalid OpenSSL traditional private key format.");
+      }
+    }
+    return key;
+  }
+
+
+  /**
+   * Parses an EC private key as defined in RFC 5915.
+   * <pre>
+   *      ECPrivateKey ::= SEQUENCE {
+   *        version        INTEGER { ecPrivkeyVer1(1) } (ecPrivkeyVer1),
+   *        privateKey     OCTET STRING,
+   *        parameters [0] ECParameters {{ NamedCurve }} OPTIONAL,
+   *        publicKey  [1] BIT STRING OPTIONAL
+   *      }
+   * </pre>
+   *
+   * @param  seq  ASN1 sequence to parse
+   *
+   * @return  EC private key
+   */
+  private ECPrivateKeyParameters parseECPrivateKey(final ASN1Sequence seq)
+  {
+    final ASN1TaggedObject asn1Params = ASN1TaggedObject.getInstance(seq.getObjectAt(2));
+    final X9ECParameters params;
+    if (asn1Params.getBaseObject() instanceof ASN1ObjectIdentifier) {
+      params = ECUtil.getNamedCurveByOid(ASN1ObjectIdentifier.getInstance(asn1Params.getBaseObject()));
+    } else {
+      params = X9ECParameters.getInstance(asn1Params.getBaseObject());
+    }
+    return new ECPrivateKeyParameters(
+      new BigInteger(1, ASN1OctetString.getInstance(seq.getObjectAt(1)).getOctets()),
+      new ECDomainParameters(params.getCurve(), params.getG(), params.getN(), params.getH(), params.getSeed()));
+  }
+}
diff --git a/src/main/java/org/cryptacular/asn/PKCS8PrivateKeyDecoder.java b/src/main/java/org/cryptacular/asn/PKCS8PrivateKeyDecoder.java
new file mode 100644
index 0000000..289fbdb
--- /dev/null
+++ b/src/main/java/org/cryptacular/asn/PKCS8PrivateKeyDecoder.java
@@ -0,0 +1,54 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.asn;
+
+import java.io.IOException;
+import org.bouncycastle.asn1.ASN1InputStream;
+import org.bouncycastle.asn1.pkcs.EncryptedPrivateKeyInfo;
+import org.bouncycastle.asn1.pkcs.PBEParameter;
+import org.bouncycastle.asn1.pkcs.PBES2Parameters;
+import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers;
+import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
+import org.bouncycastle.crypto.params.AsymmetricKeyParameter;
+import org.bouncycastle.crypto.util.PrivateKeyFactory;
+import org.cryptacular.EncodingException;
+import org.cryptacular.pbe.EncryptionScheme;
+import org.cryptacular.pbe.PBES1Algorithm;
+import org.cryptacular.pbe.PBES1EncryptionScheme;
+import org.cryptacular.pbe.PBES2EncryptionScheme;
+
+/**
+ * Decodes PEM or DER-encoded PKCS#8 private keys.
+ *
+ * @author  Middleware Services
+ */
+public class PKCS8PrivateKeyDecoder extends AbstractPrivateKeyDecoder<AsymmetricKeyParameter>
+{
+
+  @Override
+  protected byte[] decryptKey(final byte[] encrypted, final char[] password)
+  {
+    final EncryptionScheme scheme;
+    final EncryptedPrivateKeyInfo ki = EncryptedPrivateKeyInfo.getInstance(tryConvertPem(encrypted));
+    final AlgorithmIdentifier alg = ki.getEncryptionAlgorithm();
+    if (PKCSObjectIdentifiers.id_PBES2.equals(alg.getAlgorithm())) {
+      scheme = new PBES2EncryptionScheme(PBES2Parameters.getInstance(alg.getParameters()), password);
+    } else {
+      scheme = new PBES1EncryptionScheme(
+        PBES1Algorithm.fromOid(alg.getAlgorithm().getId()),
+        PBEParameter.getInstance(alg.getParameters()),
+        password);
+    }
+    return scheme.decrypt(ki.getEncryptedData());
+  }
+
+
+  @Override
+  protected AsymmetricKeyParameter decodeASN1(final byte[] encoded)
+  {
+    try (ASN1InputStream is = new ASN1InputStream(encoded)) {
+      return PrivateKeyFactory.createKey(is.readObject().getEncoded());
+    } catch (IOException e) {
+      throw new EncodingException("ASN.1 decoding error", e);
+    }
+  }
+}
diff --git a/src/main/java/org/cryptacular/asn/PublicKeyDecoder.java b/src/main/java/org/cryptacular/asn/PublicKeyDecoder.java
new file mode 100644
index 0000000..cdeb4d7
--- /dev/null
+++ b/src/main/java/org/cryptacular/asn/PublicKeyDecoder.java
@@ -0,0 +1,33 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.asn;
+
+import java.io.IOException;
+import org.bouncycastle.asn1.ASN1InputStream;
+import org.bouncycastle.crypto.params.AsymmetricKeyParameter;
+import org.bouncycastle.crypto.util.PublicKeyFactory;
+import org.cryptacular.EncodingException;
+import org.cryptacular.util.PemUtil;
+
+/**
+ * Decodes public keys formatted in an X.509 SubjectPublicKeyInfo structure in either PEM or DER encoding.
+ *
+ * @author  Middleware Services
+ */
+public class PublicKeyDecoder implements ASN1Decoder<AsymmetricKeyParameter>
+{
+
+  @Override
+  public AsymmetricKeyParameter decode(final byte[] encoded, final Object... args)
+  {
+    try {
+      if (PemUtil.isPem(encoded)) {
+        return PublicKeyFactory.createKey(PemUtil.decode(encoded));
+      }
+      try (ASN1InputStream is = new ASN1InputStream(encoded)) {
+        return PublicKeyFactory.createKey(is.readObject().getEncoded());
+      }
+    } catch (IOException e) {
+      throw new EncodingException("ASN.1 decoding error", e);
+    }
+  }
+}
diff --git a/src/main/java/org/cryptacular/bean/AEADBlockCipherBean.java b/src/main/java/org/cryptacular/bean/AEADBlockCipherBean.java
new file mode 100644
index 0000000..542513b
--- /dev/null
+++ b/src/main/java/org/cryptacular/bean/AEADBlockCipherBean.java
@@ -0,0 +1,107 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.bean;
+
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.security.KeyStore;
+import javax.crypto.SecretKey;
+import org.bouncycastle.crypto.modes.AEADBlockCipher;
+import org.bouncycastle.crypto.params.AEADParameters;
+import org.bouncycastle.crypto.params.KeyParameter;
+import org.cryptacular.CiphertextHeader;
+import org.cryptacular.adapter.AEADBlockCipherAdapter;
+import org.cryptacular.generator.Nonce;
+import org.cryptacular.spec.Spec;
+
+/**
+ * Cipher bean that performs encryption with a block cipher in AEAD mode (e.g. GCM, CCM).
+ *
+ * @author  Middleware Services
+ */
+public class AEADBlockCipherBean extends AbstractBlockCipherBean
+{
+
+  /** Mac size in bits. */
+  public static final int MAC_SIZE_BITS = 128;
+
+  /** AEAD block cipher specification (algorithm, mode, padding). */
+  private Spec<AEADBlockCipher> blockCipherSpec;
+
+
+  /** Creates a new instance. */
+  public AEADBlockCipherBean() {}
+
+
+  /**
+   * Creates a new instance by specifying all properties.
+   *
+   * @param  blockCipherSpec  Block cipher specification.
+   * @param  keyStore  Key store containing encryption key.
+   * @param  keyAlias  Name of encryption key entry in key store.
+   * @param  keyPassword  Password used to decrypt key entry in keystore.
+   * @param  nonce  Nonce/IV generator.
+   */
+  public AEADBlockCipherBean(
+    final Spec<AEADBlockCipher> blockCipherSpec,
+    final KeyStore keyStore,
+    final String keyAlias,
+    final String keyPassword,
+    final Nonce nonce)
+  {
+    super(keyStore, keyAlias, keyPassword, nonce);
+    setBlockCipherSpec(blockCipherSpec);
+  }
+
+
+  /** @return  Block cipher specification. */
+  public Spec<AEADBlockCipher> getBlockCipherSpec()
+  {
+    return blockCipherSpec;
+  }
+
+
+  /**
+   * Sets the AEAD block cipher specification.
+   *
+   * @param  blockCipherSpec  Describes a block cipher in terms of algorithm, mode, and padding.
+   */
+  public void setBlockCipherSpec(final Spec<AEADBlockCipher> blockCipherSpec)
+  {
+    this.blockCipherSpec = blockCipherSpec;
+  }
+
+
+  @Override
+  public void encrypt(final InputStream input, final OutputStream output)
+  {
+    if (blockCipherSpec.toString().endsWith("CCM")) {
+      throw new UnsupportedOperationException("CCM mode ciphers do not support chunked encryption.");
+    }
+    super.encrypt(input, output);
+  }
+
+
+  @Override
+  public void decrypt(final InputStream input, final OutputStream output)
+  {
+    if (blockCipherSpec.toString().endsWith("CCM")) {
+      throw new UnsupportedOperationException("CCM mode ciphers do not support chunked decryption.");
+    }
+    super.decrypt(input, output);
+  }
+
+
+  @Override
+  protected AEADBlockCipherAdapter newCipher(final CiphertextHeader header, final boolean mode)
+  {
+    final AEADBlockCipher cipher = blockCipherSpec.newInstance();
+    final SecretKey key = lookupKey(header.getKeyName());
+    final AEADParameters params = new AEADParameters(
+      new KeyParameter(key.getEncoded()),
+      MAC_SIZE_BITS,
+      header.getNonce(),
+      header.encode());
+    cipher.init(mode, params);
+    return new AEADBlockCipherAdapter(cipher);
+  }
+}
diff --git a/src/main/java/org/cryptacular/bean/AbstractBlockCipherBean.java b/src/main/java/org/cryptacular/bean/AbstractBlockCipherBean.java
new file mode 100644
index 0000000..0d06b32
--- /dev/null
+++ b/src/main/java/org/cryptacular/bean/AbstractBlockCipherBean.java
@@ -0,0 +1,115 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.bean;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.security.KeyStore;
+import org.cryptacular.CiphertextHeader;
+import org.cryptacular.StreamException;
+import org.cryptacular.adapter.BlockCipherAdapter;
+import org.cryptacular.generator.Nonce;
+import org.cryptacular.util.StreamUtil;
+
+/**
+ * Base class for all cipher beans that use block cipher.
+ *
+ * @author  Middleware Services
+ */
+public abstract class AbstractBlockCipherBean extends AbstractCipherBean
+{
+
+  /** Creates a new instance. */
+  public AbstractBlockCipherBean() {}
+
+
+  /**
+   * Creates a new instance by specifying all properties.
+   *
+   * @param  keyStore  Key store containing encryption key.
+   * @param  keyAlias  Name of encryption key entry in key store.
+   * @param  keyPassword  Password used to decrypt key entry in keystore.
+   * @param  nonce  Nonce/IV generator.
+   */
+  public AbstractBlockCipherBean(
+    final KeyStore keyStore,
+    final String keyAlias,
+    final String keyPassword,
+    final Nonce nonce)
+  {
+    super(keyStore, keyAlias, keyPassword, nonce);
+  }
+
+
+  @Override
+  protected byte[] process(final CiphertextHeader header, final boolean mode, final byte[] input)
+  {
+    final BlockCipherAdapter cipher = newCipher(header, mode);
+    int outOff;
+    final int inOff;
+    final int length;
+    final byte[] output;
+    if (mode) {
+      final byte[] headerBytes = header.encode();
+      final int outSize = headerBytes.length + cipher.getOutputSize(input.length);
+      output = new byte[outSize];
+      System.arraycopy(headerBytes, 0, output, 0, headerBytes.length);
+      inOff = 0;
+      outOff = headerBytes.length;
+      length = input.length;
+    } else {
+      outOff = 0;
+      inOff = header.getLength();
+      length = input.length - inOff;
+
+      final int outSize = cipher.getOutputSize(length);
+      output = new byte[outSize];
+    }
+    outOff += cipher.processBytes(input, inOff, length, output, outOff);
+    outOff += cipher.doFinal(output, outOff);
+    if (outOff < output.length) {
+      final byte[] copy = new byte[outOff];
+      System.arraycopy(output, 0, copy, 0, outOff);
+      return copy;
+    }
+    return output;
+  }
+
+
+  @Override
+  protected void process(
+    final CiphertextHeader header,
+    final boolean mode,
+    final InputStream input,
+    final OutputStream output)
+  {
+    final BlockCipherAdapter cipher = newCipher(header, mode);
+    final int outSize = cipher.getOutputSize(StreamUtil.CHUNK_SIZE);
+    final byte[] outBuf = new byte[Math.max(outSize, StreamUtil.CHUNK_SIZE)];
+    StreamUtil.pipeAll(
+      input,
+      output,
+      (in, inOff, len, out) -> {
+        final int n = cipher.processBytes(in, inOff, len, outBuf, 0);
+        out.write(outBuf, 0, n);
+      });
+
+    final int n = cipher.doFinal(outBuf, 0);
+    try {
+      output.write(outBuf, 0, n);
+    } catch (IOException e) {
+      throw new StreamException(e);
+    }
+  }
+
+
+  /**
+   * Creates a new cipher adapter instance suitable for the block cipher used by this class.
+   *
+   * @param  header  Ciphertext header.
+   * @param  mode  True for encryption; false for decryption.
+   *
+   * @return  Block cipher adapter that wraps an initialized block cipher that is ready for use in the given mode.
+   */
+  protected abstract BlockCipherAdapter newCipher(CiphertextHeader header, boolean mode);
+}
diff --git a/src/main/java/org/cryptacular/bean/AbstractCipherBean.java b/src/main/java/org/cryptacular/bean/AbstractCipherBean.java
new file mode 100644
index 0000000..fd73763
--- /dev/null
+++ b/src/main/java/org/cryptacular/bean/AbstractCipherBean.java
@@ -0,0 +1,219 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.bean;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.security.Key;
+import java.security.KeyStore;
+import javax.crypto.SecretKey;
+import org.cryptacular.CiphertextHeader;
+import org.cryptacular.CiphertextHeaderV2;
+import org.cryptacular.CryptoException;
+import org.cryptacular.EncodingException;
+import org.cryptacular.StreamException;
+import org.cryptacular.generator.Nonce;
+import org.cryptacular.util.CipherUtil;
+
+/**
+ * Base class for all cipher beans. The base class assumes all ciphertext output will contain a prepended {@link
+ * CiphertextHeaderV2} containing metadata that facilitates decryption.
+ *
+ * @author  Middleware Services
+ */
+public abstract class AbstractCipherBean implements CipherBean
+{
+
+  /** Keystore containing symmetric key(s). */
+  private KeyStore keyStore;
+
+  /** Keystore entry for alias of current key. */
+  private String keyAlias;
+
+  /** Password on private key entry. */
+  private String keyPassword;
+
+  /** Nonce generator. */
+  private Nonce nonce;
+
+
+  /** Creates a new instance. */
+  public AbstractCipherBean() {}
+
+
+  /**
+   * Creates a new instance by specifying all properties.
+   *
+   * @param  keyStore  Key store containing encryption key.
+   * @param  keyAlias  Name of encryption key entry in key store.
+   * @param  keyPassword  Password used to decrypt key entry in keystore.
+   * @param  nonce  Nonce/IV generator.
+   */
+  public AbstractCipherBean(final KeyStore keyStore, final String keyAlias, final String keyPassword, final Nonce nonce)
+  {
+    setKeyStore(keyStore);
+    setKeyAlias(keyAlias);
+    setKeyPassword(keyPassword);
+    setNonce(nonce);
+  }
+
+
+  /** @return  Keystore that contains the {@link SecretKey}. */
+  public KeyStore getKeyStore()
+  {
+    return keyStore;
+  }
+
+
+  /**
+   * Sets the keystore containing encryption/decryption key(s). The keystore must contain a {@link SecretKey} entry
+   * whose alias is given by {@link #setKeyAlias(String)}, which will be used at the encryption key. It may contain
+   * additional symmetric keys to support, for example, key rollover where some existing ciphertexts have headers
+   * specifying a different key. In general all keys used for outstanding ciphertexts should be contained in the
+   * keystore.
+   *
+   * @param  keyStore  Keystore containing encryption key(s).
+   */
+  public void setKeyStore(final KeyStore keyStore)
+  {
+    this.keyStore = keyStore;
+  }
+
+
+  /** @return  Alias that specifies the {@link KeyStore} entry containing the {@link SecretKey}. */
+  public String getKeyAlias()
+  {
+    return keyAlias;
+  }
+
+
+  /**
+   * Sets the keystore entry alias used to locate the current encryption key.
+   *
+   * @param  keyAlias  Alias of {@link SecretKey} used for encryption.
+   */
+  public void setKeyAlias(final String keyAlias)
+  {
+    this.keyAlias = keyAlias;
+  }
+
+
+  /**
+   * Sets the password used to access the encryption key.
+   *
+   * @param  keyPassword  Encryption key password.
+   */
+  public void setKeyPassword(final String keyPassword)
+  {
+    this.keyPassword = keyPassword;
+  }
+
+
+  /** @return  Nonce/IV generation strategy. */
+  public Nonce getNonce()
+  {
+    return nonce;
+  }
+
+
+  /**
+   * Sets the nonce/IV generation strategy.
+   *
+   * @param  nonce  Nonce generator.
+   */
+  public void setNonce(final Nonce nonce)
+  {
+    this.nonce = nonce;
+  }
+
+
+  @Override
+  public byte[] encrypt(final byte[] input) throws CryptoException
+  {
+    return process(header(), true, input);
+  }
+
+
+  @Override
+  public void encrypt(final InputStream input, final OutputStream output) throws CryptoException, StreamException
+  {
+    final CiphertextHeaderV2 header = header();
+    try {
+      output.write(header.encode());
+    } catch (IOException e) {
+      throw new StreamException(e);
+    }
+    process(header, true, input, output);
+  }
+
+
+  @Override
+  public byte[] decrypt(final byte[] input) throws CryptoException, EncodingException
+  {
+    return process(CipherUtil.decodeHeader(input, this::lookupKey), false, input);
+  }
+
+
+  @Override
+  public void decrypt(final InputStream input, final OutputStream output)
+      throws CryptoException, EncodingException, StreamException
+  {
+    process(CipherUtil.decodeHeader(input, this::lookupKey), false, input, output);
+  }
+
+
+  /**
+   * Looks up secret key entry in the {@link #keyStore}.
+   *
+   * @param  alias  Name of secret key entry.
+   *
+   * @return  Secret key.
+   */
+  protected SecretKey lookupKey(final String alias)
+  {
+    final Key key;
+    try {
+      key = keyStore.getKey(alias, keyPassword.toCharArray());
+    } catch (Exception e) {
+      throw new CryptoException("Error accessing keystore entry " + alias, e);
+    }
+    if (key instanceof SecretKey) {
+      return (SecretKey) key;
+    }
+    throw new CryptoException(alias + " is not a secret key");
+  }
+
+
+  /**
+   * Processes the given data under the action of the cipher.
+   *
+   * @param  header  Ciphertext header.
+   * @param  mode  True for encryption; false for decryption.
+   * @param  input  Data to process by cipher.
+   *
+   * @return  Ciphertext data under encryption, plaintext data under decryption.
+   */
+  protected abstract byte[] process(CiphertextHeader header, boolean mode, byte[] input);
+
+
+  /**
+   * Processes the given data under the action of the cipher.
+   *
+   * @param  header  Ciphertext header.
+   * @param  mode  True for encryption; false for decryption.
+   * @param  input  Stream containing input data.
+   * @param  output  Stream that receives output of cipher.
+   */
+  protected abstract void process(CiphertextHeader header, boolean mode, InputStream input, OutputStream output);
+
+
+  /**
+   * @return  New ciphertext header for a pending encryption or decryption operation performed by this instance.
+   */
+  private CiphertextHeaderV2 header()
+  {
+    final CiphertextHeaderV2 header = new CiphertextHeaderV2(nonce.generate(), keyAlias);
+    header.setKeyLookup(this::lookupKey);
+    return header;
+  }
+}
diff --git a/src/main/java/org/cryptacular/bean/AbstractHashBean.java b/src/main/java/org/cryptacular/bean/AbstractHashBean.java
new file mode 100644
index 0000000..23be306
--- /dev/null
+++ b/src/main/java/org/cryptacular/bean/AbstractHashBean.java
@@ -0,0 +1,106 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.bean;
+
+import org.bouncycastle.crypto.Digest;
+import org.cryptacular.spec.Spec;
+import org.cryptacular.util.HashUtil;
+
+/**
+ * Abstract base class for all hash beans.
+ *
+ * @author  Middleware Services
+ */
+public abstract class AbstractHashBean
+{
+
+  /** Digest specification. */
+  private Spec<Digest> digestSpec;
+
+  /** Number of hash rounds. */
+  private int iterations = 1;
+
+
+  /** Creates a new instance. */
+  public AbstractHashBean() {}
+
+
+  /**
+   * Creates a new instance by specifying all properties.
+   *
+   * @param  digestSpec  Digest specification.
+   * @param  iterations  Number of hash rounds.
+   */
+  public AbstractHashBean(final Spec<Digest> digestSpec, final int iterations)
+  {
+    setDigestSpec(digestSpec);
+    setIterations(iterations);
+  }
+
+
+  /** @return  Digest specification that determines the instance of {@link Digest} used to compute the hash. */
+  public Spec<Digest> getDigestSpec()
+  {
+    return digestSpec;
+  }
+
+
+  /**
+   * Sets the digest specification that determines the instance of {@link Digest} used to compute the hash.
+   *
+   * @param  digestSpec  Digest algorithm specification.
+   */
+  public void setDigestSpec(final Spec<Digest> digestSpec)
+  {
+    this.digestSpec = digestSpec;
+  }
+
+
+  /** @return  Number of iterations the digest function is applied to the input data. */
+  public int getIterations()
+  {
+    return iterations;
+  }
+
+
+  /**
+   * Sets the number of iterations the digest function is applied to the input data.
+   *
+   * @param  iterations  Number of hash rounds. Default value is 1.
+   */
+  public void setIterations(final int iterations)
+  {
+    if (iterations < 1) {
+      throw new IllegalArgumentException("Iterations must be positive");
+    }
+    this.iterations = iterations;
+  }
+
+
+  /**
+   * Hashes the given data.
+   *
+   * @param  data  Data to hash.
+   *
+   * @return  Digest output.
+   */
+  protected byte[] hashInternal(final Object... data)
+  {
+    return HashUtil.hash(digestSpec.newInstance(), iterations, data);
+  }
+
+
+  /**
+   * Compares the hash of the given data against a known hash output.
+   *
+   * @param  hash  Known hash value. If the length of the array is greater than the length of the digest output,
+   *               anything beyond the digest length is considered salt data that is hashed <strong>after</strong> the
+   *               input data.
+   * @param  data  Data to hash.
+   *
+   * @return  True if hashed data equals known hash output, false otherwise.
+   */
+  protected boolean compareInternal(final byte[] hash, final Object... data)
+  {
+    return HashUtil.compareHash(digestSpec.newInstance(), hash, iterations, data);
+  }
+}
diff --git a/src/main/java/org/cryptacular/bean/BCryptHashBean.java b/src/main/java/org/cryptacular/bean/BCryptHashBean.java
new file mode 100644
index 0000000..a77b144
--- /dev/null
+++ b/src/main/java/org/cryptacular/bean/BCryptHashBean.java
@@ -0,0 +1,330 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.bean;
+
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import java.nio.charset.StandardCharsets;
+import org.bouncycastle.crypto.generators.BCrypt;
+import org.cryptacular.CryptoException;
+import org.cryptacular.StreamException;
+import org.cryptacular.codec.Base64Decoder;
+import org.cryptacular.codec.Base64Encoder;
+import org.cryptacular.codec.Decoder;
+import org.cryptacular.codec.Encoder;
+import org.cryptacular.util.ByteUtil;
+
+/**
+ * {@link HashBean} implementation that uses the <em>bcrypt</em> algorithm for hashing. Hash strings of the following
+ * format are supported:
+ * <br>
+ * <code>
+ *   $2n$cost$xxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
+ *
+ *   where:
+ *     n is an optional bcrypt algorithm version (typically "a" or "b")
+ *     4 &le; cost &le; 31
+ *     x is 22 characters of encoded salt
+ *     y is 31 characters of encoded hash bytes
+ * </code>
+ * <p>
+ * The encoding for salt and hash bytes is a variant of base-64 encoding without padding in the following alphabet:
+ * </p>
+ * <br>
+ * <code>./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789</code>
+ *
+ * @author  Middleware Services
+ */
+public class BCryptHashBean implements HashBean<String>
+{
+  /** Custom base-64 alphabet. */
+  private static final String ALPHABET = "./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+
+  /** BCrypt cost factor in the range [4, 31]. Default value is {@value}. */
+  private int cost = 12;
+
+  /** BCrypt version used when computing hashes. Default value is {@value}. */
+  private String version = "2b";
+
+
+  /** Creates a new instance. */
+  public BCryptHashBean() {}
+
+
+  /**
+   * Creates a new instance that uses the given cost factor when hashing.
+   *
+   * @param costFactor BCrypt cost in the range [4, 31].
+   */
+  public BCryptHashBean(final int costFactor)
+  {
+    setCost(costFactor);
+  }
+
+
+  /**
+   * Sets the bcrypt cost factor.
+   *
+   * @param costFactor BCrypt cost in the range [4, 31].
+   */
+  public void setCost(final int costFactor)
+  {
+    if (costFactor < 4 || costFactor > 31) {
+      throw new IllegalArgumentException("Cost must be in the range [4, 31].");
+    }
+    cost = costFactor;
+  }
+
+
+  /**
+   * Sets the bcrypt version.
+   *
+   * @param  ver  Bcrypt version, e.g. "2b"
+   */
+  public void setVersion(final String ver)
+  {
+    if (!ver.startsWith("2") && ver.length() <= 2) {
+      throw new IllegalArgumentException("Invalid version: " + ver);
+    }
+    version = ver;
+  }
+
+  /**
+   * Compute a bcrypt hash of the form <code>$2n$cost$xxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy</code>
+   * given a salt and a password.
+   * @param  data  A 2-element array containing salt and password. The salt may be encoded per the bcrypt standard
+   *               or raw bytes.
+   *
+   * @return An encoded bcrypt hash, <code>yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy</code> in the specification above.
+   *
+   * @throws CryptoException on bcrypt algorithm errors.
+   */
+  @Override
+  public String hash(final Object... data) throws CryptoException
+  {
+    if (data.length != 2) {
+      throw new IllegalArgumentException("Expected exactly two elements in data array but got " + data.length);
+    }
+    return encode(BCrypt.generate(password(version, data[1]), salt(data[0]), cost), 23);
+  }
+
+
+  /**
+   * Compares a bcrypt hash of the form <code>$2n$cost$xxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy</code>
+   * with the computed hash from the given password. The bcrypt algorithm parameters are derived from the reference
+   * bcrypt hash string.
+   *
+   * @param  data  A 1-element array containing password.
+   *
+   * @return True if the computed hash is exactly equal to the reference hash, false otherwise.
+   *
+   * @throws CryptoException on bcrypt algorithm errors.
+   */
+  @Override
+  public boolean compare(final String hash, final Object... data) throws CryptoException, StreamException
+  {
+    if (data.length != 1) {
+      throw new IllegalArgumentException("Expected exactly one element in data array but got " + data.length);
+    }
+    final BCryptParameters params = new BCryptParameters(hash);
+    final byte[] computed = BCrypt.generate(password(params.getVersion(), data[0]), params.getSalt(), params.getCost());
+    for (int i = 0; i < 23; i++) {
+      if (params.getHash()[i] != computed[i]) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+
+  /**
+   * Encodes an input byte array into a string using the configured encoder.
+   *
+   * @param  bytes  Input bytes to encode.
+   * @param  length  Number of bytes of input to encode.
+   *
+   * @return  Input encoded as a string.
+   */
+  private static String encode(final byte[] bytes, final int length)
+  {
+    final Encoder encoder = new Base64Encoder.Builder().setAlphabet(ALPHABET).setPadding(false).build();
+    // Only want 184 bits (23 bytes) of the output
+    final ByteBuffer input = ByteBuffer.wrap(bytes, 0, length);
+    final CharBuffer output = CharBuffer.allocate(encoder.outputSize(length));
+    encoder.encode(input, output);
+    encoder.finalize(output);
+    return output.flip().toString();
+  }
+
+
+  /**
+   * Decodes an input string into a byte array using the configured decoder.
+   *
+   * @param  input  Input string to decode.
+   * @param  length  Desired output size in bytes.
+   *
+   * @return  Input decoded as a byte array.
+   */
+  private static byte[] decode(final String input, final int length)
+  {
+    final Decoder decoder = new Base64Decoder.Builder().setAlphabet(ALPHABET).setPadding(false).build();
+    final ByteBuffer output = ByteBuffer.allocate(decoder.outputSize(input.length()));
+    decoder.decode(CharBuffer.wrap(input), output);
+    decoder.finalize(output);
+    output.flip();
+    if (output.limit() != length) {
+      throw new IllegalArgumentException("Input is not of the expected size: " + output.limit() + "!=" + length);
+    }
+    return ByteUtil.toArray(output);
+  }
+
+
+  /**
+   * Converts an input object into a salt as an array of bytes.
+   *
+   * @param  data  Input salt as a byte array or encoded string.
+   *
+   * @return  Salt as byte array.
+   */
+  private static byte[] salt(final Object data)
+  {
+    if (data instanceof byte[]) {
+      return (byte[]) data;
+    } else if (data instanceof String) {
+      return decode((String) data, 16);
+    }
+    throw new IllegalArgumentException("Expected byte array or base-64 string.");
+  }
+
+
+  /**
+   * Converts an input object into a password as an array of UTF-8 bytes. A null terminator is added if the supplied
+   * data does not end with one.
+   *
+   * @param  version  Bcrypt version, e.g. "2a".
+   * @param  data  Input password.
+   *
+   * @return  Null terminated password as UTF-8 byte array.
+   */
+  private static byte[] password(final String version, final Object data)
+  {
+    if (data instanceof byte[]) {
+      final byte[] origData = (byte[]) data;
+      final byte[] newData;
+      if (origData[origData.length - 1] != 0x00) {
+        newData = new byte[origData.length + 1];
+        System.arraycopy(origData, 0, newData, 0, origData.length);
+        newData[newData.length - 1] = 0x00;
+      } else {
+        newData = origData;
+      }
+      return newData;
+    }
+    final StringBuilder sb = new StringBuilder();
+    if (data instanceof char[]) {
+      sb.append((char[]) data);
+    } else if (data instanceof String) {
+      sb.append((String) data);
+    } else {
+      throw new IllegalArgumentException("Expected byte array or string.");
+    }
+    if (sb.charAt(sb.length() - 1) != '\0') {
+      // Version 2a and later requires null terminator on password
+      sb.append('\0');
+    }
+    return sb.toString().getBytes(StandardCharsets.UTF_8);
+  }
+
+
+  /**
+   * Handles encoding and decoding a bcrypt hash of the form
+   * <code>$2n$cost$xxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy</code>.
+   */
+  public static class BCryptParameters
+  {
+    /** bcrypt version. */
+    private final String version;
+
+    /** bcrypt cost. */
+    private final int cost;
+
+    /** bcrypt salt. */
+    private final byte[] salt;
+
+    /** bcrypt hash. */
+    private final byte[] hash;
+
+
+    /**
+     * Decodes bcrypt parameters from a string.
+     *
+     * @param  bCryptString  bcrypt hash of the form
+     *                       <code>$2n$cost$xxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy</code>
+     */
+    protected BCryptParameters(final String bCryptString)
+    {
+      if (!bCryptString.startsWith("$2")) {
+        throw new IllegalArgumentException("Expected bcrypt hash of the form $2n$cost$salthash");
+      }
+      final String[] parts = bCryptString.split("\\$");
+      if (parts.length != 4) {
+        throw new IllegalArgumentException("Invalid bcrypt hash");
+      }
+      version = parts[1];
+      cost = Integer.parseInt(parts[2]);
+      salt = decode(parts[3].substring(0, 22), 16);
+      hash = decode(parts[3].substring(22), 23);
+    }
+
+
+    /** @return  bcrypt version. */
+    public String getVersion()
+    {
+      return version;
+    }
+
+
+    /** @return  bcrypt cost in the range [4, 31]. */
+    public int getCost()
+    {
+      return cost;
+    }
+
+
+    /** @return  bcrypt salt. */
+    public byte[] getSalt()
+    {
+      return salt;
+    }
+
+
+    /** @return  bcrypt hash. */
+    public byte[] getHash()
+    {
+      return hash;
+    }
+
+
+    /**
+     * Produces an encoded bcrypt hash string from bcrypt parameter data.
+     *
+     * @return  Bcrypt hash of the form <code>$2n$cost$xxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy</code>.
+     */
+    public String encode()
+    {
+      return '$' + version + '$' + cost + '$' + BCryptHashBean.encode(salt, 16) + BCryptHashBean.encode(hash, 23);
+    }
+
+
+    /**
+     * Produces an encoded bcrypt hash string from bcrypt parameters and a provided hash string.
+     *
+     * @param  hash  Encoded bcrypt hash bytes; e.g. the value produced from {@link #hash(Object...)}.
+     *
+     * @return  Bcrypt hash of the form <code>$2n$cost$xxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy</code>.
+     */
+    public String encode(final String hash)
+    {
+      return '$' + version + '$' + cost + '$' + BCryptHashBean.encode(salt, 16) + hash;
+    }
+  }
+}
diff --git a/src/main/java/org/cryptacular/bean/BufferedBlockCipherBean.java b/src/main/java/org/cryptacular/bean/BufferedBlockCipherBean.java
new file mode 100644
index 0000000..55d2f18
--- /dev/null
+++ b/src/main/java/org/cryptacular/bean/BufferedBlockCipherBean.java
@@ -0,0 +1,82 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.bean;
+
+import java.security.KeyStore;
+import org.bouncycastle.crypto.BufferedBlockCipher;
+import org.bouncycastle.crypto.CipherParameters;
+import org.bouncycastle.crypto.params.KeyParameter;
+import org.bouncycastle.crypto.params.ParametersWithIV;
+import org.cryptacular.CiphertextHeader;
+import org.cryptacular.adapter.BufferedBlockCipherAdapter;
+import org.cryptacular.generator.Nonce;
+import org.cryptacular.spec.Spec;
+
+/**
+ * Cipher bean that performs symmetric encryption/decryption using a standard block cipher in a standard mode (e.g. CBC,
+ * OFB) with padding to support processing inputs of arbitrary length.
+ *
+ * @author  Middleware Services
+ */
+public class BufferedBlockCipherBean extends AbstractBlockCipherBean
+{
+
+  /** Block cipher specification (algorithm, mode, padding). */
+  private Spec<BufferedBlockCipher> blockCipherSpec;
+
+
+  /** Creates a new instance. */
+  public BufferedBlockCipherBean() {}
+
+
+  /**
+   * Creates a new instance by specifying all properties.
+   *
+   * @param  blockCipherSpec  Block cipher specification.
+   * @param  keyStore  Key store containing encryption key.
+   * @param  keyAlias  Name of encryption key entry in key store.
+   * @param  keyPassword  Password used to decrypt key entry in keystore.
+   * @param  nonce  Nonce/IV generator.
+   */
+  public BufferedBlockCipherBean(
+    final Spec<BufferedBlockCipher> blockCipherSpec,
+    final KeyStore keyStore,
+    final String keyAlias,
+    final String keyPassword,
+    final Nonce nonce)
+  {
+    super(keyStore, keyAlias, keyPassword, nonce);
+    setBlockCipherSpec(blockCipherSpec);
+  }
+
+
+  /** @return  Block cipher specification. */
+  public Spec<BufferedBlockCipher> getBlockCipherSpec()
+  {
+    return blockCipherSpec;
+  }
+
+
+  /**
+   * Sets the block cipher specification.
+   *
+   * @param  blockCipherSpec  Describes a block cipher in terms of algorithm, mode, and padding.
+   */
+  public void setBlockCipherSpec(final Spec<BufferedBlockCipher> blockCipherSpec)
+  {
+    this.blockCipherSpec = blockCipherSpec;
+  }
+
+
+  @Override
+  protected BufferedBlockCipherAdapter newCipher(final CiphertextHeader header, final boolean mode)
+  {
+    final BufferedBlockCipher cipher = blockCipherSpec.newInstance();
+    CipherParameters params = new KeyParameter(lookupKey(header.getKeyName()).getEncoded());
+    final String algName = cipher.getUnderlyingCipher().getAlgorithmName();
+    if (algName.endsWith("CBC") || algName.endsWith("OFB") || algName.endsWith("CFB")) {
+      params = new ParametersWithIV(params, header.getNonce());
+    }
+    cipher.init(mode, params);
+    return new BufferedBlockCipherAdapter(cipher);
+  }
+}
diff --git a/src/main/java/org/cryptacular/bean/CipherBean.java b/src/main/java/org/cryptacular/bean/CipherBean.java
new file mode 100644
index 0000000..9429a3f
--- /dev/null
+++ b/src/main/java/org/cryptacular/bean/CipherBean.java
@@ -0,0 +1,67 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.bean;
+
+import java.io.InputStream;
+import java.io.OutputStream;
+import org.cryptacular.CryptoException;
+import org.cryptacular.StreamException;
+
+/**
+ * Bean that performs encryption/decryption using a symmetric cipher.
+ *
+ * @author  Middleware Services
+ */
+public interface CipherBean
+{
+
+  /**
+   * Encrypts the input data using a symmetric cipher.
+   *
+   * @param  input  Plaintext data to encrypt.
+   *
+   * @return  Ciphertext output.
+   *
+   * @throws  CryptoException  on underlying cipher data handling errors.
+   */
+  byte[] encrypt(byte[] input) throws CryptoException;
+
+
+  /**
+   * Encrypts the data from the input stream onto the output stream using a symmetric cipher.
+   *
+   * <p>The caller is responsible for providing and managing the streams (e.g. closing them when finished).</p>
+   *
+   * @param  input  Input stream containing plaintext data to encrypt.
+   * @param  output  Output stream containing ciphertext produced by cipher in encryption mode.
+   *
+   * @throws  CryptoException  on underlying cipher data handling errors.
+   * @throws  StreamException  on stream IO errors.
+   */
+  void encrypt(InputStream input, OutputStream output) throws CryptoException, StreamException;
+
+
+  /**
+   * Decrypts the input data using a block cipher.
+   *
+   * @param  input  Ciphertext data to encrypt.
+   *
+   * @return  Plaintext output.
+   *
+   * @throws  CryptoException  on underlying cipher data handling errors.
+   */
+  byte[] decrypt(byte[] input) throws CryptoException;
+
+
+  /**
+   * Decrypts the data from the input stream onto the output stream using a symmetric cipher.
+   *
+   * <p>The caller is responsible for providing and managing the streams (e.g. closing them when finished).</p>
+   *
+   * @param  input  Input stream containing ciphertext data to decrypt.
+   * @param  output  Output stream containing plaintext produced by cipher in decryption mode.
+   *
+   * @throws  CryptoException  on underlying cipher data handling errors.
+   * @throws  StreamException  on stream IO errors.
+   */
+  void decrypt(InputStream input, OutputStream output) throws CryptoException, StreamException;
+}
diff --git a/src/main/java/org/cryptacular/bean/EncodingHashBean.java b/src/main/java/org/cryptacular/bean/EncodingHashBean.java
new file mode 100644
index 0000000..c1f9a65
--- /dev/null
+++ b/src/main/java/org/cryptacular/bean/EncodingHashBean.java
@@ -0,0 +1,165 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.bean;
+
+import org.bouncycastle.crypto.Digest;
+import org.bouncycastle.util.Arrays;
+import org.cryptacular.CryptoException;
+import org.cryptacular.EncodingException;
+import org.cryptacular.StreamException;
+import org.cryptacular.codec.Codec;
+import org.cryptacular.spec.Spec;
+import org.cryptacular.util.CodecUtil;
+
+/**
+ * Computes a hash in an encoded format, e.g. hex, base64.
+ *
+ * @author  Middleware Services
+ */
+public class EncodingHashBean extends AbstractHashBean implements HashBean<String>
+{
+
+  /** Determines kind of encoding. */
+  private Spec<Codec> codecSpec;
+
+  /** Whether data provided to this bean includes a salt. */
+  private boolean salted;
+
+
+  /** Creates a new instance. */
+  public EncodingHashBean() {}
+
+
+  /**
+   * Creates a new instance that will not be salted. Delegates to {@link #EncodingHashBean(Spec, Spec, int, boolean)}.
+   *
+   * @param  codecSpec  Digest specification.
+   * @param  digestSpec  Digest specification.
+   */
+  public EncodingHashBean(final Spec<Codec> codecSpec, final Spec<Digest> digestSpec)
+  {
+    this(codecSpec, digestSpec, 1, false);
+  }
+
+
+  /**
+   * Creates a new instance that will not be salted. Delegates to {@link #EncodingHashBean(Spec, Spec, int, boolean)}.
+   *
+   * @param  codecSpec  Digest specification.
+   * @param  digestSpec  Digest specification.
+   * @param  iterations  Number of hash rounds.
+   */
+  public EncodingHashBean(final Spec<Codec> codecSpec, final Spec<Digest> digestSpec, final int iterations)
+  {
+    this(codecSpec, digestSpec, iterations, false);
+  }
+
+
+  /**
+   * Creates a new instance by specifying all properties.
+   *
+   * @param  codecSpec  Digest specification.
+   * @param  digestSpec  Digest specification.
+   * @param  iterations  Number of hash rounds.
+   * @param  salted  Whether hash data will be salted.
+   */
+  public EncodingHashBean(
+    final Spec<Codec> codecSpec,
+    final Spec<Digest> digestSpec,
+    final int iterations,
+    final boolean salted)
+  {
+    super(digestSpec, iterations);
+    setCodecSpec(codecSpec);
+    setSalted(salted);
+  }
+
+
+  /** @return  Codec specification that determines the encoding applied to the hash output bytes. */
+  public Spec<Codec> getCodecSpec()
+  {
+    return codecSpec;
+  }
+
+
+  /**
+   * Sets the codec specification that determines the encoding applied to the hash output bytes.
+   *
+   * @param  codecSpec  Codec specification, e.g. {@link org.cryptacular.spec.CodecSpec#BASE64}, {@link
+   *                    org.cryptacular.spec.CodecSpec#HEX}.
+   */
+  public void setCodecSpec(final Spec<Codec> codecSpec)
+  {
+    this.codecSpec = codecSpec;
+  }
+
+
+  /**
+   * Whether data provided to {@link #hash(Object...)} includes a salt as the last parameter.
+   *
+   * @return  whether hash data includes a salt
+   */
+  public boolean isSalted()
+  {
+    return salted;
+  }
+
+
+  /**
+   * Sets whether {@link #hash(Object...)} should expect a salt as the last parameter.
+   *
+   * @param  salted  whether hash data includes a salt
+   */
+  public void setSalted(final boolean salted)
+  {
+    this.salted = salted;
+  }
+
+
+  /**
+   * Hashes the given data. If {@link #isSalted()} is true then the last parameter MUST be a byte array containing the
+   * salt. The salt value will be appended to the encoded hash that is returned.
+   *
+   * @param  data  Data to hash.
+   *
+   * @return  Encoded digest output, including a salt if provided.
+   *
+   * @throws  CryptoException  on hash computation errors.
+   * @throws  EncodingException  on encoding errors.
+   * @throws  StreamException  on stream IO errors.
+   */
+  @Override
+  public String hash(final Object... data) throws CryptoException, EncodingException, StreamException
+  {
+    if (salted) {
+      if (data.length < 2 || !(data[data.length - 1] instanceof byte[])) {
+        throw new IllegalArgumentException("Last parameter must be a salt of type byte[]");
+      }
+
+      final byte[] hashSalt = (byte[]) data[data.length - 1];
+      return CodecUtil.encode(codecSpec.newInstance().getEncoder(), Arrays.concatenate(hashInternal(data), hashSalt));
+    }
+    return CodecUtil.encode(codecSpec.newInstance().getEncoder(), hashInternal(data));
+  }
+
+
+  /**
+   * Compares a known hash value with the hash of the given data.
+   *
+   * @param  hash  Known encoded hash value. If the length of the hash bytes after decoding is greater than the length
+   *               of the digest output, anything beyond the digest length is considered salt data that is hashed
+   *               <strong>after</strong> the input data.
+   * @param  data  Data to hash.
+   *
+   * @return  True if the hashed data matches the given hash, false otherwise.
+   *
+   * @throws  CryptoException  on hash computation errors.
+   * @throws  EncodingException  on encoding errors.
+   * @throws  StreamException  on stream IO errors.
+   */
+  @Override
+  public boolean compare(final String hash, final Object... data)
+      throws CryptoException, EncodingException, StreamException
+  {
+    return compareInternal(CodecUtil.decode(codecSpec.newInstance().getDecoder(), hash), data);
+  }
+}
diff --git a/src/main/java/org/cryptacular/bean/FactoryBean.java b/src/main/java/org/cryptacular/bean/FactoryBean.java
new file mode 100644
index 0000000..ff2fee8
--- /dev/null
+++ b/src/main/java/org/cryptacular/bean/FactoryBean.java
@@ -0,0 +1,16 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.bean;
+
+/**
+ * Factory bean strategy interface.
+ *
+ * @param  <T>  Type produced by factory.
+ *
+ * @author  Middleware Services
+ */
+public interface FactoryBean<T>
+{
+
+  /** @return  New instance of the type handled by this factory. */
+  T newInstance();
+}
diff --git a/src/main/java/org/cryptacular/bean/HashBean.java b/src/main/java/org/cryptacular/bean/HashBean.java
new file mode 100644
index 0000000..b851cc4
--- /dev/null
+++ b/src/main/java/org/cryptacular/bean/HashBean.java
@@ -0,0 +1,46 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.bean;
+
+import org.cryptacular.CryptoException;
+import org.cryptacular.StreamException;
+
+/**
+ * Strategy interface to support beans that produce hash outputs in various formats, e.g. raw bytes, hex output, etc.
+ *
+ * @param  <T>  Type of output (e.g. byte[], string) produced by hash bean.
+ *
+ * @author  Middleware Services
+ */
+public interface HashBean<T>
+{
+
+  /**
+   * Hashes the given data.
+   *
+   * @param  data  Data to hash. Callers should expect support for at least the following types: <code>byte[]</code>,
+   *               {@link CharSequence}, {@link java.io.InputStream}, and {@link org.cryptacular.io.Resource}. Unless
+   *               otherwise noted, character data is processed in the <code>UTF-8</code> character set; if another
+   *               character set is desired, the caller should convert to <code>byte[]</code> and provide the resulting
+   *               bytes.
+   *
+   * @return  Digest output.
+   *
+   * @throws  CryptoException  on hash computation errors.
+   * @throws  StreamException  on stream IO errors.
+   */
+  T hash(Object... data) throws CryptoException, StreamException;
+
+
+  /**
+   * Compares a known hash value with the hash of the given data.
+   *
+   * @param  hash  Known hash value.
+   * @param  data  Data to hash.
+   *
+   * @return  True if the hashed data matches the given hash, false otherwise.
+   *
+   * @throws  CryptoException  on hash computation errors.
+   * @throws  StreamException  on stream IO errors.
+   */
+  boolean compare(T hash, Object... data) throws CryptoException, StreamException;
+}
diff --git a/src/main/java/org/cryptacular/bean/KeyStoreBasedKeyFactoryBean.java b/src/main/java/org/cryptacular/bean/KeyStoreBasedKeyFactoryBean.java
new file mode 100644
index 0000000..73fa3e6
--- /dev/null
+++ b/src/main/java/org/cryptacular/bean/KeyStoreBasedKeyFactoryBean.java
@@ -0,0 +1,111 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.bean;
+
+import java.security.Key;
+import java.security.KeyStore;
+import java.security.KeyStoreException;
+import java.security.NoSuchAlgorithmException;
+import java.security.UnrecoverableKeyException;
+import org.cryptacular.CryptoException;
+
+/**
+ * Factory that produces either a {@link javax.crypto.SecretKey} or {@link java.security.PrivateKey}.
+ *
+ * <p>from a {@link KeyStore}.</p>
+ *
+ * @param  <T>  Type of key, either {@link javax.crypto.SecretKey} or {@link java.security.PrivateKey}.
+ *
+ * @author  Middleware Services
+ */
+public class KeyStoreBasedKeyFactoryBean<T extends Key> implements FactoryBean<T>
+{
+
+  /** Keystore containing secret key. */
+  private KeyStore keyStore;
+
+  /** Alias of keystore entry containing secret key. */
+  private String alias;
+
+  /** Password required to read key entry. */
+  private String password;
+
+
+  /** Creates a new instance. */
+  public KeyStoreBasedKeyFactoryBean() {}
+
+
+  /**
+   * Creates a new instance by specifying all properties.
+   *
+   * @param  keyStore  Key store containing encryption key.
+   * @param  alias  Name of encryption key entry in key store.
+   * @param  password  Password used to decrypt key entry in keystore.
+   */
+  public KeyStoreBasedKeyFactoryBean(final KeyStore keyStore, final String alias, final String password)
+  {
+    setKeyStore(keyStore);
+    setAlias(alias);
+    setPassword(password);
+  }
+
+
+  /** @return  Keystore that contains the {@link #keyStore}. */
+  public KeyStore getKeyStore()
+  {
+    return keyStore;
+  }
+
+
+  /**
+   * Sets the keystore that contains the key.
+   *
+   * @param  keyStore  Non-null keystore.
+   */
+  public void setKeyStore(final KeyStore keyStore)
+  {
+    this.keyStore = keyStore;
+  }
+
+
+  /** @return  Alias that specifies the {@link KeyStore} entry containing the key. */
+  public String getAlias()
+  {
+    return alias;
+  }
+
+
+  /**
+   * Sets the alias that specifies the {@link KeyStore} entry containing the key.
+   *
+   * @param  alias  Keystore alias of key entry.
+   */
+  public void setAlias(final String alias)
+  {
+    this.alias = alias;
+  }
+
+
+  /**
+   * Sets the password used to access the key entry.
+   *
+   * @param  password  Key entry password.
+   */
+  public void setPassword(final String password)
+  {
+    this.password = password;
+  }
+
+
+  @Override
+  @SuppressWarnings("unchecked")
+  public T newInstance()
+  {
+    final Key key;
+    try {
+      key = keyStore.getKey(alias, password.toCharArray());
+    } catch (KeyStoreException | NoSuchAlgorithmException | UnrecoverableKeyException e) {
+      throw new CryptoException("Error accessing keystore entry " + alias, e);
+    }
+    return (T) key;
+  }
+}
diff --git a/src/main/java/org/cryptacular/bean/KeyStoreFactoryBean.java b/src/main/java/org/cryptacular/bean/KeyStoreFactoryBean.java
new file mode 100644
index 0000000..d692f38
--- /dev/null
+++ b/src/main/java/org/cryptacular/bean/KeyStoreFactoryBean.java
@@ -0,0 +1,127 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.bean;
+
+import java.io.IOException;
+import java.security.KeyStore;
+import java.security.KeyStoreException;
+import java.security.NoSuchAlgorithmException;
+import java.security.cert.CertificateException;
+import org.cryptacular.CryptoException;
+import org.cryptacular.StreamException;
+import org.cryptacular.io.Resource;
+
+/**
+ * Factory bean that produces a {@link KeyStore} from a file or URI.
+ *
+ * @author  Middleware Services
+ */
+public class KeyStoreFactoryBean implements FactoryBean<KeyStore>
+{
+
+  /** Default keystore type, {@value}. */
+  public static final String DEFAULT_TYPE = "JCEKS";
+
+  /** Keystore type, e.g. JKS, JCEKS, BKS. */
+  private String type = DEFAULT_TYPE;
+
+  /** Resource that provides encoded keystore data. */
+  private Resource resource;
+
+  /** Keystore password. */
+  private String password;
+
+
+  /** Creates a new instance. */
+  public KeyStoreFactoryBean() {}
+
+
+  /**
+   * Creates a new instance by specifying all properties.
+   *
+   * @param  resource  Resource containing encoded keystore data.
+   * @param  type  Keystore type, e.g. JCEKS.
+   * @param  password  Password used to decrypt key entry in keystore.
+   */
+  public KeyStoreFactoryBean(final Resource resource, final String type, final String password)
+  {
+    setResource(resource);
+    setType(type);
+    setPassword(password);
+  }
+
+
+  /** @return  Keystore type. */
+  public String getType()
+  {
+    return type;
+  }
+
+
+  /**
+   * Sets the keystore type.
+   *
+   * @param  type  JCEKS (default), JKS, PKCS12, or BKS. <strong>NOTE:</strong> BKS type is supported only when BC
+   *               provider is installed.
+   */
+  public void setType(final String type)
+  {
+    this.type = type;
+  }
+
+
+  /** @return  Resource that provides encoded keystore data. */
+  public Resource getResource()
+  {
+    return resource;
+  }
+
+
+  /**
+   * Sets the resource that provides encoded keystore data.
+   *
+   * @param  resource  Keystore resource.
+   */
+  public void setResource(final Resource resource)
+  {
+    this.resource = resource;
+  }
+
+
+  /**
+   * Sets the keystore password required to decrypt an encrypted keystore.
+   *
+   * @param  password  Keystore password.
+   */
+  public void setPassword(final String password)
+  {
+    this.password = password;
+  }
+
+
+  @Override
+  public KeyStore newInstance()
+  {
+    if (resource == null) {
+      throw new IllegalStateException("Must provide resource.");
+    }
+
+    final KeyStore store;
+    try {
+      store = KeyStore.getInstance(type);
+    } catch (KeyStoreException e) {
+      String message = "Unsupported keystore type " + type;
+      if ("BKS".equalsIgnoreCase(type)) {
+        message += ". Is BC provider installed?";
+      }
+      throw new CryptoException(message, e);
+    }
+    try {
+      store.load(resource.getInputStream(), password.toCharArray());
+    } catch (CertificateException | NoSuchAlgorithmException e) {
+      throw new CryptoException("Error loading keystore", e);
+    } catch (IOException e) {
+      throw new StreamException(e);
+    }
+    return store;
+  }
+}
diff --git a/src/main/java/org/cryptacular/bean/PemBasedPrivateKeyFactoryBean.java b/src/main/java/org/cryptacular/bean/PemBasedPrivateKeyFactoryBean.java
new file mode 100644
index 0000000..05a4123
--- /dev/null
+++ b/src/main/java/org/cryptacular/bean/PemBasedPrivateKeyFactoryBean.java
@@ -0,0 +1,67 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.bean;
+
+import java.security.PrivateKey;
+import org.cryptacular.EncodingException;
+import org.cryptacular.util.ByteUtil;
+import org.cryptacular.util.KeyPairUtil;
+import org.cryptacular.util.PemUtil;
+
+/**
+ * Factory for creating a public key from a PEM-encoded private key in any format supported by {@link
+ * KeyPairUtil#decodePrivateKey(byte[])}. Note that this component does not support encrypted private keys; see {@link
+ * ResourceBasedPrivateKeyFactoryBean} for encryption support.
+ *
+ * @author  Middleware Services
+ * @see  org.cryptacular.util.KeyPairUtil#decodePrivateKey(byte[])
+ * @see  ResourceBasedPrivateKeyFactoryBean
+ */
+public class PemBasedPrivateKeyFactoryBean implements FactoryBean<PrivateKey>
+{
+
+  /** PEM-encoded public key data. */
+  private String encodedKey;
+
+
+  /** Creates a new instance. */
+  public PemBasedPrivateKeyFactoryBean() {}
+
+
+  /**
+   * Creates a new instance by specifying all properties.
+   *
+   * @param  pemEncodedKey  PEM-encoded private key data.
+   */
+  public PemBasedPrivateKeyFactoryBean(final String pemEncodedKey)
+  {
+    setEncodedKey(pemEncodedKey);
+  }
+
+
+  /** @return  PEM-encoded private key data. */
+  public String getEncodedKey()
+  {
+    return encodedKey;
+  }
+
+
+  /**
+   * Sets the PEM-encoded private key data.
+   *
+   * @param  pemEncodedKey  PEM-encoded private key data.
+   */
+  public void setEncodedKey(final String pemEncodedKey)
+  {
+    if (!PemUtil.isPem(ByteUtil.toBytes(pemEncodedKey))) {
+      throw new IllegalArgumentException("Data is not PEM encoded.");
+    }
+    this.encodedKey = pemEncodedKey;
+  }
+
+
+  @Override
+  public PrivateKey newInstance() throws EncodingException
+  {
+    return KeyPairUtil.decodePrivateKey(PemUtil.decode(encodedKey));
+  }
+}
diff --git a/src/main/java/org/cryptacular/bean/PemBasedPublicKeyFactoryBean.java b/src/main/java/org/cryptacular/bean/PemBasedPublicKeyFactoryBean.java
new file mode 100644
index 0000000..31c305b
--- /dev/null
+++ b/src/main/java/org/cryptacular/bean/PemBasedPublicKeyFactoryBean.java
@@ -0,0 +1,77 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.bean;
+
+import java.security.PublicKey;
+import org.cryptacular.EncodingException;
+import org.cryptacular.util.ByteUtil;
+import org.cryptacular.util.KeyPairUtil;
+import org.cryptacular.util.PemUtil;
+
+/**
+ * Factory for creating a public key from a PEM-encoded string:
+ *
+ * <pre>-----BEGIN PUBLIC KEY-----
+ MIIBtzCCASsGByqGSM44BAEwggEeAoGBAOulifG+AGGBVGWEjunG4661rydB7eFy
+ RfHzbwVAVaPU0H3zFcOY35z1l6Pk4ZANVHq7hCbViJBR7XyrkYKaUcaB0nSPLgg3
+ vWWOmvGqhuR6tWRGbz4fyHl1urCRk9mrJum4mAJd3OlLugCyuIqozsYUtvJ5mlGe
+ vir1zmxinKd7AhUA7fBEySYP53g7FLOlcEyuhIjvQAECgYBJ9baoGzn0zKpeteC4
+ jfbGVuKrFksr2eeY0AFJOeTtyFkCnVqrNnF674eN1RAOwA2tzzhWZ96G0AGux8ah
+ mGsNRbj/qaUTDNRWr7BPBIvDd+8LpMin4Cb5j4c/A7uOY+5WxhUm3TNifueBRohw
+ h1NnexYQqpclcuTRA/ougLX48gOBhQACgYEA6Tw2khtb1g0vcHu6JRgggWPZVTuj
+ /HOH3FyjufsfHogWKrlKebZ6hnQ73qAcEgLLYKctPdCX6wnpXN+BsQGYdTkc0FsU
+ NZD4VW5L5kaWRiLVfE8x55wXdMZtXKWqg1vL6aXYZw7RFe9U9Ck+/AG90knThDC+
+ xrX2FTDm6uC25rk=
+ -----END PUBLIC KEY-----</pre>
+ *
+ * @author  Middleware Services
+ * @see  KeyPairUtil#decodePublicKey(byte[])
+ */
+public class PemBasedPublicKeyFactoryBean implements FactoryBean<PublicKey>
+{
+
+  /** PEM-encoded public key data. */
+  private String encodedKey;
+
+
+  /** Creates a new instance. */
+  public PemBasedPublicKeyFactoryBean() {}
+
+
+  /**
+   * Creates a new instance by specifying all properties.
+   *
+   * @param  pemEncodedKey  PEM-encoded public key data.
+   */
+  public PemBasedPublicKeyFactoryBean(final String pemEncodedKey)
+  {
+    setEncodedKey(pemEncodedKey);
+  }
+
+
+  /** @return  PEM-encoded public key data. */
+  public String getEncodedKey()
+  {
+    return encodedKey;
+  }
+
+
+  /**
+   * Sets the PEM-encoded public key data.
+   *
+   * @param  pemEncodedKey  PEM-encoded public key data.
+   */
+  public void setEncodedKey(final String pemEncodedKey)
+  {
+    if (!PemUtil.isPem(ByteUtil.toBytes(pemEncodedKey))) {
+      throw new IllegalArgumentException("Data is not PEM encoded.");
+    }
+    this.encodedKey = pemEncodedKey;
+  }
+
+
+  @Override
+  public PublicKey newInstance() throws EncodingException
+  {
+    return KeyPairUtil.decodePublicKey(PemUtil.decode(encodedKey));
+  }
+}
diff --git a/src/main/java/org/cryptacular/bean/ResourceBasedPrivateKeyFactoryBean.java b/src/main/java/org/cryptacular/bean/ResourceBasedPrivateKeyFactoryBean.java
new file mode 100644
index 0000000..ad8e821
--- /dev/null
+++ b/src/main/java/org/cryptacular/bean/ResourceBasedPrivateKeyFactoryBean.java
@@ -0,0 +1,98 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.bean;
+
+import java.io.IOException;
+import java.security.PrivateKey;
+import org.cryptacular.EncodingException;
+import org.cryptacular.StreamException;
+import org.cryptacular.io.Resource;
+import org.cryptacular.util.KeyPairUtil;
+
+/**
+ * Factory for reading a private from a {@link Resource} containing data in any of the formats supported by {@link
+ * KeyPairUtil#readPrivateKey(java.io.InputStream, char[])}.
+ *
+ * @author  Middleware Services
+ * @see  KeyPairUtil#readPrivateKey(java.io.InputStream, char[])
+ * @see  KeyPairUtil#readPrivateKey(java.io.InputStream)
+ */
+public class ResourceBasedPrivateKeyFactoryBean implements FactoryBean<PrivateKey>
+{
+
+  /** Resource containing key data. */
+  private Resource resource;
+
+  /** Password required to decrypt an encrypted private key. */
+  private String password;
+
+
+  /** Creates a new instance. */
+  public ResourceBasedPrivateKeyFactoryBean() {}
+
+
+  /**
+   * Creates a new instance capable of reading an unencrypted private key.
+   *
+   * @param  resource  Resource containing encoded key data.
+   */
+  public ResourceBasedPrivateKeyFactoryBean(final Resource resource)
+  {
+    setResource(resource);
+  }
+
+
+  /**
+   * Creates a new instance of reading an encrypted private key.
+   *
+   * @param  resource  Resource containing encoded key data.
+   * @param  decryptionPassword  Password-based encryption key.
+   */
+  public ResourceBasedPrivateKeyFactoryBean(final Resource resource, final String decryptionPassword)
+  {
+    setResource(resource);
+    setPassword(decryptionPassword);
+  }
+
+
+  /** @return  Resource containing key data. */
+  public Resource getResource()
+  {
+    return resource;
+  }
+
+
+  /**
+   * Sets the resource containing key data.
+   *
+   * @param  resource  Resource containing key bytes.
+   */
+  public void setResource(final Resource resource)
+  {
+    this.resource = resource;
+  }
+
+
+  /**
+   * Sets the password-based key used to decrypt an encrypted private key.
+   *
+   * @param  decryptionPassword  Password-based encryption key.
+   */
+  public void setPassword(final String decryptionPassword)
+  {
+    this.password = decryptionPassword;
+  }
+
+
+  @Override
+  public PrivateKey newInstance() throws EncodingException, StreamException
+  {
+    try {
+      if (password != null) {
+        return KeyPairUtil.readPrivateKey(resource.getInputStream(), password.toCharArray());
+      }
+      return KeyPairUtil.readPrivateKey(resource.getInputStream());
+    } catch (IOException e) {
+      throw new StreamException(e);
+    }
+  }
+}
diff --git a/src/main/java/org/cryptacular/bean/ResourceBasedPublicKeyFactoryBean.java b/src/main/java/org/cryptacular/bean/ResourceBasedPublicKeyFactoryBean.java
new file mode 100644
index 0000000..a8c9034
--- /dev/null
+++ b/src/main/java/org/cryptacular/bean/ResourceBasedPublicKeyFactoryBean.java
@@ -0,0 +1,67 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.bean;
+
+import java.io.IOException;
+import java.security.PublicKey;
+import org.cryptacular.EncodingException;
+import org.cryptacular.StreamException;
+import org.cryptacular.io.Resource;
+import org.cryptacular.util.KeyPairUtil;
+
+/**
+ * Factory for creating a public key from a {@link Resource} containing data in any of the formats supported by {@link
+ * KeyPairUtil#readPublicKey(java.io.InputStream)}.
+ *
+ * @author  Middleware Services
+ * @see  KeyPairUtil#readPublicKey(java.io.InputStream)
+ */
+public class ResourceBasedPublicKeyFactoryBean implements FactoryBean<PublicKey>
+{
+
+  /** Resource containing key data. */
+  private Resource resource;
+
+
+  /** Creates a new instance. */
+  public ResourceBasedPublicKeyFactoryBean() {}
+
+
+  /**
+   * Creates a new instance by specifying all properties.
+   *
+   * @param  resource  Resource containing encoded key data.
+   */
+  public ResourceBasedPublicKeyFactoryBean(final Resource resource)
+  {
+    setResource(resource);
+  }
+
+
+  /** @return  Resource containing key data. */
+  public Resource getResource()
+  {
+    return resource;
+  }
+
+
+  /**
+   * Sets the resource containing key data.
+   *
+   * @param  resource  Resource containing key bytes.
+   */
+  public void setResource(final Resource resource)
+  {
+    this.resource = resource;
+  }
+
+
+  @Override
+  public PublicKey newInstance() throws EncodingException, StreamException
+  {
+    try {
+      return KeyPairUtil.readPublicKey(resource.getInputStream());
+    } catch (IOException e) {
+      throw new StreamException(e);
+    }
+  }
+}
diff --git a/src/main/java/org/cryptacular/bean/ResourceBasedSecretKeyFactoryBean.java b/src/main/java/org/cryptacular/bean/ResourceBasedSecretKeyFactoryBean.java
new file mode 100644
index 0000000..b80c0b1
--- /dev/null
+++ b/src/main/java/org/cryptacular/bean/ResourceBasedSecretKeyFactoryBean.java
@@ -0,0 +1,88 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.bean;
+
+import java.io.IOException;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.SecretKeySpec;
+import org.cryptacular.StreamException;
+import org.cryptacular.io.Resource;
+import org.cryptacular.util.StreamUtil;
+
+/**
+ * Factory that produces a {@link SecretKey} from a {@link Resource}.
+ *
+ * @author  Middleware Services
+ */
+public class ResourceBasedSecretKeyFactoryBean implements FactoryBean<SecretKey>
+{
+
+  /** Key algorithm. */
+  private String algorithm;
+
+  /** Resource containing key data. */
+  private Resource resource;
+
+
+  /** Creates a new instance. */
+  public ResourceBasedSecretKeyFactoryBean() {}
+
+
+  /**
+   * Creates a new instance by specifying all properties.
+   *
+   * @param  resource  Resource containing encoded key data.
+   * @param  algorithm  Algorithm name of cipher with which key will be used.
+   */
+  public ResourceBasedSecretKeyFactoryBean(final Resource resource, final String algorithm)
+  {
+    setResource(resource);
+    setAlgorithm(algorithm);
+  }
+
+
+  /** @return  Key algorithm name, e.g. AES. */
+  public String getAlgorithm()
+  {
+    return algorithm;
+  }
+
+
+  /**
+   * Sets the key algorithm.
+   *
+   * @param  algorithm  Secret key algorithm, e.g. AES.
+   */
+  public void setAlgorithm(final String algorithm)
+  {
+    this.algorithm = algorithm;
+  }
+
+
+  /** @return  Resource containing key data. */
+  public Resource getResource()
+  {
+    return resource;
+  }
+
+
+  /**
+   * Sets the resource containing key data.
+   *
+   * @param  resource  Resource containing key bytes.
+   */
+  public void setResource(final Resource resource)
+  {
+    this.resource = resource;
+  }
+
+
+  @Override
+  public SecretKey newInstance() throws StreamException
+  {
+    try {
+      return new SecretKeySpec(StreamUtil.readAll(resource.getInputStream()), algorithm);
+    } catch (IOException e) {
+      throw new StreamException(e);
+    }
+  }
+}
diff --git a/src/main/java/org/cryptacular/bean/SimpleHashBean.java b/src/main/java/org/cryptacular/bean/SimpleHashBean.java
new file mode 100644
index 0000000..e5f75be
--- /dev/null
+++ b/src/main/java/org/cryptacular/bean/SimpleHashBean.java
@@ -0,0 +1,58 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.bean;
+
+import org.bouncycastle.crypto.Digest;
+import org.cryptacular.CryptoException;
+import org.cryptacular.StreamException;
+import org.cryptacular.spec.Spec;
+
+/**
+ * Computes a hash using an instance of {@link Digest} specified by {@link #setDigestSpec(org.cryptacular.spec.Spec)}.
+ *
+ * @author  Middleware Services
+ */
+public class SimpleHashBean extends AbstractHashBean implements HashBean<byte[]>
+{
+
+  /** Creates a new instance. */
+  public SimpleHashBean() {}
+
+
+  /**
+   * Creates a new instance by specifying all properties.
+   *
+   * @param  digestSpec  Digest specification.
+   * @param  iterations  Number of hash rounds.
+   */
+  public SimpleHashBean(final Spec<Digest> digestSpec, final int iterations)
+  {
+    super(digestSpec, iterations);
+  }
+
+
+  @Override
+  public byte[] hash(final Object... data) throws CryptoException, StreamException
+  {
+    return hashInternal(data);
+  }
+
+
+  /**
+   * Compares a known hash value with the hash of the given data.
+   *
+   * @param  hash  Known hash value. If the length of the array is greater than the length of the digest output,
+   *               anything beyond the digest length is considered salt data that is hashed <strong>after</strong> the
+   *               input data.
+   * @param  data  Data to hash.
+   *
+   * @return  True if the hashed data matches the given hash, false otherwise.
+   *
+   * @throws  CryptoException  on hash computation errors.
+   * @throws  StreamException  on stream IO errors.
+   */
+  @Override
+  public boolean compare(final byte[] hash, final Object... data) throws CryptoException, StreamException
+  {
+    return compareInternal(hash, data);
+  }
+}
diff --git a/src/main/java/org/cryptacular/codec/AbstractBaseNDecoder.java b/src/main/java/org/cryptacular/codec/AbstractBaseNDecoder.java
new file mode 100644
index 0000000..1a8d23f
--- /dev/null
+++ b/src/main/java/org/cryptacular/codec/AbstractBaseNDecoder.java
@@ -0,0 +1,155 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.codec;
+
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import org.cryptacular.EncodingException;
+
+/**
+ * Base decoder class for encoding schemes described in RFC 3548.
+ *
+ * @author  Middleware Services
+ */
+public abstract class AbstractBaseNDecoder implements Decoder
+{
+
+  /** Block of encoded characters. */
+  private final char[] block = new char[getBlockLength() / getBitsPerChar()];
+
+  /** Decoding table. */
+  private final byte[] table;
+
+  /** Current position in character block. */
+  private int blockPos;
+
+  /** Flag indicating whether input is padded. True by default. */
+  private boolean paddedInput = true;
+
+
+  /**
+   * Creates a new instance with given parameters.
+   *
+   * @param  decodingTable  Byte array indexed by characters in the character set encoding.
+   */
+  public AbstractBaseNDecoder(final byte[] decodingTable)
+  {
+    table = decodingTable;
+  }
+
+
+  /** @return  True if padded input is accepted (default), false otherwise. */
+  public boolean isPaddedInput()
+  {
+    return paddedInput;
+  }
+
+
+  /**
+   * Determines whether padded input is accepted.
+   *
+   * @param  enabled  True to enable support for padded input, false otherwise.
+   */
+  public void setPaddedInput(final boolean enabled)
+  {
+    this.paddedInput = enabled;
+  }
+
+
+  @Override
+  public void decode(final CharBuffer input, final ByteBuffer output) throws EncodingException
+  {
+    char current;
+    while (input.hasRemaining()) {
+      current = input.get();
+      if (Character.isWhitespace(current) || current == '=') {
+        continue;
+      }
+      block[blockPos++] = current;
+      if (blockPos == block.length) {
+        writeOutput(output, block.length);
+      }
+    }
+  }
+
+
+  @Override
+  public void finalize(final ByteBuffer output) throws EncodingException
+  {
+    if (blockPos > 0) {
+      writeOutput(output, blockPos);
+    }
+  }
+
+
+  @Override
+  public int outputSize(final int inputSize)
+  {
+    final int size;
+    if (paddedInput) {
+      size = inputSize;
+    } else {
+      // For unpadded input, add the maximum number of padding characters to get worst-case estimate
+      size = inputSize + getBlockLength() / 8 - 1;
+    }
+    return size * getBitsPerChar() / 8;
+  }
+
+
+  /** @return  Number of bits in a block of encoded characters. */
+  protected abstract int getBlockLength();
+
+
+  /** @return  Number of bits encoding a single character. */
+  protected abstract int getBitsPerChar();
+
+
+  /**
+   * Converts the given alphabet into a base-N decoding table.
+   *
+   * @param  alphabet  Decoding alphabet to use.
+   * @param  n  Encoding base.
+   *
+   * @return  Decoding table of 128 elements.
+   */
+  protected static byte[] decodingTable(final String alphabet, final int n)
+  {
+    if (alphabet.length() != n) {
+      throw new IllegalArgumentException("Alphabet must be exactly " + n + " characters long");
+    }
+    final byte[] decodingTable = new byte[128];
+    for (int i = 0; i < n; i++) {
+      decodingTable[alphabet.charAt(i)] = (byte) i;
+    }
+    return decodingTable;
+  }
+
+
+  /**
+   * Writes bytes in the current encoding block to the output buffer.
+   *
+   * @param  output  Output buffer.
+   * @param  len  Number of characters to decode in current block.
+   */
+  private void writeOutput(final ByteBuffer output, final int len)
+  {
+    long b;
+    long value = 0;
+    int shift = getBlockLength();
+    for (int i = 0; i < len; i++) {
+      b = table[block[i] & 0x7F];
+      if (b < 0) {
+        throw new EncodingException("Invalid character " + block[i]);
+      }
+      shift -= getBitsPerChar();
+      value |= b << shift;
+    }
+
+    final int stop = shift + getBitsPerChar() - 1;
+    int offset = getBlockLength();
+    while (offset > stop) {
+      offset -= 8;
+      output.put((byte) ((value & (0xffL << offset)) >> offset));
+    }
+    blockPos = 0;
+  }
+}
diff --git a/src/main/java/org/cryptacular/codec/AbstractBaseNEncoder.java b/src/main/java/org/cryptacular/codec/AbstractBaseNEncoder.java
new file mode 100644
index 0000000..c0c6858
--- /dev/null
+++ b/src/main/java/org/cryptacular/codec/AbstractBaseNEncoder.java
@@ -0,0 +1,184 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.codec;
+
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import org.cryptacular.EncodingException;
+
+/**
+ * Base encoder class for encoding schemes described in RFC 3548.
+ *
+ * @author  Middleware Services
+ */
+public abstract class AbstractBaseNEncoder implements Encoder
+{
+
+  /** Platform-specific line terminator string, e.g. LF (Unix), CRLF (Windows). */
+  private static final String NEWLINE = System.lineSeparator();
+
+  /** Number of base64 characters per line. */
+  protected final int lineLength;
+
+  /** Encoding character set. */
+  private final char[] charset;
+
+  /** Number of bits in a block. */
+  private final int blockLength = getBlockLength();
+
+  /** Number of bits encoding a single character. */
+  private final int bitsPerChar = getBitsPerChar();
+
+  /** Initial bit mask for selecting characters in a block. */
+  private final long initialBitMask;
+
+  /** Holds a block of bytes to encode. */
+  private long block;
+
+  /** Number of bits in encode block remaining. */
+  private int remaining = blockLength;
+
+  /** Number of characters written. */
+  private int outCount;
+
+  /** Flag indicating whether output is padded. True by default. */
+  private boolean paddedOutput = true;
+
+
+  /**
+   * Creates a new instance with given parameters.
+   *
+   * @param  characterSet  Encoding character set.
+   * @param  charactersPerLine  Number of characters per line.
+   */
+  public AbstractBaseNEncoder(final char[] characterSet, final int charactersPerLine)
+  {
+    charset = characterSet;
+
+    long mask = 0;
+    for (int i = 1; i <= bitsPerChar; i++) {
+      mask |= 1L << (blockLength - i);
+    }
+    initialBitMask = mask;
+    lineLength = charactersPerLine;
+  }
+
+
+  /**
+   * @return True if padded output is enabled (default), false otherwise.
+   */
+  public boolean isPaddedOutput()
+  {
+    return paddedOutput;
+  }
+
+
+  /**
+   * Sets the output padding mode.
+   *
+   * @param  enabled  True to enable padded output, false otherwise.
+   */
+  public void setPaddedOutput(final boolean enabled)
+  {
+    this.paddedOutput = enabled;
+  }
+
+
+  @Override
+  public void encode(final ByteBuffer input, final CharBuffer output) throws EncodingException
+  {
+    while (input.hasRemaining()) {
+      remaining -= 8;
+      block |= (input.get() & 0xffL) << remaining;
+      if (remaining == 0) {
+        writeOutput(output, 0);
+      }
+    }
+  }
+
+
+  @Override
+  public void finalize(final CharBuffer output) throws EncodingException
+  {
+    if (remaining < blockLength) {
+      // Floor division
+      final int stop = remaining / bitsPerChar * bitsPerChar;
+      writeOutput(output, stop);
+      if (paddedOutput) {
+        for (int i = stop; i > 0; i -= bitsPerChar) {
+          output.put('=');
+        }
+      }
+    }
+    // Append trailing newline to make consistent with OpenSSL base64 output
+    if (lineLength > 0 && output.position() > 0) {
+      output.append(NEWLINE);
+    }
+    outCount = 0;
+  }
+
+
+  @Override
+  public int outputSize(final int inputSize)
+  {
+    int len = (inputSize + (blockLength / 8) - 1) * 8 / bitsPerChar;
+    if (lineLength > 0) {
+      len += (len / lineLength + 1) * NEWLINE.length();
+    }
+    return len;
+  }
+
+
+  /** @return  Number of bits in a block of encoded characters. */
+  protected abstract int getBlockLength();
+
+
+  /** @return  Number of bits encoding a single character. */
+  protected abstract int getBitsPerChar();
+
+
+  /**
+   * Converts the given alphabet into a base-N encoding table.
+   *
+   * @param  alphabet  Encoding alphabet to use.
+   * @param  n  Encoding base.
+   *
+   * @return  Encoding table of N elements.
+   */
+  protected static char[] encodingTable(final String alphabet, final int n)
+  {
+    if (alphabet.length() != n) {
+      throw new IllegalArgumentException("Alphabet must be exactly " + n + " characters long");
+    }
+    final char[] encodingTable = new char[n];
+    for (int i = 0; i < n; i++) {
+      encodingTable[i] = alphabet.charAt(i);
+    }
+    return encodingTable;
+  }
+
+
+  /**
+   * Writes bytes in the current encoding block to the output buffer.
+   *
+   * @param  output  Output buffer.
+   * @param  stop  Bit shift stop position where data in current encoding block ends.
+   */
+  private void writeOutput(final CharBuffer output, final int stop)
+  {
+    int shift = blockLength;
+    long mask = initialBitMask;
+    int index;
+    while (shift > stop) {
+      shift -= bitsPerChar;
+      index = (int) ((block & mask) >> shift);
+      output.put(charset[index]);
+      outCount++;
+      if (lineLength > 0 && outCount % lineLength == 0) {
+        output.put(NEWLINE);
+      }
+      mask >>= bitsPerChar;
+    }
+    block = 0;
+    remaining = blockLength;
+  }
+}
diff --git a/src/main/java/org/cryptacular/codec/Base32Codec.java b/src/main/java/org/cryptacular/codec/Base32Codec.java
new file mode 100644
index 0000000..632e58b
--- /dev/null
+++ b/src/main/java/org/cryptacular/codec/Base32Codec.java
@@ -0,0 +1,103 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.codec;
+
+/**
+ * Base 32 encoder/decoder pair.
+ *
+ * @author  Middleware Services
+ */
+public class Base32Codec implements Codec
+{
+
+  /** Encoder. */
+  private final Encoder encoder;
+
+  /** Decoder. */
+  private final Decoder decoder;
+
+  /** Custom alphabet to use. */
+  private final String customAlphabet;
+
+  /** Whether input/output padding is supported. */
+  private final boolean padding;
+
+
+  /**
+   * Creates a new instance using the RFC 4328 alphabet, <code>ABCDEFGHIJKLMNOPQRSTUVWXYZ234567</code>.
+   */
+  public Base32Codec()
+  {
+    encoder = new Base32Encoder();
+    decoder = new Base32Decoder();
+    customAlphabet = null;
+    padding = true;
+  }
+
+
+  /**
+   * Creates a new instance using the given 32-character alphabet.
+   *
+   * @param  alphabet  32-character alphabet to use.
+   */
+  public Base32Codec(final String alphabet)
+  {
+    this(alphabet, true);
+  }
+
+
+  /**
+   * Creates a new instance using the given 32-character alphabet with option to enable/disable padding.
+   *
+   * @param  alphabet  32-character alphabet to use.
+   * @param  inputOutputPadding  True to enable support for padding, false otherwise.
+   */
+  public Base32Codec(final String alphabet, final boolean inputOutputPadding)
+  {
+    customAlphabet = alphabet;
+    padding = inputOutputPadding;
+    encoder = newEncoder();
+    decoder = newDecoder();
+  }
+
+
+  @Override
+  public Encoder getEncoder()
+  {
+    return encoder;
+  }
+
+
+  @Override
+  public Decoder getDecoder()
+  {
+    return decoder;
+  }
+
+
+  @Override
+  public Encoder newEncoder()
+  {
+    final Base32Encoder encoder;
+    if (customAlphabet != null) {
+      encoder = new Base32Encoder(customAlphabet);
+    } else {
+      encoder = new Base32Encoder();
+    }
+    encoder.setPaddedOutput(padding);
+    return encoder;
+  }
+
+
+  @Override
+  public Decoder newDecoder()
+  {
+    final Base32Decoder decoder;
+    if (customAlphabet != null) {
+      decoder = new Base32Decoder(customAlphabet);
+    } else {
+      decoder = new Base32Decoder();
+    }
+    decoder.setPaddedInput(padding);
+    return decoder;
+  }
+}
diff --git a/src/main/java/org/cryptacular/codec/Base32Decoder.java b/src/main/java/org/cryptacular/codec/Base32Decoder.java
new file mode 100644
index 0000000..4299f48
--- /dev/null
+++ b/src/main/java/org/cryptacular/codec/Base32Decoder.java
@@ -0,0 +1,54 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.codec;
+
+/**
+ * Stateful base 32 decoder with support for line breaks.
+ *
+ * @author  Middleware Services
+ */
+public class Base32Decoder extends AbstractBaseNDecoder
+{
+
+  /** Base-32 character decoding table. */
+  private static final byte[] DECODING_TABLE;
+
+
+  /* Initializes the character decoding table. */
+  static
+  {
+    DECODING_TABLE = decodingTable("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567", 32);
+  }
+
+  /**
+   * Creates a new instance using the RFC 4648 alphabet, <code>ABCDEFGHIJKLMNOPQRSTUVWXYZ234567</code>, for decoding.
+   */
+  public Base32Decoder()
+  {
+    super(DECODING_TABLE);
+  }
+
+
+  /**
+   * Creates a new instance using the given 32-character alphabet for decoding.
+   *
+   * @param  alphabet  32-character alphabet to use.
+   */
+  public Base32Decoder(final String alphabet)
+  {
+    super(decodingTable(alphabet, 32));
+  }
+
+
+  @Override
+  protected int getBlockLength()
+  {
+    return 40;
+  }
+
+
+  @Override
+  protected int getBitsPerChar()
+  {
+    return 5;
+  }
+}
diff --git a/src/main/java/org/cryptacular/codec/Base32Encoder.java b/src/main/java/org/cryptacular/codec/Base32Encoder.java
new file mode 100644
index 0000000..6dcba61
--- /dev/null
+++ b/src/main/java/org/cryptacular/codec/Base32Encoder.java
@@ -0,0 +1,83 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.codec;
+
+/**
+ * Stateful base 32 encoder with support for configurable line breaks.
+ *
+ * @author  Middleware Services
+ */
+public class Base32Encoder extends AbstractBaseNEncoder
+{
+
+  /** Base 32 character encoding table. */
+  private static final char[] ENCODING_TABLE;
+
+
+  /* Initializes the default character encoding table. */
+  static
+  {
+    ENCODING_TABLE = encodingTable("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567", 32);
+  }
+
+
+  /**
+   * Creates a new instance that produces base 32-encoded output in the RFC 4648 alphabet,
+   * <code>ABCDEFGHIJKLMNOPQRSTUVWXYZ234567</code>, with no line breaks in the output.
+   */
+  public Base32Encoder()
+  {
+    // Default to no line breaks.
+    this(-1);
+  }
+
+
+  /**
+   * Creates a new instance that produces base 32-encoded output in the RFC 4648 alphabet,
+   * <code>ABCDEFGHIJKLMNOPQRSTUVWXYZ234567</code>, with the given number of characters per line in the output.
+   *
+   * @param  charactersPerLine  Number of characters per line. A zero or negative value disables line breaks.
+   */
+  public Base32Encoder(final int charactersPerLine)
+  {
+    super(ENCODING_TABLE, charactersPerLine);
+  }
+
+
+  /**
+   * Creates a new instance that produces base 32-encoded output in the given 32-character alphabet with no line
+   * breaks in the output.
+   *
+   * @param  alphabet  32-character alphabet to use.
+   */
+  public Base32Encoder(final String alphabet)
+  {
+    this(alphabet, -1);
+  }
+
+
+  /**
+   * Creates a new instance that produces base 32-encoded output in the given 32-character alphabet
+   * with the given number of characters per line in the output.
+   *
+   * @param  alphabet  32-character alphabet to use.
+   * @param  charactersPerLine  Number of characters per line. A zero or negative value disables line breaks.
+   */
+  public Base32Encoder(final String alphabet, final int charactersPerLine)
+  {
+    super(encodingTable(alphabet, 32), charactersPerLine);
+  }
+
+
+  @Override
+  protected int getBlockLength()
+  {
+    return 40;
+  }
+
+
+  @Override
+  protected int getBitsPerChar()
+  {
+    return 5;
+  }
+}
diff --git a/src/main/java/org/cryptacular/codec/Base64Codec.java b/src/main/java/org/cryptacular/codec/Base64Codec.java
new file mode 100644
index 0000000..14d9e7a
--- /dev/null
+++ b/src/main/java/org/cryptacular/codec/Base64Codec.java
@@ -0,0 +1,103 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.codec;
+
+/**
+ * Base 64 encoder/decoder pair.
+ *
+ * @author  Middleware Services
+ */
+public class Base64Codec implements Codec
+{
+
+  /** Encoder. */
+  private final Encoder encoder;
+
+  /** Decoder. */
+  private final Decoder decoder;
+
+  /** Custom alphabet to use. */
+  private final String customAlphabet;
+
+  /** Whether input/output padding is supported. */
+  private final boolean padding;
+
+
+  /**
+   * Creates a new instance using the base-64 alphabet defined in RFC 4648.
+   */
+  public Base64Codec()
+  {
+    encoder = new Base64Encoder();
+    decoder = new Base64Decoder();
+    customAlphabet = null;
+    padding = true;
+  }
+
+
+  /**
+   * Creates a new instance using the given 64-character alphabet.
+   *
+   * @param  alphabet  64-character alphabet to use.
+   */
+  public Base64Codec(final String alphabet)
+  {
+    this(alphabet, true);
+  }
+
+
+  /**
+   * Creates a new instance using the given 64-character alphabet with option to enable/disable padding.
+   *
+   * @param  alphabet  64-character alphabet to use.
+   * @param  inputOutputPadding  True to enable support for padding, false otherwise.
+   */
+  public Base64Codec(final String alphabet, final boolean inputOutputPadding)
+  {
+    customAlphabet = alphabet;
+    padding = inputOutputPadding;
+    encoder = newEncoder();
+    decoder = newDecoder();
+  }
+
+
+  @Override
+  public Encoder getEncoder()
+  {
+    return encoder;
+  }
+
+
+  @Override
+  public Decoder getDecoder()
+  {
+    return decoder;
+  }
+
+
+  @Override
+  public Encoder newEncoder()
+  {
+    final Base64Encoder encoder;
+    if (customAlphabet != null) {
+      encoder = new Base64Encoder(customAlphabet);
+    } else {
+      encoder = new Base64Encoder();
+    }
+    encoder.setPaddedOutput(padding);
+    return encoder;
+  }
+
+
+  @Override
+  public Decoder newDecoder()
+  {
+    final Base64Decoder decoder;
+    if (customAlphabet != null) {
+      decoder = new Base64Decoder(customAlphabet);
+    } else {
+      decoder = new Base64Decoder();
+    }
+    decoder.setPaddedInput(padding);
+    return decoder;
+  }
+}
diff --git a/src/main/java/org/cryptacular/codec/Base64Decoder.java b/src/main/java/org/cryptacular/codec/Base64Decoder.java
new file mode 100644
index 0000000..57bc791
--- /dev/null
+++ b/src/main/java/org/cryptacular/codec/Base64Decoder.java
@@ -0,0 +1,144 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.codec;
+
+/**
+ * Stateful base 64 decoder with support for line breaks.
+ *
+ * @author  Middleware Services
+ */
+public class Base64Decoder extends AbstractBaseNDecoder
+{
+
+  /** Default base-64 character decoding table. */
+  private static final byte[] DEFAULT_DECODING_TABLE;
+
+  /** URL and filesystem-safe base-64 character decoding table. */
+  private static final byte[] URLSAFE_DECODING_TABLE;
+
+
+  /* Initializes the character decoding table. */
+  static
+  {
+    DEFAULT_DECODING_TABLE = decodingTable("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/", 64);
+    URLSAFE_DECODING_TABLE = decodingTable("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_", 64);
+  }
+
+
+  /** Creates a new instance that decodes base 64-encoded input in the default character set. */
+  public Base64Decoder()
+  {
+    this(false);
+  }
+
+
+  /**
+   * Creates a new instance that decodes base 64-encoded input in the optional URL-safe character set.
+   *
+   * @param  urlSafe  True to use URL and filesystem-safe character set, false otherwise.
+   */
+  public Base64Decoder(final boolean urlSafe)
+  {
+    super(urlSafe ? URLSAFE_DECODING_TABLE : DEFAULT_DECODING_TABLE);
+  }
+
+
+  /**
+   * Creates a new instance that decodes base-64 character data encoded in the given alphabet.
+   *
+   * @param  alphabet  Base-64 alphabet to use for decoding
+   */
+  public Base64Decoder(final String alphabet)
+  {
+    super(decodingTable(alphabet, 64));
+  }
+
+
+  @Override
+  protected int getBlockLength()
+  {
+    return 24;
+  }
+
+
+  @Override
+  protected int getBitsPerChar()
+  {
+    return 6;
+  }
+
+
+  /**
+   * Builder for base-64 decoders.
+   */
+  public static class Builder
+  {
+    /** URL-safe alphabet flag. */
+    private boolean urlSafe;
+
+    /** Arbitrary alphbet. */
+    private String alphabet;
+
+    /** Padding flag. */
+    private boolean padding;
+
+
+    /**
+     * Sets the URL-safe alphabet flag.
+     *
+     * @param safe True for URL-safe alphabet, false otherwise.
+     *
+     * @return This instance.
+     */
+    public Builder setUrlSafe(final boolean safe)
+    {
+      urlSafe = safe;
+      return this;
+    }
+
+
+    /**
+     * Sets an arbitrary 64-character alphabet for decoding.
+     *
+     * @param alpha Alternative alphabet.
+     *
+     * @return This instance.
+     */
+    public Builder setAlphabet(final String alpha)
+    {
+      alphabet = alpha;
+      return this;
+    }
+
+
+    /**
+     * Sets padding flag on the decoder.
+     *
+     * @param pad True for base-64 padding, false otherwise.
+     *
+     * @return This instance.
+     */
+    public Builder setPadding(final boolean pad)
+    {
+      padding = pad;
+      return this;
+    }
+
+
+    /**
+     * Builds a base-64 decoder with the given options.
+     *
+     * @return New base-64 decoder instance.
+     */
+    public Base64Decoder build()
+    {
+      final Base64Decoder decoder;
+      if (alphabet != null) {
+        decoder = new Base64Decoder(alphabet);
+      } else {
+        decoder = new Base64Decoder(urlSafe);
+      }
+      decoder.setPaddedInput(padding);
+      return decoder;
+    }
+  }
+}
diff --git a/src/main/java/org/cryptacular/codec/Base64Encoder.java b/src/main/java/org/cryptacular/codec/Base64Encoder.java
new file mode 100644
index 0000000..a397f8c
--- /dev/null
+++ b/src/main/java/org/cryptacular/codec/Base64Encoder.java
@@ -0,0 +1,201 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.codec;
+
+/**
+ * Stateful base 64 encoder with support for configurable line breaks.
+ *
+ * @author  Middleware Services
+ */
+public class Base64Encoder extends AbstractBaseNEncoder
+{
+
+  /** Default base 64 character encoding table. */
+  private static final char[] DEFAULT_ENCODING_TABLE;
+
+  /** Filesystem and URL-safe base 64 character encoding table. */
+  private static final char[] URLSAFE_ENCODING_TABLE;
+
+
+  /* Initializes the default character encoding tables. */
+  static
+  {
+    DEFAULT_ENCODING_TABLE = encodingTable("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/", 64);
+    URLSAFE_ENCODING_TABLE = encodingTable("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_", 64);
+  }
+
+
+  /** Creates a new instance that produces base 64-encoded output with no line breaks in the default character set. */
+  public Base64Encoder()
+  {
+    // Default to no line breaks.
+    this(-1);
+  }
+
+
+  /**
+   * Creates a new instance that produces base 64-encoded output with no line breaks and optional URL-safe character
+   * set.
+   *
+   * @param  urlSafe  True to use URL and filesystem-safe character set, false otherwise.
+   */
+  public Base64Encoder(final boolean urlSafe)
+  {
+    this(urlSafe, -1);
+  }
+
+
+  /**
+   * Creates a new instance that produces base 64-encoded output with the given number of characters per line in the
+   * default character set.
+   *
+   * @param  charactersPerLine  Number of characters per line. A zero or negative value disables line breaks.
+   */
+  public Base64Encoder(final int charactersPerLine)
+  {
+    this(false, charactersPerLine);
+  }
+
+
+  /**
+   * Creates a new instance that produces base 64-encoded output with the given number of characters per line with the
+   * option of URL-safe character set.
+   *
+   * @param  urlSafe  True to use URL and filesystem-safe character set, false otherwise.
+   * @param  charactersPerLine  Number of characters per line. A zero or negative value disables line breaks.
+   */
+  public Base64Encoder(final boolean urlSafe, final int charactersPerLine)
+  {
+    super(urlSafe ? URLSAFE_ENCODING_TABLE : DEFAULT_ENCODING_TABLE, charactersPerLine);
+  }
+
+
+  /**
+   * Creates a new instance that produces base 64-encoded output with the given 64-character alphabet.
+   *
+   * @param  alphabet  64-character alphabet to use.
+   */
+  public Base64Encoder(final String alphabet)
+  {
+    this(alphabet, -1);
+  }
+
+
+  /**
+   * Creates a new instance that produces base 64-encoded output with the given 64-character alphabet with line
+   * wrapping at the specified line length;
+   *
+   * @param  alphabet  64-character alphabet to use.
+   * @param  charactersPerLine  Number of characters per line. A zero or negative value disables line breaks.
+   */
+  public Base64Encoder(final String alphabet, final int charactersPerLine)
+  {
+    super(encodingTable(alphabet, 64), charactersPerLine);
+  }
+
+
+  @Override
+  protected int getBlockLength()
+  {
+    return 24;
+  }
+
+
+  @Override
+  protected int getBitsPerChar()
+  {
+    return 6;
+  }
+
+
+  /**
+   * Builder for base-64 encoders.
+   */
+  public static class Builder
+  {
+    /** URL-safe alphabet flag. */
+    private boolean urlSafe;
+
+    /** Arbitrary alphbet. */
+    private String alphabet;
+
+    /** Padding flag. */
+    private boolean padding;
+
+    /** Number of base-64 characters per line in output. */
+    private int charactersPerLine = -1;
+
+
+    /**
+     * Sets the URL-safe alphabet flag.
+     *
+     * @param  safe  True for URL-safe alphabet, false otherwise.
+     *
+     * @return  This instance.
+     */
+    public Base64Encoder.Builder setUrlSafe(final boolean safe)
+    {
+      urlSafe = safe;
+      return this;
+    }
+
+
+    /**
+     * Sets an arbitrary 64-character alphabet for encoding.
+     *
+     * @param  alpha  Alternative alphabet.
+     *
+     * @return  This instance.
+     */
+    public Base64Encoder.Builder setAlphabet(final String alpha)
+    {
+      alphabet = alpha;
+      return this;
+    }
+
+
+    /**
+     * Sets padding flag on the encoder.
+     *
+     * @param  pad  True for base-64 padding, false otherwise.
+     *
+     * @return  This instance.
+     */
+    public Base64Encoder.Builder setPadding(final boolean pad)
+    {
+      padding = pad;
+      return this;
+    }
+
+
+    /**
+     * Sets the number of characters per line in output produced by the encoder.
+     *
+     * @param  lineLength  Number of characters per line. Set to <code>-1</code> to suppress line breaks.
+     *
+     * @return  This instance.
+     */
+    public Base64Encoder.Builder setCharactersPerLine(final int lineLength)
+    {
+      charactersPerLine = lineLength;
+      return this;
+    }
+
+
+    /**
+     * Builds a base-64 encoder with the given options.
+     *
+     * @return  New base-64 encoder instance.
+     */
+    public Base64Encoder build()
+    {
+      final Base64Encoder decoder;
+      if (alphabet != null) {
+        decoder = new Base64Encoder(alphabet, charactersPerLine);
+      } else {
+        decoder = new Base64Encoder(urlSafe, charactersPerLine);
+      }
+      decoder.setPaddedOutput(padding);
+      return decoder;
+    }
+  }
+}
diff --git a/src/main/java/org/cryptacular/codec/Codec.java b/src/main/java/org/cryptacular/codec/Codec.java
new file mode 100644
index 0000000..1c97226
--- /dev/null
+++ b/src/main/java/org/cryptacular/codec/Codec.java
@@ -0,0 +1,27 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.codec;
+
+/**
+ * Container for an encoder/decoder pair.
+ *
+ * @author  Middleware Services
+ */
+public interface Codec
+{
+
+
+  /** @return  The byte-to-char encoder of the codec pair. */
+  Encoder getEncoder();
+
+
+  /** @return  The char-to-byte decoder of the codec pair. */
+  Decoder getDecoder();
+
+
+  /** @return  A new instance of the byte-to-char encoder of the codec pair. */
+  Encoder newEncoder();
+
+
+  /** @return  A new instance of the char-to-byte decoder of the codec pair. */
+  Decoder newDecoder();
+}
diff --git a/src/main/java/org/cryptacular/codec/Decoder.java b/src/main/java/org/cryptacular/codec/Decoder.java
new file mode 100644
index 0000000..fbf759a
--- /dev/null
+++ b/src/main/java/org/cryptacular/codec/Decoder.java
@@ -0,0 +1,46 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.codec;
+
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import org.cryptacular.EncodingException;
+
+/**
+ * Describes a potentially stateful character-to-byte decoder.
+ *
+ * @author  Middleware Services
+ */
+public interface Decoder
+{
+
+  /**
+   * Decodes characters in input buffer into bytes placed in the output buffer. This method may be called multiple
+   * times, followed by {@link #finalize(ByteBuffer)}. after all input bytes have been provided.
+   *
+   * @param  input  Input character buffer.
+   * @param  output  Output byte buffer.
+   *
+   * @throws  EncodingException  on decoding errors.
+   */
+  void decode(CharBuffer input, ByteBuffer output) throws EncodingException;
+
+
+  /**
+   * Performs final output decoding (e.g. padding) after all input characters have been provided.
+   *
+   * @param  output  Output byte buffer.
+   *
+   * @throws  EncodingException  on decoding errors.
+   */
+  void finalize(ByteBuffer output) throws EncodingException;
+
+
+  /**
+   * Expected number of bytes in the output buffer for an input buffer of the given size.
+   *
+   * @param  inputSize  Size of input buffer in characters.
+   *
+   * @return  Minimum byte buffer size required to store all decoded characters in input buffer.
+   */
+  int outputSize(int inputSize);
+}
diff --git a/src/main/java/org/cryptacular/codec/Encoder.java b/src/main/java/org/cryptacular/codec/Encoder.java
new file mode 100644
index 0000000..ac53606
--- /dev/null
+++ b/src/main/java/org/cryptacular/codec/Encoder.java
@@ -0,0 +1,46 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.codec;
+
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import org.cryptacular.EncodingException;
+
+/**
+ * Describes a potentially stateful byte-to-character encoder.
+ *
+ * @author  Middleware Services
+ */
+public interface Encoder
+{
+
+  /**
+   * Encodes bytes in input buffer into characters placed in the output buffer. This method may be called multiple
+   * times, followed by {@link #finalize(java.nio.CharBuffer)} after all input bytes have been provided.
+   *
+   * @param  input  Input byte buffer.
+   * @param  output  Output character buffer.
+   *
+   * @throws  EncodingException  on encoding errors.
+   */
+  void encode(ByteBuffer input, CharBuffer output) throws EncodingException;
+
+
+  /**
+   * Performs final output encoding (e.g. padding) after all input bytes have been provided.
+   *
+   * @param  output  Output character buffer.
+   *
+   * @throws  EncodingException  on encoding errors.
+   */
+  void finalize(CharBuffer output) throws EncodingException;
+
+
+  /**
+   * Expected number of characters in the output buffer for an input buffer of the given size.
+   *
+   * @param  inputSize  Size of input buffer in bytes.
+   *
+   * @return  Minimum character buffer size required to store all encoded input bytes.
+   */
+  int outputSize(int inputSize);
+}
diff --git a/src/main/java/org/cryptacular/codec/HexCodec.java b/src/main/java/org/cryptacular/codec/HexCodec.java
new file mode 100644
index 0000000..005f468
--- /dev/null
+++ b/src/main/java/org/cryptacular/codec/HexCodec.java
@@ -0,0 +1,69 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.codec;
+
+/**
+ * Hexadecimal encoder/decoder pair.
+ *
+ * @author  Middleware Services
+ */
+public class HexCodec implements Codec
+{
+
+  /** Encoder. */
+  private final Encoder encoder;
+
+  /** Decoder. */
+  private final Decoder decoder = new HexDecoder();
+
+  /** True to encode in uppercase characters, false otherwise. */
+  private final boolean uppercase;
+
+
+  /**
+   * Creates a new instance that outputs lowercase hex characters and supports decoding in either case.
+   */
+  public HexCodec()
+  {
+    this(false);
+  }
+
+
+  /**
+   * Creates a new instance that optionally outputs uppercase hex characters and supports decoding in either case.
+   *
+   * @param  uppercaseOutput  True to output uppercase alphabetic characters, false for lowercase.
+   */
+  public HexCodec(final boolean uppercaseOutput)
+  {
+    uppercase = uppercaseOutput;
+    encoder = new HexEncoder(false, uppercase);
+  }
+
+
+  @Override
+  public Encoder getEncoder()
+  {
+    return encoder;
+  }
+
+
+  @Override
+  public Decoder getDecoder()
+  {
+    return decoder;
+  }
+
+
+  @Override
+  public Encoder newEncoder()
+  {
+    return new HexEncoder(false, uppercase);
+  }
+
+
+  @Override
+  public Decoder newDecoder()
+  {
+    return new HexDecoder();
+  }
+}
diff --git a/src/main/java/org/cryptacular/codec/HexDecoder.java b/src/main/java/org/cryptacular/codec/HexDecoder.java
new file mode 100644
index 0000000..175f691
--- /dev/null
+++ b/src/main/java/org/cryptacular/codec/HexDecoder.java
@@ -0,0 +1,91 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.codec;
+
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import java.util.Arrays;
+import org.cryptacular.EncodingException;
+
+/**
+ * Stateful hexadecimal character-to-byte decoder.
+ *
+ * @author  Middleware Services
+ */
+public class HexDecoder implements Decoder
+{
+
+  /** Hex character decoding table. */
+  private static final byte[] DECODING_TABLE = new byte[128];
+
+  /* Initializes the character decoding table. */
+  static {
+    Arrays.fill(DECODING_TABLE, (byte) -1);
+    for (int i = 0; i < 10; i++) {
+      DECODING_TABLE[i + 48] = (byte) i;
+    }
+    for (int i = 0; i < 6; i++) {
+      DECODING_TABLE[i + 65] = (byte) (10 + i);
+      DECODING_TABLE[i + 97] = (byte) (10 + i);
+    }
+  }
+
+  /** Number of encoded characters processed. */
+  private int count;
+
+
+  @Override
+  public void decode(final CharBuffer input, final ByteBuffer output) throws EncodingException
+  {
+    // Ignore leading 0x characters if present
+    if (input.get(0) == '0' && input.get(1) == 'x') {
+      input.position(input.position() + 2);
+    }
+
+    byte hi = 0;
+    byte lo;
+    char current;
+    while (input.hasRemaining()) {
+      current = input.get();
+      if (current == ':' || Character.isWhitespace(current)) {
+        continue;
+      }
+      if ((count++ & 0x01) == 0) {
+        hi = lookup(current);
+      } else {
+        lo = lookup(current);
+        output.put((byte) ((hi << 4) | lo));
+      }
+    }
+  }
+
+
+  @Override
+  public void finalize(final ByteBuffer output) throws EncodingException
+  {
+    count = 0;
+  }
+
+
+  @Override
+  public int outputSize(final int inputSize)
+  {
+    return inputSize / 2;
+  }
+
+
+  /**
+   * Looks up the byte that corresponds to the given character.
+   *
+   * @param  c  Encoded character.
+   *
+   * @return  Decoded byte.
+   */
+  private static byte lookup(final char c)
+  {
+    final byte b = DECODING_TABLE[c & 0x7F];
+    if (b < 0) {
+      throw new EncodingException("Invalid hex character " + c);
+    }
+    return b;
+  }
+}
diff --git a/src/main/java/org/cryptacular/codec/HexEncoder.java b/src/main/java/org/cryptacular/codec/HexEncoder.java
new file mode 100644
index 0000000..b259bec
--- /dev/null
+++ b/src/main/java/org/cryptacular/codec/HexEncoder.java
@@ -0,0 +1,109 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.codec;
+
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import org.cryptacular.EncodingException;
+
+/**
+ * Stateless hexadecimal byte-to-character encoder.
+ *
+ * @author  Middleware Services
+ */
+public class HexEncoder implements Encoder
+{
+
+  /** Lowercase hex character encoding table. */
+  private static final char[] LC_ENCODING_TABLE = new char[16];
+
+  /** Uppercase hex character encoding table. */
+  private static final char[] UC_ENCODING_TABLE = new char[16];
+
+
+
+  /* Initializes the encoding character table. */
+  static {
+    initTable("0123456789abcdef", LC_ENCODING_TABLE);
+    initTable("0123456789ABCDEF", UC_ENCODING_TABLE);
+  }
+
+  /** Flag indicating whether to delimit every two characters with ':' as in key fingerprints, etc. */
+  private final boolean delimit;
+
+  /** Encoding table to use. */
+  private final char[] table;
+
+
+  /** Creates a new instance that does not delimit bytes in the output hex string. */
+  public HexEncoder()
+  {
+    this(false, false);
+  }
+
+  /**
+   * Creates a new instance with optional colon-delimiting of bytes.
+   *
+   * @param  delimitBytes  True to delimit every two characters (i.e. every byte) with ':' character.
+   */
+  public HexEncoder(final boolean delimitBytes)
+  {
+    this(delimitBytes, false);
+  }
+
+
+  /**
+   * Creates a new instance with optional colon-delimiting of bytes and uppercase output.
+   *
+   * @param  delimitBytes  True to delimit every two characters (i.e. every byte) with ':' character.
+   * @param  uppercase  True to output uppercase alphabetic characters, false for lowercase.
+   */
+  public HexEncoder(final boolean delimitBytes, final boolean uppercase)
+  {
+    delimit = delimitBytes;
+    table = uppercase ? UC_ENCODING_TABLE : LC_ENCODING_TABLE;
+  }
+
+
+  @Override
+  public void encode(final ByteBuffer input, final CharBuffer output) throws EncodingException
+  {
+    byte current;
+    while (input.hasRemaining()) {
+      current = input.get();
+      output.put(table[(current & 0xf0) >> 4]);
+      output.put(table[current & 0x0f]);
+      if (delimit && input.hasRemaining()) {
+        output.put(':');
+      }
+    }
+  }
+
+
+  @Override
+  public void finalize(final CharBuffer output) throws EncodingException {}
+
+
+  @Override
+  public int outputSize(final int inputSize)
+  {
+    int size = inputSize * 2;
+    if (delimit) {
+      size += inputSize - 1;
+    }
+    return size;
+  }
+
+
+  /**
+   * Initializes the encoding table for the given character set.
+   *
+   * @param  charset  Character set.
+   * @param  table  Encoding table.
+   */
+  private static void initTable(final String charset, final char[] table)
+  {
+    for (int i = 0; i < charset.length(); i++) {
+      table[i] = charset.charAt(i);
+    }
+  }
+}
diff --git a/src/main/java/org/cryptacular/generator/AESP12Generator.java b/src/main/java/org/cryptacular/generator/AESP12Generator.java
new file mode 100644
index 0000000..e0c8d28
--- /dev/null
+++ b/src/main/java/org/cryptacular/generator/AESP12Generator.java
@@ -0,0 +1,202 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.generator;
+
+import java.io.OutputStream;
+import java.security.SecureRandom;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import org.bouncycastle.asn1.ASN1ObjectIdentifier;
+import org.bouncycastle.asn1.DERNull;
+import org.bouncycastle.asn1.DEROctetString;
+import org.bouncycastle.asn1.nist.NISTObjectIdentifiers;
+import org.bouncycastle.asn1.pkcs.EncryptionScheme;
+import org.bouncycastle.asn1.pkcs.KeyDerivationFunc;
+import org.bouncycastle.asn1.pkcs.PBES2Parameters;
+import org.bouncycastle.asn1.pkcs.PBKDF2Params;
+import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers;
+import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
+import org.bouncycastle.crypto.util.PBKDF2Config;
+import org.bouncycastle.operator.GenericKey;
+import org.bouncycastle.operator.OutputEncryptor;
+import org.cryptacular.pbe.PBES2Algorithm;
+import org.cryptacular.pbe.PBES2EncryptionScheme;
+import org.cryptacular.spec.DigestSpec;
+
+
+/**
+ * Generates PKCS12 containers using the PBES2 algorithm with the AES-256-CBC cipher for encryption, which is the
+ * most portable and secure algorithm in use with PKCS12 at this time.
+ *
+ * @author Marvin S. Addison
+ */
+public class AESP12Generator extends AbstractP12Generator
+{
+  /** Set of supported digest algorithms. */
+  public static final Set<ASN1ObjectIdentifier> SUPPORTED_DIGEST_ALGORITHMS = Collections.unmodifiableSet(
+    new HashSet<>(Arrays.asList(
+      NISTObjectIdentifiers.id_sha256,
+      NISTObjectIdentifiers.id_sha512,
+      NISTObjectIdentifiers.id_sha3_256,
+      NISTObjectIdentifiers.id_sha3_384,
+      NISTObjectIdentifiers.id_sha3_512
+    )));
+
+  /** Map of digest algorithm identifiers to digest specifications. */
+  private static final Map<ASN1ObjectIdentifier, DigestSpec> DIGEST_ID_TO_DIGEST_SPEC_MAP = new HashMap<>();
+
+  /** Map of digest algorithm identifiers to HMAC algorithm IDs. */
+  private static final Map<ASN1ObjectIdentifier, ASN1ObjectIdentifier> DIGEST_ID_TO_HMAC_ID_MAP = new HashMap<>();
+
+  /** Digest algorithm used for all HMAC operations. */
+  private final ASN1ObjectIdentifier digestAlgorithm;
+
+  /** PBKDF2 configuration. */
+  private final PBKDF2Config pbkdf2Config;
+
+  /** Produces encryptors that use the PBES2 algorithm. */
+  private final PBES2OutputEncryptorBuilder outputEncryptorBuilder;
+
+
+  static
+  {
+    DIGEST_ID_TO_DIGEST_SPEC_MAP.put(NISTObjectIdentifiers.id_sha256, new DigestSpec("SHA256"));
+    DIGEST_ID_TO_DIGEST_SPEC_MAP.put(NISTObjectIdentifiers.id_sha512, new DigestSpec("SHA512"));
+    DIGEST_ID_TO_DIGEST_SPEC_MAP.put(NISTObjectIdentifiers.id_sha3_256, new DigestSpec("SHA3", 256));
+    DIGEST_ID_TO_DIGEST_SPEC_MAP.put(NISTObjectIdentifiers.id_sha3_384, new DigestSpec("SHA3", 384));
+    DIGEST_ID_TO_DIGEST_SPEC_MAP.put(NISTObjectIdentifiers.id_sha3_512, new DigestSpec("SHA3", 512));
+    DIGEST_ID_TO_HMAC_ID_MAP.put(NISTObjectIdentifiers.id_sha256, PKCSObjectIdentifiers.id_hmacWithSHA256);
+    DIGEST_ID_TO_HMAC_ID_MAP.put(NISTObjectIdentifiers.id_sha512, PKCSObjectIdentifiers.id_hmacWithSHA512);
+    DIGEST_ID_TO_HMAC_ID_MAP.put(NISTObjectIdentifiers.id_sha3_256, NISTObjectIdentifiers.id_hmacWithSHA3_256);
+    DIGEST_ID_TO_HMAC_ID_MAP.put(NISTObjectIdentifiers.id_sha3_384, NISTObjectIdentifiers.id_hmacWithSHA3_384);
+    DIGEST_ID_TO_HMAC_ID_MAP.put(NISTObjectIdentifiers.id_sha3_512, NISTObjectIdentifiers.id_hmacWithSHA3_512);
+  }
+
+
+  /**
+   * Creates a new instance that encrypts with AES-256-CBC and SHA256 using 2048 rounds of hashing.
+   */
+  public AESP12Generator()
+  {
+    this(NISTObjectIdentifiers.id_sha256, 2048);
+  }
+
+  /**
+   * Creates a new instance that encrypts with AES-256-CBC and SHA256 using the given number of hashing rounds.
+   *
+   * @param iterations Number of rounds of encryption.
+   */
+  public AESP12Generator(final int iterations)
+  {
+    this(NISTObjectIdentifiers.id_sha256, iterations);
+  }
+
+  /**
+   * Creates a new instances that uses AES-256-CBC and the given digest algorithm to encrypt data.
+   *
+   * @param digestAlgId Digest algorithm identifier.
+   * @param iterations Number of rounds of hashing.
+   */
+  public AESP12Generator(final ASN1ObjectIdentifier digestAlgId, final int iterations)
+  {
+    if (!SUPPORTED_DIGEST_ALGORITHMS.contains(digestAlgId)) {
+      throw new IllegalArgumentException("Unsupported digest algorithm");
+    }
+    digestAlgorithm = digestAlgId;
+    final ASN1ObjectIdentifier hmacAlgId = DIGEST_ID_TO_HMAC_ID_MAP.get(digestAlgId);
+    // The default behavior of the builder is to select salt size based on HMAC algorithm,
+    // which is the desirable behavior here
+    pbkdf2Config = new PBKDF2Config.Builder()
+      .withIterationCount(iterations)
+      .withPRF(new AlgorithmIdentifier(hmacAlgId, DERNull.INSTANCE))
+      .build();
+    outputEncryptorBuilder = new PBES2OutputEncryptorBuilder(PBES2Algorithm.AES256, pbkdf2Config);
+  }
+
+  @Override
+  public int getIterations()
+  {
+    return pbkdf2Config.getIterationCount();
+  }
+
+  @Override
+  protected ASN1ObjectIdentifier getDigestAlgorithmId()
+  {
+    return digestAlgorithm;
+  }
+
+  @Override
+  protected DigestSpec getDigestSpec()
+  {
+    return DIGEST_ID_TO_DIGEST_SPEC_MAP.get(digestAlgorithm);
+  }
+
+  @Override
+  protected OutputEncryptor keyOutputEncryptor(final char[] password)
+  {
+    return outputEncryptorBuilder.build(password);
+  }
+
+  @Override
+  protected OutputEncryptor dataOutputEncryptor(final char[] password)
+  {
+    return outputEncryptorBuilder.build(password);
+  }
+
+  /**
+   * Builds an output encryptor based on PBKDF2 domain parameters.
+   */
+  static class PBES2OutputEncryptorBuilder
+  {
+    /** Source of cryptographically-strong randomness. */
+    private final SecureRandom random = new SecureRandom();
+
+    /** PBES2 encryption algorithm. */
+    private final PBES2Algorithm encryptionAlg;
+
+    /** PBKDF2 domain parameters. */
+    private final PBKDF2Config pbkdf2Config;
+
+
+    PBES2OutputEncryptorBuilder(final PBES2Algorithm encAlg, final PBKDF2Config config)
+    {
+      this.encryptionAlg = encAlg;
+      this.pbkdf2Config = config;
+    }
+
+    OutputEncryptor build(final char[] password)
+    {
+      final byte[] salt = new byte[pbkdf2Config.getSaltLength()];
+      random.nextBytes(salt);
+      final byte[] iv = new byte[encryptionAlg.getBlockSize() / 8];
+      random.nextBytes(iv);
+      final ASN1ObjectIdentifier encryptionAlgId = new ASN1ObjectIdentifier(encryptionAlg.getOid());
+      final EncryptionScheme encryptionScheme = new EncryptionScheme(encryptionAlgId, new DEROctetString(iv));
+      final PBKDF2Params pbkdf2Params = new PBKDF2Params(salt, pbkdf2Config.getIterationCount(), pbkdf2Config.getPRF());
+      final PBES2Parameters pbes2Parameters = new PBES2Parameters(
+        new KeyDerivationFunc(PKCSObjectIdentifiers.id_PBKDF2, pbkdf2Params),
+        encryptionScheme);
+      final PBES2EncryptionScheme scheme = new PBES2EncryptionScheme(pbes2Parameters, password);
+      return new OutputEncryptor()
+      {
+        public AlgorithmIdentifier getAlgorithmIdentifier()
+        {
+          return new AlgorithmIdentifier(PKCSObjectIdentifiers.id_PBES2, pbes2Parameters);
+        }
+
+        public OutputStream getOutputStream(final OutputStream out)
+        {
+          return scheme.wrap(true, out);
+        }
+
+        public GenericKey getKey()
+        {
+          return null;
+        }
+      };
+    }
+  }
+}
diff --git a/src/main/java/org/cryptacular/generator/AbstractOTPGenerator.java b/src/main/java/org/cryptacular/generator/AbstractOTPGenerator.java
new file mode 100644
index 0000000..6260207
--- /dev/null
+++ b/src/main/java/org/cryptacular/generator/AbstractOTPGenerator.java
@@ -0,0 +1,99 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.generator;
+
+import org.bouncycastle.crypto.Digest;
+import org.bouncycastle.crypto.macs.HMac;
+import org.bouncycastle.crypto.params.KeyParameter;
+import org.cryptacular.util.ByteUtil;
+
+/**
+ * Abstract base class for <a href="https://tools.ietf.org/html/rfc4226">HOTP</a> and <a
+ * href="https://tools.ietf.org/html/rfc6238">TOTP</a> OTP generation schemes.
+ *
+ * @author  Middleware Services
+ */
+public abstract class AbstractOTPGenerator
+{
+
+  /** Array of modulus values indexed per number of digits in OTP output. */
+  private static final int[] MODULUS = new int[] {
+    1,
+    10,
+    100,
+    1000,
+    10000,
+    100000,
+    1000000,
+    10000000,
+    100000000,
+    1000000000,
+  };
+
+  /** Number of digits in generated OTP. */
+  private int numberOfDigits = 6;
+
+
+  /** @return  Number of digits in generated OTP. */
+  public int getNumberOfDigits()
+  {
+    return numberOfDigits;
+  }
+
+
+  /**
+   * Sets the numbers in the generated OTP.
+   *
+   * @param  digits  Number of digits in generated OTP. MUST be in the range 6 - 9. Default is 6.
+   */
+  public void setNumberOfDigits(final int digits)
+  {
+    if (digits < 6 || digits > 9) {
+      throw new IllegalArgumentException("Number of generated digits must be in range 6-9.");
+    }
+    this.numberOfDigits = digits;
+  }
+
+
+  /**
+   * Internal OTP generation method.
+   *
+   * @param  key  Per-user key.
+   * @param  count  Counter moving factor.
+   *
+   * @return  Integer OTP.
+   */
+  protected int generateInternal(final byte[] key, final long count)
+  {
+    final HMac hmac = new HMac(getDigest());
+    final byte[] output = new byte[hmac.getMacSize()];
+    hmac.init(new KeyParameter(key));
+    hmac.update(ByteUtil.toBytes(count), 0, 8);
+    hmac.doFinal(output, 0);
+    return truncate(output) % MODULUS[numberOfDigits];
+  }
+
+
+  /** @return  Digest algorithm used for HMAC operation. */
+  protected abstract Digest getDigest();
+
+
+  /**
+   * Truncates HMAC output onto an unsigned (i.e. 31-bit) integer using the strategy discussed in RFC 4226,
+   * section 5.3.
+   *
+   * @param  hmac  HMAC output.
+   *
+   * @return  Truncated output.
+   */
+  private int truncate(final byte[] hmac)
+  {
+    // Offset is the lowest 4 bits of the computed hash
+    final int offset = hmac[hmac.length - 1] & 0xf;
+    return
+      (hmac[offset] & 0x7f) << 24 |
+      (hmac[offset + 1] & 0xff) << 16 |
+      (hmac[offset + 2] & 0xff) << 8 |
+      (hmac[offset + 3] & 0xff);
+  }
+
+}
diff --git a/src/main/java/org/cryptacular/generator/AbstractP12Generator.java b/src/main/java/org/cryptacular/generator/AbstractP12Generator.java
new file mode 100644
index 0000000..e73dd46
--- /dev/null
+++ b/src/main/java/org/cryptacular/generator/AbstractP12Generator.java
@@ -0,0 +1,116 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.generator;
+
+import java.io.IOException;
+import java.security.NoSuchAlgorithmException;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import org.bouncycastle.asn1.ASN1ObjectIdentifier;
+import org.bouncycastle.asn1.DERBMPString;
+import org.bouncycastle.asn1.DERNull;
+import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers;
+import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
+import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils;
+import org.bouncycastle.crypto.ExtendedDigest;
+import org.bouncycastle.operator.OutputEncryptor;
+import org.bouncycastle.pkcs.PKCS12MacCalculatorBuilder;
+import org.bouncycastle.pkcs.PKCS12PfxPdu;
+import org.bouncycastle.pkcs.PKCS12PfxPduBuilder;
+import org.bouncycastle.pkcs.PKCS12SafeBag;
+import org.bouncycastle.pkcs.PKCS12SafeBagBuilder;
+import org.bouncycastle.pkcs.PKCSException;
+import org.bouncycastle.pkcs.bc.BcPKCS12MacCalculatorBuilder;
+import org.bouncycastle.pkcs.jcajce.JcaPKCS12SafeBagBuilder;
+import org.cryptacular.CryptoException;
+import org.cryptacular.spec.DigestSpec;
+
+/**
+ * Base class for all PKCS12 generation components.
+ *
+ * @author Marvin S. Addison
+ */
+public abstract class AbstractP12Generator implements P12Generator
+{
+
+  @Override
+  public PKCS12PfxPdu generate(final char[] password, final PrivateKey key, final String alias,
+    final X509Certificate... certificates)
+  {
+    String label;
+    if (certificates.length < 1) {
+      throw new IllegalArgumentException("At least one certificate must be provided");
+    }
+    if (password == null || password.length == 0) {
+      throw new IllegalArgumentException("Password cannot be null or empty");
+    }
+    final PKCS12PfxPduBuilder pfxPduBuilder = new PKCS12PfxPduBuilder();
+    final PKCS12SafeBag[] certBags = new PKCS12SafeBag[certificates.length];
+    final JcaX509ExtensionUtils extUtils;
+    try {
+      extUtils = new JcaX509ExtensionUtils();
+      final PKCS12SafeBagBuilder keyBagBuilder = new JcaPKCS12SafeBagBuilder(
+        key, keyOutputEncryptor(password));
+      keyBagBuilder.addBagAttribute(PKCSObjectIdentifiers.pkcs_9_at_friendlyName, new DERBMPString(alias));
+      keyBagBuilder.addBagAttribute(
+        PKCSObjectIdentifiers.pkcs_9_at_localKeyId,
+        extUtils.createSubjectKeyIdentifier(certificates[0].getPublicKey()));
+      certBags[0] = new JcaPKCS12SafeBagBuilder(certificates[0])
+        .addBagAttribute(PKCSObjectIdentifiers.pkcs_9_at_friendlyName, new DERBMPString(alias))
+        .addBagAttribute(
+          PKCSObjectIdentifiers.pkcs_9_at_localKeyId,
+          extUtils.createSubjectKeyIdentifier(certificates[0].getPublicKey()))
+        .build();
+      for (int i = 1; i < certificates.length; i++) {
+        label = "ca-cert-" + i;
+        certBags[i] = new JcaPKCS12SafeBagBuilder(certificates[i])
+          .addBagAttribute(PKCSObjectIdentifiers.pkcs_9_at_friendlyName, new DERBMPString(label))
+          .build();
+      }
+      // Add certificates before private key as is the usual ordering produced by OpenSSL
+      pfxPduBuilder.addEncryptedData(dataOutputEncryptor(password), certBags);
+      pfxPduBuilder.addData(keyBagBuilder.build());
+      final DigestSpec digestSpec = getDigestSpec();
+      final PKCS12MacCalculatorBuilder macCalculatorBuilder = new BcPKCS12MacCalculatorBuilder(
+        (ExtendedDigest) digestSpec.newInstance(),
+        new AlgorithmIdentifier(getDigestAlgorithmId(), DERNull.INSTANCE)
+      ).setIterationCount(getIterations());
+      return pfxPduBuilder.build(macCalculatorBuilder, password);
+    } catch (IOException | NoSuchAlgorithmException | PKCSException e) {
+      throw new CryptoException("P12 generation failed", e);
+    }
+  }
+
+  @Override
+  public PKCS12PfxPdu generate(final char[] password, final PrivateKey key, final X509Certificate... certificates)
+  {
+    return generate(password, key, "end-entity-cert", certificates);
+  }
+
+  /** @return Number of hashing rounds. */
+  public abstract int getIterations();
+
+  /** @return Digest algorithm object identifier. */
+  protected abstract ASN1ObjectIdentifier getDigestAlgorithmId();
+
+  /** @return Digest specification. */
+  protected abstract DigestSpec getDigestSpec();
+
+  /**
+   * Builds a new output encryptor that performs password-based encryption on keys in the P12 file.
+   *
+   * @param password Password tha will the basis of an encryption key.
+   *
+   * @return Output encryptor.
+   */
+  protected abstract OutputEncryptor keyOutputEncryptor(char[] password);
+
+
+  /**
+   * Builds a new output encryptor that performs password-based encryption on encrypted data in the P12 file.
+   *
+   * @param password Password tha will the basis of an encryption key.
+   *
+   * @return Output encryptor.
+   */
+  protected abstract OutputEncryptor dataOutputEncryptor(char[] password);
+}
diff --git a/src/main/java/org/cryptacular/generator/HOTPGenerator.java b/src/main/java/org/cryptacular/generator/HOTPGenerator.java
new file mode 100644
index 0000000..7de1330
--- /dev/null
+++ b/src/main/java/org/cryptacular/generator/HOTPGenerator.java
@@ -0,0 +1,35 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.generator;
+
+import org.bouncycastle.crypto.Digest;
+import org.bouncycastle.crypto.digests.SHA1Digest;
+
+/**
+ * OTP generator component that implements the HOTP scheme described in <a href="https://tools.ietf.org/html/rfc4226">
+ * RFC 4226</a>.
+ *
+ * @author  Middleware Services
+ */
+public class HOTPGenerator extends AbstractOTPGenerator
+{
+
+  /**
+   * Generates the OTP given a per-user key and invocation count.
+   *
+   * @param  key  Per-user key.
+   * @param  count  Counter moving factor.
+   *
+   * @return  Integer OTP.
+   */
+  public int generate(final byte[] key, final long count)
+  {
+    return generateInternal(key, count);
+  }
+
+
+  @Override
+  protected Digest getDigest()
+  {
+    return new SHA1Digest();
+  }
+}
diff --git a/src/main/java/org/cryptacular/generator/IdGenerator.java b/src/main/java/org/cryptacular/generator/IdGenerator.java
new file mode 100644
index 0000000..122f225
--- /dev/null
+++ b/src/main/java/org/cryptacular/generator/IdGenerator.java
@@ -0,0 +1,18 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.generator;
+
+/**
+ * Generation strategy for random identifiers.
+ *
+ * @author  Middleware Services
+ */
+public interface IdGenerator
+{
+
+  /**
+   * Generates a random identifier.
+   *
+   * @return  Random identifier.
+   */
+  String generate();
+}
diff --git a/src/main/java/org/cryptacular/generator/KeyPairGenerator.java b/src/main/java/org/cryptacular/generator/KeyPairGenerator.java
new file mode 100644
index 0000000..6e92db4
--- /dev/null
+++ b/src/main/java/org/cryptacular/generator/KeyPairGenerator.java
@@ -0,0 +1,91 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.generator;
+
+import java.security.InvalidAlgorithmParameterException;
+import java.security.KeyPair;
+import java.security.SecureRandom;
+import org.bouncycastle.jce.spec.ECNamedCurveGenParameterSpec;
+
+/**
+ * Static factory that generates various types of asymmetric key pairs.
+ *
+ * @author  Middleware Services
+ */
+public final class KeyPairGenerator
+{
+
+  /** Private constructor of static factory. */
+  private KeyPairGenerator() {}
+
+
+  /**
+   * Generates a DSA key pair.
+   *
+   * @param  random  Random source required for key generation.
+   * @param  bitLength  Desired key size in bits.
+   *
+   * @return  DSA key pair of desired size.
+   */
+  public static KeyPair generateDSA(final SecureRandom random, final int bitLength)
+  {
+    final org.bouncycastle.jcajce.provider.asymmetric.dsa.KeyPairGeneratorSpi generator =
+      new org.bouncycastle.jcajce.provider.asymmetric.dsa.KeyPairGeneratorSpi();
+    generator.initialize(bitLength, random);
+    return generator.generateKeyPair();
+  }
+
+
+  /**
+   * Generates an RSA key pair.
+   *
+   * @param  random  Random source required for key generation.
+   * @param  bitLength  Desired key size in bits.
+   *
+   * @return  RSA key pair of desired size.
+   */
+  public static KeyPair generateRSA(final SecureRandom random, final int bitLength)
+  {
+    final org.bouncycastle.jcajce.provider.asymmetric.rsa.KeyPairGeneratorSpi generator =
+      new org.bouncycastle.jcajce.provider.asymmetric.rsa.KeyPairGeneratorSpi();
+    generator.initialize(bitLength, random);
+    return generator.generateKeyPair();
+  }
+
+
+  /**
+   * Generates an EC key pair.
+   *
+   * @param  random  Random source required for key generation.
+   * @param  bitLength  Desired key size in bits.
+   *
+   * @return  EC key pair of desired size.
+   */
+  public static KeyPair generateEC(final SecureRandom random, final int bitLength)
+  {
+    final org.bouncycastle.jcajce.provider.asymmetric.ec.KeyPairGeneratorSpi.EC generator =
+      new org.bouncycastle.jcajce.provider.asymmetric.ec.KeyPairGeneratorSpi.EC();
+    generator.initialize(bitLength, random);
+    return generator.generateKeyPair();
+  }
+
+
+  /**
+   * Generates an EC key pair.
+   *
+   * @param  random  Random source required for key generation.
+   * @param  namedCurve  Well-known elliptic curve name that includes domain parameters including key size.
+   *
+   * @return  EC key pair according to named curve.
+   */
+  public static KeyPair generateEC(final SecureRandom random, final String namedCurve)
+  {
+    final org.bouncycastle.jcajce.provider.asymmetric.ec.KeyPairGeneratorSpi.EC generator =
+      new org.bouncycastle.jcajce.provider.asymmetric.ec.KeyPairGeneratorSpi.EC();
+    try {
+      generator.initialize(new ECNamedCurveGenParameterSpec(namedCurve), random);
+    } catch (InvalidAlgorithmParameterException e) {
+      throw new IllegalArgumentException("Invalid EC curve " + namedCurve, e);
+    }
+    return generator.generateKeyPair();
+  }
+}
diff --git a/src/main/java/org/cryptacular/generator/LegacyP12Generator.java b/src/main/java/org/cryptacular/generator/LegacyP12Generator.java
new file mode 100644
index 0000000..415fc6c
--- /dev/null
+++ b/src/main/java/org/cryptacular/generator/LegacyP12Generator.java
@@ -0,0 +1,86 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.generator;
+
+import org.bouncycastle.asn1.ASN1ObjectIdentifier;
+import org.bouncycastle.asn1.oiw.OIWObjectIdentifiers;
+import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers;
+import org.bouncycastle.crypto.engines.DESedeEngine;
+import org.bouncycastle.crypto.engines.RC2Engine;
+import org.bouncycastle.crypto.modes.CBCBlockCipher;
+import org.bouncycastle.operator.OutputEncryptor;
+import org.bouncycastle.pkcs.bc.BcPKCS12PBEOutputEncryptorBuilder;
+import org.cryptacular.spec.DigestSpec;
+
+/**
+ * Generates PKCS12 containers using DES3+SHA1 for private keys and 40-bit RC2+SHA1 for encrypted data.
+ * These algorithms are considered unsecure by today's standards (2024), but are needed for interoperability
+ * in some cases. Importing a keypair into the Mac keychain is a notable use case.
+ *
+ * @author Marvin S. Addison
+ */
+public class LegacyP12Generator extends AbstractP12Generator
+{
+  /** Number of hashing rounds. */
+  private final int iterations;
+
+  /** Key encryptor builder. */
+  private final BcPKCS12PBEOutputEncryptorBuilder keyEncryptorBuilder;
+
+  /** Data encryptor builder. */
+  private final BcPKCS12PBEOutputEncryptorBuilder dataEncryptorBuilder;
+
+
+  /**
+   * Creates a new instance that encrypts with 1024 rounds of hashing.
+   */
+  public LegacyP12Generator()
+  {
+    this(1024);
+  }
+
+  /**
+   * Creates a new instance that encrypts with the given number of hashing rounds.
+   *
+   * @param iterations Number of hashing rounds.
+   */
+  public LegacyP12Generator(final int iterations)
+  {
+    this.iterations = iterations;
+    this.keyEncryptorBuilder = new BcPKCS12PBEOutputEncryptorBuilder(
+      PKCSObjectIdentifiers.pbeWithSHAAnd3_KeyTripleDES_CBC, CBCBlockCipher.newInstance(new DESedeEngine()))
+      .setIterationCount(iterations);
+    this.dataEncryptorBuilder = new BcPKCS12PBEOutputEncryptorBuilder(
+      PKCSObjectIdentifiers.pbeWithSHAAnd40BitRC2_CBC, CBCBlockCipher.newInstance(new RC2Engine()))
+      .setIterationCount(iterations);
+  }
+
+  @Override
+  public int getIterations()
+  {
+    return iterations;
+  }
+
+  @Override
+  protected ASN1ObjectIdentifier getDigestAlgorithmId()
+  {
+    return OIWObjectIdentifiers.idSHA1;
+  }
+
+  @Override
+  protected DigestSpec getDigestSpec()
+  {
+    return new DigestSpec("SHA1");
+  }
+
+  @Override
+  protected OutputEncryptor keyOutputEncryptor(final char[] password)
+  {
+    return keyEncryptorBuilder.build(password);
+  }
+
+  @Override
+  protected OutputEncryptor dataOutputEncryptor(final char[] password)
+  {
+    return dataEncryptorBuilder.build(password);
+  }
+}
diff --git a/src/main/java/org/cryptacular/generator/LimitException.java b/src/main/java/org/cryptacular/generator/LimitException.java
new file mode 100644
index 0000000..a4d98b5
--- /dev/null
+++ b/src/main/java/org/cryptacular/generator/LimitException.java
@@ -0,0 +1,22 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.generator;
+
+/**
+ * Runtime exception that describes a condition where some fundamental limit imposed by the implementation or
+ * specification of a generator has been exceeded.
+ *
+ * @author  Middleware Services
+ */
+public class LimitException extends RuntimeException
+{
+
+  /**
+   * Creates a new instance with the given error description.
+   *
+   * @param  message  Error message.
+   */
+  public LimitException(final String message)
+  {
+    super(message);
+  }
+}
diff --git a/src/main/java/org/cryptacular/generator/Nonce.java b/src/main/java/org/cryptacular/generator/Nonce.java
new file mode 100644
index 0000000..e51c6ff
--- /dev/null
+++ b/src/main/java/org/cryptacular/generator/Nonce.java
@@ -0,0 +1,25 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.generator;
+
+/**
+ * Nonce generation strategy.
+ *
+ * @author  Middleware Services
+ */
+public interface Nonce
+{
+
+  /**
+   * Generates a nonce value.
+   *
+   * @return  Nonce bytes.
+   *
+   * @throws  LimitException  When a limit imposed by the nonce generation strategy, if any, is exceeded.
+   */
+  byte[] generate()
+    throws LimitException;
+
+
+  /** @return  Length in bytes of generated nonce values. */
+  int getLength();
+}
diff --git a/src/main/java/org/cryptacular/generator/P12Generator.java b/src/main/java/org/cryptacular/generator/P12Generator.java
new file mode 100644
index 0000000..262bc56
--- /dev/null
+++ b/src/main/java/org/cryptacular/generator/P12Generator.java
@@ -0,0 +1,39 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.generator;
+
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import org.bouncycastle.pkcs.PKCS12PfxPdu;
+
+/**
+ * Provides a simple interface for generating PKCS12 containers.
+ *
+ * @author Marvin S. Addison
+ */
+public interface P12Generator
+{
+  /**
+   * Generates a PKCS12 container object that contains the given private key and certificates.
+   *
+   * @param password PKCS12 encryption password. This secret is also used to encrypt the inner private key.
+   * @param key Private key.
+   * @param certificates One or more certificates. If more than one certificate is provided, the first is taken as the
+   *                     end-entity certificate.
+   *
+   * @return Bouncy Castle PKCS12 container object.
+   */
+  PKCS12PfxPdu generate(char[] password, PrivateKey key, X509Certificate... certificates);
+
+  /**
+   * Generates a PKCS12 container object that contains the given private key and certificates with the given alias.
+   *
+   * @param password PKCS12 encryption password. This secret is also used to encrypt the inner private key.
+   * @param key Private key.
+   * @param alias Keystore alias.
+   * @param certificates One or more certificates. If more than one certificate is provided, the first is taken as the
+   *                     end-entity certificate.
+   *
+   * @return Bouncy Castle PKCS12 container object.
+   */
+  PKCS12PfxPdu generate(char[] password, PrivateKey key, String alias, X509Certificate... certificates);
+}
diff --git a/src/main/java/org/cryptacular/generator/RandomIdGenerator.java b/src/main/java/org/cryptacular/generator/RandomIdGenerator.java
new file mode 100644
index 0000000..aa77cdc
--- /dev/null
+++ b/src/main/java/org/cryptacular/generator/RandomIdGenerator.java
@@ -0,0 +1,72 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.generator;
+
+import java.security.SecureRandom;
+
+/**
+ * Generates random identifiers with an alphanumeric character set by default.
+ *
+ * @author  Middleware Services
+ */
+public class RandomIdGenerator implements IdGenerator
+{
+
+  /** Default character set. */
+  public static final String DEFAULT_CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+
+  /** Size of generated identifiers. */
+  private final int length;
+
+  /** Identifier character set. */
+  private final String charset;
+
+  /** Source of randomness. */
+  private final SecureRandom secureRandom;
+
+  /**
+   * Creates a new instance with the default character set.
+   *
+   * @param  length  Number of characters in generated identifiers.
+   */
+  public RandomIdGenerator(final int length)
+  {
+    this(length, DEFAULT_CHARSET);
+  }
+
+
+  /**
+   * Creates a new instance with a defined character set.
+   *
+   * @param  length  Number of characters in generated identifiers.
+   * @param  charset  Character set.
+   */
+  public RandomIdGenerator(final int length, final String charset)
+  {
+    if (length < 1) {
+      throw new IllegalArgumentException("Length must be positive");
+    }
+    this.length = length;
+    if (charset == null || charset.length() < 2 || charset.length() > 128) {
+      throw new IllegalArgumentException("Charset length must be in the range 2 - 128");
+    }
+    this.charset = charset;
+    secureRandom = new SecureRandom();
+    // Call nextBytes to force seeding via default process
+    secureRandom.nextBytes(new byte[1]);
+  }
+
+
+  @Override
+  public String generate()
+  {
+    final StringBuilder id = new StringBuilder(length);
+    final byte[] output = new byte[length];
+    secureRandom.nextBytes(output);
+    int index;
+    for (int i = 0; i < output.length && id.length() < length; i++) {
+      index = 0x7F & output[i];
+      id.append(charset.charAt(index % charset.length()));
+    }
+    return id.toString();
+  }
+}
diff --git a/src/main/java/org/cryptacular/generator/SecretKeyGenerator.java b/src/main/java/org/cryptacular/generator/SecretKeyGenerator.java
new file mode 100644
index 0000000..8e6681b
--- /dev/null
+++ b/src/main/java/org/cryptacular/generator/SecretKeyGenerator.java
@@ -0,0 +1,69 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.generator;
+
+import java.security.SecureRandom;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.SecretKeySpec;
+import org.bouncycastle.crypto.BlockCipher;
+import org.bouncycastle.crypto.digests.SHA256Digest;
+import org.bouncycastle.crypto.prng.SP800SecureRandomBuilder;
+import org.cryptacular.util.NonceUtil;
+
+/**
+ * Factory class with static methods for generating {@link SecretKey}s.
+ *
+ * @author  Middleware Services
+ */
+public final class SecretKeyGenerator
+{
+
+  /** Private constructor of static class. */
+  private SecretKeyGenerator() {}
+
+
+  /**
+   * Generates a symmetric encryption key whose size is equal to the cipher block size.
+   *
+   * @param  cipher  Cipher with key will be used.
+   *
+   * @return  Symmetric encryption key.
+   */
+  public static SecretKey generate(final BlockCipher cipher)
+  {
+    return generate(cipher.getBlockSize() * 8, cipher);
+  }
+
+
+  /**
+   * Generates a symmetric encryption key of the given length.
+   *
+   * @param  bitLength  Desired key length in bits.
+   * @param  cipher  Cipher with key will be used.
+   *
+   * @return  Symmetric encryption key.
+   */
+  public static SecretKey generate(final int bitLength, final BlockCipher cipher)
+  {
+    // Want as much nonce data as key bits
+    final byte[] nonce = NonceUtil.randomNonce((bitLength + 7) / 8);
+    return generate(bitLength, cipher, new SP800SecureRandomBuilder().buildHash(new SHA256Digest(), nonce, false));
+  }
+
+
+  /**
+   * Generates a symmetric encryption key of the given length.
+   *
+   * @param  bitLength  Desired key length in bits.
+   * @param  cipher  Cipher with key will be used.
+   * @param  random  Randomness provider for key generation.
+   *
+   * @return  Symmetric encryption key.
+   */
+  public static SecretKey generate(final int bitLength, final BlockCipher cipher, final SecureRandom random)
+  {
+    // Round up for bit lengths that are not a multiple of 8
+    final byte[] key = new byte[(bitLength + 7) / 8];
+    random.nextBytes(key);
+    return new SecretKeySpec(key, cipher.getAlgorithmName());
+  }
+}
diff --git a/src/main/java/org/cryptacular/generator/TOTPGenerator.java b/src/main/java/org/cryptacular/generator/TOTPGenerator.java
new file mode 100644
index 0000000..3443297
--- /dev/null
+++ b/src/main/java/org/cryptacular/generator/TOTPGenerator.java
@@ -0,0 +1,140 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.generator;
+
+import org.bouncycastle.crypto.Digest;
+import org.cryptacular.spec.DigestSpec;
+import org.cryptacular.spec.Spec;
+
+/**
+ * OTP generator component that implements the TOTP scheme described in <a href="https://tools.ietf.org/html/rfc6238">
+ * RFC 6238</a>.
+ *
+ * @author  Middleware Services
+ */
+public class TOTPGenerator extends AbstractOTPGenerator
+{
+
+  /** Digest algorithm specification. */
+  private Spec<Digest> digestSpecification = new DigestSpec("SHA1");
+
+  /**
+   * Current system time in seconds since the start of the epoch, 1970-01-01T00:00:00.
+   * This value is used if and only if it is a non-negative value; otherwise the current system time is used.
+   */
+  private long currentTime = -1;
+
+  /** Reference start time, T0. Default 0, i.e. 1970-01-01T00:00:00. */
+  private int startTime;
+
+  /** Time step in seconds, X. Default is 30 seconds. */
+  private int timeStep = 30;
+
+
+  /** @return  Digest algorithm used with the HMAC function. */
+  public Spec<Digest> getDigestSpecification()
+  {
+    return digestSpecification;
+  }
+
+
+  /**
+   * Sets the digest algorithm used with the HMAC function.
+   *
+   * @param  specification  SHA-1, SHA-256, or SHA-512 digest specification.
+   */
+  public void setDigestSpecification(final Spec<Digest> specification)
+  {
+    if ("SHA1".equalsIgnoreCase(specification.getAlgorithm()) ||
+        "SHA-1".equalsIgnoreCase(specification.getAlgorithm()) ||
+        "SHA256".equalsIgnoreCase(specification.getAlgorithm()) ||
+        "SHA-256".equalsIgnoreCase(specification.getAlgorithm()) ||
+        "SHA512".equalsIgnoreCase(specification.getAlgorithm()) ||
+        "SHA-512".equalsIgnoreCase(specification.getAlgorithm())) {
+      this.digestSpecification = specification;
+      return;
+    }
+    throw new IllegalArgumentException("Unsupported digest algorithm " + specification);
+  }
+
+
+  /** @return  Reference start time. */
+  public int getStartTime()
+  {
+    return startTime;
+  }
+
+
+  /**
+   * Sets the reference start time, T0. Default 0, i.e. 1970-01-01T00:00:00.
+   *
+   * @param  seconds  Start time in seconds.
+   */
+  public void setStartTime(final int seconds)
+  {
+    this.startTime = seconds;
+  }
+
+
+  /** @return  Time step in seconds. */
+  public int getTimeStep()
+  {
+    return timeStep;
+  }
+
+
+  /**
+   * Sets the time step, X.
+   *
+   * @param  seconds  Time step in seconds. Default is 30. This value determines the validity window of generated OTP
+   *                  values.
+   */
+  public void setTimeStep(final int seconds)
+  {
+    this.timeStep = seconds;
+  }
+
+
+  /**
+   * Generates the OTP given a per-user key.
+   *
+   * @param  key  Per-user key.
+   *
+   * @return  Integer OTP.
+   */
+  public int generate(final byte[] key)
+  {
+    final long t = (currentTime() - startTime) / timeStep;
+    return generateInternal(key, t);
+  }
+
+
+  @Override
+  protected Digest getDigest()
+  {
+    return digestSpecification.newInstance();
+  }
+
+
+  /**
+   * Sets the current time (supports testing). This value is used if and only if it is a non-negative value; otherwise
+   * the current system time is used.
+   *
+   * @param epochSeconds Seconds since the start of the epoch, 1970-01-01T00:00:00.
+   */
+  protected void setCurrentTime(final long epochSeconds)
+  {
+    currentTime = epochSeconds;
+  }
+
+
+  /**
+   * @return Current system time in seconds since the start of epoch, 1970-01-01T00:00:00.
+   */
+  protected long currentTime()
+  {
+    if (currentTime >= 0) {
+      return currentTime;
+    }
+    return System.currentTimeMillis() / 1000;
+  }
+}
diff --git a/src/main/java/org/cryptacular/generator/sp80038a/BigIntegerCounterNonce.java b/src/main/java/org/cryptacular/generator/sp80038a/BigIntegerCounterNonce.java
new file mode 100644
index 0000000..2df8d0b
--- /dev/null
+++ b/src/main/java/org/cryptacular/generator/sp80038a/BigIntegerCounterNonce.java
@@ -0,0 +1,71 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.generator.sp80038a;
+
+import java.math.BigInteger;
+import java.util.Arrays;
+import org.cryptacular.generator.LimitException;
+import org.cryptacular.generator.Nonce;
+
+/**
+ * Uses a {@link BigInteger} to back a counter in order to produce nonces of arbitrary length.
+ *
+ * <p>A common use case for this component is creation of IVs for ciphers with 16-byte block size, e.g. AES.</p>
+ *
+ * <p>Instances of this class are thread safe.</p>
+ *
+ * @author  Middleware Services
+ */
+public class BigIntegerCounterNonce implements Nonce
+{
+
+  /** Counter. */
+  private BigInteger counter;
+
+  /** Length of generated counter nonce values in bytes. */
+  private final int length;
+
+
+  /**
+   * Creates a new instance with given parameters.
+   *
+   * @param  counter  Initial counter value.
+   * @param  length  Maximum length of generated counter values in bytes.
+   */
+  public BigIntegerCounterNonce(final BigInteger counter, final int length)
+  {
+    if (length < 1) {
+      throw new IllegalArgumentException("Length must be positive");
+    }
+    this.length = length;
+    this.counter = counter;
+  }
+
+
+  @Override
+  public byte[] generate()
+    throws LimitException
+  {
+    final byte[] value;
+    synchronized (this) {
+      counter = counter.add(BigInteger.ONE);
+      value = counter.toByteArray();
+    }
+    if (value.length > length) {
+      throw new LimitException("Counter value exceeded max byte length " + length);
+    }
+    if (value.length < length) {
+      final byte[] temp = new byte[length];
+      Arrays.fill(temp, (byte) 0);
+      System.arraycopy(value, 0, temp, temp.length - value.length, value.length);
+      return temp;
+    }
+    return value;
+  }
+
+
+  @Override
+  public int getLength()
+  {
+    return length;
+  }
+}
diff --git a/src/main/java/org/cryptacular/generator/sp80038a/EncryptedNonce.java b/src/main/java/org/cryptacular/generator/sp80038a/EncryptedNonce.java
new file mode 100644
index 0000000..60e198b
--- /dev/null
+++ b/src/main/java/org/cryptacular/generator/sp80038a/EncryptedNonce.java
@@ -0,0 +1,76 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.generator.sp80038a;
+
+import javax.crypto.SecretKey;
+import org.bouncycastle.crypto.BlockCipher;
+import org.bouncycastle.crypto.params.KeyParameter;
+import org.cryptacular.generator.LimitException;
+import org.cryptacular.generator.Nonce;
+import org.cryptacular.spec.Spec;
+import org.cryptacular.util.NonceUtil;
+
+/**
+ * Nonce generation strategy that produces a random value according to NIST <a href="http://goo.gl/S9z8qF">
+ * SP-800-38a</a>, appendix C, method 1 (encrypted nonce), suitable for use with any block cipher mode described in that
+ * standard except OFB.
+ *
+ * <p>Instances of this class are thread safe.</p>
+ *
+ * @author  Middleware Services
+ */
+public class EncryptedNonce implements Nonce
+{
+
+  /** Block cipher. */
+  private final BlockCipher cipher;
+
+  /** Encryption key. */
+  private final SecretKey key;
+
+
+  /**
+   * Creates a new instance.
+   *
+   * @param  cipherSpec  Block cipher specification.
+   * @param  key  Symmetric key.
+   */
+  public EncryptedNonce(final Spec<BlockCipher> cipherSpec, final SecretKey key)
+  {
+    this(cipherSpec.newInstance(), key);
+  }
+
+
+  /**
+   * Creates a new instance.
+   *
+   * @param  cipher  Block cipher to use.
+   * @param  key  Symmetric key.
+   */
+  public EncryptedNonce(final BlockCipher cipher, final SecretKey key)
+  {
+    this.cipher = cipher;
+    this.key = key;
+  }
+
+
+  @Override
+  public byte[] generate()
+    throws LimitException
+  {
+    final byte[] result = new byte[cipher.getBlockSize()];
+    final byte[] nonce = NonceUtil.randomNonce(result.length);
+    synchronized (cipher) {
+      cipher.init(true, new KeyParameter(key.getEncoded()));
+      cipher.processBlock(nonce, 0, result, 0);
+      cipher.reset();
+    }
+    return result;
+  }
+
+
+  @Override
+  public int getLength()
+  {
+    return cipher.getBlockSize();
+  }
+}
diff --git a/src/main/java/org/cryptacular/generator/sp80038a/LongCounterNonce.java b/src/main/java/org/cryptacular/generator/sp80038a/LongCounterNonce.java
new file mode 100644
index 0000000..5c47faf
--- /dev/null
+++ b/src/main/java/org/cryptacular/generator/sp80038a/LongCounterNonce.java
@@ -0,0 +1,56 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.generator.sp80038a;
+
+import java.util.concurrent.atomic.AtomicLong;
+import org.cryptacular.generator.LimitException;
+import org.cryptacular.generator.Nonce;
+import org.cryptacular.util.ByteUtil;
+
+/**
+ * Simple counter nonce that uses a long integer counter internally and produces 8-byte nonces. Note that this component
+ * is suitable exclusively for ciphers with block length 8, e.g. Blowfish.
+ *
+ * <p>Instances of this class are thread safe.</p>
+ *
+ * @author  Middleware Services
+ * @see  BigIntegerCounterNonce
+ */
+public class LongCounterNonce implements Nonce
+{
+
+  /** Counter. */
+  private final AtomicLong counter;
+
+
+  /** Creates a new instance whose counter values start at 1. */
+  public LongCounterNonce()
+  {
+    this(0);
+  }
+
+
+  /**
+   * Creates a new instance whose counter values start above the given value.
+   *
+   * @param  start  Start value.
+   */
+  public LongCounterNonce(final long start)
+  {
+    counter = new AtomicLong(start);
+  }
+
+
+  @Override
+  public byte[] generate()
+    throws LimitException
+  {
+    return ByteUtil.toBytes(counter.incrementAndGet());
+  }
+
+
+  @Override
+  public int getLength()
+  {
+    return 8;
+  }
+}
diff --git a/src/main/java/org/cryptacular/generator/sp80038a/RBGNonce.java b/src/main/java/org/cryptacular/generator/sp80038a/RBGNonce.java
new file mode 100644
index 0000000..4d2e2af
--- /dev/null
+++ b/src/main/java/org/cryptacular/generator/sp80038a/RBGNonce.java
@@ -0,0 +1,67 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.generator.sp80038a;
+
+import org.bouncycastle.crypto.prng.drbg.SP80090DRBG;
+import org.cryptacular.generator.LimitException;
+import org.cryptacular.generator.Nonce;
+import org.cryptacular.util.NonceUtil;
+
+/**
+ * Nonce generation strategy that produces a random value according to NIST <a href="http://goo.gl/S9z8qF">
+ * SP-800-38a</a>, appendix C, method 2 (random number generator), suitable for use with any block cipher mode described
+ * in that standard except OFB.
+ *
+ * <p>Instances of this class are thread safe.</p>
+ *
+ * @author  Middleware Services
+ */
+public class RBGNonce implements Nonce
+{
+
+  /** Length of generated nonces. */
+  private final int length;
+
+  /** Random bit generator. */
+  private final SP80090DRBG rbg;
+
+
+  /** Creates a new instance that produces 16-bytes (128-bits) of random data. */
+  public RBGNonce()
+  {
+    this(16);
+  }
+
+
+  /**
+   * Creates a new instance that produces length bytes of random data.
+   *
+   * @param  length  Number of bytes in generated nonce values.
+   */
+  public RBGNonce(final int length)
+  {
+    if (length < 1) {
+      throw new IllegalArgumentException("Length must be positive");
+    }
+    this.length = length;
+    this.rbg = NonceUtil.newRBG(length);
+  }
+
+
+  @Override
+  public byte[] generate()
+    throws LimitException
+  {
+    final byte[] random = new byte[length];
+    synchronized (rbg) {
+      rbg.generate(random, null, false);
+    }
+    return random;
+  }
+
+
+  @Override
+  public int getLength()
+  {
+    return length;
+  }
+}
diff --git a/src/main/java/org/cryptacular/generator/sp80038d/CounterNonce.java b/src/main/java/org/cryptacular/generator/sp80038d/CounterNonce.java
new file mode 100644
index 0000000..1ed9732
--- /dev/null
+++ b/src/main/java/org/cryptacular/generator/sp80038d/CounterNonce.java
@@ -0,0 +1,135 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.generator.sp80038d;
+
+import java.util.concurrent.atomic.AtomicLong;
+import org.cryptacular.generator.LimitException;
+import org.cryptacular.generator.Nonce;
+import org.cryptacular.util.ByteUtil;
+
+/**
+ * Deterministic nonce generation strategy that uses a counter for the invocation field as described in NIST <a
+ * href="http://csrc.nist.gov/publications/nistpubs/800-38D/SP-800-38D.pdf">SP-800-38D</a>, section 8.2.1. The
+ * invocation part of the sequence is always 64 bits (8 bytes) due to the use of a <code>long</code>, thus the length of
+ * the nonce is determined by the length of the fixed part: <code>length = 8 + fixed.length</code>.
+ *
+ * <p><strong>NOTE:</strong> users of this class are responsible for maintaining the invocation count in order to
+ * support enforcement of constraints described in section 8.3; namely the following:</p>
+ *
+ * <blockquote>The total number of invocations of the authenticated encryption function shall not exceed 2<sup>32</sup>,
+ * including all IV lengths and all instances of the authenticated encryption function with the given key.</blockquote>
+ *
+ * <p>Instances of this class enforce this constraint by considering the nonce length, which determines whether the
+ * constraint applies, and the invocation count. The invocation count is incremented upon every invocation of {@link
+ * #generate()} method. The current invocation count is accessible via {@link #getInvocations()}.</p>
+ *
+ * <p>Instances of this class are thread safe.</p>
+ *
+ * @author  Middleware Services
+ */
+public class CounterNonce implements Nonce
+{
+
+  /** Default nonce getLength is {@value} bytes. */
+  public static final int DEFAULT_LENGTH = 12;
+
+  /**
+   * Maximum invocations is 2<sup>32</sup>. Does not apply to nonces with default getLength, {@value #DEFAULT_LENGTH}.
+   */
+  public static final long MAX_INVOCATIONS = 0xFFFFFFFFL;
+
+  /** Fixed field value. */
+  private final byte[] fixed;
+
+  /** Invocation count. */
+  private final AtomicLong count;
+
+
+  /**
+   * Creates a new instance.
+   *
+   * @param  fixed  User-defined fixed field value.
+   * @param  invocations  Initial invocation count. The invocations field is incremented _before_ use in {@link
+   *                      #generate()}.
+   */
+  public CounterNonce(final String fixed, final long invocations)
+  {
+    this(ByteUtil.toBytes(fixed), invocations);
+  }
+
+
+  /**
+   * Creates a new instance. Instances of this method produces nonces of the default length, {@value #DEFAULT_LENGTH},
+   * and are not subject to constraints on the number of invocations.
+   *
+   * @param  fixed  User-defined fixed field value.
+   * @param  invocations  Initial invocation count. The invocations field is incremented _before_ use in {@link
+   *                      #generate()}.
+   */
+  public CounterNonce(final int fixed, final long invocations)
+  {
+    this(ByteUtil.toBytes(fixed), invocations);
+  }
+
+
+  /**
+   * Creates a new instance.
+   *
+   * @param  fixed  User-defined fixed field value.
+   * @param  invocations  Initial invocation count. The invocations field is incremented _before_ use in {@link
+   *                      #generate()}.
+   */
+  public CounterNonce(final long fixed, final long invocations)
+  {
+    this(ByteUtil.toBytes(fixed), invocations);
+  }
+
+
+  /**
+   * Creates a new instance.
+   *
+   * @param  fixed  User-defined fixed field value.
+   * @param  invocations  Initial invocation count. The invocations field is incremented _before_ use in {@link
+   *                      #generate()}.
+   */
+  public CounterNonce(final byte[] fixed, final long invocations)
+  {
+    if (fixed == null || fixed.length == 0) {
+      throw new IllegalArgumentException("Fixed part cannot be null or empty.");
+    }
+    this.count = new AtomicLong(invocations);
+    this.fixed = fixed;
+  }
+
+
+  @Override
+  public byte[] generate()
+    throws LimitException
+  {
+    final byte[] value = new byte[getLength()];
+    System.arraycopy(fixed, 0, value, 0, fixed.length);
+
+    final long next = count.incrementAndGet();
+    if (value.length != DEFAULT_LENGTH) {
+      // Enforce constraints described in section 8.3
+      if (next > MAX_INVOCATIONS) {
+        throw new LimitException("Exceeded 2^32 invocations.");
+      }
+    }
+    ByteUtil.toBytes(next, value, fixed.length);
+    return value;
+  }
+
+
+  @Override
+  public int getLength()
+  {
+    return fixed.length + 8;
+  }
+
+
+  /** @return  Current invocation count. */
+  public long getInvocations()
+  {
+    return count.get();
+  }
+}
diff --git a/src/main/java/org/cryptacular/generator/sp80038d/RBGNonce.java b/src/main/java/org/cryptacular/generator/sp80038d/RBGNonce.java
new file mode 100644
index 0000000..ae9f1fb
--- /dev/null
+++ b/src/main/java/org/cryptacular/generator/sp80038d/RBGNonce.java
@@ -0,0 +1,117 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.generator.sp80038d;
+
+import org.bouncycastle.crypto.digests.SHA256Digest;
+import org.bouncycastle.crypto.prng.drbg.HashSP800DRBG;
+import org.bouncycastle.crypto.prng.drbg.SP80090DRBG;
+import org.cryptacular.generator.LimitException;
+import org.cryptacular.generator.Nonce;
+import org.cryptacular.util.ByteUtil;
+import org.cryptacular.util.NonceUtil;
+
+/**
+ * RBG-based nonce generation strategy that uses a RBG component to produce values for the invocation field as described
+ * in NIST <a href="http://csrc.nist.gov/publications/nistpubs/800-38D/SP-800-38D.pdf">SP-800-38D</a>, section 8.2.2.
+ *
+ * <p><strong>NOTE:</strong> users of this class are responsible for counting number of invocations and enforcing the
+ * constraints described in section 8.3; namely the following:</p>
+ *
+ * <blockquote>The total number of invocations of the authenticated encryption function shall not exceed 2<sup>32</sup>,
+ * including all IV lengths and all instances of the authenticated encryption function with the given key.</blockquote>
+ *
+ * <p>Instances of this class are thread safe.</p>
+ *
+ * @author  Middleware Services
+ */
+public class RBGNonce implements Nonce
+{
+
+  /** Fixed field value. */
+  private final byte[] fixed;
+
+  /** Number of bytes of random data in invocation field. */
+  private final int randomLength;
+
+  /** Random bit generator. */
+  private final SP80090DRBG rbg;
+
+
+  /**
+   * Creates a new instance that produces 12-bytes (96-bits) of random data; that is, the fixed field of the nonce is
+   * null.
+   */
+  public RBGNonce()
+  {
+    this(12);
+  }
+
+
+  /**
+   * Creates a new instance that produces length bytes of random data; that is, the fixed field of the nonce is null.
+   *
+   * @param  randomLength  Number of bytes in the random part of the nonce. MUST be at least 12.
+   */
+  public RBGNonce(final int randomLength)
+  {
+    this(null, randomLength);
+  }
+
+
+  /**
+   * Creates a new instance using the given fixed field value.
+   *
+   * @param  fixed  User-defined fixed field value.
+   * @param  randomLength  Number of bytes in the random part of the nonce. MUST be at least 12.
+   */
+  public RBGNonce(final String fixed, final int randomLength)
+  {
+    if (randomLength < 12) {
+      throw new IllegalArgumentException("Must specify at least 12 bytes (96 bits) for random part.");
+    }
+    this.randomLength = randomLength;
+    if (fixed != null) {
+      this.fixed = ByteUtil.toBytes(fixed);
+    } else {
+      this.fixed = new byte[0];
+    }
+    this.rbg = newRBG(this.randomLength, this.fixed);
+  }
+
+
+  @Override
+  public byte[] generate()
+    throws LimitException
+  {
+    final byte[] random = new byte[randomLength];
+    synchronized (rbg) {
+      rbg.generate(random, null, false);
+    }
+
+    final byte[] value = new byte[getLength()];
+    System.arraycopy(fixed, 0, value, 0, fixed.length);
+    System.arraycopy(random, 0, value, fixed.length, random.length);
+    return value;
+  }
+
+
+  @Override
+  public int getLength()
+  {
+    return fixed.length + randomLength;
+  }
+
+
+  /**
+   * Creates a new DRBG instance.
+   *
+   * @param  length  Length in bits of values produced by DRBG.
+   * @param  domain  Domain qualifier.
+   *
+   * @return  New DRBG instance.
+   */
+  private static SP80090DRBG newRBG(final int length, final byte[] domain)
+  {
+    return new HashSP800DRBG(
+        new SHA256Digest(), length, NonceUtil.randomEntropySource(length), domain, NonceUtil.timestampNonce(8));
+  }
+}
diff --git a/src/main/java/org/cryptacular/io/ChunkHandler.java b/src/main/java/org/cryptacular/io/ChunkHandler.java
new file mode 100644
index 0000000..8e64ab1
--- /dev/null
+++ b/src/main/java/org/cryptacular/io/ChunkHandler.java
@@ -0,0 +1,27 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.io;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * Callback interface that supports arbitrary processing of data chunks read from an input stream.
+ *
+ * @author  Middleware Services
+ */
+public interface ChunkHandler
+{
+
+  /**
+   * Processes the given chunk of data and writes it to the output stream.
+   *
+   * @param  input  Chunk of input data to process.
+   * @param  offset  Offset into input array where data to process starts.
+   * @param  count  Number of bytes of input data to process.
+   * @param  output  Output stream where processed data is written.
+   *
+   * @throws  IOException  On IO errors.
+   */
+  void handle(byte[] input, int offset, int count, OutputStream output)
+    throws IOException;
+}
diff --git a/src/main/java/org/cryptacular/io/ClassPathResource.java b/src/main/java/org/cryptacular/io/ClassPathResource.java
new file mode 100644
index 0000000..c3ed037
--- /dev/null
+++ b/src/main/java/org/cryptacular/io/ClassPathResource.java
@@ -0,0 +1,58 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.io;
+
+import java.io.InputStream;
+
+/**
+ * Resource that produces a {@link InputStream} from a classpath resource.
+ *
+ * @author  Middleware Services
+ */
+public class ClassPathResource implements Resource
+{
+
+  /** Classpath location of resource. */
+  private final String classPath;
+
+  /** Class loader used to get input streams on classpath locations. */
+  private final ClassLoader classLoader;
+
+
+  /**
+   * Creates a new resource that reads from the given classpath location. <code>
+   * Thread.currentThread().getContextClassLoader()</code> is used to obtain the class loader used to obtain an input
+   * stream on the given classpath.
+   *
+   * @param  path  Classpath location.
+   */
+  public ClassPathResource(final String path)
+  {
+    this(path, Thread.currentThread().getContextClassLoader());
+  }
+
+
+  /**
+   * Creates a new resource that reads from the given classpath location.
+   *
+   * @param  path  Classpath location.
+   * @param  loader  Class loader used to obtain an input stream on the given classpath location.
+   */
+  public ClassPathResource(final String path, final ClassLoader loader)
+  {
+    // Strip leading / since absolute paths are not supported by
+    // ClassLoader#getResourceAsStream(...)
+    if (path.startsWith("/")) {
+      this.classPath = path.substring(1);
+    } else {
+      this.classPath = path;
+    }
+    this.classLoader = loader;
+  }
+
+
+  @Override
+  public InputStream getInputStream()
+  {
+    return classLoader.getResourceAsStream(classPath);
+  }
+}
diff --git a/src/main/java/org/cryptacular/io/DecodingInputStream.java b/src/main/java/org/cryptacular/io/DecodingInputStream.java
new file mode 100644
index 0000000..0b6794a
--- /dev/null
+++ b/src/main/java/org/cryptacular/io/DecodingInputStream.java
@@ -0,0 +1,140 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.io;
+
+import java.io.FilterInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import org.cryptacular.codec.Base64Decoder;
+import org.cryptacular.codec.Decoder;
+import org.cryptacular.codec.HexDecoder;
+
+/**
+ * Filters read bytes through a {@link Decoder} such that consumers obtain raw (decoded) bytes from read operations.
+ *
+ * @author  Middleware Services
+ */
+public class DecodingInputStream extends FilterInputStream
+{
+
+  /** Performs decoding. */
+  private final Decoder decoder;
+
+  /** Wraps the input stream to convert bytes to characters. */
+  private final InputStreamReader reader;
+
+  /** Holds input bytes as characters. */
+  private CharBuffer input;
+
+  /** Receives decoding result. */
+  private ByteBuffer output;
+
+
+  /**
+   * Creates a new instance that wraps the given stream and performs decoding using the given encoder component.
+   *
+   * @param  in  Input stream to wrap.
+   * @param  d  Decoder that provides on-the-fly decoding.
+   */
+  public DecodingInputStream(final InputStream in, final Decoder d)
+  {
+    super(in);
+    if (d == null) {
+      throw new IllegalArgumentException("Decoder cannot be null.");
+    }
+    decoder = d;
+    reader = new InputStreamReader(in);
+  }
+
+
+  @Override
+  public int read()
+    throws IOException
+  {
+    return read(new byte[1]);
+  }
+
+
+  @Override
+  public int read(final byte[] b)
+    throws IOException
+  {
+    return read(b, 0, b.length);
+  }
+
+
+  @Override
+  public int read(final byte[] b, final int off, final int len)
+    throws IOException
+  {
+    prepareInputBuffer(len - off);
+    prepareOutputBuffer();
+    if (reader.read(input) < 0) {
+      decoder.finalize(output);
+      if (output.position() == 0) {
+        return -1;
+      }
+    } else {
+      input.flip();
+      decoder.decode(input, output);
+    }
+    output.flip();
+    output.get(b, off, output.limit());
+    return output.position();
+  }
+
+
+  /**
+   * Creates a new instance that decodes base64 input from the given stream.
+   *
+   * @param  in  Wrapped input stream.
+   *
+   * @return  Decoding input stream that decodes base64 output.
+   */
+  public static DecodingInputStream base64(final InputStream in)
+  {
+    return new DecodingInputStream(in, new Base64Decoder());
+  }
+
+
+  /**
+   * Creates a new instance that decodes hexadecimal input from the given stream.
+   *
+   * @param  in  Wrapped input stream.
+   *
+   * @return  Decoding input stream that decodes hexadecimal output.
+   */
+  public static DecodingInputStream hex(final InputStream in)
+  {
+    return new DecodingInputStream(in, new HexDecoder());
+  }
+
+
+  /**
+   * Prepares the input buffer to receive the given number of bytes.
+   *
+   * @param  required  Number of bytes required.
+   */
+  private void prepareInputBuffer(final int required)
+  {
+    if (input == null || input.capacity() < required) {
+      input = CharBuffer.allocate(required);
+    } else {
+      input.clear();
+    }
+  }
+
+
+  /** Prepares the output buffer based on input buffer capacity. */
+  private void prepareOutputBuffer()
+  {
+    final int required = decoder.outputSize(input.capacity());
+    if (output == null || output.capacity() < required) {
+      output = ByteBuffer.allocate(required);
+    } else {
+      output.clear();
+    }
+  }
+}
diff --git a/src/main/java/org/cryptacular/io/DirectByteArrayOutputStream.java b/src/main/java/org/cryptacular/io/DirectByteArrayOutputStream.java
new file mode 100644
index 0000000..9477f2d
--- /dev/null
+++ b/src/main/java/org/cryptacular/io/DirectByteArrayOutputStream.java
@@ -0,0 +1,41 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.io;
+
+import java.io.ByteArrayOutputStream;
+
+/**
+ * Extends {@link ByteArrayOutputStream} by allowing direct access to the internal byte buffer.
+ *
+ * @author  Middleware Services
+ */
+public class DirectByteArrayOutputStream extends ByteArrayOutputStream
+{
+
+  /** Creates a new instance with a buffer of the default size. */
+  public DirectByteArrayOutputStream()
+  {
+    super();
+  }
+
+
+  /**
+   * Creates a new instance with a buffer of the given initial capacity.
+   *
+   * @param  capacity  Initial capacity of internal buffer.
+   */
+  public DirectByteArrayOutputStream(final int capacity)
+  {
+    super(capacity);
+  }
+
+
+  /**
+   * Gets the internal byte buffer.
+   *
+   * @return  Internal buffer that holds written bytes.
+   */
+  public byte[] getBuffer()
+  {
+    return buf;
+  }
+}
diff --git a/src/main/java/org/cryptacular/io/EncodingOutputStream.java b/src/main/java/org/cryptacular/io/EncodingOutputStream.java
new file mode 100644
index 0000000..cc8e985
--- /dev/null
+++ b/src/main/java/org/cryptacular/io/EncodingOutputStream.java
@@ -0,0 +1,153 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.io;
+
+import java.io.FilterOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import org.cryptacular.codec.Base64Encoder;
+import org.cryptacular.codec.Encoder;
+import org.cryptacular.codec.HexEncoder;
+
+/**
+ * Filters written bytes through an {@link Encoder} such that encoded data is written to the underlying output stream.
+ *
+ * @author  Middleware Services
+ */
+public class EncodingOutputStream extends FilterOutputStream
+{
+
+  /** Performs decoding. */
+  private final Encoder encoder;
+
+  /** Wraps the output stream to convert characters to bytes. */
+  private final OutputStreamWriter writer;
+
+  /** Receives encoding result. */
+  private CharBuffer output;
+
+
+  /**
+   * Creates a new instance that wraps the given stream and performs encoding using the given encoder component.
+   *
+   * @param  out  Output stream to wrap.
+   * @param  e  Encoder that provides on-the-fly encoding.
+   */
+  public EncodingOutputStream(final OutputStream out, final Encoder e)
+  {
+    super(out);
+    if (e == null) {
+      throw new IllegalArgumentException("Encoder cannot be null.");
+    }
+    encoder = e;
+    writer = new OutputStreamWriter(out);
+  }
+
+
+  @Override
+  public void write(final int b)
+    throws IOException
+  {
+    write(new byte[] {(byte) b});
+  }
+
+
+  @Override
+  public void write(final byte[] b)
+    throws IOException
+  {
+    write(b, 0, b.length);
+  }
+
+
+  @Override
+  public void write(final byte[] b, final int off, final int len)
+    throws IOException
+  {
+    final ByteBuffer input = ByteBuffer.wrap(b, off, len);
+    final int required = encoder.outputSize(len - off);
+    if (output == null || output.capacity() < required) {
+      output = CharBuffer.allocate(required);
+    } else {
+      output.clear();
+    }
+    encoder.encode(input, output);
+    output.flip();
+    writer.write(output.toString());
+    writer.flush();
+  }
+
+
+  @Override
+  public void flush()
+    throws IOException
+  {
+    writer.flush();
+  }
+
+
+  @Override
+  public void close()
+    throws IOException
+  {
+    if (output == null) {
+      output = CharBuffer.allocate(8);
+    } else {
+      output.clear();
+    }
+    encoder.finalize(output);
+    output.flip();
+    writer.write(output.toString());
+    writer.flush();
+    writer.close();
+  }
+
+
+  /**
+   * Creates a new instance that produces base64 output in the given stream.
+   *
+   * <p><strong>NOTE:</strong> there are no line breaks in the output with this version.</p>
+   *
+   * @param  out  Wrapped output stream.
+   *
+   * @return  Encoding output stream that produces base64 output.
+   */
+  public static EncodingOutputStream base64(final OutputStream out)
+  {
+    return base64(out, -1);
+  }
+
+
+  /**
+   * Creates a new instance that produces base64 output in the given stream.
+   *
+   * <p><strong>NOTE:</strong> this version supports output with configurable line breaks.</p>
+   *
+   * @param  out  Wrapped output stream.
+   * @param  lineLength  Length of each base64-encoded line in output. A zero or negative value disables line breaks.
+   *
+   * @return  Encoding output stream that produces base64 output.
+   */
+  public static EncodingOutputStream base64(final OutputStream out, final int lineLength)
+  {
+    return new EncodingOutputStream(out, new Base64Encoder(lineLength));
+  }
+
+
+  /**
+   * Creates a new instance that produces hexadecimal output in the given stream.
+   *
+   * <p><strong>NOTE:</strong> there are no line breaks in the output.</p>
+   *
+   * @param  out  Wrapped output stream.
+   *
+   * @return  Encoding output stream that produces hexadecimal output.
+   */
+  public static EncodingOutputStream hex(final OutputStream out)
+  {
+    return new EncodingOutputStream(out, new HexEncoder());
+  }
+
+}
diff --git a/src/main/java/org/cryptacular/io/FileResource.java b/src/main/java/org/cryptacular/io/FileResource.java
new file mode 100644
index 0000000..3c04ea8
--- /dev/null
+++ b/src/main/java/org/cryptacular/io/FileResource.java
@@ -0,0 +1,50 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.io;
+
+
+import java.io.BufferedInputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * Resource that produces a buffered {@link FileInputStream} from a file.
+ *
+ * @author  Middleware Services
+ */
+public class FileResource implements Resource
+{
+
+  /** Underlying file resource. */
+  private final File file;
+
+
+  /**
+   * Creates a new file resource.
+   *
+   * @param  file  Non-null file.
+   */
+  public FileResource(final File file)
+  {
+    if (file == null) {
+      throw new IllegalArgumentException("File cannot be null.");
+    }
+    this.file = file;
+  }
+
+
+  @Override
+  public InputStream getInputStream()
+    throws IOException
+  {
+    return new BufferedInputStream(new FileInputStream(file));
+  }
+
+
+  @Override
+  public String toString()
+  {
+    return file.toString();
+  }
+}
diff --git a/src/main/java/org/cryptacular/io/Resource.java b/src/main/java/org/cryptacular/io/Resource.java
new file mode 100644
index 0000000..9858d62
--- /dev/null
+++ b/src/main/java/org/cryptacular/io/Resource.java
@@ -0,0 +1,28 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.io;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * Resource descriptor that provides a strategy to get an {@link InputStream} to read bytes.
+ *
+ * @author  Middleware Services
+ */
+public interface Resource
+{
+
+  /**
+   * Gets an input stream around the resource. Callers of this method are responsible for resource cleanup; it should be
+   * sufficient to simply call {@link java.io.InputStream#close()} unless otherwise noted.
+   *
+   * <p>Implementers should produce a new instance on every call to this method to provide for thread-safe usage
+   * patterns on a shared resource.</p>
+   *
+   * @return  Input stream around underlying resource, e.g. file, remote resource (URI), etc.
+   *
+   * @throws  IOException  On IO errors.
+   */
+  InputStream getInputStream()
+    throws IOException;
+}
diff --git a/src/main/java/org/cryptacular/io/URLResource.java b/src/main/java/org/cryptacular/io/URLResource.java
new file mode 100644
index 0000000..31d43dd
--- /dev/null
+++ b/src/main/java/org/cryptacular/io/URLResource.java
@@ -0,0 +1,47 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.io;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+
+/**
+ * Describes a (presumably remote) resource accessible via URL.
+ *
+ * @author  Middleware Services
+ */
+public class URLResource implements Resource
+{
+
+  /** Location of resource. */
+  private final URL url;
+
+
+  /**
+   * Creates a new URL resource.
+   *
+   * @param  url  Non-null URL where resource is located.
+   */
+  public URLResource(final URL url)
+  {
+    if (url == null) {
+      throw new IllegalArgumentException("URL cannot be null.");
+    }
+    this.url = url;
+  }
+
+
+  @Override
+  public InputStream getInputStream()
+    throws IOException
+  {
+    return url.openStream();
+  }
+
+
+  @Override
+  public String toString()
+  {
+    return url.toString();
+  }
+}
diff --git a/src/main/java/org/cryptacular/pbe/AbstractEncryptionScheme.java b/src/main/java/org/cryptacular/pbe/AbstractEncryptionScheme.java
new file mode 100644
index 0000000..ce8ea81
--- /dev/null
+++ b/src/main/java/org/cryptacular/pbe/AbstractEncryptionScheme.java
@@ -0,0 +1,124 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.pbe;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.Arrays;
+import org.bouncycastle.crypto.BufferedBlockCipher;
+import org.bouncycastle.crypto.CipherParameters;
+import org.bouncycastle.crypto.InvalidCipherTextException;
+import org.bouncycastle.crypto.io.CipherInputStream;
+import org.bouncycastle.crypto.io.CipherOutputStream;
+import org.bouncycastle.util.io.Streams;
+import org.cryptacular.CryptoException;
+
+/**
+ * Abstract base class for password-based encryption schemes based on salt data and iterated hashing as the basis of the
+ * key derivation function.
+ *
+ * <p>NOTE: Classes derived from this class are not thread safe. In particular, care should be take to prevent multiple
+ * threads from performing encryption and/or decryption concurrently.</p>
+ *
+ * @author  Middleware Services
+ * @version  $Revision: 2744 $
+ */
+public abstract class AbstractEncryptionScheme implements EncryptionScheme
+{
+
+  /** Cipher used for encryption and decryption. */
+  private BufferedBlockCipher cipher;
+
+  /** Cipher initialization parameters. */
+  private CipherParameters parameters;
+
+
+  @Override
+  public byte[] encrypt(final byte[] plaintext)
+  {
+    cipher.init(true, parameters);
+    return process(plaintext);
+  }
+
+
+  @Override
+  public void encrypt(final InputStream in, final OutputStream out)
+    throws IOException
+  {
+    cipher.init(true, parameters);
+    Streams.pipeAll(in, new CipherOutputStream(out, cipher));
+  }
+
+
+  @Override
+  public byte[] decrypt(final byte[] ciphertext)
+  {
+    cipher.init(false, parameters);
+    return process(ciphertext);
+  }
+
+
+  @Override
+  public void decrypt(final InputStream in, final OutputStream out)
+    throws IOException
+  {
+    cipher.init(false, parameters);
+    Streams.pipeAll(new CipherInputStream(in, cipher), out);
+  }
+
+
+  @Override
+  public OutputStream wrap(final boolean encryptionFlag, final OutputStream out)
+  {
+    cipher.init(encryptionFlag, parameters);
+    return new CipherOutputStream(out, cipher);
+  }
+
+
+  /**
+   * Sets the block cipher used for encryption/decryption.
+   *
+   * @param  bufferedBlockCipher  Buffered block cipher.
+   */
+  protected void setCipher(final BufferedBlockCipher bufferedBlockCipher)
+  {
+    if (bufferedBlockCipher == null) {
+      throw new IllegalArgumentException("Block cipher cannot be null");
+    }
+    this.cipher = bufferedBlockCipher;
+  }
+
+
+  /**
+   * Sets block cipher initialization parameters.
+   *
+   * @param  parameters  Cipher-specific init params.
+   */
+  protected void setCipherParameters(final CipherParameters parameters)
+  {
+    if (parameters == null) {
+      throw new IllegalArgumentException("Cipher parameters cannot be null");
+    }
+    this.parameters = parameters;
+  }
+
+
+  /**
+   * Run the given data through the initialized underlying cipher and return the result.
+   *
+   * @param  input  Input data.
+   *
+   * @return  Result of cipher acting on input.
+   */
+  private byte[] process(final byte[] input)
+  {
+    final byte[] output = new byte[cipher.getOutputSize(input.length)];
+    int processed = cipher.processBytes(input, 0, input.length, output, 0);
+    try {
+      processed += cipher.doFinal(output, processed);
+    } catch (InvalidCipherTextException e) {
+      throw new CryptoException("Cipher error", e);
+    }
+    return Arrays.copyOfRange(output, 0, processed);
+  }
+}
diff --git a/src/main/java/org/cryptacular/pbe/EncryptionScheme.java b/src/main/java/org/cryptacular/pbe/EncryptionScheme.java
new file mode 100644
index 0000000..38bff71
--- /dev/null
+++ b/src/main/java/org/cryptacular/pbe/EncryptionScheme.java
@@ -0,0 +1,73 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.pbe;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+/**
+ * Describes a password-based encryption scheme.
+ *
+ * @author  Middleware Services
+ * @version  $Revision: 2744 $
+ */
+public interface EncryptionScheme
+{
+
+  /**
+   * Encrypts the given plaintext bytes into a byte array of ciphertext using the derived key.
+   *
+   * @param  plaintext  Input plaintext bytes.
+   *
+   * @return  Ciphertext resulting from plaintext encryption.
+   */
+  byte[] encrypt(byte[] plaintext);
+
+
+  /**
+   * Encrypts the data in the given plaintext input stream into ciphertext in the output stream. Use {@link
+   * org.cryptacular.io.EncodingOutputStream} to produce ciphertext bytes that encoded as a string data in the output
+   * stream.
+   *
+   * @param  in  Input stream of plaintext.
+   * @param  out  Output stream of ciphertext.
+   *
+   * @throws  IOException  On stream read/write errors.
+   */
+  void encrypt(InputStream in, OutputStream out)
+    throws IOException;
+
+
+  /**
+   * Decrypts the given ciphertext into plaintext using the derived key.
+   *
+   * @param  ciphertext  Input ciphertext bytes.
+   *
+   * @return  Plaintext resulting from ciphertext decryption.
+   */
+  byte[] decrypt(byte[] ciphertext);
+
+
+  /**
+   * Decrypts ciphertext from an input stream into plaintext in the output stream. Use {@link
+   * org.cryptacular.io.DecodingInputStream} to handle input ciphertext encoded as string data.
+   *
+   * @param  in  Input stream of ciphertext.
+   * @param  out  Output stream of plaintext.
+   *
+   * @throws  IOException  On stream read/write errors.
+   */
+  void decrypt(InputStream in, OutputStream out)
+    throws IOException;
+
+
+  /***
+   * Wraps an output stream with one that performs encryption or decryption on the fly.
+   *
+   * @param  encryptionFlag  True to signal encryption, false for decryption.
+   * @param  out  Output stream to wrap
+   *
+   * @return Wrapped output steam.
+   */
+  OutputStream wrap(boolean encryptionFlag, OutputStream out);
+}
diff --git a/src/main/java/org/cryptacular/pbe/OpenSSLAlgorithm.java b/src/main/java/org/cryptacular/pbe/OpenSSLAlgorithm.java
new file mode 100644
index 0000000..7f83da1
--- /dev/null
+++ b/src/main/java/org/cryptacular/pbe/OpenSSLAlgorithm.java
@@ -0,0 +1,85 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.pbe;
+
+import org.cryptacular.spec.KeyedBlockCipherSpec;
+
+/**
+ * Describes block ciphers allowed with the OpenSSL password-based encryption scheme.
+ *
+ * @author  Middleware Services
+ */
+public enum OpenSSLAlgorithm {
+
+  /** AES-128 in CBC mode. */
+  AES_128_CBC("aes-128-cbc", new KeyedBlockCipherSpec("AES", "CBC", "PKCS5", 128)),
+
+  /** AES-192 in CBC mode. */
+  AES_192_CBC("aes-192-cbc", new KeyedBlockCipherSpec("AES", "CBC", "PKCS5", 192)),
+
+  /** AES-256 in CBC mode. */
+  AES_256_CBC("aes-256-cbc", new KeyedBlockCipherSpec("AES", "CBC", "PKCS5", 256)),
+
+  /** DES in CBC mode. */
+  DES_CBC("des-cbc", new KeyedBlockCipherSpec("DES", "CBC", "PKCS5", 64)),
+
+  /** Triple DES in CBC mode. */
+  DES_EDE3_CBC("des-ede3-cbc", new KeyedBlockCipherSpec("DESede", "CBC", "PKCS5", 192)),
+
+  /** 128-bit RC2 in CBC mode. */
+  RC2_CBC("rc2-cbc", new KeyedBlockCipherSpec("RC2", "CBC", "PKCS5", 128)),
+
+  /** 40-bit RC2 in CBC mode. */
+  RC2_40_CBC("rc2-40-cbc", new KeyedBlockCipherSpec("RC2", "CBC", "PKCS5", 40)),
+
+  /** 64-bit RC2 in CBC mode. */
+  RC2_64_CBC("rc2-64-cbc", new KeyedBlockCipherSpec("RC2", "CBC", "PKCS5", 64));
+
+
+  /** Algorithm identifier, e.g. aes-128-cbc. */
+  private final String algorithmId;
+
+  /** Cipher algorithm specification. */
+  private final KeyedBlockCipherSpec cipherSpec;
+
+  /**
+   * Creates a new instance with given parameters.
+   *
+   * @param  algId  Algorithm identifier, e.g. aes-128-cbc.
+   * @param  cipherSpec  Block cipher specification that corresponds to algorithm ID.
+   */
+  OpenSSLAlgorithm(final String algId, final KeyedBlockCipherSpec cipherSpec)
+  {
+    this.algorithmId = algId;
+    this.cipherSpec = cipherSpec;
+  }
+
+  /** @return  OpenSSL algorithm identifier, e.g. aes-128-cbc. */
+  public String getAlgorithmId()
+  {
+    return algorithmId;
+  }
+
+  /** @return  Cipher algorithm specification. */
+  public KeyedBlockCipherSpec getCipherSpec()
+  {
+    return cipherSpec;
+  }
+
+
+  /**
+   * Converts an OID to the corresponding algorithm specification.
+   *
+   * @param  algorithmId  Algorithm OID.
+   *
+   * @return  Algorithm spec.
+   */
+  public static OpenSSLAlgorithm fromAlgorithmId(final String algorithmId)
+  {
+    for (OpenSSLAlgorithm alg : values()) {
+      if (alg.getAlgorithmId().equalsIgnoreCase(algorithmId)) {
+        return alg;
+      }
+    }
+    throw new IllegalArgumentException("Unsupported algorithm " + algorithmId);
+  }
+}
diff --git a/src/main/java/org/cryptacular/pbe/OpenSSLEncryptionScheme.java b/src/main/java/org/cryptacular/pbe/OpenSSLEncryptionScheme.java
new file mode 100644
index 0000000..c3f5fb2
--- /dev/null
+++ b/src/main/java/org/cryptacular/pbe/OpenSSLEncryptionScheme.java
@@ -0,0 +1,60 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.pbe;
+
+import org.bouncycastle.crypto.BufferedBlockCipher;
+import org.bouncycastle.crypto.PBEParametersGenerator;
+import org.bouncycastle.crypto.generators.OpenSSLPBEParametersGenerator;
+import org.bouncycastle.crypto.params.ParametersWithIV;
+
+/**
+ * Password-based encryption scheme used by OpenSSL for encrypting private keys.
+ *
+ * @author  Middleware Services
+ * @version  $Revision: 2744 $
+ */
+public class OpenSSLEncryptionScheme extends AbstractEncryptionScheme
+{
+
+  /**
+   * Creates a new instance using the given parameters.
+   *
+   * @param  cipher  Buffered block cipher algorithm.
+   * @param  salt  Salt data for key generation function.
+   * @param  keyBitLength  Size of derived keys in bits.
+   * @param  password  Password used to derive key.
+   */
+  public OpenSSLEncryptionScheme(
+    final BufferedBlockCipher cipher,
+    final byte[] salt,
+    final int keyBitLength,
+    final char[] password)
+  {
+    final OpenSSLPBEParametersGenerator generator = new OpenSSLPBEParametersGenerator();
+    generator.init(PBEParametersGenerator.PKCS5PasswordToUTF8Bytes(password), salt);
+    setCipher(cipher);
+    setCipherParameters(generator.generateDerivedParameters(keyBitLength));
+  }
+
+
+  /**
+   * Creates a new instance from an algorithm and salt data.
+   *
+   * @param  algorithm  OpenSSL key encryption algorithm.
+   * @param  iv  Explicit IV; first 8 bytes also used for salt in PBE key generation.
+   * @param  password  Password used to derive key.
+   */
+  public OpenSSLEncryptionScheme(final OpenSSLAlgorithm algorithm, final byte[] iv, final char[] password)
+  {
+    byte[] salt = iv;
+    if (iv.length > 8) {
+      salt = new byte[8];
+      System.arraycopy(iv, 0, salt, 0, 8);
+    }
+
+    final OpenSSLPBEParametersGenerator generator = new OpenSSLPBEParametersGenerator();
+    generator.init(PBEParametersGenerator.PKCS5PasswordToUTF8Bytes(password), salt);
+    setCipher(algorithm.getCipherSpec().newInstance());
+    setCipherParameters(
+      new ParametersWithIV(generator.generateDerivedParameters(algorithm.getCipherSpec().getKeyLength()), iv));
+  }
+}
diff --git a/src/main/java/org/cryptacular/pbe/PBES1Algorithm.java b/src/main/java/org/cryptacular/pbe/PBES1Algorithm.java
new file mode 100644
index 0000000..2c33685
--- /dev/null
+++ b/src/main/java/org/cryptacular/pbe/PBES1Algorithm.java
@@ -0,0 +1,116 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.pbe;
+
+import org.cryptacular.spec.BufferedBlockCipherSpec;
+import org.cryptacular.spec.DigestSpec;
+
+/**
+ * Password-based encryption algorithms defined in PKCS#5 for PBES1 scheme.
+ *
+ * @author  Middleware Services
+ * @version  $Revision: 2745 $
+ */
+public enum PBES1Algorithm {
+
+  /** PBES1 encryption method with MD2 hash and DES CBC cipher. */
+  PbeWithMD2AndDES_CBC(
+    "1.2.840.113549.1.5.1",
+    new BufferedBlockCipherSpec("DES", "CBC", "PKCS5"),
+    new DigestSpec("MD2")),
+
+  /** PBES1 encryption method with MD2 hash and RC2 CBC cipher. */
+  PbeWithMD2AndRC2_CBC(
+    "1.2.840.113549.1.5.4",
+    new BufferedBlockCipherSpec("RC2", "CBC", "PKCS5"),
+    new DigestSpec("MD2")),
+
+  /** PBES1 encryption method with MD5 hash and DES CBC cipher. */
+  PbeWithMD5AndDES_CBC(
+    "1.2.840.113549.1.5.3",
+    new BufferedBlockCipherSpec("DES", "CBC", "PKCS5"),
+    new DigestSpec("MD5")),
+
+  /** PBES1 encryption method with MD5 hash and RC2 CBC cipher. */
+  PbeWithMD5AndRC2_CBC(
+    "1.2.840.113549.1.5.6",
+    new BufferedBlockCipherSpec("RC2", "CBC", "PKCS5"),
+    new DigestSpec("MD5")),
+
+  /** PBES1 encryption method with SHA1 hash and DES CBC cipher. */
+  PbeWithSHA1AndDES_CBC(
+    "1.2.840.113549.1.5.10",
+    new BufferedBlockCipherSpec("DES", "CBC", "PKCS5"),
+    new DigestSpec("SHA1")),
+
+  /** PBES1 encryption method with SHA1 hash and RC2 CBC cipher. */
+  PbeWithSHA1AndRC2_CBC(
+    "1.2.840.113549.1.5.11",
+    new BufferedBlockCipherSpec("RC2", "CBC", "PKCS5"),
+    new DigestSpec("SHA1"));
+
+
+  /** Algorithm identifier OID. */
+  private final String oid;
+
+  /** Cipher algorithm specification. */
+  private final BufferedBlockCipherSpec cipherSpec;
+
+  /** Pseudorandom function digest specification. */
+  private final DigestSpec digestSpec;
+
+
+  /**
+   * Creates a new instance with given parameters.
+   *
+   * @param  id  Algorithm OID.
+   * @param  cipherSpec  Cipher algorithm specification.
+   * @param  digestSpec  Digest specification used for pseudorandom function.
+   */
+  PBES1Algorithm(final String id, final BufferedBlockCipherSpec cipherSpec, final DigestSpec digestSpec)
+  {
+    this.oid = id;
+    this.cipherSpec = cipherSpec;
+    this.digestSpec = digestSpec;
+  }
+
+
+  /**
+   * Gets the PBE algorithm for the given object identifier.
+   *
+   * @param  oid  PBE algorithm OID.
+   *
+   * @return  Algorithm whose identifier equals given value.
+   *
+   * @throws  IllegalArgumentException  If no matching algorithm found.
+   */
+  public static PBES1Algorithm fromOid(final String oid)
+  {
+    for (PBES1Algorithm a : PBES1Algorithm.values()) {
+      if (a.getOid().equals(oid)) {
+        return a;
+      }
+    }
+    throw new IllegalArgumentException("Unknown PBES1Algorithm for OID " + oid);
+  }
+
+
+  /** @return  the oid */
+  public String getOid()
+  {
+    return oid;
+  }
+
+
+  /** @return  Cipher algorithm specification. */
+  public BufferedBlockCipherSpec getCipherSpec()
+  {
+    return cipherSpec;
+  }
+
+
+  /** @return  Digest algorithm. */
+  public DigestSpec getDigestSpec()
+  {
+    return digestSpec;
+  }
+}
diff --git a/src/main/java/org/cryptacular/pbe/PBES1EncryptionScheme.java b/src/main/java/org/cryptacular/pbe/PBES1EncryptionScheme.java
new file mode 100644
index 0000000..9d7555a
--- /dev/null
+++ b/src/main/java/org/cryptacular/pbe/PBES1EncryptionScheme.java
@@ -0,0 +1,40 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.pbe;
+
+import org.bouncycastle.asn1.pkcs.PBEParameter;
+import org.bouncycastle.crypto.PBEParametersGenerator;
+import org.bouncycastle.crypto.generators.PKCS5S1ParametersGenerator;
+
+/**
+ * Implements the PBES1 encryption scheme defined in PKCS#5v2.
+ *
+ * @author  Middleware Services
+ * @version  $Revision: 2744 $
+ */
+public class PBES1EncryptionScheme extends AbstractEncryptionScheme
+{
+
+  /** Number of bits in derived key. */
+  public static final int KEY_LENGTH = 64;
+
+  /** Number of bits IV. */
+  public static final int IV_LENGTH = 64;
+
+
+  /**
+   * Creates a new instance with the given parameters.
+   *
+   * @param  alg  Describes hash/algorithm pair suitable for PBES1 scheme.
+   * @param  params  Key generation function salt and iteration count.
+   * @param  password  Password used to derive key.
+   */
+  public PBES1EncryptionScheme(final PBES1Algorithm alg, final PBEParameter params, final char[] password)
+  {
+    final byte[] salt = params.getSalt();
+    final int iterations = params.getIterationCount().intValue();
+    final PKCS5S1ParametersGenerator generator = new PKCS5S1ParametersGenerator(alg.getDigestSpec().newInstance());
+    generator.init(PBEParametersGenerator.PKCS5PasswordToUTF8Bytes(password), salt, iterations);
+    setCipher(alg.getCipherSpec().newInstance());
+    setCipherParameters(generator.generateDerivedParameters(KEY_LENGTH, IV_LENGTH));
+  }
+}
diff --git a/src/main/java/org/cryptacular/pbe/PBES2Algorithm.java b/src/main/java/org/cryptacular/pbe/PBES2Algorithm.java
new file mode 100644
index 0000000..1fc0671
--- /dev/null
+++ b/src/main/java/org/cryptacular/pbe/PBES2Algorithm.java
@@ -0,0 +1,113 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.pbe;
+
+import org.cryptacular.spec.BufferedBlockCipherSpec;
+
+/**
+ * Supported password-based encryption algorithms for PKCS#5 PBES2 encryption scheme. The ciphers mentioned in PKCS#5
+ * are supported as well as others in common use or of presumed value.
+ *
+ * @author  Middleware Services
+ * @version  $Revision: 2745 $
+ */
+public enum PBES2Algorithm {
+
+  /** DES CBC cipher. */
+  DES("1.3.14.3.2.7", new BufferedBlockCipherSpec("DES", "CBC", "PKCS5"), 64, 64),
+
+  /** 3-DES CBC cipher. */
+  DESede("1.2.840.113549.3.7", new BufferedBlockCipherSpec("DESede", "CBC", "PKCS5"), 64, 192),
+
+  /** RC2 CBC cipher. */
+  RC2("1.2.840.113549.3.2", new BufferedBlockCipherSpec("RC2", "CBC", "PKCS5"), 0, 64),
+
+  /** RC5 CBC cipher. */
+  RC5("1.2.840.113549.3.9", new BufferedBlockCipherSpec("RC5", "CBC", "PKCS5"), 0, 128),
+
+  /** AES-128 CBC cipher. */
+  AES128("2.16.840.1.101.3.4.1.2", new BufferedBlockCipherSpec("AES", "CBC", "PKCS5"), 128, 128),
+
+  /** AES-192 CBC cipher. */
+  AES192("2.16.840.1.101.3.4.1.22", new BufferedBlockCipherSpec("AES", "CBC", "PKCS5"), 128, 192),
+
+  /** AES-256 CBC cipher. */
+  AES256("2.16.840.1.101.3.4.1.42", new BufferedBlockCipherSpec("AES", "CBC", "PKCS5"), 128, 256);
+
+
+  /** Algorithm identifier OID. */
+  private final String oid;
+
+  /** Cipher algorithm specification. */
+  private final BufferedBlockCipherSpec cipherSpec;
+
+  /** Cipher block size in bits. */
+  private final int blockSize;
+
+  /** Cipher key size in bits. */
+  private final int keySize;
+
+
+  /**
+   * Creates a new instance with given parameters.
+   *
+   * @param  id  Algorithm OID.
+   * @param  cipherSpec  Cipher algorithm specification.
+   * @param  cipherBlockSize  Block cipher size in bits.
+   * @param  keySizeBits  Size of derived key in bits to be used with cipher.
+   */
+  PBES2Algorithm(
+    final String id, final BufferedBlockCipherSpec cipherSpec, final int cipherBlockSize, final int keySizeBits)
+  {
+    this.oid = id;
+    this.cipherSpec = cipherSpec;
+    this.blockSize = cipherBlockSize;
+    this.keySize = keySizeBits;
+  }
+
+
+  /**
+   * Gets the PBE algorithm for the given object identifier.
+   *
+   * @param  oid  PBE algorithm OID.
+   *
+   * @return  Algorithm whose identifier equals given value.
+   *
+   * @throws  IllegalArgumentException  If no matching algorithm found.
+   */
+  public static PBES2Algorithm fromOid(final String oid)
+  {
+    for (PBES2Algorithm a : PBES2Algorithm.values()) {
+      if (a.getOid().equals(oid)) {
+        return a;
+      }
+    }
+    throw new IllegalArgumentException("Unknown PBES1Algorithm for OID " + oid);
+  }
+
+
+  /** @return  the oid */
+  public String getOid()
+  {
+    return oid;
+  }
+
+
+  /** @return  Cipher algorithm specification. */
+  public BufferedBlockCipherSpec getCipherSpec()
+  {
+    return cipherSpec;
+  }
+
+
+  /** @return Cipher block size. */
+  public int getBlockSize()
+  {
+    return blockSize;
+  }
+
+  /** @return  Size of derived key in bits or -1 if algorithm does not define a key size. */
+  public int getKeySize()
+  {
+    return keySize;
+  }
+}
diff --git a/src/main/java/org/cryptacular/pbe/PBES2EncryptionScheme.java b/src/main/java/org/cryptacular/pbe/PBES2EncryptionScheme.java
new file mode 100644
index 0000000..da8ad18
--- /dev/null
+++ b/src/main/java/org/cryptacular/pbe/PBES2EncryptionScheme.java
@@ -0,0 +1,136 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.pbe;
+
+import java.util.HashMap;
+import java.util.Map;
+import org.bouncycastle.asn1.ASN1Integer;
+import org.bouncycastle.asn1.ASN1ObjectIdentifier;
+import org.bouncycastle.asn1.ASN1OctetString;
+import org.bouncycastle.asn1.ASN1Sequence;
+import org.bouncycastle.asn1.nist.NISTObjectIdentifiers;
+import org.bouncycastle.asn1.pkcs.PBES2Parameters;
+import org.bouncycastle.asn1.pkcs.PBKDF2Params;
+import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers;
+import org.bouncycastle.crypto.CipherParameters;
+import org.bouncycastle.crypto.PBEParametersGenerator;
+import org.bouncycastle.crypto.engines.RC532Engine;
+import org.bouncycastle.crypto.engines.RC564Engine;
+import org.bouncycastle.crypto.generators.PKCS5S2ParametersGenerator;
+import org.bouncycastle.crypto.modes.CBCBlockCipher;
+import org.bouncycastle.crypto.paddings.PKCS7Padding;
+import org.bouncycastle.crypto.paddings.PaddedBufferedBlockCipher;
+import org.bouncycastle.crypto.params.KeyParameter;
+import org.bouncycastle.crypto.params.ParametersWithIV;
+import org.bouncycastle.crypto.params.RC2Parameters;
+import org.bouncycastle.crypto.params.RC5Parameters;
+import org.cryptacular.spec.DigestSpec;
+
+/**
+ * Implements the PBES2 encryption scheme defined in PKCS#5v2.
+ *
+ * @author  Middleware Services
+ * @version  $Revision: 2744 $
+ */
+public class PBES2EncryptionScheme extends AbstractEncryptionScheme
+{
+  /** Map of HMAC algorithm identifiers to digest specifications. */
+  private static final Map<ASN1ObjectIdentifier, DigestSpec> HMAC_ID_TO_DIGEST_SPEC_MAP = new HashMap<>();
+
+  /** Size of derived key in bits. */
+  private int keyLength;
+
+
+  static
+  {
+    HMAC_ID_TO_DIGEST_SPEC_MAP.put(PKCSObjectIdentifiers.id_hmacWithSHA1, new DigestSpec("SHA1"));
+    HMAC_ID_TO_DIGEST_SPEC_MAP.put(PKCSObjectIdentifiers.id_hmacWithSHA256, new DigestSpec("SHA256"));
+    HMAC_ID_TO_DIGEST_SPEC_MAP.put(PKCSObjectIdentifiers.id_hmacWithSHA512, new DigestSpec("SHA512"));
+    HMAC_ID_TO_DIGEST_SPEC_MAP.put(NISTObjectIdentifiers.id_hmacWithSHA3_256, new DigestSpec("SHA3", 256));
+    HMAC_ID_TO_DIGEST_SPEC_MAP.put(NISTObjectIdentifiers.id_hmacWithSHA3_384, new DigestSpec("SHA3", 384));
+    HMAC_ID_TO_DIGEST_SPEC_MAP.put(NISTObjectIdentifiers.id_hmacWithSHA3_512, new DigestSpec("SHA3", 512));
+  }
+
+
+  /**
+   * Creates a new instance with the given parameters.
+   *
+   * @param  params  PBES2 parameters describing the key derivation function and encryption scheme.
+   * @param  password  Password used to derive key.
+   */
+  public PBES2EncryptionScheme(final PBES2Parameters params, final char[] password)
+  {
+    final PBKDF2Params kdfParams = PBKDF2Params.getInstance(params.getKeyDerivationFunc().getParameters());
+    final byte[] salt = kdfParams.getSalt();
+    final int iterations = kdfParams.getIterationCount().intValue();
+    if (kdfParams.getKeyLength() != null) {
+      keyLength = kdfParams.getKeyLength().intValue() * 8;
+    }
+    final DigestSpec digestSpec = HMAC_ID_TO_DIGEST_SPEC_MAP.get(kdfParams.getPrf().getAlgorithm());
+    if (digestSpec == null) {
+      throw new IllegalArgumentException("Unsupported PBKDF2 PRF HMAC algorithm");
+    }
+    final PKCS5S2ParametersGenerator generator = new PKCS5S2ParametersGenerator(digestSpec.newInstance());
+    generator.init(PBEParametersGenerator.PKCS5PasswordToUTF8Bytes(password), salt, iterations);
+    initCipher(generator, params.getEncryptionScheme());
+  }
+
+
+  /**
+   * Initializes the block cipher and sets up its initialization parameters.
+   *
+   * @param  generator  Derived key generator.
+   * @param  scheme  PKCS#5 encryption scheme.
+   */
+  private void initCipher(
+    final PKCS5S2ParametersGenerator generator,
+    final org.bouncycastle.asn1.pkcs.EncryptionScheme scheme)
+  {
+    final PBES2Algorithm alg = PBES2Algorithm.fromOid(scheme.getAlgorithm().getId());
+    if (keyLength == 0) {
+      keyLength = alg.getKeySize();
+    }
+
+    byte[] iv = null;
+    CipherParameters cipherParameters = generator.generateDerivedParameters(keyLength);
+    switch (alg) {
+
+    case RC2:
+      setCipher(alg.getCipherSpec().newInstance());
+
+      final ASN1Sequence rc2Params = ASN1Sequence.getInstance(scheme.getParameters());
+      if (rc2Params.size() > 1) {
+        cipherParameters = new RC2Parameters(
+          ((KeyParameter) cipherParameters).getKey(),
+          ASN1Integer.getInstance(rc2Params.getObjectAt(0)).getValue().intValue());
+        iv = ASN1OctetString.getInstance(rc2Params.getObjectAt(0)).getOctets();
+      }
+      break;
+
+    case RC5:
+
+      final ASN1Sequence rc5Params = ASN1Sequence.getInstance(scheme.getParameters());
+      final int rounds = ASN1Integer.getInstance(rc5Params.getObjectAt(1)).getValue().intValue();
+      final int blockSize = ASN1Integer.getInstance(rc5Params.getObjectAt(2)).getValue().intValue();
+      if (blockSize == 64) {
+        setCipher(new PaddedBufferedBlockCipher(new CBCBlockCipher(new RC564Engine()), new PKCS7Padding()));
+      } else if (blockSize == 32) {
+        setCipher(new PaddedBufferedBlockCipher(new CBCBlockCipher(new RC532Engine()), new PKCS7Padding()));
+      } else {
+        throw new IllegalArgumentException("Invalid RC5 block size: " + blockSize);
+      }
+      cipherParameters = new RC5Parameters(((KeyParameter) cipherParameters).getKey(), rounds);
+      if (rc5Params.size() > 3) {
+        iv = ASN1OctetString.getInstance(rc5Params.getObjectAt(3)).getOctets();
+      }
+      break;
+
+    default:
+      setCipher(alg.getCipherSpec().newInstance());
+      iv = ASN1OctetString.getInstance(scheme.getParameters()).getOctets();
+    }
+    if (iv != null) {
+      cipherParameters = new ParametersWithIV(cipherParameters, iv);
+    }
+    setCipherParameters(cipherParameters);
+  }
+}
diff --git a/src/main/java/org/cryptacular/spec/AEADBlockCipherSpec.java b/src/main/java/org/cryptacular/spec/AEADBlockCipherSpec.java
new file mode 100644
index 0000000..f163118
--- /dev/null
+++ b/src/main/java/org/cryptacular/spec/AEADBlockCipherSpec.java
@@ -0,0 +1,121 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.spec;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.bouncycastle.crypto.BlockCipher;
+import org.bouncycastle.crypto.modes.AEADBlockCipher;
+import org.bouncycastle.crypto.modes.CCMBlockCipher;
+import org.bouncycastle.crypto.modes.EAXBlockCipher;
+import org.bouncycastle.crypto.modes.GCMBlockCipher;
+import org.bouncycastle.crypto.modes.OCBBlockCipher;
+
+/**
+ * Describes an AEAD block cipher in terms of a (algorithm, mode) tuple and provides a facility to create a new instance
+ * of the cipher via the {@link #newInstance()} method.
+ *
+ * @author  Middleware Services
+ * @version  $Revision: 2744 $
+ */
+public class AEADBlockCipherSpec implements Spec<AEADBlockCipher>
+{
+
+  /** String specification format, <code>algorithm/mode</code>. */
+  public static final Pattern FORMAT = Pattern.compile("(?<alg>[A-Za-z0-9_-]+)/(?<mode>\\w+)");
+
+  /** Cipher algorithm. */
+  private final String algorithm;
+
+  /** Cipher mode, e.g. GCM, CCM. */
+  private final String mode;
+
+
+  /**
+   * Creates a new instance from a cipher algorithm and mode.
+   *
+   * @param  algName  Cipher algorithm name.
+   * @param  cipherMode  Cipher mode, e.g. GCM, CCM.
+   */
+  public AEADBlockCipherSpec(final String algName, final String cipherMode)
+  {
+    this.algorithm = algName;
+    this.mode = cipherMode;
+  }
+
+
+  @Override
+  public String getAlgorithm()
+  {
+    return algorithm;
+  }
+
+
+  /**
+   * Gets the cipher mode.
+   *
+   * @return  Cipher mode, e.g. CBC, OFB.
+   */
+  public String getMode()
+  {
+    return mode;
+  }
+
+
+  /**
+   * Creates a new AEAD block cipher from the specification in this instance.
+   *
+   * @return  New AEAD block cipher instance.
+   */
+  @Override
+  public AEADBlockCipher newInstance()
+  {
+    final BlockCipher blockCipher = new BlockCipherSpec(algorithm).newInstance();
+    final AEADBlockCipher aeadBlockCipher;
+    switch (mode) {
+
+    case "GCM":
+      aeadBlockCipher = new GCMBlockCipher(blockCipher);
+      break;
+
+    case "CCM":
+      aeadBlockCipher = new CCMBlockCipher(blockCipher);
+      break;
+
+    case "OCB":
+      aeadBlockCipher = new OCBBlockCipher(blockCipher, new BlockCipherSpec(algorithm).newInstance());
+      break;
+
+    case "EAX":
+      aeadBlockCipher = new EAXBlockCipher(blockCipher);
+      break;
+
+    default:
+      throw new IllegalStateException("Unsupported mode " + mode);
+    }
+    return aeadBlockCipher;
+  }
+
+
+  @Override
+  public String toString()
+  {
+    return algorithm + '/' + mode;
+  }
+
+
+  /**
+   * Parses a string representation of a AEAD block cipher specification into an instance of this class.
+   *
+   * @param  specification  AEAD block cipher specification of the form <code>algorithm/mode</code>.
+   *
+   * @return  Buffered block cipher specification instance.
+   */
+  public static AEADBlockCipherSpec parse(final String specification)
+  {
+    final Matcher m = FORMAT.matcher(specification);
+    if (!m.matches()) {
+      throw new IllegalArgumentException("Invalid specification " + specification);
+    }
+    return new AEADBlockCipherSpec(m.group("alg"), m.group("mode"));
+  }
+}
diff --git a/src/main/java/org/cryptacular/spec/BlockCipherSpec.java b/src/main/java/org/cryptacular/spec/BlockCipherSpec.java
new file mode 100644
index 0000000..05e82f4
--- /dev/null
+++ b/src/main/java/org/cryptacular/spec/BlockCipherSpec.java
@@ -0,0 +1,107 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.spec;
+
+import org.bouncycastle.crypto.BlockCipher;
+import org.bouncycastle.crypto.engines.AESEngine;
+import org.bouncycastle.crypto.engines.BlowfishEngine;
+import org.bouncycastle.crypto.engines.CAST5Engine;
+import org.bouncycastle.crypto.engines.CAST6Engine;
+import org.bouncycastle.crypto.engines.CamelliaEngine;
+import org.bouncycastle.crypto.engines.DESEngine;
+import org.bouncycastle.crypto.engines.DESedeEngine;
+import org.bouncycastle.crypto.engines.GOST28147Engine;
+import org.bouncycastle.crypto.engines.NoekeonEngine;
+import org.bouncycastle.crypto.engines.RC2Engine;
+import org.bouncycastle.crypto.engines.RC564Engine;
+import org.bouncycastle.crypto.engines.RC6Engine;
+import org.bouncycastle.crypto.engines.SEEDEngine;
+import org.bouncycastle.crypto.engines.SerpentEngine;
+import org.bouncycastle.crypto.engines.SkipjackEngine;
+import org.bouncycastle.crypto.engines.TEAEngine;
+import org.bouncycastle.crypto.engines.TwofishEngine;
+import org.bouncycastle.crypto.engines.XTEAEngine;
+
+/**
+ * Block cipher specification.
+ *
+ * @author  Middleware Services
+ */
+public class BlockCipherSpec implements Spec<BlockCipher>
+{
+
+  /** Cipher algorithm. */
+  private final String algorithm;
+
+
+  /**
+   * Creates a new instance that describes the given block cipher algorithm.
+   *
+   * @param  algName  Block cipher algorithm.
+   */
+  public BlockCipherSpec(final String algName)
+  {
+    this.algorithm = algName;
+  }
+
+
+  @Override
+  public String getAlgorithm()
+  {
+    return algorithm;
+  }
+
+
+  @Override
+  public BlockCipher newInstance()
+  {
+    final BlockCipher cipher;
+    if ("AES".equalsIgnoreCase(algorithm)) {
+      cipher = new AESEngine();
+    } else if ("Blowfish".equalsIgnoreCase(algorithm)) {
+      cipher = new BlowfishEngine();
+    } else if ("Camellia".equalsIgnoreCase(algorithm)) {
+      cipher = new CamelliaEngine();
+    } else if ("CAST5".equalsIgnoreCase(algorithm)) {
+      cipher = new CAST5Engine();
+    } else if ("CAST6".equalsIgnoreCase(algorithm)) {
+      cipher = new CAST6Engine();
+    } else if ("DES".equalsIgnoreCase(algorithm)) {
+      cipher = new DESEngine();
+    } else if ("DESede".equalsIgnoreCase(algorithm) || "DES3".equalsIgnoreCase(algorithm)) {
+      cipher = new DESedeEngine();
+    } else if ("GOST".equalsIgnoreCase(algorithm) || "GOST28147".equals(algorithm)) {
+      cipher = new GOST28147Engine();
+    } else if ("Noekeon".equalsIgnoreCase(algorithm)) {
+      cipher = new NoekeonEngine();
+    } else if ("RC2".equalsIgnoreCase(algorithm)) {
+      cipher = new RC2Engine();
+    } else if ("RC5".equalsIgnoreCase(algorithm)) {
+      cipher = new RC564Engine();
+    } else if ("RC6".equalsIgnoreCase(algorithm)) {
+      cipher = new RC6Engine();
+    } else if ("SEED".equalsIgnoreCase(algorithm)) {
+      cipher = new SEEDEngine();
+    } else if ("Serpent".equalsIgnoreCase(algorithm)) {
+      cipher = new SerpentEngine();
+    } else if ("Skipjack".equalsIgnoreCase(algorithm)) {
+      cipher = new SkipjackEngine();
+    } else if ("TEA".equalsIgnoreCase(algorithm)) {
+      cipher = new TEAEngine();
+    } else if ("Twofish".equalsIgnoreCase(algorithm)) {
+      cipher = new TwofishEngine();
+    } else if ("XTEA".equalsIgnoreCase(algorithm)) {
+      cipher = new XTEAEngine();
+    } else {
+      throw new IllegalStateException("Unsupported cipher algorithm " + algorithm);
+    }
+    return cipher;
+  }
+
+
+  @Override
+  public String toString()
+  {
+    return algorithm;
+  }
+
+}
diff --git a/src/main/java/org/cryptacular/spec/BufferedBlockCipherSpec.java b/src/main/java/org/cryptacular/spec/BufferedBlockCipherSpec.java
new file mode 100644
index 0000000..c1e5366
--- /dev/null
+++ b/src/main/java/org/cryptacular/spec/BufferedBlockCipherSpec.java
@@ -0,0 +1,220 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.spec;
+
+import java.io.Serializable;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.bouncycastle.crypto.BlockCipher;
+import org.bouncycastle.crypto.BufferedBlockCipher;
+import org.bouncycastle.crypto.modes.CBCBlockCipher;
+import org.bouncycastle.crypto.modes.CFBBlockCipher;
+import org.bouncycastle.crypto.modes.OFBBlockCipher;
+import org.bouncycastle.crypto.paddings.BlockCipherPadding;
+import org.bouncycastle.crypto.paddings.ISO10126d2Padding;
+import org.bouncycastle.crypto.paddings.ISO7816d4Padding;
+import org.bouncycastle.crypto.paddings.PKCS7Padding;
+import org.bouncycastle.crypto.paddings.PaddedBufferedBlockCipher;
+import org.bouncycastle.crypto.paddings.TBCPadding;
+import org.bouncycastle.crypto.paddings.X923Padding;
+import org.bouncycastle.crypto.paddings.ZeroBytePadding;
+
+/**
+ * Describes a block cipher in terms of a (algorithm, mode, padding) tuple and provides a facility to create a new
+ * instance of the cipher via the {@link #newInstance()} method.
+ *
+ * @author  Middleware Services
+ * @version  $Revision: 2744 $
+ */
+public class BufferedBlockCipherSpec implements Spec<BufferedBlockCipher>, Serializable
+{
+
+  /** String specification format, <code>algorithm/mode/padding</code>. */
+  public static final Pattern FORMAT = Pattern.compile("(?<alg>[A-Za-z0-9_-]+)/(?<mode>\\w+)/(?<padding>\\w+)");
+
+  /** serialVersionUID. */
+  private static final long serialVersionUID = 2900237827716742605L;
+
+  /** Cipher algorithm. */
+  private final String algorithm;
+
+  /** Cipher mode, e.g. CBC, OFB. */
+  private final String mode;
+
+  /** Cipher padding scheme, e.g. PKCS5Padding. */
+  private final String padding;
+
+
+  /**
+   * Creates a new instance from an algorithm name.
+   *
+   * @param  algName  Cipher algorithm name.
+   */
+  public BufferedBlockCipherSpec(final String algName)
+  {
+    this(algName, null, null);
+  }
+
+
+  /**
+   * Creates a new instance from a cipher algorithm and mode.
+   *
+   * @param  algName  Cipher algorithm name.
+   * @param  cipherMode  Cipher mode.
+   */
+  public BufferedBlockCipherSpec(final String algName, final String cipherMode)
+  {
+    this(algName, cipherMode, null);
+  }
+
+
+  /**
+   * Creates a new instance from the given cipher specifications.
+   *
+   * @param  algName  Cipher algorithm name.
+   * @param  cipherMode  Cipher mode.
+   * @param  cipherPadding  Cipher padding scheme algorithm.
+   */
+  public BufferedBlockCipherSpec(final String algName, final String cipherMode, final String cipherPadding)
+  {
+    this.algorithm = algName;
+    this.mode = cipherMode;
+    this.padding = cipherPadding;
+  }
+
+
+  @Override
+  public String getAlgorithm()
+  {
+    return algorithm;
+  }
+
+
+  /**
+   * Gets the cipher mode.
+   *
+   * @return  Cipher mode, e.g. CBC, OFB.
+   */
+  public String getMode()
+  {
+    return mode;
+  }
+
+
+  /**
+   * Gets the cipher padding scheme.
+   *
+   * @return  Padding scheme algorithm, e.g. PKCS5Padding. The following names are equivalent for no padding: NULL,
+   *          Zero, None.
+   */
+  public String getPadding()
+  {
+    return padding;
+  }
+
+
+  /**
+   * Gets the simple block cipher specification corresponding to this instance.
+   *
+   * @return  Simple block cipher specification.
+   */
+  public BlockCipherSpec getBlockCipherSpec()
+  {
+    return new BlockCipherSpec(this.algorithm);
+  }
+
+
+  /**
+   * Creates a new buffered block cipher from the specification in this instance.
+   *
+   * @return  New buffered block cipher instance.
+   */
+  @Override
+  public BufferedBlockCipher newInstance()
+  {
+    BlockCipher cipher = getBlockCipherSpec().newInstance();
+
+    switch (mode) {
+
+    case "CBC":
+      cipher = new CBCBlockCipher(cipher);
+      break;
+
+    case "OFB":
+      cipher = new OFBBlockCipher(cipher, cipher.getBlockSize());
+      break;
+
+    case "CFB":
+      cipher = new CFBBlockCipher(cipher, cipher.getBlockSize());
+      break;
+
+    default:
+      break;
+    }
+
+    if (padding != null) {
+      return new PaddedBufferedBlockCipher(cipher, getPadding(padding));
+    }
+    return new BufferedBlockCipher(cipher);
+  }
+
+
+  @Override
+  public String toString()
+  {
+    return algorithm + '/' + mode + '/' + padding;
+  }
+
+
+  /**
+   * Parses a string representation of a buffered block cipher specification into an instance of this class.
+   *
+   * @param  specification  Block cipher specification of the form <code>algorithm/mode/padding</code>.
+   *
+   * @return  Buffered block cipher specification instance.
+   */
+  public static BufferedBlockCipherSpec parse(final String specification)
+  {
+    final Matcher m = FORMAT.matcher(specification);
+    if (!m.matches()) {
+      throw new IllegalArgumentException("Invalid specification " + specification);
+    }
+    return new BufferedBlockCipherSpec(m.group("alg"), m.group("mode"), m.group("padding"));
+  }
+
+
+  /**
+   * Gets an instance of block cipher padding from a padding name string.
+   *
+   * @param  padding  Name of padding algorithm.
+   *
+   * @return  Block cipher padding instance.
+   */
+  private static BlockCipherPadding getPadding(final String padding)
+  {
+    final String name;
+    final int pIndex = padding.indexOf("Padding");
+    if (pIndex > -1) {
+      name = padding.substring(0, pIndex);
+    } else {
+      name = padding;
+    }
+
+    final BlockCipherPadding blockCipherPadding;
+    if ("ISO7816d4".equalsIgnoreCase(name) || "ISO7816".equalsIgnoreCase(name)) {
+      blockCipherPadding = new ISO7816d4Padding();
+    } else if ("ISO10126".equalsIgnoreCase(name) || "ISO10126-2".equalsIgnoreCase(name)) {
+      blockCipherPadding = new ISO10126d2Padding();
+    } else if ("PKCS7".equalsIgnoreCase(name) || "PKCS5".equalsIgnoreCase(name)) {
+      blockCipherPadding = new PKCS7Padding();
+    } else if ("TBC".equalsIgnoreCase(name)) {
+      blockCipherPadding = new TBCPadding();
+    } else if ("X923".equalsIgnoreCase(name)) {
+      blockCipherPadding = new X923Padding();
+    } else if ("NULL".equalsIgnoreCase(name) || "Zero".equalsIgnoreCase(name) || "None".equalsIgnoreCase(name)) {
+      blockCipherPadding = new ZeroBytePadding();
+    } else {
+      throw new IllegalArgumentException("Invalid padding " + padding);
+    }
+    return blockCipherPadding;
+  }
+}
diff --git a/src/main/java/org/cryptacular/spec/CodecSpec.java b/src/main/java/org/cryptacular/spec/CodecSpec.java
new file mode 100644
index 0000000..5c99141
--- /dev/null
+++ b/src/main/java/org/cryptacular/spec/CodecSpec.java
@@ -0,0 +1,100 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.spec;
+
+
+import org.cryptacular.codec.Base32Codec;
+import org.cryptacular.codec.Base64Codec;
+import org.cryptacular.codec.Codec;
+import org.cryptacular.codec.HexCodec;
+
+/**
+ * Describes a string-to-byte encoding provides a means to create a new instance of the coed via the {@link
+ * #newInstance()} method.
+ *
+ * @author  Middleware Services
+ */
+public class CodecSpec implements Spec<Codec>
+{
+
+  /** Hexadecimal encoding specification. */
+  public static final CodecSpec HEX = new CodecSpec("Hex");
+
+  /** Lowercase hexadecimal encoding specification. */
+  public static final CodecSpec HEX_LOWER = new CodecSpec("Hex-Lower");
+
+  /** Uppercase hexadecimal encoding specification. */
+  public static final CodecSpec HEX_UPPER = new CodecSpec("Hex-Upper");
+
+  /** Base32 encoding specification. */
+  public static final CodecSpec BASE32 = new CodecSpec("Base32");
+
+  /** Unpadded base32 encoding specification. */
+  public static final CodecSpec BASE32_UNPADDED = new CodecSpec("Base32-Unpadded");
+
+  /** Base64 encoding specification. */
+  public static final CodecSpec BASE64 = new CodecSpec("Base64");
+
+  /** URL-safe base64 encoding specification. */
+  public static final CodecSpec BASE64_URLSAFE = new CodecSpec("Base64-URLSafe");
+
+  /** Unpadded base64 encoding specification. */
+  public static final CodecSpec BASE64_UNPADDED = new CodecSpec("Base64-Unpadded");
+
+
+  /** Name of encoding, e.g. "Hex, "Base64". */
+  private final String encoding;
+
+
+  /**
+   * Creates a new instance of the given encoding.
+   *
+   * @param  encoding  Name of encoding.
+   */
+  public CodecSpec(final String encoding)
+  {
+    if (encoding == null) {
+      throw new IllegalArgumentException("Encoding cannot be null.");
+    }
+    this.encoding = encoding;
+  }
+
+
+  /** @return  The name of the encoding, e.g. "Hex", "Base32", "Base64". */
+  @Override
+  public String getAlgorithm()
+  {
+    return encoding;
+  }
+
+
+  @Override
+  public Codec newInstance()
+  {
+    final Codec codec;
+    if ("Hex".equalsIgnoreCase(encoding) || "Hex-Lower".equalsIgnoreCase(encoding)) {
+      codec = new HexCodec();
+    } else if ("Hex-Upper".equalsIgnoreCase(encoding)) {
+      codec = new HexCodec(true);
+    } else if ("Base32".equalsIgnoreCase(encoding) || "Base-32".equalsIgnoreCase(encoding)) {
+      codec = new Base32Codec();
+    } else if ("Base32-Unpadded".equalsIgnoreCase(encoding)) {
+      codec = new Base32Codec("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567", true);
+    } else if ("Base64".equalsIgnoreCase(encoding) || "Base-64".equalsIgnoreCase(encoding)) {
+      codec = new Base64Codec();
+    } else if ("Base64-URLSafe".equalsIgnoreCase(encoding)) {
+      codec = new Base64Codec("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_");
+    } else if ("Base64-Unpadded".equalsIgnoreCase(encoding)) {
+      codec = new Base64Codec("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/", false);
+    } else {
+      throw new IllegalArgumentException("Invalid encoding.");
+    }
+    return codec;
+  }
+
+
+  @Override
+  public String toString()
+  {
+    return encoding;
+  }
+}
diff --git a/src/main/java/org/cryptacular/spec/DigestSpec.java b/src/main/java/org/cryptacular/spec/DigestSpec.java
new file mode 100644
index 0000000..4d0c4b7
--- /dev/null
+++ b/src/main/java/org/cryptacular/spec/DigestSpec.java
@@ -0,0 +1,139 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.spec;
+
+import org.bouncycastle.crypto.Digest;
+import org.bouncycastle.crypto.digests.GOST3411Digest;
+import org.bouncycastle.crypto.digests.MD2Digest;
+import org.bouncycastle.crypto.digests.MD4Digest;
+import org.bouncycastle.crypto.digests.MD5Digest;
+import org.bouncycastle.crypto.digests.RIPEMD128Digest;
+import org.bouncycastle.crypto.digests.RIPEMD160Digest;
+import org.bouncycastle.crypto.digests.RIPEMD256Digest;
+import org.bouncycastle.crypto.digests.RIPEMD320Digest;
+import org.bouncycastle.crypto.digests.SHA1Digest;
+import org.bouncycastle.crypto.digests.SHA224Digest;
+import org.bouncycastle.crypto.digests.SHA256Digest;
+import org.bouncycastle.crypto.digests.SHA384Digest;
+import org.bouncycastle.crypto.digests.SHA3Digest;
+import org.bouncycastle.crypto.digests.SHA512Digest;
+import org.bouncycastle.crypto.digests.TigerDigest;
+import org.bouncycastle.crypto.digests.WhirlpoolDigest;
+
+/**
+ * Describes a message digest function by name and provides a means to create a new instance of the digest via the
+ * {@link #newInstance()} method.
+ *
+ * @author  Middleware Services
+ */
+public class DigestSpec implements Spec<Digest>
+{
+
+  /** Digest algorithm name. */
+  private final String algorithm;
+
+  /** Requested size of variable-size hash algorithms, e.g. SHA-3. -1 for hashes with fixed size outputs. */
+  private final int size;
+
+
+  /**
+   * Creates a new instance from the given algorithm name.
+   *
+   * @param  algName  Digest algorithm name.
+   */
+  public DigestSpec(final String algName)
+  {
+    if (algName == null) {
+      throw new IllegalArgumentException("Algorithm name is required.");
+    }
+    this.algorithm = algName;
+    this.size = -1;
+  }
+
+
+  /**
+   * Constructor for digests that have variable output size, e.g. SHA3.
+   *
+   * @param  algName  Digest algorithm name.
+   * @param  digestSize  Size of resultant digest in bits.
+   */
+  public DigestSpec(final String algName, final int digestSize)
+  {
+    if (algName == null) {
+      throw new IllegalArgumentException("Algorithm name is required.");
+    }
+    this.algorithm = algName;
+    if (digestSize < 0) {
+      throw new IllegalArgumentException("Digest size must be positive.");
+    }
+    this.size = digestSize;
+  }
+
+
+  @Override
+  public String getAlgorithm()
+  {
+    return algorithm;
+  }
+
+
+  /** @return  Size of digest output in bytes, or -1 if the digest does not support variable size output. */
+  public int getSize()
+  {
+    return size;
+  }
+
+
+  /**
+   * Creates a new digest instance.
+   *
+   * @return  Digest instance.
+   */
+  @Override
+  public Digest newInstance()
+  {
+    final Digest digest;
+    if ("GOST3411".equalsIgnoreCase(algorithm)) {
+      digest = new GOST3411Digest();
+    } else if ("MD2".equalsIgnoreCase(algorithm)) {
+      digest = new MD2Digest();
+    } else if ("MD4".equalsIgnoreCase(algorithm)) {
+      digest = new MD4Digest();
+    } else if ("MD5".equalsIgnoreCase(algorithm)) {
+      digest = new MD5Digest();
+    } else if ("RIPEMD128".equalsIgnoreCase(algorithm) || "RIPEMD-128".equalsIgnoreCase(algorithm)) {
+      digest = new RIPEMD128Digest();
+    } else if ("RIPEMD160".equalsIgnoreCase(algorithm) || "RIPEMD-160".equalsIgnoreCase(algorithm)) {
+      digest = new RIPEMD160Digest();
+    } else if ("RIPEMD256".equalsIgnoreCase(algorithm) || "RIPEMD-256".equalsIgnoreCase(algorithm)) {
+      digest = new RIPEMD256Digest();
+    } else if ("RIPEMD320".equalsIgnoreCase(algorithm) || "RIPEMD-320".equalsIgnoreCase(algorithm)) {
+      digest = new RIPEMD320Digest();
+    } else if ("SHA1".equalsIgnoreCase(algorithm) || "SHA-1".equalsIgnoreCase(algorithm)) {
+      digest = new SHA1Digest();
+    } else if ("SHA224".equalsIgnoreCase(algorithm) || "SHA-224".equalsIgnoreCase(algorithm)) {
+      digest = new SHA224Digest();
+    } else if ("SHA256".equalsIgnoreCase(algorithm) || "SHA-256".equalsIgnoreCase(algorithm)) {
+      digest = new SHA256Digest();
+    } else if ("SHA384".equalsIgnoreCase(algorithm) || "SHA-384".equalsIgnoreCase(algorithm)) {
+      digest = new SHA384Digest();
+    } else if ("SHA512".equalsIgnoreCase(algorithm) || "SHA-512".equalsIgnoreCase(algorithm)) {
+      digest = new SHA512Digest();
+    } else if ("SHA3".equalsIgnoreCase(algorithm) || "SHA-3".equalsIgnoreCase(algorithm)) {
+      digest = new SHA3Digest(size);
+    } else if ("Tiger".equalsIgnoreCase(algorithm)) {
+      digest = new TigerDigest();
+    } else if ("Whirlpool".equalsIgnoreCase(algorithm)) {
+      digest = new WhirlpoolDigest();
+    } else {
+      throw new IllegalStateException("Unsupported digest algorithm " + algorithm);
+    }
+    return digest;
+  }
+
+
+  @Override
+  public String toString()
+  {
+    return algorithm;
+  }
+}
diff --git a/src/main/java/org/cryptacular/spec/KeyedBlockCipherSpec.java b/src/main/java/org/cryptacular/spec/KeyedBlockCipherSpec.java
new file mode 100644
index 0000000..15c42eb
--- /dev/null
+++ b/src/main/java/org/cryptacular/spec/KeyedBlockCipherSpec.java
@@ -0,0 +1,50 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.spec;
+
+/**
+ * Describes a block cipher algorithm with a known key size.
+ *
+ * @author  Middleware Services
+ */
+public class KeyedBlockCipherSpec extends BufferedBlockCipherSpec
+{
+
+  /** serialVersionUID. */
+  private static final long serialVersionUID = -7623413862633189082L;
+
+  /** Key length in bits. */
+  private final int keyLength;
+
+
+  /**
+   * Creates a new instance from the given cipher specifications.
+   *
+   * @param  algName  Cipher algorithm name.
+   * @param  cipherMode  Cipher mode.
+   * @param  cipherPadding  Cipher padding scheme algorithm.
+   * @param  keyBitLength  Key length in bits.
+   */
+  public KeyedBlockCipherSpec(
+    final String algName,
+    final String cipherMode,
+    final String cipherPadding,
+    final int keyBitLength)
+  {
+    super(algName, cipherMode, cipherPadding);
+    if (keyBitLength < 0) {
+      throw new IllegalArgumentException("Key length must be non-negative");
+    }
+    this.keyLength = keyBitLength;
+  }
+
+
+  /**
+   * Gets the cipher key length in bits.
+   *
+   * @return  Key length in bits.
+   */
+  public int getKeyLength()
+  {
+    return keyLength;
+  }
+}
diff --git a/src/main/java/org/cryptacular/spec/Spec.java b/src/main/java/org/cryptacular/spec/Spec.java
new file mode 100644
index 0000000..e3307b0
--- /dev/null
+++ b/src/main/java/org/cryptacular/spec/Spec.java
@@ -0,0 +1,24 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.spec;
+
+/**
+ * Specification for a cryptographic primitive, e.g. block cipher, message digest, etc.
+ *
+ * @param  <T>  Type of specification.
+ *
+ * @author  Middleware Services
+ */
+public interface Spec<T>
+{
+
+  /** @return  Cryptographic algorithm name. */
+  String getAlgorithm();
+
+
+  /**
+   * Creates a new instance of the cryptographic primitive described by this specification.
+   *
+   * @return  New instance of cryptographic primitive.
+   */
+  T newInstance();
+}
diff --git a/src/main/java/org/cryptacular/spec/StreamCipherSpec.java b/src/main/java/org/cryptacular/spec/StreamCipherSpec.java
new file mode 100644
index 0000000..55e9296
--- /dev/null
+++ b/src/main/java/org/cryptacular/spec/StreamCipherSpec.java
@@ -0,0 +1,75 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.spec;
+
+import org.bouncycastle.crypto.StreamCipher;
+import org.bouncycastle.crypto.engines.Grain128Engine;
+import org.bouncycastle.crypto.engines.HC128Engine;
+import org.bouncycastle.crypto.engines.HC256Engine;
+import org.bouncycastle.crypto.engines.ISAACEngine;
+import org.bouncycastle.crypto.engines.RC4Engine;
+import org.bouncycastle.crypto.engines.Salsa20Engine;
+import org.bouncycastle.crypto.engines.VMPCEngine;
+
+/**
+ * Stream cipher specification.
+ *
+ * @author  Middleware Services
+ */
+public class StreamCipherSpec implements Spec<StreamCipher>
+{
+
+  /** Cipher algorithm. */
+  private final String algorithm;
+
+
+  /**
+   * Creates a new instance that describes the given stream cipher algorithm.
+   *
+   * @param  algName  Stream cipher algorithm.
+   */
+  public StreamCipherSpec(final String algName)
+  {
+    this.algorithm = algName;
+  }
+
+
+  @Override
+  public String getAlgorithm()
+  {
+    return algorithm;
+  }
+
+
+  @Override
+  public StreamCipher newInstance()
+  {
+    final StreamCipher cipher;
+    if ("Grainv1".equalsIgnoreCase(algorithm) || "Grain-v1".equalsIgnoreCase(algorithm)) {
+      cipher = new ISAACEngine();
+    } else if ("Grain128".equalsIgnoreCase(algorithm) || "Grain-128".equalsIgnoreCase(algorithm)) {
+      cipher = new Grain128Engine();
+    } else if ("ISAAC".equalsIgnoreCase(algorithm)) {
+      cipher = new ISAACEngine();
+    } else if ("HC128".equalsIgnoreCase(algorithm)) {
+      cipher = new HC128Engine();
+    } else if ("HC256".equalsIgnoreCase(algorithm)) {
+      cipher = new HC256Engine();
+    } else if ("RC4".equalsIgnoreCase(algorithm)) {
+      cipher = new RC4Engine();
+    } else if ("Salsa20".equalsIgnoreCase(algorithm)) {
+      cipher = new Salsa20Engine();
+    } else if ("VMPC".equalsIgnoreCase(algorithm)) {
+      cipher = new VMPCEngine();
+    } else {
+      throw new IllegalStateException("Unsupported cipher algorithm " + algorithm);
+    }
+    return cipher;
+  }
+
+
+  @Override
+  public String toString()
+  {
+    return algorithm;
+  }
+}
diff --git a/src/main/java/org/cryptacular/util/ByteUtil.java b/src/main/java/org/cryptacular/util/ByteUtil.java
new file mode 100644
index 0000000..7525c8a
--- /dev/null
+++ b/src/main/java/org/cryptacular/util/ByteUtil.java
@@ -0,0 +1,291 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.util;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import org.cryptacular.StreamException;
+
+/**
+ * Utilities for working with bytes.
+ *
+ * @author  Middleware Services
+ */
+public final class ByteUtil
+{
+
+  /** Default character set for bytes is UTF-8. */
+  public static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8;
+
+  /** ASCII character set. */
+  public static final Charset ASCII_CHARSET = StandardCharsets.US_ASCII;
+
+  /** Private constructor of utilty class. */
+  private ByteUtil() {}
+
+
+  /**
+   * Converts the big-endian representation of a 32-bit integer to the equivalent integer value.
+   *
+   * @param  data  4-byte array in big-endian format.
+   *
+   * @return  Integer value.
+   */
+  public static int toInt(final byte[] data)
+  {
+    return (data[0] << 24) | ((data[1] & 0xff) << 16) | ((data[2] & 0xff) << 8) | (data[3] & 0xff);
+  }
+
+
+  /**
+   * Converts an unsigned byte into an integer.
+   *
+   * @param  unsigned  Unsigned byte.
+   *
+   * @return  Integer value.
+   */
+  public static int toInt(final byte unsigned)
+  {
+    return 0x000000FF & unsigned;
+  }
+
+
+  /**
+   * Reads 4-bytes from the input stream and converts to a 32-bit integer.
+   *
+   * @param  in  Stream from which to read 4 bytes.
+   *
+   * @return  Integer value.
+   *
+   * @throws  StreamException  on stream IO errors.
+   */
+  public static int readInt(final InputStream in) throws StreamException
+  {
+    try {
+      return (in.read() << 24) | ((in.read() & 0xff) << 16) | ((in.read() & 0xff) << 8) | (in.read() & 0xff);
+    } catch (IOException e) {
+      throw new StreamException(e);
+    }
+  }
+
+
+  /**
+   * Converts the big-endian representation of a 64-bit integer to the equivalent long value.
+   *
+   * @param  data  8-byte array in big-endian format.
+   *
+   * @return  Long integer value.
+   */
+  public static long toLong(final byte[] data)
+  {
+    return
+      ((long) data[0] << 56) | (((long) data[1] & 0xff) << 48) |
+      (((long) data[2] & 0xff) << 40) | (((long) data[3] & 0xff) << 32) |
+      (((long) data[4] & 0xff) << 24) | (((long) data[5] & 0xff) << 16) |
+      (((long) data[6] & 0xff) << 8) | ((long) data[7] & 0xff);
+  }
+
+
+  /**
+   * Reads 8-bytes from the input stream and converts to a 64-bit long integer.
+   *
+   * @param  in  Stream from which to read 8 bytes.
+   *
+   * @return  Long integer value.
+   *
+   * @throws  StreamException  on stream IO errors.
+   */
+  public static long readLong(final InputStream in) throws StreamException
+  {
+    try {
+      return
+        ((long) in.read() << 56) | (((long) in.read() & 0xff) << 48) |
+        (((long) in.read() & 0xff) << 40) | (((long) in.read() & 0xff) << 32) |
+        (((long) in.read() & 0xff) << 24) | (((long) in.read() & 0xff) << 16) |
+        (((long) in.read() & 0xff) << 8) | ((long) in.read() & 0xff);
+    } catch (IOException e) {
+      throw new StreamException(e);
+    }
+  }
+
+
+  /**
+   * Converts an integer into a 4-byte big endian array.
+   *
+   * @param  value  Integer value to convert.
+   *
+   * @return  4-byte big-endian representation of integer value.
+   */
+  public static byte[] toBytes(final int value)
+  {
+    final byte[] bytes = new byte[4];
+    toBytes(value, bytes, 0);
+    return bytes;
+  }
+
+
+  /**
+   * Converts an integer into a 4-byte big endian array.
+   *
+   * @param  value  Integer value to convert.
+   * @param  output  Array into which bytes are placed.
+   * @param  offset  Offset into output array at which output bytes start.
+   */
+  public static void toBytes(final int value, final byte[] output, final int offset)
+  {
+    int shift = 24;
+    for (int i = 0; i < 4; i++) {
+      output[offset + i] = (byte) (value >> shift);
+      shift -= 8;
+    }
+  }
+
+
+  /**
+   * Converts a long integer into an 8-byte big endian array.
+   *
+   * @param  value  Long integer value to convert.
+   *
+   * @return  8-byte big-endian representation of long integer value.
+   */
+  public static byte[] toBytes(final long value)
+  {
+    final byte[] bytes = new byte[8];
+    toBytes(value, bytes, 0);
+    return bytes;
+  }
+
+
+  /**
+   * Converts an integer into an 8-byte big endian array.
+   *
+   * @param  value  Long value to convert.
+   * @param  output  Array into which bytes are placed.
+   * @param  offset  Offset into output array at which output bytes start.
+   */
+  public static void toBytes(final long value, final byte[] output, final int offset)
+  {
+    int shift = 56;
+    for (int i = 0; i < 8; i++) {
+      output[offset + i] = (byte) (value >> shift);
+      shift -= 8;
+    }
+  }
+
+
+  /**
+   * Converts a byte array into a string in the UTF-8 character set.
+   *
+   * @param  bytes  Byte array to convert.
+   *
+   * @return  UTF-8 string representation of bytes.
+   */
+  public static String toString(final byte[] bytes)
+  {
+    return new String(bytes, DEFAULT_CHARSET);
+  }
+
+
+  /**
+   * Converts a portion of a byte array into a string in the UTF-8 character set.
+   *
+   * @param  bytes  Byte array to convert.
+   * @param  offset  Offset into byte array where string content begins.
+   * @param  length  Total number of bytes to convert.
+   *
+   * @return  UTF-8 string representation of bytes.
+   */
+  public static String toString(final byte[] bytes, final int offset, final int length)
+  {
+    return new String(bytes, offset, length, DEFAULT_CHARSET);
+  }
+
+
+  /**
+   * Converts a byte buffer into a string in the UTF-8 character set.
+   *
+   * @param  buffer  Byte buffer to convert.
+   *
+   * @return  UTF-8 string representation of bytes.
+   */
+  public static String toString(final ByteBuffer buffer)
+  {
+    return toCharBuffer(buffer).toString();
+  }
+
+  /**
+   * Converts a byte buffer into a character buffer.
+   *
+   * @param  buffer  Byte buffer to convert.
+   *
+   * @return  Character buffer containing UTF-8 string representation of bytes.
+   */
+  public static CharBuffer toCharBuffer(final ByteBuffer buffer)
+  {
+    return DEFAULT_CHARSET.decode(buffer);
+  }
+
+
+  /**
+   * Converts a string into bytes in the UTF-8 character set.
+   *
+   * @param  s  String to convert.
+   *
+   * @return  Byte buffer containing byte representation of string.
+   */
+  public static ByteBuffer toByteBuffer(final String s)
+  {
+    return DEFAULT_CHARSET.encode(CharBuffer.wrap(s));
+  }
+
+
+  /**
+   * Converts a string into bytes in the UTF-8 character set.
+   *
+   * @param  s  String to convert.
+   *
+   * @return  Byte array containing byte representation of string.
+   */
+  public static byte[] toBytes(final String s)
+  {
+    return s.getBytes(DEFAULT_CHARSET);
+  }
+
+
+  /**
+   * Converts an integer into an unsigned byte. All bits above 1 byte are truncated.
+   *
+   * @param  b  Integer value.
+   *
+   * @return  Unsigned byte as a byte.
+   */
+  public static byte toUnsignedByte(final int b)
+  {
+    return (byte) (0x000000FF & b);
+  }
+
+
+  /**
+   * Converts a byte buffer into a byte array.
+   *
+   * @param  buffer  Byte buffer to convert.
+   *
+   * @return  Byte array corresponding to bytes of buffer from current position to limit.
+   */
+  public static byte[] toArray(final ByteBuffer buffer)
+  {
+    final int size = buffer.limit() - buffer.position();
+    if (buffer.hasArray() && size == buffer.capacity()) {
+      return buffer.array();
+    }
+
+    final byte[] array = new byte[size];
+    buffer.get(array);
+    return array;
+  }
+
+
+}
diff --git a/src/main/java/org/cryptacular/util/CertUtil.java b/src/main/java/org/cryptacular/util/CertUtil.java
new file mode 100644
index 0000000..5f70797
--- /dev/null
+++ b/src/main/java/org/cryptacular/util/CertUtil.java
@@ -0,0 +1,712 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.util;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.math.BigInteger;
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import java.security.KeyPair;
+import java.security.PrivateKey;
+import java.security.cert.Certificate;
+import java.security.cert.CertificateEncodingException;
+import java.security.cert.CertificateException;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Date;
+import java.util.List;
+import javax.security.auth.x500.X500Principal;
+import org.bouncycastle.asn1.x500.X500Name;
+import org.bouncycastle.asn1.x500.style.RFC4519Style;
+import org.bouncycastle.asn1.x509.BasicConstraints;
+import org.bouncycastle.asn1.x509.Extension;
+import org.bouncycastle.asn1.x509.GeneralName;
+import org.bouncycastle.asn1.x509.GeneralNames;
+import org.bouncycastle.asn1.x509.GeneralNamesBuilder;
+import org.bouncycastle.asn1.x509.KeyPurposeId;
+import org.bouncycastle.asn1.x509.KeyUsage;
+import org.bouncycastle.asn1.x509.PolicyInformation;
+import org.bouncycastle.cert.CertIOException;
+import org.bouncycastle.cert.X509v3CertificateBuilder;
+import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
+import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.bouncycastle.operator.ContentSigner;
+import org.bouncycastle.operator.OperatorCreationException;
+import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
+import org.cryptacular.EncodingException;
+import org.cryptacular.StreamException;
+import org.cryptacular.codec.Base64Encoder;
+import org.cryptacular.x509.ExtensionReader;
+import org.cryptacular.x509.GeneralNameType;
+import org.cryptacular.x509.KeyUsageBits;
+import org.cryptacular.x509.dn.NameReader;
+import org.cryptacular.x509.dn.StandardAttributeType;
+
+/**
+ * Utility class providing convenience methods for common operations on X.509 certificates.
+ *
+ * @author  Middleware Services
+ */
+public final class CertUtil
+{
+
+  /** Private constructor of utility class. */
+  private CertUtil() {}
+
+
+  /**
+   * Gets the common name attribute (CN) of the certificate subject distinguished name.
+   *
+   * @param  cert  Certificate to examine.
+   *
+   * @return  Subject CN or null if no CN attribute is defined in the subject DN.
+   *
+   * @throws  EncodingException  on cert field extraction.
+   */
+  public static String subjectCN(final X509Certificate cert) throws EncodingException
+  {
+    return new NameReader(cert).readSubject().getValue(StandardAttributeType.CommonName);
+  }
+
+
+  /**
+   * Gets all subject alternative names defined on the given certificate.
+   *
+   * @param  cert  X.509 certificate to examine.
+   *
+   * @return  List of subject alternative names or null if no subject alt names are defined.
+   *
+   * @throws  EncodingException  on cert field extraction.
+   */
+  public static GeneralNames subjectAltNames(final X509Certificate cert) throws EncodingException
+  {
+    return new ExtensionReader(cert).readSubjectAlternativeName();
+  }
+
+
+  /**
+   * Gets all subject alternative names of the given type(s) on the given cert.
+   *
+   * @param  cert  X.509 certificate to examine.
+   * @param  types  One or more subject alternative name types to fetch.
+   *
+   * @return  List of subject alternative names of the matching type(s) or null if none found.
+   *
+   * @throws  EncodingException  on cert field extraction.
+   */
+  public static GeneralNames subjectAltNames(final X509Certificate cert, final GeneralNameType... types)
+    throws EncodingException
+  {
+    final GeneralNamesBuilder builder = new GeneralNamesBuilder();
+    final GeneralNames altNames = subjectAltNames(cert);
+    if (altNames != null) {
+      for (GeneralName name : altNames.getNames()) {
+        for (GeneralNameType type : types) {
+          if (type.ordinal() == name.getTagNo()) {
+            builder.addName(name);
+          }
+        }
+      }
+    }
+
+    final GeneralNames names = builder.build();
+    if (names.getNames().length == 0) {
+      return null;
+    }
+    return names;
+  }
+
+
+  /**
+   * Gets a list of all subject names defined for the given certificate. The list includes the first common name (CN)
+   * specified in the subject distinguished name (if defined) and all subject alternative names.
+   *
+   * @param  cert  X.509 certificate to examine.
+   *
+   * @return  List of subject names.
+   *
+   * @throws  EncodingException  on cert field extraction.
+   */
+  public static List<String> subjectNames(final X509Certificate cert) throws EncodingException
+  {
+    final List<String> names = new ArrayList<>();
+    final String cn = subjectCN(cert);
+    if (cn != null) {
+      names.add(cn);
+    }
+
+    final GeneralNames altNames = subjectAltNames(cert);
+    if (altNames == null) {
+      return names;
+    }
+    for (GeneralName name : altNames.getNames()) {
+      names.add(name.getName().toString());
+    }
+    return names;
+  }
+
+
+  /**
+   * Gets a list of subject names defined for the given certificate. The list includes the first common name (CN)
+   * specified in the subject distinguished name (if defined) and all subject alternative names of the given type.
+   *
+   * @param  cert  X.509 certificate to examine.
+   * @param  types  One or more subject alternative name types to fetch.
+   *
+   * @return  List of subject names.
+   *
+   * @throws  EncodingException  on cert field extraction.
+   */
+  public static List<String> subjectNames(final X509Certificate cert, final GeneralNameType... types)
+    throws EncodingException
+  {
+    final List<String> names = new ArrayList<>();
+    final String cn = subjectCN(cert);
+    if (cn != null) {
+      names.add(cn);
+    }
+
+    final GeneralNames altNames = subjectAltNames(cert, types);
+    if (altNames == null) {
+      return names;
+    }
+    for (GeneralName name : altNames.getNames()) {
+      names.add(name.getName().toString());
+    }
+    return names;
+  }
+
+
+  /**
+   * Finds a certificate whose public key is paired with the given private key.
+   *
+   * @param  key  Private key used to find matching public key.
+   * @param  candidates  Array of candidate certificates.
+   *
+   * @return  Certificate whose public key forms a keypair with the private key or null if no match is found.
+   *
+   * @throws  EncodingException  on cert field extraction.
+   */
+  public static X509Certificate findEntityCertificate(final PrivateKey key, final X509Certificate... candidates)
+    throws EncodingException
+  {
+    return findEntityCertificate(key, Arrays.asList(candidates));
+  }
+
+
+  /**
+   * Finds a certificate whose public key is paired with the given private key.
+   *
+   * @param  key  Private key used to find matching public key.
+   * @param  candidates  Collection of candidate certificates.
+   *
+   * @return  Certificate whose public key forms a keypair with the private key or null if no match is found.
+   *
+   * @throws  EncodingException  on cert field extraction.
+   */
+  public static X509Certificate findEntityCertificate(
+    final PrivateKey key,
+    final Collection<X509Certificate> candidates)
+    throws EncodingException
+  {
+    for (X509Certificate candidate : candidates) {
+      if (KeyPairUtil.isKeyPair(candidate.getPublicKey(), key)) {
+        return candidate;
+      }
+    }
+    return null;
+  }
+
+
+  /**
+   * Reads an X.509 certificate from ASN.1 encoded format in the file at the given location.
+   *
+   * @param  path  Path to file containing an DER or PEM encoded X.509 certificate.
+   *
+   * @return  Certificate.
+   *
+   * @throws  EncodingException  on cert parsing errors.
+   * @throws  StreamException  on IO errors.
+   */
+  public static X509Certificate readCertificate(final String path) throws EncodingException, StreamException
+  {
+    return readCertificate(StreamUtil.makeStream(new File(path)));
+  }
+
+
+  /**
+   * Reads an X.509 certificate from ASN.1 encoded format from the given file.
+   *
+   * @param  file  File containing an DER or PEM encoded X.509 certificate.
+   *
+   * @return  Certificate.
+   *
+   * @throws  EncodingException  on cert parsing errors.
+   * @throws  StreamException  on IO errors.
+   */
+  public static X509Certificate readCertificate(final File file) throws EncodingException, StreamException
+  {
+    return readCertificate(StreamUtil.makeStream(file));
+  }
+
+
+  /**
+   * Reads an X.509 certificate from ASN.1 encoded data in the given stream.
+   *
+   * @param  in  Input stream containing PEM or DER encoded X.509 certificate.
+   *
+   * @return  Certificate.
+   *
+   * @throws  EncodingException  on cert parsing errors.
+   * @throws  StreamException  on IO errors.
+   */
+  public static X509Certificate readCertificate(final InputStream in) throws EncodingException, StreamException
+  {
+    try {
+      final CertificateFactory factory = CertificateFactory.getInstance("X.509");
+      return (X509Certificate) factory.generateCertificate(in);
+    } catch (CertificateException e) {
+      if (e.getCause() instanceof IOException) {
+        throw new StreamException((IOException) e.getCause());
+      }
+      throw new EncodingException("Cannot decode certificate", e);
+    }
+  }
+
+
+  /**
+   * Creates an X.509 certificate from its ASN.1 encoded form.
+   *
+   * @param  encoded  PEM or DER encoded ASN.1 data.
+   *
+   * @return  Certificate.
+   *
+   * @throws  EncodingException  on cert parsing errors.
+   */
+  public static X509Certificate decodeCertificate(final byte[] encoded) throws EncodingException
+  {
+    return readCertificate(new ByteArrayInputStream(encoded));
+  }
+
+
+  /**
+   * Reads an X.509 certificate chain from ASN.1 encoded format in the file at the given location.
+   *
+   * @param  path  Path to file containing a sequence of PEM or DER encoded certificates or PKCS#7 certificate chain.
+   *
+   * @return  Certificate.
+   *
+   * @throws  EncodingException  on cert parsing errors.
+   * @throws  StreamException  on IO errors.
+   */
+  public static X509Certificate[] readCertificateChain(final String path) throws EncodingException, StreamException
+  {
+    return readCertificateChain(StreamUtil.makeStream(new File(path)));
+  }
+
+
+  /**
+   * Reads an X.509 certificate chain from ASN.1 encoded format from the given file.
+   *
+   * @param  file  File containing a sequence of PEM or DER encoded certificates or PKCS#7 certificate chain.
+   *
+   * @return  Certificate.
+   *
+   * @throws  EncodingException  on cert parsing errors.
+   * @throws  StreamException  on IO errors.
+   */
+  public static X509Certificate[] readCertificateChain(final File file) throws EncodingException, StreamException
+  {
+    return readCertificateChain(StreamUtil.makeStream(file));
+  }
+
+
+  /**
+   * Reads an X.509 certificate chain from ASN.1 encoded data in the given stream.
+   *
+   * @param  in  Input stream containing a sequence of PEM or DER encoded certificates or PKCS#7 certificate chain.
+   *
+   * @return  Certificate.
+   *
+   * @throws  EncodingException  on cert parsing errors.
+   * @throws  StreamException  on IO errors.
+   */
+  public static X509Certificate[] readCertificateChain(final InputStream in) throws EncodingException, StreamException
+  {
+    try {
+      final CertificateFactory factory = CertificateFactory.getInstance("X.509");
+      final Collection<? extends Certificate> certs = factory.generateCertificates(in);
+      return certs.toArray(new X509Certificate[0]);
+    } catch (CertificateException e) {
+      if (e.getCause() instanceof IOException) {
+        throw new StreamException((IOException) e.getCause());
+      }
+      throw new EncodingException("Cannot decode certificate", e);
+    }
+  }
+
+
+  /**
+   * Creates an X.509 certificate chain from its ASN.1 encoded form.
+   *
+   * @param  encoded  Sequence of PEM or DER encoded certificates or PKCS#7 certificate chain.
+   *
+   * @return  Certificate.
+   *
+   * @throws  EncodingException  on cert parsing errors.
+   */
+  public static X509Certificate[] decodeCertificateChain(final byte[] encoded) throws EncodingException
+  {
+    return readCertificateChain(new ByteArrayInputStream(encoded));
+  }
+
+
+  /**
+   * Determines whether the certificate allows the given basic key usages.
+   *
+   * @param  cert  Certificate to check.
+   * @param  bits  One or more basic key usage types to check.
+   *
+   * @return  True if certificate allows all given usage types, false otherwise.
+   *
+   * @throws  EncodingException  on cert field extraction.
+   */
+  public static boolean allowsUsage(final X509Certificate cert, final KeyUsageBits... bits) throws EncodingException
+  {
+    final KeyUsage usage = new ExtensionReader(cert).readKeyUsage();
+    for (KeyUsageBits bit : bits) {
+      if (!bit.isSet(usage)) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+
+  /**
+   * Determines whether the certificate allows the given extended key usages.
+   *
+   * @param  cert  Certificate to check.
+   * @param  purposes  One or more extended key usage purposes to check.
+   *
+   * @return  True if certificate allows all given purposes, false otherwise.
+   *
+   * @throws  EncodingException  on cert field extraction.
+   */
+  public static boolean allowsUsage(final X509Certificate cert, final KeyPurposeId... purposes) throws EncodingException
+  {
+    final List<KeyPurposeId> allowedUses = new ExtensionReader(cert).readExtendedKeyUsage();
+    for (KeyPurposeId purpose : purposes) {
+      if (allowedUses == null || !allowedUses.contains(purpose)) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+
+  /**
+   * Determines whether the certificate defines all the given certificate policies.
+   *
+   * @param  cert  Certificate to check.
+   * @param  policyOidsToCheck  One or more certificate policy OIDs to check.
+   *
+   * @return  True if certificate defines all given policy OIDs, false otherwise.
+   *
+   * @throws  EncodingException  on cert field extraction.
+   */
+  public static boolean hasPolicies(final X509Certificate cert, final String... policyOidsToCheck)
+    throws EncodingException
+  {
+    final List<PolicyInformation> policies = new ExtensionReader(cert).readCertificatePolicies();
+    boolean hasPolicy;
+    for (String policyOid : policyOidsToCheck) {
+      hasPolicy = false;
+      if (policies != null) {
+        for (PolicyInformation policy : policies) {
+          if (policy.getPolicyIdentifier().getId().equals(policyOid)) {
+            hasPolicy = true;
+            break;
+          }
+        }
+      }
+      if (!hasPolicy) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+
+  /**
+   * Gets the subject key identifier of the given certificate in delimited hexadecimal format, e.g. <code>
+   * 25:48:2f:28:ec:5d:19:bb:1d:25:ae:94:93:b1:7b:b5:35:96:24:66</code>.
+   *
+   * @param  cert  Certificate to process.
+   *
+   * @return  Subject key identifier in colon-delimited hex format.
+   *
+   * @throws  EncodingException  on cert field extraction.
+   */
+  public static String subjectKeyId(final X509Certificate cert) throws EncodingException
+  {
+    return CodecUtil.hex(new ExtensionReader(cert).readSubjectKeyIdentifier().getKeyIdentifier(), true);
+  }
+
+
+  /**
+   * Gets the authority key identifier of the given certificate in delimited hexadecimal format, e.g. <code>
+   * 25:48:2f:28:ec:5d:19:bb:1d:25:ae:94:93:b1:7b:b5:35:96:24:66</code>.
+   *
+   * @param  cert  Certificate to process.
+   *
+   * @return  Authority key identifier in colon-delimited hex format.
+   *
+   * @throws  EncodingException  on cert field extraction.
+   */
+  public static String authorityKeyId(final X509Certificate cert) throws EncodingException
+  {
+    return CodecUtil.hex(new ExtensionReader(cert).readAuthorityKeyIdentifier().getKeyIdentifier(), true);
+  }
+
+
+  /**
+   * PEM encodes the given certificate with the provided encoding type.
+   *
+   * @param <T> type of encoding
+   *
+   * @param certificate X.509 certificate.
+   * @param encodeType Type of encoding. {@link EncodeType#X509} or {@link EncodeType#PKCS7}
+   *
+   * @return either DER encoded certificate or PEM-encoded certificate header and footer defined by {@link EncodeType}
+   * and data wrapped at 64 characters per line.
+   *
+   * @throws RuntimeException if a certificate encoding error occurs
+   */
+  public static <T> T encodeCert(final X509Certificate certificate, final EncodeType<T> encodeType)
+  {
+    try {
+      return encodeType.encode(certificate);
+    } catch (CertificateEncodingException e) {
+      throw new RuntimeException("Error getting encoded X.509 certificate data", e);
+    }
+  }
+
+  /**
+   * Retrieves the subject distinguished name (DN) of the provided X.509 certificate.
+   *
+   * The subject DN represents the identity of the certificate holder and typically includes information
+   * such as the common name (CN), organizational unit (OU), organization (O), locality (L), state (ST),
+   * country (C), and other attributes.
+   *
+   * @param cert   The X.509 certificate from which to extract the subject DN.
+   * @param format Controls whether the output contains spaces between attributes in the DN.
+   *               Use {@link X500PrincipalFormat#READABLE} to generate a DN with spaces after the commas separating
+   *               attribute-value pairs, {@link X500PrincipalFormat#RFC2253} for no spaces.
+   * @return The subject DN string of the X.509 certificate.
+   *
+   * @throws NullPointerException  If the provided certificate is null.
+   */
+  public static String subjectDN(final X509Certificate cert, final X500PrincipalFormat format)
+  {
+    final X500Principal subjectX500Principal = cert.getSubjectX500Principal();
+    return X500PrincipalFormat.READABLE.equals(format) ?
+      subjectX500Principal.toString() : subjectX500Principal.getName(X500Principal.RFC2253);
+  }
+
+  /**
+   * Generates a self-signed certificate.
+   *
+   * @param keyPair used for signing the certificate
+   * @param dn Subject dn
+   * @param duration Validity period of the certificate. The <em>notAfter</em> field is set to {@code now}
+   * plus this value.
+   * @param signatureAlgo the signature algorithm identifier to use
+   *
+   * @return a self-signed X509Certificate
+   */
+  public static X509Certificate generateX509Certificate(final KeyPair keyPair, final String dn,
+    final Duration duration, final String signatureAlgo)
+  {
+    final Instant now = Instant.now();
+    final Date notBefore = Date.from(now);
+    final Date notAfter = Date.from(now.plus(duration));
+    return generateX509Certificate(keyPair, dn, notBefore, notAfter, signatureAlgo);
+  }
+
+  /**
+   * Generates a self-signed certificate.
+   *
+   * @param keyPair used for signing the certificate
+   * @param dn Subject dn
+   * @param notBefore the date and time when the certificate validity period starts
+   * @param notAfter  the date and time when the certificate validity period ends
+   * @param signatureAlgo the signature algorithm identifier to use
+   *
+   * @return a self-signed X509Certificate
+   */
+  public static X509Certificate generateX509Certificate(final KeyPair keyPair, final String dn,
+    final Date notBefore, final Date notAfter, final String signatureAlgo)
+  {
+    final Instant now = Instant.now();
+    final BigInteger serial = BigInteger.valueOf(now.toEpochMilli());
+
+    try {
+      final ContentSigner contentSigner = new JcaContentSignerBuilder(signatureAlgo)
+        .build(keyPair.getPrivate());
+      final X500Name x500Name = new X500Name(RFC4519Style.INSTANCE, dn);
+      final X509v3CertificateBuilder certificateBuilder =
+        new JcaX509v3CertificateBuilder(x500Name,
+          serial,
+          notBefore,
+          notAfter,
+          x500Name,
+          keyPair.getPublic())
+          .addExtension(Extension.basicConstraints, true, new BasicConstraints(true));
+
+      return new JcaX509CertificateConverter()
+        .setProvider(new BouncyCastleProvider()).getCertificate(certificateBuilder.build(contentSigner));
+    } catch (OperatorCreationException | CertIOException | CertificateException e) {
+      throw new RuntimeException("Certificate generation error", e);
+    }
+  }
+
+  /**
+   * Describes the behavior of string formatting of X.500 distinguished names.
+   */
+  public enum X500PrincipalFormat
+  {
+    /** The format described in RFC2253 (without spaces). */
+    RFC2253,
+
+    /** Similar to RFC2253, but with spaces. */
+    READABLE
+  }
+
+  /**
+   * Marker interface for encoding types.
+   *
+   * @param <T> type of encoding
+   */
+  public interface EncodeType<T>
+  {
+
+    /** DER encode type.*/
+    EncodeType<byte[]> DER = new DEREncodeType();
+
+    /** X509 encode type. */
+    EncodeType<String> X509 = new X509EncodeType();
+
+    /** PKCS7 encode type. */
+    EncodeType<String> PKCS7 = new PKCS7EncodeType();
+
+    /**
+     * Returns the type of encoding.
+     *
+     * @return type
+     */
+    String getType();
+
+    /**
+     * Encodes the supplied certificate.
+     *
+     * @param cert to encode
+     *
+     * @return encoded certificate
+     *
+     * @throws CertificateEncodingException if an error occurs encoding the certificate
+     */
+    T encode(X509Certificate cert) throws CertificateEncodingException;
+  }
+
+  /**
+   * Base implementation for PEM encoded types.
+   */
+  private abstract static class AbstractPemEncodeType implements EncodeType<String>
+  {
+
+    /**
+     * Returns a PEM encoding of the supplied DER bytes.
+     *
+     * @param der to encode
+     *
+     * @return PEM encoded certificate
+     */
+    protected String encodePem(final byte[] der)
+    {
+      final Base64Encoder encoder = new Base64Encoder(64);
+      final ByteBuffer input = ByteBuffer.wrap(der);
+      // Space for Base64-encoded data + header, footer, line breaks, and potential padding
+      final CharBuffer output = CharBuffer.allocate(encoder.outputSize(der.length) + 100);
+      output.append("-----BEGIN ").append(getType()).append("-----");
+      output.append(System.lineSeparator());
+      encoder.encode(input, output);
+      encoder.finalize(output);
+      output.flip();
+      return output.toString().trim()
+        .concat(System.lineSeparator()).concat("-----END ").concat(getType()).concat("-----");
+    }
+  }
+
+  /** DER encode type. */
+  private static class DEREncodeType implements EncodeType<byte[]>
+  {
+
+    @Override
+    public String getType()
+    {
+      return "DER";
+    }
+
+    @Override
+    public byte[] encode(final X509Certificate cert)
+      throws CertificateEncodingException
+    {
+      return cert.getEncoded();
+    }
+  }
+
+  /** X509 encode type. */
+  private static final class X509EncodeType extends AbstractPemEncodeType
+  {
+
+    @Override
+    public String getType()
+    {
+      return "CERTIFICATE";
+    }
+
+    @Override
+    public String encode(final X509Certificate cert)
+      throws CertificateEncodingException
+    {
+      return encodePem(cert.getEncoded());
+    }
+  }
+
+  /** PKCS7 encode type. */
+  private static final class PKCS7EncodeType extends AbstractPemEncodeType
+  {
+
+    @Override
+    public String getType()
+    {
+      return "PKCS7";
+    }
+
+    @Override
+    public String encode(final X509Certificate cert)
+      throws CertificateEncodingException
+    {
+      return encodePem(cert.getEncoded());
+    }
+  }
+}
diff --git a/src/main/java/org/cryptacular/util/CipherUtil.java b/src/main/java/org/cryptacular/util/CipherUtil.java
new file mode 100644
index 0000000..3e71015
--- /dev/null
+++ b/src/main/java/org/cryptacular/util/CipherUtil.java
@@ -0,0 +1,394 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.util;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.function.Function;
+import javax.crypto.SecretKey;
+import org.bouncycastle.crypto.BlockCipher;
+import org.bouncycastle.crypto.modes.AEADBlockCipher;
+import org.bouncycastle.crypto.paddings.PKCS7Padding;
+import org.bouncycastle.crypto.paddings.PaddedBufferedBlockCipher;
+import org.bouncycastle.crypto.params.AEADParameters;
+import org.bouncycastle.crypto.params.KeyParameter;
+import org.bouncycastle.crypto.params.ParametersWithIV;
+import org.cryptacular.CiphertextHeader;
+import org.cryptacular.CiphertextHeaderV2;
+import org.cryptacular.CryptoException;
+import org.cryptacular.EncodingException;
+import org.cryptacular.StreamException;
+import org.cryptacular.adapter.AEADBlockCipherAdapter;
+import org.cryptacular.adapter.BlockCipherAdapter;
+import org.cryptacular.adapter.BufferedBlockCipherAdapter;
+import org.cryptacular.generator.Nonce;
+
+/**
+ * Utility class that performs encryption and decryption operations using a block cipher.
+ *
+ * @author  Middleware Services
+ */
+public final class CipherUtil
+{
+
+  /** Mac size in bits. */
+  private static final int MAC_SIZE_BITS = 128;
+
+  /** Private constructor of utility class. */
+  private CipherUtil() {}
+
+
+  /**
+   * Encrypts data using an AEAD cipher. A {@link CiphertextHeaderV2} is prepended to the resulting ciphertext and
+   * used as AAD (Additional Authenticated Data) passed to the AEAD cipher.
+   *
+   * @param  cipher  AEAD cipher.
+   * @param  key  Encryption key.
+   * @param  nonce  Nonce generator.
+   * @param  data  Plaintext data to be encrypted.
+   *
+   * @return  Concatenation of encoded {@link CiphertextHeaderV2} and encrypted data that completely fills the returned
+   *          byte array.
+   *
+   * @throws  CryptoException  on encryption errors.
+   */
+  public static byte[] encrypt(final AEADBlockCipher cipher, final SecretKey key, final Nonce nonce, final byte[] data)
+    throws CryptoException
+  {
+    final byte[] iv = nonce.generate();
+    final byte[] header = new CiphertextHeaderV2(iv, "1").encode(key);
+    cipher.init(true, new AEADParameters(new KeyParameter(key.getEncoded()), MAC_SIZE_BITS, iv, header));
+    return encrypt(new AEADBlockCipherAdapter(cipher), header, data);
+  }
+
+
+  /**
+   * Encrypts data using an AEAD cipher. A {@link CiphertextHeaderV2} is prepended to the resulting ciphertext and used
+   * as AAD (Additional Authenticated Data) passed to the AEAD cipher.
+   *
+   * @param  cipher  AEAD cipher.
+   * @param  key  Encryption key.
+   * @param  nonce  Nonce generator.
+   * @param  input  Input stream containing plaintext data.
+   * @param  output  Output stream that receives a {@link CiphertextHeaderV2} followed by ciphertext data produced by
+   *                 the AEAD cipher in encryption mode.
+   *
+   * @throws  CryptoException  on encryption errors.
+   * @throws  StreamException  on IO errors.
+   */
+  public static void encrypt(
+    final AEADBlockCipher cipher,
+    final SecretKey key,
+    final Nonce nonce,
+    final InputStream input,
+    final OutputStream output)
+    throws CryptoException, StreamException
+  {
+    final byte[] iv = nonce.generate();
+    final byte[] header = new CiphertextHeaderV2(iv, "1").encode(key);
+    cipher.init(true, new AEADParameters(new KeyParameter(key.getEncoded()), MAC_SIZE_BITS, iv, header));
+    writeHeader(header, output);
+    process(new AEADBlockCipherAdapter(cipher), input, output);
+  }
+
+
+  /**
+   * Decrypts data using an AEAD cipher.
+   *
+   * @param  cipher  AEAD cipher.
+   * @param  key  Encryption key.
+   * @param  data  Ciphertext data containing a prepended {@link CiphertextHeaderV2}. The header is treated as AAD input
+   *               to the cipher that is verified during decryption.
+   *
+   * @return  Decrypted data that completely fills the returned byte array.
+   *
+   * @throws  CryptoException  on encryption errors.
+   * @throws  EncodingException  on decoding cyphertext header.
+   */
+  public static byte[] decrypt(final AEADBlockCipher cipher, final SecretKey key, final byte[] data)
+      throws CryptoException, EncodingException
+  {
+    final CiphertextHeader header = decodeHeader(data, String -> key);
+    final byte[] nonce = header.getNonce();
+    final byte[] hbytes = header.encode();
+    cipher.init(false, new AEADParameters(new KeyParameter(key.getEncoded()), MAC_SIZE_BITS, nonce, hbytes));
+    return decrypt(new AEADBlockCipherAdapter(cipher), data, header.getLength());
+  }
+
+
+  /**
+   * Decrypts data using an AEAD cipher.
+   *
+   * @param  cipher  AEAD cipher.
+   * @param  key  Encryption key.
+   * @param  input  Input stream containing a {@link CiphertextHeaderV2} followed by ciphertext data. The header is
+   *                treated as AAD input to the cipher that is verified during decryption.
+   * @param  output  Output stream that receives plaintext produced by block cipher in decryption mode.
+   *
+   * @throws  CryptoException  on encryption errors.
+   * @throws  EncodingException  on decoding cyphertext header.
+   * @throws  StreamException  on IO errors.
+   */
+  public static void decrypt(
+    final AEADBlockCipher cipher,
+    final SecretKey key,
+    final InputStream input,
+    final OutputStream output)
+    throws CryptoException, EncodingException, StreamException
+  {
+    final CiphertextHeader header = decodeHeader(input, String -> key);
+    final byte[] nonce = header.getNonce();
+    final byte[] hbytes = header.encode();
+    cipher.init(false, new AEADParameters(new KeyParameter(key.getEncoded()), MAC_SIZE_BITS, nonce, hbytes));
+    process(new AEADBlockCipherAdapter(cipher), input, output);
+  }
+
+
+  /**
+   * Encrypts data using the given block cipher with PKCS5 padding. A {@link CiphertextHeaderV2} is prepended to the
+   * resulting ciphertext.
+   *
+   * @param  cipher  Block cipher.
+   * @param  key  Encryption key.
+   * @param  nonce  IV generator. Callers must take care to ensure that the length of generated IVs is equal to the
+   *                cipher block size.
+   * @param  data  Plaintext data to be encrypted.
+   *
+   * @return  Concatenation of encoded {@link CiphertextHeaderV2} and encrypted data that completely fills the returned
+   *          byte array.
+   *
+   * @throws  CryptoException  on encryption errors.
+   */
+  public static byte[] encrypt(final BlockCipher cipher, final SecretKey key, final Nonce nonce, final byte[] data)
+    throws CryptoException
+  {
+    final byte[] iv = nonce.generate();
+    final byte[] header = new CiphertextHeaderV2(iv, "1").encode(key);
+    final PaddedBufferedBlockCipher padded = new PaddedBufferedBlockCipher(cipher, new PKCS7Padding());
+    padded.init(true, new ParametersWithIV(new KeyParameter(key.getEncoded()), iv));
+    return encrypt(new BufferedBlockCipherAdapter(padded), header, data);
+  }
+
+
+  /**
+   * Encrypts data using the given block cipher with PKCS5 padding. A {@link CiphertextHeader} is prepended to the
+   * resulting ciphertext.
+   *
+   * @param  cipher  Block cipher.
+   * @param  key  Encryption key.
+   * @param  nonce  IV generator. Callers must take care to ensure that the length of generated IVs is equal to the
+   *                cipher block size.
+   * @param  input  Input stream containing plaintext data.
+   * @param  output  Output stream that receives ciphertext produced by block cipher in encryption mode.
+   *
+   * @throws  CryptoException  on encryption errors.
+   * @throws  StreamException  on IO errors.
+   */
+  public static void encrypt(
+    final BlockCipher cipher,
+    final SecretKey key,
+    final Nonce nonce,
+    final InputStream input,
+    final OutputStream output)
+    throws CryptoException, StreamException
+  {
+    final byte[] iv = nonce.generate();
+    final byte[] header = new CiphertextHeaderV2(iv, "1").encode(key);
+    final PaddedBufferedBlockCipher padded = new PaddedBufferedBlockCipher(cipher, new PKCS7Padding());
+    padded.init(true, new ParametersWithIV(new KeyParameter(key.getEncoded()), iv));
+    writeHeader(header, output);
+    process(new BufferedBlockCipherAdapter(padded), input, output);
+  }
+
+
+  /**
+   * Decrypts data using the given block cipher with PKCS5 padding.
+   *
+   * @param  cipher  Block cipher.
+   * @param  key  Encryption key.
+   * @param  data  Ciphertext data containing a prepended {@link CiphertextHeader}.
+   *
+   * @return  Decrypted data that completely fills the returned byte array.
+   *
+   * @throws  CryptoException  on encryption errors.
+   * @throws  EncodingException  on decoding cyphertext header.
+   */
+  public static byte[] decrypt(final BlockCipher cipher, final SecretKey key, final byte[] data)
+    throws CryptoException, EncodingException
+  {
+    final CiphertextHeader header = decodeHeader(data, String -> key);
+    final PaddedBufferedBlockCipher padded = new PaddedBufferedBlockCipher(cipher, new PKCS7Padding());
+    padded.init(false, new ParametersWithIV(new KeyParameter(key.getEncoded()), header.getNonce()));
+    return decrypt(new BufferedBlockCipherAdapter(padded), data, header.getLength());
+  }
+
+
+  /**
+   * Decrypts data using the given block cipher with PKCS5 padding.
+   *
+   * @param  cipher  Block cipher.
+   * @param  key  Encryption key.
+   * @param  input  Input stream containing a {@link CiphertextHeader} followed by ciphertext data.
+   * @param  output  Output stream that receives plaintext produced by block cipher in decryption mode.
+   *
+   * @throws  CryptoException  on encryption errors.
+   * @throws  EncodingException  on decoding cyphertext header.
+   * @throws  StreamException  on IO errors.
+   */
+  public static void decrypt(
+    final BlockCipher cipher,
+    final SecretKey key,
+    final InputStream input,
+    final OutputStream output)
+    throws CryptoException, EncodingException, StreamException
+  {
+    final CiphertextHeader header = decodeHeader(input, String -> key);
+    final PaddedBufferedBlockCipher padded = new PaddedBufferedBlockCipher(cipher, new PKCS7Padding());
+    padded.init(false, new ParametersWithIV(new KeyParameter(key.getEncoded()), header.getNonce()));
+    process(new BufferedBlockCipherAdapter(padded), input, output);
+  }
+
+
+  /**
+   * Decodes the ciphertext header at the start of the given byte array.
+   * Supports both original (deprecated) and v2 formats.
+   *
+   * @param  data  Ciphertext data with prepended header.
+   * @param  keyLookup  Decryption key lookup function.
+   *
+   * @return  Ciphertext header instance.
+   */
+  public static CiphertextHeader decodeHeader(final byte[] data, final Function<String, SecretKey> keyLookup)
+  {
+    try {
+      return CiphertextHeaderV2.decode(data, keyLookup);
+    } catch (EncodingException e) {
+      return CiphertextHeader.decode(data);
+    }
+  }
+
+
+  /**
+   * Decodes the ciphertext header at the start of the given input stream.
+   * Supports both original (deprecated) and v2 formats.
+   *
+   * @param  in  Ciphertext stream that is positioned at the start of the ciphertext header.
+   * @param  keyLookup  Decryption key lookup function.
+   *
+   * @return  Ciphertext header instance.
+   */
+  public static CiphertextHeader decodeHeader(final InputStream in, final Function<String, SecretKey> keyLookup)
+  {
+    CiphertextHeader header;
+    try {
+      // Mark the stream start position, so we can try again with old format header
+      if (in.markSupported()) {
+        in.mark(4);
+      }
+      header = CiphertextHeaderV2.decode(in, keyLookup);
+    } catch (EncodingException e) {
+      try {
+        in.reset();
+      } catch (IOException ioe) {
+        throw new StreamException("Stream error trying to process old header format: " + ioe.getMessage());
+      }
+      header = CiphertextHeader.decode(in);
+    }
+    return header;
+  }
+
+
+  /**
+   * Encrypts the given data.
+   *
+   * @param  cipher  Adapter for either a block or AEAD cipher.
+   * @param  header  Encoded ciphertext header.
+   * @param  data  Plaintext data to encrypt.
+   *
+   * @return  Concatenation of encoded header and encrypted data that completely fills the returned byte array.
+   */
+  private static byte[] encrypt(final BlockCipherAdapter cipher, final byte[] header, final byte[] data)
+  {
+    final int outSize = header.length + cipher.getOutputSize(data.length);
+    final byte[] output = new byte[outSize];
+    System.arraycopy(header, 0, output, 0, header.length);
+
+    int outOff = header.length;
+    outOff += cipher.processBytes(data, 0, data.length, output, outOff);
+    cipher.doFinal(output, outOff);
+    cipher.reset();
+    return output;
+  }
+
+
+  /**
+   * Decrypts the given data.
+   *
+   * @param  cipher  Adapter for either a block or AEAD cipher.
+   * @param  data  Ciphertext data containing prepended header bytes.
+   * @param  inOff  Offset into ciphertext at which encrypted data starts (i.e. after header).
+   *
+   * @return  Decrypted data that completely fills the returned byte array.
+   */
+  private static byte[] decrypt(final BlockCipherAdapter cipher, final byte[] data, final int inOff)
+  {
+    final int len = data.length - inOff;
+    final int outSize = cipher.getOutputSize(len);
+    final byte[] output = new byte[outSize];
+    int outOff = cipher.processBytes(data, inOff, len, output, 0);
+    outOff += cipher.doFinal(output, outOff);
+    cipher.reset();
+    if (outOff < output.length) {
+      final byte[] temp = new byte[outOff];
+      System.arraycopy(output, 0, temp, 0, outOff);
+      return temp;
+    }
+    return output;
+  }
+
+
+  /**
+   * Performs encryption or decryption on the given input stream based on the underlying cipher mode and writes the
+   * result to the given output stream.
+   *
+   * @param  cipher  Adapter for either a block or AEAD cipher.
+   * @param  input  Input stream containing data to be processed by the cipher.
+   * @param  output  Output stream that receives the output of the cipher acting on the input.
+   */
+  private static void process(final BlockCipherAdapter cipher, final InputStream input, final OutputStream output)
+  {
+    final int inSize = 1024;
+    final int outSize = cipher.getOutputSize(inSize);
+    final byte[] inBuf = new byte[inSize];
+    final byte[] outBuf = new byte[outSize > inSize ? outSize : inSize];
+    int readLen;
+    int writeLen;
+    try {
+      while ((readLen = input.read(inBuf)) > 0) {
+        writeLen = cipher.processBytes(inBuf, 0, readLen, outBuf, 0);
+        output.write(outBuf, 0, writeLen);
+      }
+      writeLen = cipher.doFinal(outBuf, 0);
+      output.write(outBuf, 0, writeLen);
+    } catch (IOException e) {
+      throw new StreamException(e);
+    }
+  }
+
+
+  /**
+   * Writes a ciphertext header to the output stream.
+   *
+   * @param  header  Ciphertext header bytes.
+   * @param  output  Output stream.
+   */
+  private static void writeHeader(final byte[] header, final OutputStream output)
+  {
+    try {
+      output.write(header, 0, header.length);
+    } catch (IOException e) {
+      throw new StreamException(e);
+    }
+  }
+
+}
diff --git a/src/main/java/org/cryptacular/util/CodecUtil.java b/src/main/java/org/cryptacular/util/CodecUtil.java
new file mode 100644
index 0000000..10c9eeb
--- /dev/null
+++ b/src/main/java/org/cryptacular/util/CodecUtil.java
@@ -0,0 +1,205 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.util;
+
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import org.cryptacular.EncodingException;
+import org.cryptacular.codec.Base32Decoder;
+import org.cryptacular.codec.Base32Encoder;
+import org.cryptacular.codec.Base64Decoder;
+import org.cryptacular.codec.Base64Encoder;
+import org.cryptacular.codec.Decoder;
+import org.cryptacular.codec.Encoder;
+import org.cryptacular.codec.HexDecoder;
+import org.cryptacular.codec.HexEncoder;
+
+
+/**
+ * Utility class for common encoding conversions.
+ *
+ * @author  Middleware Services
+ */
+public final class CodecUtil
+{
+
+  /** Private constructor of utility class. */
+  private CodecUtil() {}
+
+
+  /**
+   * Encodes raw bytes to the equivalent hexadecimal encoded string.
+   *
+   * @param  raw  Raw bytes to encode.
+   *
+   * @return  Hexadecimal encoded string.
+   *
+   * @throws  EncodingException  on encoding errors.
+   */
+  public static String hex(final byte[] raw) throws EncodingException
+  {
+    return encode(new HexEncoder(), raw);
+  }
+
+
+  /**
+   * Encodes raw bytes to the equivalent hexadecimal encoded string with optional delimiting of output.
+   *
+   * @param  raw  Raw bytes to encode.
+   * @param  delimit  True to delimit every two characters (i.e. every byte) of output with ':' character, false
+   *                  otherwise.
+   *
+   * @return  Hexadecimal encoded string.
+   *
+   * @throws  EncodingException  on encoding errors.
+   */
+  public static String hex(final byte[] raw, final boolean delimit) throws EncodingException
+  {
+    return encode(new HexEncoder(delimit), raw);
+  }
+
+
+  /**
+   * Decodes a hexadecimal encoded string to raw bytes.
+   *
+   * @param  encoded  Hex encoded character data.
+   *
+   * @return  Raw bytes of hex string.
+   *
+   * @throws  EncodingException  on decoding errors.
+   */
+  public static byte[] hex(final CharSequence encoded) throws EncodingException
+  {
+    return decode(new HexDecoder(), encoded);
+  }
+
+
+  /**
+   * Encodes bytes into base 64-encoded string.
+   *
+   * @param  raw  Raw bytes to encode.
+   *
+   * @return  Base64-encoded string.
+   *
+   * @throws  EncodingException  on encoding errors.
+   */
+  public static String b64(final byte[] raw) throws EncodingException
+  {
+    return encode(new Base64Encoder(), raw);
+  }
+
+
+  /**
+   * Decodes a base64-encoded string into raw bytes.
+   *
+   * @param  encoded  Base64-encoded character data.
+   *
+   * @return  Base64-decoded bytes.
+   *
+   * @throws  EncodingException  on decoding errors.
+   */
+  public static byte[] b64(final CharSequence encoded) throws EncodingException
+  {
+    return decode(new Base64Decoder(), encoded);
+  }
+
+
+  /**
+   * Encodes bytes into base64-encoded string.
+   *
+   * @param  raw  Raw bytes to encode.
+   * @param  lineLength  Length of each base64-encoded line in output.
+   *
+   * @return  Base64-encoded string.
+   *
+   * @throws  EncodingException  on encoding errors.
+   */
+  public static String b64(final byte[] raw, final int lineLength) throws EncodingException
+  {
+    return encode(new Base64Encoder(lineLength), raw);
+  }
+
+
+  /**
+   * Encodes bytes into base 32-encoded string.
+   *
+   * @param  raw  Raw bytes to encode.
+   *
+   * @return  Base32-encoded string.
+   *
+   * @throws  EncodingException  on encoding errors.
+   */
+  public static String b32(final byte[] raw) throws EncodingException
+  {
+    return encode(new Base32Encoder(), raw);
+  }
+
+
+  /**
+   * Decodes a base32-encoded string into raw bytes.
+   *
+   * @param  encoded  Base32-encoded character data.
+   *
+   * @return  Base64-decoded bytes.
+   *
+   * @throws  EncodingException  on decoding errors.
+   */
+  public static byte[] b32(final CharSequence encoded) throws EncodingException
+  {
+    return decode(new Base32Decoder(), encoded);
+  }
+
+
+  /**
+   * Encodes bytes into base32-encoded string.
+   *
+   * @param  raw  Raw bytes to encode.
+   * @param  lineLength  Length of each base32-encoded line in output.
+   *
+   * @return  Base32-encoded string.
+   *
+   * @throws  EncodingException  on encoding errors.
+   */
+  public static String b32(final byte[] raw, final int lineLength) throws EncodingException
+  {
+    return encode(new Base32Encoder(lineLength), raw);
+  }
+
+
+  /**
+   * Encodes raw bytes using the given encoder.
+   *
+   * @param  encoder  Encoder to perform byte-to-char conversion.
+   * @param  raw  Raw bytes to encode.
+   *
+   * @return  Encoded data as a string.
+   *
+   * @throws  EncodingException  on encoding errors.
+   */
+  public static String encode(final Encoder encoder, final byte[] raw) throws EncodingException
+  {
+    final CharBuffer output = CharBuffer.allocate(encoder.outputSize(raw.length));
+    encoder.encode(ByteBuffer.wrap(raw), output);
+    encoder.finalize(output);
+    return output.flip().toString();
+  }
+
+
+  /**
+   * Decodes the given encoded data using the given char-to-byte decoder.
+   *
+   * @param  decoder  Decoder to perform char-to-byte conversion.
+   * @param  encoded  Encoded character data.
+   *
+   * @return  Decoded data as raw bytes.
+   *
+   * @throws  EncodingException  on decoding errors.
+   */
+  public static byte[] decode(final Decoder decoder, final CharSequence encoded) throws EncodingException
+  {
+    final ByteBuffer output = ByteBuffer.allocate(decoder.outputSize(encoded.length()));
+    decoder.decode(CharBuffer.wrap(encoded), output);
+    decoder.finalize(output);
+    output.flip();
+    return ByteUtil.toArray(output);
+  }
+}
diff --git a/src/main/java/org/cryptacular/util/CsrUtil.java b/src/main/java/org/cryptacular/util/CsrUtil.java
new file mode 100644
index 0000000..f548e00
--- /dev/null
+++ b/src/main/java/org/cryptacular/util/CsrUtil.java
@@ -0,0 +1,273 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.util;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.StringWriter;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyPair;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import javax.security.auth.x500.X500Principal;
+import org.bouncycastle.asn1.ASN1Encodable;
+import org.bouncycastle.asn1.ASN1Set;
+import org.bouncycastle.asn1.pkcs.Attribute;
+import org.bouncycastle.asn1.pkcs.CertificationRequest;
+import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers;
+import org.bouncycastle.asn1.x509.Extension;
+import org.bouncycastle.asn1.x509.Extensions;
+import org.bouncycastle.asn1.x509.GeneralName;
+import org.bouncycastle.asn1.x509.GeneralNames;
+import org.bouncycastle.asn1.x509.GeneralNamesBuilder;
+import org.bouncycastle.crypto.params.AsymmetricKeyParameter;
+import org.bouncycastle.crypto.params.ECKeyParameters;
+import org.bouncycastle.crypto.params.ECPublicKeyParameters;
+import org.bouncycastle.crypto.params.RSAKeyParameters;
+import org.bouncycastle.crypto.util.PublicKeyFactory;
+import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
+import org.bouncycastle.operator.AlgorithmNameFinder;
+import org.bouncycastle.operator.ContentSigner;
+import org.bouncycastle.operator.DefaultAlgorithmNameFinder;
+import org.bouncycastle.operator.OperatorCreationException;
+import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
+import org.bouncycastle.pkcs.PKCS10CertificationRequest;
+import org.bouncycastle.pkcs.PKCS10CertificationRequestBuilder;
+import org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequestBuilder;
+import org.cryptacular.CryptoException;
+import org.cryptacular.EncodingException;
+import org.cryptacular.x509.dn.NameReader;
+import org.cryptacular.x509.dn.RDNSequence;
+import org.cryptacular.x509.dn.StandardAttributeType;
+
+/**
+ * PKCS#10 certificate signing request (CSR) utilities.
+ *
+ * @author Marvin S. Addison
+ */
+public final class CsrUtil
+{
+  /** Maps algorithm OIDs onto typical algorithm names like "SHA256withRSA". */
+  private static final AlgorithmNameFinder ALG_NAME_FINDER = new DefaultAlgorithmNameFinder();
+
+
+  /**
+   * Private constructor of utility class.
+   */
+  private CsrUtil() {}
+
+  /**
+   * Encodes a PKCS#10 certificate signing request to PEM-encoded string format.
+   *
+   * @param csr Certificate signing request.
+   *
+   * @return PEM-encoded CSR.
+   *
+   * @throws EncodingException on errors writing PEM-encoded data.
+   */
+  public static String encodeCsr(final PKCS10CertificationRequest csr)
+  {
+    final StringWriter writer = new StringWriter();
+    try (JcaPEMWriter pemWriter = new JcaPEMWriter(writer)) {
+      pemWriter.writeObject(csr);
+      pemWriter.close();
+      return writer.toString();
+    } catch (IOException e) {
+      throw new EncodingException("CSR encoding error", e);
+    }
+  }
+
+  /**
+   * Decodes PEM-encoded PKCS#10 certificate signing request into a structured object.
+   *
+   * @param csr PEM-encoded CSR.
+   *
+   * @return Decoded CSR.
+   *
+   * @throws IllegalArgumentException if input does not appear to be PEM-encoded data.
+   */
+  public static CertificationRequest decodeCsr(final String csr)
+  {
+    byte[] csrBytes = csr.getBytes(StandardCharsets.US_ASCII);
+    if (!PemUtil.isPem(csrBytes)) {
+      throw new IllegalArgumentException("Input is not PEM-encoded as required");
+    }
+    csrBytes = PemUtil.decode(csrBytes);
+    return CertificationRequest.getInstance(csrBytes);
+  }
+
+  /**
+   * Decodes DER-encoded PKCS#10 certificate signing request into a structured object.
+   *
+   * @param csr Bytes of a DER-encoded CSR.
+   *
+   * @return Decoded CSR.
+   */
+  public static CertificationRequest decodeCsr(final byte[] csr)
+  {
+    return CertificationRequest.getInstance(csr);
+  }
+
+  /**
+   * Decodes either a PEM or DER-encoded PKCS#10 certificate signing request from a file into a structured object.
+   *
+   * @param file File containing PEM or DER-encoded data.
+   *
+   * @return Decoded CSR.
+   */
+  public static CertificationRequest readCsr(final File file)
+  {
+    return readCsr(StreamUtil.makeStream(file));
+  }
+
+  /**
+   * Decodes either a PEM or DER-encoded PKCS#10 certificate signing request from a stream into a structured object.
+   *
+   * @param in Input stream containing PEM or DER-encoded data.
+   *
+   * @return Decoded CSR.
+   */
+  public static CertificationRequest readCsr(final InputStream in)
+  {
+    final byte[] data = StreamUtil.readAll(in);
+    if (PemUtil.isPem(data)) {
+      return decodeCsr(PemUtil.decode(data));
+    }
+    return decodeCsr(data);
+  }
+
+  /**
+   * Gets all the common names from the subject of the certificate request.
+   *
+   * @param csr Certificate request.
+   *
+   * @return List of zero or more common names.
+   */
+  public static List<String> commonNames(final CertificationRequest csr)
+  {
+    final RDNSequence sequence = NameReader.readX500Name(csr.getCertificationRequestInfo().getSubject());
+    return sequence.getValues(StandardAttributeType.CommonName);
+  }
+
+  /**
+   * Gets all subject alternative names mentioned on the certificate request.
+   *
+   * @param csr Certificate request.
+   *
+   * @return List of subject alternative names.
+   */
+  public static List<String> subjectAltNames(final CertificationRequest csr)
+  {
+    final List<String> names = new ArrayList<>();
+    final ASN1Set attributeSet = csr.getCertificationRequestInfo().getAttributes();
+    if (attributeSet == null) {
+      return names;
+    }
+    for (ASN1Encodable item : attributeSet)
+    {
+      final Attribute attr = Attribute.getInstance(item);
+      if (attr.getAttrType().equals(PKCSObjectIdentifiers.pkcs_9_at_extensionRequest)) {
+        final Extensions extensions = Extensions.getInstance(attr.getAttributeValues()[0]);
+        final GeneralNames subjAltNames = GeneralNames.fromExtensions(extensions, Extension.subjectAlternativeName);
+        if (subjAltNames != null) {
+          for (GeneralName gn : subjAltNames.getNames()) {
+            names.add(gn.getName().toString().toLowerCase(Locale.ROOT));
+          }
+        }
+      }
+    }
+    return names;
+  }
+
+  /**
+   * Gets the name of the signature algorithm mentioned in the CSR.
+   *
+   * @param csr Certificate request.
+   *
+   * @return Signature algorithm name, e.g. "SHA256withRSA"
+   */
+  public static String sigAlgName(final CertificationRequest csr)
+  {
+    return ALG_NAME_FINDER.getAlgorithmName(csr.getSignatureAlgorithm()).replace("WITH", "with");
+  }
+
+  /**
+   * Gets the size in bits of the public key in the CSR.
+   *
+   * @param csr Certificate request.
+   *
+   * @return Public key size in bits.
+   *
+   * @throws IllegalArgumentException if CSR specifies a key algorithm other than RSA or EC.
+   * @throws CryptoException on errors creating a public key from data in the CSR.
+   */
+  public static int keyLength(final CertificationRequest csr)
+  {
+    final AsymmetricKeyParameter pubKeyParam;
+    try {
+      pubKeyParam = PublicKeyFactory.createKey(
+          csr.getCertificationRequestInfo().getSubjectPublicKeyInfo());
+    } catch (IOException e) {
+      throw new CryptoException("Error creating public key parameters", e);
+    }
+    final int length;
+    if (pubKeyParam instanceof RSAKeyParameters) {
+      length = ((RSAKeyParameters) pubKeyParam).getModulus().bitLength();
+    } else if (pubKeyParam instanceof ECKeyParameters) {
+      length = ((ECPublicKeyParameters) pubKeyParam).getQ().getXCoord().getFieldSize();
+    } else {
+      throw new IllegalArgumentException("Unsupported key algorithm");
+    }
+    return length;
+  }
+
+  /**
+   * Generates a CSR given a key pair, subject DN, and optional subject alternative names.
+   *
+   * @param keyPair Key pair.
+   * @param subjectDN Subject distinguished name, e.g. "CN=host.example.org, DC=example, DC=org".
+   * @param subjectAltNames Zero or more DNS subject alternative names.
+   *
+   * @return PKCS#10 certification request. Use {@link PKCS10CertificationRequest#toASN1Structure()} to get the
+   * underlying {@link CertificationRequest} that may be used with other helper methods.
+   *
+   * @throws IllegalArgumentException if CSR specifies a key algorithm other than RSA or EC.
+   * @throws CryptoException on errors generating the CSR from data provided.
+   */
+  public static PKCS10CertificationRequest generateCsr(
+    final KeyPair keyPair, final String subjectDN, final String ... subjectAltNames)
+  {
+    final String keyAlg = keyPair.getPublic().getAlgorithm();
+    final String sigAlg;
+    if ("RSA".equals(keyAlg)) {
+      sigAlg = "SHA256withRSA";
+    } else if ("EC".equals(keyAlg)) {
+      sigAlg = "SHA256withECDSA";
+    } else {
+      throw new IllegalArgumentException("Unsupported key algorithm");
+    }
+    final PKCS10CertificationRequestBuilder p10Builder = new JcaPKCS10CertificationRequestBuilder(
+        new X500Principal(subjectDN), keyPair.getPublic());
+    if (subjectAltNames != null && subjectAltNames.length > 0) {
+      final GeneralNamesBuilder namesBuilder = new GeneralNamesBuilder();
+      for (String subjectAltName : subjectAltNames) {
+        namesBuilder.addName(new GeneralName(GeneralName.dNSName, subjectAltName));
+      }
+      final GeneralNames names = namesBuilder.build();
+      try {
+        final Extension sanExtension = Extension.create(Extension.subjectAlternativeName, false, names);
+        p10Builder.addAttribute(PKCSObjectIdentifiers.pkcs_9_at_extensionRequest, new Extensions(sanExtension));
+      } catch (IOException e) {
+        throw new CryptoException("Error adding subject alt names to CSR", e);
+      }
+    }
+    final JcaContentSignerBuilder csBuilder = new JcaContentSignerBuilder(sigAlg);
+    try {
+      final ContentSigner signer = csBuilder.build(keyPair.getPrivate());
+      return p10Builder.build(signer);
+    } catch (OperatorCreationException e) {
+      throw new CryptoException("Failed generating CSR", e);
+    }
+  }
+}
diff --git a/src/main/java/org/cryptacular/util/HashUtil.java b/src/main/java/org/cryptacular/util/HashUtil.java
new file mode 100644
index 0000000..b028bc9
--- /dev/null
+++ b/src/main/java/org/cryptacular/util/HashUtil.java
@@ -0,0 +1,269 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.util;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Arrays;
+import org.bouncycastle.crypto.Digest;
+import org.bouncycastle.crypto.digests.SHA1Digest;
+import org.bouncycastle.crypto.digests.SHA256Digest;
+import org.bouncycastle.crypto.digests.SHA3Digest;
+import org.bouncycastle.crypto.digests.SHA512Digest;
+import org.cryptacular.CryptoException;
+import org.cryptacular.SaltedHash;
+import org.cryptacular.StreamException;
+import org.cryptacular.io.Resource;
+
+/**
+ * Utility class for computing cryptographic hashes.
+ *
+ * @author  Middleware Services
+ */
+public final class HashUtil
+{
+
+  /** Private constructor of utility class. */
+  private HashUtil() {}
+
+
+  /**
+   * Computes the hash of the given data using the given algorithm. A salted hash may be produced as follows:
+   *
+   * <pre>
+       // data is a byte array containing raw data to digest
+       final byte[] salt = new RBGNonce(16).generate();
+       final byte[] hash = HashUtil.hash(new SHA1Digest(), data, salt);
+   * </pre>
+   *
+   * @param  digest  Hash algorithm.
+   * @param  data  Data to hash. Supported types are <code>byte[]</code>, {@link CharSequence} ,{@link InputStream}, and
+   *               {@link Resource}. Character data is processed in the <code>UTF-8</code> character set; if another
+   *               character set is desired, the caller should convert to <code>byte[]</code> and provide the resulting
+   *               bytes.
+   *
+   * @return  Byte array of length {@link Digest#getDigestSize()} containing hash output.
+   *
+   * @throws  CryptoException  on hash computation errors.
+   * @throws  StreamException  on stream IO errors.
+   */
+  public static byte[] hash(final Digest digest, final Object... data) throws CryptoException, StreamException
+  {
+    for (Object o : data) {
+      if (o instanceof byte[]) {
+        final byte[] bytes = (byte[]) o;
+        digest.update(bytes, 0, bytes.length);
+      } else if (o instanceof String) {
+        final byte[] bytes = ByteUtil.toBytes((String) o);
+        digest.update(bytes, 0, bytes.length);
+      } else if (o instanceof InputStream) {
+        hashStream(digest, (InputStream) o);
+      } else if (o instanceof Resource) {
+        final InputStream in;
+        try {
+          in = ((Resource) o).getInputStream();
+        } catch (IOException e) {
+          throw new StreamException(e);
+        }
+        hashStream(digest, in);
+      } else {
+        throw new IllegalArgumentException("Invalid input data type " + o);
+      }
+    }
+
+    final byte[] output = new byte[digest.getDigestSize()];
+    try {
+      digest.doFinal(output, 0);
+    } catch (RuntimeException e) {
+      throw new CryptoException("Hash computation error", e);
+    }
+    return output;
+  }
+
+
+  /**
+   * Computes the iterated hash of the given data using the given algorithm. The following example demonstrates a
+   * typical usage pattern, a salted hash with 10 rounds:
+   *
+   * <pre>
+       // data is a byte array containing raw data to digest
+       final byte[] salt = new RBGNonce(16).generate();
+       final byte[] hash = HashUtil.hash(new SHA1Digest(), 10, data, salt);
+   * </pre>
+   *
+   * @param  digest  Hash algorithm.
+   * @param  iterations  Number of hash rounds. Must be positive value.
+   * @param  data  Data to hash. Supported types are <code>byte[]</code>, {@link CharSequence} ,{@link InputStream}, and
+   *               {@link Resource}. Character data is processed in the <code>UTF-8</code> character set; if another
+   *               character set is desired, the caller should convert to <code>byte[]</code> and provide the resulting
+   *               bytes.
+   *
+   * @return  Byte array of length {@link Digest#getDigestSize()} containing hash output.
+   *
+   * @throws  CryptoException  on hash computation errors.
+   * @throws  StreamException  on stream IO errors.
+   */
+  public static byte[] hash(final Digest digest, final int iterations, final Object... data)
+      throws CryptoException, StreamException
+  {
+    if (iterations < 1) {
+      throw new IllegalArgumentException("Iterations must be positive");
+    }
+
+    final byte[] output = hash(digest, data);
+    try {
+      for (int i = 1; i < iterations; i++) {
+        digest.update(output, 0, output.length);
+        digest.doFinal(output, 0);
+      }
+    } catch (RuntimeException e) {
+      throw new CryptoException("Hash computation error", e);
+    }
+    return output;
+  }
+
+
+  /**
+   * Determines whether the hash of the given input equals a known value.
+   *
+   * @param  digest  Hash algorithm.
+   * @param  hash  Hash to compare with. If the length of the array is greater than the length of the digest output,
+   *               anything beyond the digest length is considered salt data that is hashed <strong>after</strong> the
+   *               input data.
+   * @param  iterations  Number of hash rounds.
+   * @param  data  Data to hash.
+   *
+   * @return  True if the hash of the data under the given digest is equal to the hash, false otherwise.
+   *
+   * @throws  CryptoException  on hash computation errors.
+   * @throws  StreamException  on stream IO errors.
+   */
+  public static boolean compareHash(final Digest digest, final byte[] hash, final int iterations, final Object... data)
+      throws CryptoException, StreamException
+  {
+    if (hash.length > digest.getDigestSize()) {
+      final byte[] hashPart = Arrays.copyOfRange(hash, 0, digest.getDigestSize());
+      final byte[] saltPart = Arrays.copyOfRange(hash, digest.getDigestSize(), hash.length);
+      final Object[] dataWithSalt = Arrays.copyOf(data, data.length + 1);
+      dataWithSalt[data.length] = saltPart;
+      return Arrays.equals(hash(digest, iterations, dataWithSalt), hashPart);
+    }
+    return Arrays.equals(hash(digest, iterations, data), hash);
+  }
+
+
+  /**
+   * Determines whether the salted hash of the given input equals a known hash value.
+   *
+   * @param  digest  Hash algorithm.
+   * @param  hash  Salted hash data.
+   * @param  iterations  Number of hash rounds.
+   * @param  saltAfterData  True to apply salt after data, false to apply salt before data.
+   * @param  data  Data to hash, which should NOT include the salt value.
+   *
+   * @return  True if the hash of the data under the given digest is equal to the hash, false otherwise.
+   *
+   * @throws  CryptoException  on hash computation errors.
+   * @throws  StreamException  on stream IO errors.
+   */
+  public static boolean compareHash(
+    final Digest digest,
+    final SaltedHash hash,
+    final int iterations,
+    final boolean saltAfterData,
+    final Object... data)
+    throws CryptoException, StreamException
+  {
+    final Object[] dataWithSalt;
+    if (saltAfterData) {
+      dataWithSalt = Arrays.copyOf(data, data.length + 1);
+      dataWithSalt[data.length] = hash.getSalt();
+    } else {
+      dataWithSalt = new Object[data.length + 1];
+      dataWithSalt[0] = hash.getSalt();
+      System.arraycopy(data, 0, dataWithSalt, 1, data.length);
+    }
+    return Arrays.equals(hash(digest, iterations, dataWithSalt), hash.getHash());
+  }
+
+
+  /**
+   * Produces the SHA-1 hash of the given data.
+   *
+   * @param  data  Data to hash. See {@link #hash(Digest, Object...)} for supported inputs.
+   *
+   * @return  20-byte array containing hash output.
+   *
+   * @see  #hash(Digest, Object...)
+   */
+  public static byte[] sha1(final Object... data)
+  {
+    return hash(new SHA1Digest(), data);
+  }
+
+
+  /**
+   * Produces the SHA-256 hash of the given data.
+   *
+   * @param  data  Data to hash. See {@link #hash(Digest, Object...)} for supported inputs.
+   *
+   * @return  32-byte array containing hash output.
+   *
+   * @see  #hash(Digest, Object...)
+   */
+  public static byte[] sha256(final Object... data)
+  {
+    return hash(new SHA256Digest(), data);
+  }
+
+
+  /**
+   * Produces the SHA-512 hash of the given data.
+   *
+   * @param  data  Data to hash. See {@link #hash(Digest, Object...)} for supported inputs.
+   *
+   * @return  64-byte array containing hash output.
+   *
+   * @see  #hash(Digest, Object...)
+   */
+  public static byte[] sha512(final Object... data)
+  {
+    return hash(new SHA512Digest(), data);
+  }
+
+
+  /**
+   * Produces the SHA-3 hash of the given data.
+   *
+   * @param  bitLength  One of the supported SHA-3 output bit lengths: 224, 256, 384, or 512.
+   * @param  data  Data to hash. See {@link #hash(Digest, Object...)} for supported inputs.
+   *
+   * @return  Byte array of size <code>bitLength</code> containing hash output.
+   *
+   * @see  #hash(Digest, Object...)
+   */
+  public static byte[] sha3(final int bitLength, final Object... data)
+  {
+    return hash(new SHA3Digest(bitLength), data);
+  }
+
+
+  /**
+   * Digests the data in the given stream. Note this method does not finalize the digest process by calling {@link
+   * Digest#doFinal(byte[], int)}.
+   *
+   * @param  digest  Digest algorithm.
+   * @param  in  Input stream containing data to hash.
+   */
+  private static void hashStream(final Digest digest, final InputStream in)
+  {
+    final byte[] buffer = new byte[StreamUtil.CHUNK_SIZE];
+    int length;
+    try {
+      while ((length = in.read(buffer)) > 0) {
+        digest.update(buffer, 0, length);
+      }
+    } catch (IOException e) {
+      throw new StreamException(e);
+    }
+  }
+}
diff --git a/src/main/java/org/cryptacular/util/KeyPairUtil.java b/src/main/java/org/cryptacular/util/KeyPairUtil.java
new file mode 100644
index 0000000..3bec562
--- /dev/null
+++ b/src/main/java/org/cryptacular/util/KeyPairUtil.java
@@ -0,0 +1,484 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.util;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.InputStream;
+import java.math.BigInteger;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.security.interfaces.DSAPrivateKey;
+import java.security.interfaces.DSAPublicKey;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.ECPublicKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.security.interfaces.RSAPublicKey;
+import org.bouncycastle.crypto.CryptoException;
+import org.bouncycastle.crypto.digests.SHA256Digest;
+import org.bouncycastle.crypto.params.AsymmetricKeyParameter;
+import org.bouncycastle.crypto.params.DSAParameters;
+import org.bouncycastle.crypto.params.DSAPrivateKeyParameters;
+import org.bouncycastle.crypto.params.DSAPublicKeyParameters;
+import org.bouncycastle.crypto.params.RSAKeyParameters;
+import org.bouncycastle.crypto.signers.DSASigner;
+import org.bouncycastle.crypto.signers.ECDSASigner;
+import org.bouncycastle.crypto.signers.RSADigestSigner;
+import org.bouncycastle.jcajce.provider.asymmetric.util.ECUtil;
+import org.cryptacular.EncodingException;
+import org.cryptacular.StreamException;
+import org.cryptacular.adapter.Converter;
+import org.cryptacular.asn.OpenSSLPrivateKeyDecoder;
+import org.cryptacular.asn.PKCS8PrivateKeyDecoder;
+import org.cryptacular.asn.PublicKeyDecoder;
+
+/**
+ * Utility methods for public/private key pairs used for asymmetric encryption.
+ *
+ * @author  Middleware Services
+ */
+public final class KeyPairUtil
+{
+
+  /** Data used to verify key pairs. */
+  private static final byte[] SIGN_BYTES = ByteUtil.toBytes("Mr. Watson--come here--I want to see you.");
+
+
+  /** Private constructor of utility class. */
+  private KeyPairUtil() {}
+
+
+  /**
+   * Gets the length in bits of a public key where key size is dependent on the particulars of the algorithm.
+   *
+   * <ul>
+   *   <li>DSA - length of p</li>
+   *   <li>EC - length of p for prime fields, m for binary fields</li>
+   *   <li>RSA - length of modulus</li>
+   * </ul>
+   *
+   * @param  pubKey  Public key.
+   *
+   * @return  Size of the key in bits.
+   */
+  public static int length(final PublicKey pubKey)
+  {
+    final int size;
+    if (pubKey instanceof DSAPublicKey) {
+      size = ((DSAPublicKey) pubKey).getParams().getP().bitLength();
+    } else if (pubKey instanceof RSAPublicKey) {
+      size = ((RSAPublicKey) pubKey).getModulus().bitLength();
+    } else if (pubKey instanceof ECPublicKey) {
+      size = ((ECPublicKey) pubKey).getParams().getCurve().getField().getFieldSize();
+    } else {
+      throw new IllegalArgumentException(pubKey + " not supported.");
+    }
+    return size;
+  }
+
+
+  /**
+   * Gets the length in bits of a private key where key size is dependent on the particulars of the algorithm.
+   *
+   * <ul>
+   *   <li>DSA - length of q in bits</li>
+   *   <li>EC - length of p for prime fields, m for binary fields</li>
+   *   <li>RSA - modulus length in bits</li>
+   * </ul>
+   *
+   * @param  privKey  Private key.
+   *
+   * @return  Size of the key in bits.
+   */
+  public static int length(final PrivateKey privKey)
+  {
+    final int size;
+    if (privKey instanceof DSAPrivateKey) {
+      size = ((DSAPrivateKey) privKey).getParams().getQ().bitLength();
+    } else if (privKey instanceof RSAPrivateKey) {
+      size = ((RSAPrivateKey) privKey).getModulus().bitLength();
+    } else if (privKey instanceof ECPrivateKey) {
+      size = ((ECPrivateKey) privKey).getParams().getCurve().getField().getFieldSize();
+    } else {
+      throw new IllegalArgumentException(privKey + " not supported.");
+    }
+    return size;
+  }
+
+
+  /**
+   * Determines whether the given public and private keys form a proper key pair by computing and verifying a digital
+   * signature with the keys.
+   *
+   * @param  pubKey  DSA, RSA or EC public key.
+   * @param  privKey  DSA, RSA, or EC private key.
+   *
+   * @return  True if the keys form a functioning keypair, false otherwise. Errors during signature verification are
+   *          treated as false.
+   *
+   * @throws  org.cryptacular.CryptoException  on key validation errors.
+   */
+  public static boolean isKeyPair(final PublicKey pubKey, final PrivateKey privKey)
+      throws org.cryptacular.CryptoException
+  {
+    final String alg = pubKey.getAlgorithm();
+    if (!alg.equals(privKey.getAlgorithm())) {
+      return false;
+    }
+
+    // Dispatch onto the algorithm-specific method
+    final boolean result;
+    switch (alg) {
+
+    case "DSA":
+      result = isKeyPair((DSAPublicKey) pubKey, (DSAPrivateKey) privKey);
+      break;
+
+    case "RSA":
+      result = isKeyPair((RSAPublicKey) pubKey, (RSAPrivateKey) privKey);
+      break;
+
+    case "EC":
+      result = isKeyPair((ECPublicKey) pubKey, (ECPrivateKey) privKey);
+      break;
+
+    default:
+      throw new IllegalArgumentException(alg + " not supported.");
+    }
+    return result;
+  }
+
+
+  /**
+   * Determines whether the given DSA public and private keys form a proper key pair by computing and verifying a
+   * digital signature with the keys.
+   *
+   * @param  pubKey  DSA public key.
+   * @param  privKey  DSA private key.
+   *
+   * @return  True if the keys form a functioning keypair, false otherwise. Errors during signature verification are
+   *          treated as false.
+   *
+   * @throws  org.cryptacular.CryptoException  on key validation errors.
+   */
+  public static boolean isKeyPair(final DSAPublicKey pubKey, final DSAPrivateKey privKey)
+      throws org.cryptacular.CryptoException
+  {
+    final DSASigner signer = new DSASigner();
+    final DSAParameters params = new DSAParameters(
+      privKey.getParams().getP(),
+      privKey.getParams().getQ(),
+      privKey.getParams().getG());
+
+    try {
+      signer.init(true, new DSAPrivateKeyParameters(privKey.getX(), params));
+      final BigInteger[] sig = signer.generateSignature(SIGN_BYTES);
+      signer.init(false, new DSAPublicKeyParameters(pubKey.getY(), params));
+      return signer.verifySignature(SIGN_BYTES, sig[0], sig[1]);
+    } catch (RuntimeException e) {
+      throw new org.cryptacular.CryptoException("Signature computation error", e);
+    }
+  }
+
+
+  /**
+   * Determines whether the given RSA public and private keys form a proper key pair by computing and verifying a
+   * digital signature with the keys.
+   *
+   * @param  pubKey  RSA public key.
+   * @param  privKey  RSA private key.
+   *
+   * @return  True if the keys form a functioning keypair, false otherwise. Errors during signature verification are
+   *          treated as false.
+   *
+   * @throws  org.cryptacular.CryptoException  on key validation errors.
+   */
+  public static boolean isKeyPair(final RSAPublicKey pubKey, final RSAPrivateKey privKey)
+      throws org.cryptacular.CryptoException
+  {
+    final RSADigestSigner signer = new RSADigestSigner(new SHA256Digest());
+    try {
+      signer.init(true, new RSAKeyParameters(true, privKey.getModulus(), privKey.getPrivateExponent()));
+      signer.update(SIGN_BYTES, 0, SIGN_BYTES.length);
+      final byte[] sig = signer.generateSignature();
+      signer.init(false, new RSAKeyParameters(false, pubKey.getModulus(), pubKey.getPublicExponent()));
+      signer.update(SIGN_BYTES, 0, SIGN_BYTES.length);
+      return signer.verifySignature(sig);
+    } catch (CryptoException e) {
+      throw new org.cryptacular.CryptoException("Signature computation error", e);
+    }
+  }
+
+
+  /**
+   * Determines whether the given EC public and private keys form a proper key pair by computing and verifying a digital
+   * signature with the keys.
+   *
+   * @param  pubKey  EC public key.
+   * @param  privKey  EC private key.
+   *
+   * @return  True if the keys form a functioning keypair, false otherwise. Errors during signature verification are
+   *          treated as false.
+   *
+   * @throws  org.cryptacular.CryptoException  on key validation errors.
+   */
+  public static boolean isKeyPair(final ECPublicKey pubKey, final ECPrivateKey privKey)
+      throws org.cryptacular.CryptoException
+  {
+    final ECDSASigner signer = new ECDSASigner();
+    try {
+      signer.init(true, ECUtil.generatePrivateKeyParameter(privKey));
+
+      final BigInteger[] sig = signer.generateSignature(SIGN_BYTES);
+      signer.init(false, ECUtil.generatePublicKeyParameter(pubKey));
+      return signer.verifySignature(SIGN_BYTES, sig[0], sig[1]);
+    } catch (Exception e) {
+      throw new org.cryptacular.CryptoException("Signature computation error", e);
+    }
+  }
+
+
+  /**
+   * Reads an encoded private key from a file at the given path. Both PKCS#8 and OpenSSL "traditional" formats are
+   * supported in DER or PEM encoding. See {@link #decodePrivateKey(byte[])} for supported asymmetric algorithms.
+   *
+   * @param  path  Path to private key file.
+   *
+   * @return  Private key.
+   *
+   * @throws  EncodingException  on key encoding errors.
+   * @throws  StreamException  on IO errors reading data from file.
+   */
+  public static PrivateKey readPrivateKey(final String path) throws EncodingException, StreamException
+  {
+    return readPrivateKey(new File(path));
+  }
+
+
+  /**
+   * Reads an encoded private key from a file. Both PKCS#8 and OpenSSL "traditional" formats are supported in DER or PEM
+   * encoding. See {@link #decodePrivateKey(byte[])} for supported asymmetric algorithms.
+   *
+   * @param  file  Private key file.
+   *
+   * @return  Private key.
+   *
+   * @throws  EncodingException  on key encoding errors.
+   * @throws  StreamException  on IO errors reading data from file.
+   */
+  public static PrivateKey readPrivateKey(final File file) throws EncodingException, StreamException
+  {
+    try {
+      return readPrivateKey(new FileInputStream(file));
+    } catch (FileNotFoundException e) {
+      throw new StreamException("File not found: " + file);
+    }
+  }
+
+
+  /**
+   * Reads an encoded private key from an input stream. Both PKCS#8 and OpenSSL "traditional" formats are supported in
+   * DER or PEM encoding. See {@link #decodePrivateKey(byte[])} for supported asymmetric algorithms. The {@link
+   * InputStream} parameter is closed by this method.
+   *
+   * @param  in  Input stream containing private key data.
+   *
+   * @return  Private key.
+   *
+   * @throws  EncodingException  on key encoding errors.
+   * @throws  StreamException  on IO errors reading data from file.
+   */
+  public static PrivateKey readPrivateKey(final InputStream in) throws EncodingException, StreamException
+  {
+    return decodePrivateKey(StreamUtil.readAll(in));
+  }
+
+
+  /**
+   * Reads an encrypted private key from a file at the given path. Both PKCS#8 and OpenSSL "traditional" formats are
+   * supported in DER or PEM encoding. See {@link #decodePrivateKey(byte[])} for supported asymmetric algorithms.
+   *
+   * @param  path  Path to private key file.
+   * @param  password  Password used to encrypt private key.
+   *
+   * @return  Private key.
+   *
+   * @throws  EncodingException  on key encoding errors.
+   * @throws  StreamException  on IO errors.
+   */
+  public static PrivateKey readPrivateKey(final String path, final char[] password)
+      throws EncodingException, StreamException
+  {
+    return readPrivateKey(new File(path), password);
+  }
+
+
+  /**
+   * Reads an encrypted private key from a file. Both PKCS#8 and OpenSSL "traditional" formats are supported in DER or
+   * PEM encoding. See {@link #decodePrivateKey(byte[])} for supported asymmetric algorithms.
+   *
+   * @param  file  Private key file.
+   * @param  password  Password used to encrypt private key.
+   *
+   * @return  Private key.
+   *
+   * @throws  EncodingException  on key encoding errors.
+   * @throws  StreamException  on IO errors.
+   */
+  public static PrivateKey readPrivateKey(final File file, final char[] password)
+    throws EncodingException, StreamException
+  {
+    try {
+      return readPrivateKey(new FileInputStream(file), password);
+    } catch (FileNotFoundException e) {
+      throw new StreamException("File not found: " + file);
+    }
+  }
+
+
+  /**
+   * Reads an encrypted private key from an input stream. Both PKCS#8 and OpenSSL "traditional" formats are supported in
+   * DER or PEM encoding. See {@link #decodePrivateKey(byte[])} for supported asymmetric algorithms. The {@link
+   * InputStream} parameter is closed by this method.
+   *
+   * @param  in  Input stream containing private key data.
+   * @param  password  Password used to encrypt private key.
+   *
+   * @return  Private key.
+   *
+   * @throws  EncodingException  on key encoding errors.
+   * @throws  StreamException  on IO errors.
+   */
+  public static PrivateKey readPrivateKey(final InputStream in, final char[] password)
+    throws EncodingException, StreamException
+  {
+    return decodePrivateKey(StreamUtil.readAll(in), password);
+  }
+
+
+  /**
+   * Decodes an encoded private key in either PKCS#8 or OpenSSL "traditional" format in either DER or PEM encoding. Keys
+   * from the following asymmetric algorithms are supported:
+   *
+   * <ul>
+   *   <li>DSA</li>
+   *   <li>RSA</li>
+   *   <li>Elliptic curve</li>
+   * </ul>
+   *
+   * @param  encodedKey  Encoded private key data.
+   *
+   * @return  Private key.
+   *
+   * @throws  EncodingException  on key encoding errors.
+   */
+  public static PrivateKey decodePrivateKey(final byte[] encodedKey) throws EncodingException
+  {
+    return decodePrivateKey(encodedKey, null);
+  }
+
+
+  /**
+   * Decodes an encrypted private key. The following formats are supported:
+   *
+   * <ul>
+   *   <li>DER or PEM encoded PKCS#8 format</li>
+   *   <li>PEM encoded OpenSSL "traditional" format</li>
+   * </ul>
+   *
+   * <p>Keys from the following asymmetric algorithms are supported:</p>
+   *
+   * <ul>
+   *   <li>DSA</li>
+   *   <li>RSA</li>
+   *   <li>Elliptic curve</li>
+   * </ul>
+   *
+   * @param  encryptedKey  Encrypted private key data.
+   * @param  password  Password used to encrypt private key.
+   *
+   * @return  Private key.
+   *
+   * @throws  EncodingException  on key encoding errors.
+   */
+  public static PrivateKey decodePrivateKey(final byte[] encryptedKey, final char[] password) throws EncodingException
+  {
+    AsymmetricKeyParameter key;
+    try {
+      final PKCS8PrivateKeyDecoder decoder = new PKCS8PrivateKeyDecoder();
+      key = decoder.decode(encryptedKey, password);
+    } catch (RuntimeException e) {
+      final OpenSSLPrivateKeyDecoder decoder = new OpenSSLPrivateKeyDecoder();
+      key = decoder.decode(encryptedKey, password);
+    }
+    return Converter.convertPrivateKey(key);
+  }
+
+
+  /**
+   * Reads a DER or PEM-encoded public key from a file.
+   *
+   * @param  path  Path to DER or PEM-encoded public key file.
+   *
+   * @return  Public key.
+   *
+   * @throws  EncodingException  on key encoding errors.
+   * @throws  StreamException  on IO errors.
+   */
+  public static PublicKey readPublicKey(final String path) throws EncodingException, StreamException
+  {
+    return readPublicKey(new File(path));
+  }
+
+
+  /**
+   * Reads a DER or PEM-encoded public key from a file.
+   *
+   * @param  file  DER or PEM-encoded public key file.
+   *
+   * @return  Public key.
+   *
+   * @throws  EncodingException  on key encoding errors.
+   * @throws  StreamException  on IO errors.
+   */
+  public static PublicKey readPublicKey(final File file) throws EncodingException, StreamException
+  {
+    try {
+      return readPublicKey(new FileInputStream(file));
+    } catch (FileNotFoundException e) {
+      throw new StreamException("File not found: " + file);
+    }
+  }
+
+
+  /**
+   * Reads a DER or PEM-encoded public key from data in the given stream. The {@link InputStream} parameter is closed by
+   * this method.
+   *
+   * @param  in  Input stream containing an encoded key.
+   *
+   * @return  Public key.
+   *
+   * @throws  EncodingException  on key encoding errors.
+   * @throws  StreamException  on IO errors.
+   */
+  public static PublicKey readPublicKey(final InputStream in) throws EncodingException, StreamException
+  {
+    return decodePublicKey(StreamUtil.readAll(in));
+  }
+
+
+  /**
+   * Decodes public keys formatted in an X.509 SubjectPublicKeyInfo structure in either PEM or DER encoding.
+   *
+   * @param  encoded  Encoded public key bytes.
+   *
+   * @return  Public key.
+   *
+   * @throws  EncodingException  on key encoding errors.
+   */
+  public static PublicKey decodePublicKey(final byte[] encoded) throws EncodingException
+  {
+    return Converter.convertPublicKey(new PublicKeyDecoder().decode(encoded));
+  }
+}
diff --git a/src/main/java/org/cryptacular/util/NonceUtil.java b/src/main/java/org/cryptacular/util/NonceUtil.java
new file mode 100644
index 0000000..da14f56
--- /dev/null
+++ b/src/main/java/org/cryptacular/util/NonceUtil.java
@@ -0,0 +1,240 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.util;
+
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.security.SecureRandom;
+import javax.crypto.SecretKey;
+import org.bouncycastle.crypto.BlockCipher;
+import org.bouncycastle.crypto.Digest;
+import org.bouncycastle.crypto.digests.SHA256Digest;
+import org.bouncycastle.crypto.prng.EntropySource;
+import org.bouncycastle.crypto.prng.SP800SecureRandom;
+import org.bouncycastle.crypto.prng.drbg.HashSP800DRBG;
+import org.bouncycastle.crypto.prng.drbg.SP80090DRBG;
+import org.cryptacular.generator.sp80038a.EncryptedNonce;
+import org.cryptacular.generator.sp80038d.RBGNonce;
+
+/**
+ * Utility class for generating secure nonce and initialization vectors.
+ *
+ * @author  Middleware Services
+ */
+public final class NonceUtil
+{
+  /** Class-wide random source. */
+  private static final SecureRandom SECURE_RANDOM = new SecureRandom();
+
+  /* Seed random source. */
+  static
+  {
+    // Call nextBytes to force seeding via default process
+    SECURE_RANDOM.nextBytes(new byte[1]);
+  }
+
+  /** Private constructor of utility class. */
+  private NonceUtil() {}
+
+
+  /**
+   * Generates a nonce of the given size by repetitively concatenating system timestamps (i.e. {@link
+   * System#nanoTime()}) up to the required size.
+   *
+   * @param  length  Positive number of bytes in nonce.
+   *
+   * @return  Nonce bytes.
+   */
+  public static byte[] timestampNonce(final int length)
+  {
+    if (length <= 0) {
+      throw new IllegalArgumentException(length + " is invalid. Length must be positive.");
+    }
+
+    final byte[] nonce = new byte[length];
+    int count = 0;
+    long timestamp;
+    while (count < length) {
+      timestamp = System.nanoTime();
+      for (int i = 0; i < 8 && count < length; i++) {
+        nonce[count++] = (byte) (timestamp & 0xFF);
+        timestamp >>= 8;
+      }
+    }
+    return nonce;
+  }
+
+
+  /**
+   * Generates a random nonce of the given length in bytes.
+   *
+   * @param  length  Positive number of bytes in nonce.
+   *
+   * @return  Nonce bytes.
+   */
+  public static byte[] randomNonce(final int length)
+  {
+    if (length <= 0) {
+      throw new IllegalArgumentException(length + " is invalid. Length must be positive.");
+    }
+    final byte[] nonce = new byte[length];
+    SECURE_RANDOM.nextBytes(nonce);
+    return nonce;
+  }
+
+
+  /**
+   * Creates a new entropy source that wraps a {@link SecureRandom} to produce random bytes.
+   *
+   * @param length Size of entropy blocks.
+   *
+   * @return New random entropy source.
+   */
+  public static EntropySource randomEntropySource(final int length)
+  {
+    return new EntropySource() {
+      @Override
+      public boolean isPredictionResistant()
+      {
+        return true;
+      }
+
+      @Override
+      public byte[] getEntropy()
+      {
+        final byte[] bytes = new byte[length];
+        SECURE_RANDOM.nextBytes(bytes);
+        return bytes;
+      }
+
+      @Override
+      public int entropySize()
+      {
+        return length;
+      }
+    };
+  }
+
+
+  /**
+   * Generates a nonce/IV using the strategy described in NIST <a
+   * href="http://csrc.nist.gov/publications/nistpubs/800-38D/SP-800-38D.pdf">SP-800-38d</a>, section 8.2.2, "RBG-based
+   * Construction". The implementation uses a hash-based DRBG based on a SHA-256 digest, and uses random data for all
+   * bits of the nonce; that is, the fixed field is null.
+   *
+   * <p>This nonce generation strategy is suitable for GCM ciphers.</p>
+   *
+   * @param  length  Number of bytes in nonce; MUST be 12 or more.
+   *
+   * @return  Nonce bytes.
+   */
+  public static byte[] nist80038d(final int length)
+  {
+    return new RBGNonce(length).generate();
+  }
+
+
+  /**
+   * Generates a random IV according to NIST <a href="http://goo.gl/S9z8qF">SP-800-63a</a>, appendix C, method 1
+   * (encrypted nonce), suitable for use with any block cipher mode described in that standard. This method uses an
+   * instance of {@link EncryptedNonce} for the implementation.
+   *
+   * @param  cipher  Block cipher.
+   * @param  key  Encryption key intended for use with IV.
+   *
+   * @return  Cipher block size number of random bytes.
+   *
+   * @see  EncryptedNonce
+   */
+  public static byte[] nist80063a(final BlockCipher cipher, final SecretKey key)
+  {
+    BlockCipher raw = cipher;
+    // Get the underlying cipher if there is one
+    final Method method = ReflectUtil.getMethod(cipher.getClass(), "getUnderlyingCipher");
+    if (method != null) {
+      raw = (BlockCipher) ReflectUtil.invoke(cipher, method);
+    }
+    return new EncryptedNonce(raw, key).generate();
+  }
+
+
+  /**
+   * Generates a random IV according to NIST <a href="http://goo.gl/S9z8qF">SP-800-63a</a>, appendix C, method 2
+   * (pseudorandom), suitable for use with any block cipher mode described in that standard.
+   *
+   * @param  prng  NIST SP800-63a approved pseudorandom number generator.
+   * @param  blockSize  Cipher block size in bytes.
+   *
+   * @return  Cipher block size number of random bytes.
+   */
+  public static byte[] nist80063a(final SP800SecureRandom prng, final int blockSize)
+  {
+    prng.setSeed(randomNonce(blockSize));
+
+    final byte[] iv = new byte[blockSize];
+    prng.nextBytes(iv);
+    return iv;
+  }
+
+
+  /**
+   * Generates a random IV according to NIST <a href="http://goo.gl/S9z8qF">SP-800-63a</a>, appendix C, method 2
+   * (pseudorandom), suitable for use with any block cipher mode described in that standard. Uses an instance of {@link
+   * RBGNonce} internally with length equal to block size of given cipher.
+   *
+   * @param  cipher  Block cipher.
+   *
+   * @return  Cipher block size number of random bytes.
+   *
+   * @see  RBGNonce
+   */
+  public static byte[] nist80063a(final BlockCipher cipher)
+  {
+    return new RBGNonce(cipher.getBlockSize()).generate();
+  }
+
+
+  /**
+   * Creates a new DRBG instance based on a SHA-256 digest.
+   *
+   * @param  length  Length in bits of values to be produced by DRBG instance.
+   *
+   * @return  New DRGB instance.
+   */
+  public static SP80090DRBG newRBG(final int length)
+  {
+    return newRBG(new SHA256Digest(), length);
+  }
+
+
+  /**
+   * Creates a new hash-based DRBG instance that uses the given digest as the pseudorandom source.
+   *
+   * @param  digest  Digest algorithm.
+   * @param  length  Length in bits of values to be produced by DRBG instance.
+   *
+   * @return  New DRGB instance.
+   */
+  public static SP80090DRBG newRBG(final Digest digest, final int length)
+  {
+    return newRBG(digest, length, randomEntropySource(length));
+  }
+
+  /**
+   * Creates a new hash-based DRBG instance that uses the given digest as the pseudorandom source.
+   *
+   * @param  digest  Digest algorithm.
+   * @param  length  Length in bits of values to be produced by DRBG instance.
+   * @param  es  Entropy source.
+   *
+   * @return  New DRGB instance.
+   */
+  public static SP80090DRBG newRBG(final Digest digest, final int length, final EntropySource es)
+  {
+    return new HashSP800DRBG(
+      digest,
+      length,
+      es,
+      Thread.currentThread().getName().getBytes(StandardCharsets.UTF_8),
+      NonceUtil.timestampNonce(8));
+  }
+}
diff --git a/src/main/java/org/cryptacular/util/PemUtil.java b/src/main/java/org/cryptacular/util/PemUtil.java
new file mode 100644
index 0000000..7f4ea60
--- /dev/null
+++ b/src/main/java/org/cryptacular/util/PemUtil.java
@@ -0,0 +1,134 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.util;
+
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import java.util.regex.Pattern;
+import org.cryptacular.codec.Base64Decoder;
+
+/**
+ * Utility class with helper methods for common PEM encoding operations.
+ *
+ * @author  Middleware Services
+ */
+public final class PemUtil
+{
+
+  /** Line length. */
+  public static final int LINE_LENGTH = 64;
+
+  /** PEM encoding header start string. */
+  public static final String HEADER_BEGIN = "-----BEGIN";
+
+  /** PEM encoding footer start string. */
+  public static final String FOOTER_END = "-----END";
+
+  /** Procedure type tag for PEM-encoded private key in OpenSSL format. */
+  public static final String PROC_TYPE = "Proc-Type:";
+
+  /** Decryption infor tag for PEM-encoded private key in OpenSSL format. */
+  public static final String DEK_INFO = "DEK-Info:";
+
+  /** Pattern used to split multiple PEM-encoded objects in a single file. */
+  private static final Pattern PEM_SPLITTER = Pattern.compile("-----(?:BEGIN|END) [A-Z ]+-----");
+
+  /** Pattern used to a file by line terminator. */
+  private static final Pattern LINE_SPLITTER = Pattern.compile("[\r\n]+");
+
+
+
+  /** Private constructor of utility class. */
+  private PemUtil() {}
+
+
+  /**
+   * Determines whether the data in the given byte array is base64-encoded data of PEM encoding. The determination is
+   * made using as little data from the given array as necessary to make a reasonable determination about encoding.
+   *
+   * @param  data  Data to test for PEM encoding
+   *
+   * @return  True if data appears to be PEM encoded, false otherwise.
+   */
+  public static boolean isPem(final byte[] data)
+  {
+    final String start = new String(data, 0, 10, ByteUtil.ASCII_CHARSET).trim();
+    if (!start.startsWith(HEADER_BEGIN) && !start.startsWith(PROC_TYPE)) {
+      // Check all bytes in first line to make sure they are in the range
+      // of base64 character set encoding
+      for (int i = 0; i < LINE_LENGTH; i++) {
+        if (!isBase64Char(data[i])) {
+          // Last two bytes may be padding character '=' (61)
+          if (i > LINE_LENGTH - 3) {
+            if (data[i] != 61) {
+              return false;
+            }
+          } else {
+            return false;
+          }
+        }
+      }
+    }
+    return true;
+  }
+
+
+  /**
+   * Determines whether the given byte represents an ASCII character in the character set for base64 encoding.
+   *
+   * @param  b  Byte to test.
+   *
+   * @return  True if the byte represents an ASCII character in the set of valid characters for base64 encoding, false
+   *          otherwise. The padding character '=' is not considered valid since it may only appear at the end of a
+   *          base64 encoded value.
+   */
+  public static boolean isBase64Char(final byte b)
+  {
+    return !(b < 47 || b > 122 || b > 57 && b < 65 || b > 90 && b < 97) || b == 43;
+  }
+
+
+  /**
+   * Decodes a PEM-encoded cryptographic object into the raw bytes of its ASN.1 encoding. Header/footer data and
+   * metadata info, e.g. Proc-Type, are ignored.
+   *
+   * @param  pem  Bytes of PEM-encoded data to decode.
+   *
+   * @return  ASN.1 encoded bytes.
+   */
+  public static byte[] decode(final byte[] pem)
+  {
+    return decode(new String(pem, ByteUtil.ASCII_CHARSET));
+  }
+
+
+  /**
+   * Decodes one or more PEM-encoded cryptographic objects into the raw bytes of their ASN.1 encoding. All header and
+   * metadata, e.g. Proc-Type, are ignored. If multiple cryptographic objects are represented, the decoded bytes of
+   * each object are concatenated together and returned.
+   *
+   * @param  pem  PEM-encoded data to decode.
+   *
+   * @return  ASN.1 encoded bytes.
+   */
+  public static byte[] decode(final String pem)
+  {
+    final Base64Decoder decoder = new Base64Decoder();
+    final CharBuffer buffer = CharBuffer.allocate(pem.length());
+    final ByteBuffer output = ByteBuffer.allocate(pem.length() * 3 / 4);
+    // There may be multiple PEM-encoded objects in the input
+    for (String object : PEM_SPLITTER.split(pem)) {
+      buffer.clear();
+      for (String line : LINE_SPLITTER.split(object)) {
+        if (line.startsWith(DEK_INFO) || line.startsWith(PROC_TYPE)) {
+          continue;
+        }
+        buffer.append(line);
+      }
+      buffer.flip();
+      decoder.decode(buffer, output);
+      decoder.finalize(output);
+    }
+    output.flip();
+    return ByteUtil.toArray(output);
+  }
+}
diff --git a/src/main/java/org/cryptacular/util/ReflectUtil.java b/src/main/java/org/cryptacular/util/ReflectUtil.java
new file mode 100644
index 0000000..e56d651
--- /dev/null
+++ b/src/main/java/org/cryptacular/util/ReflectUtil.java
@@ -0,0 +1,66 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.util;
+
+import java.lang.reflect.Method;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Reflection utilities.
+ *
+ * @author  Middleware Services
+ */
+public final class ReflectUtil
+{
+
+  /** Method cache. */
+  private static final Map<String, Method> METHOD_CACHE = new HashMap<>();
+
+  /** Private constructor of utility class. */
+  private ReflectUtil() {}
+
+
+  /**
+   * Gets the method defined on the target class. The method is cached to speed up subsequent lookups.
+   *
+   * @param  target  Target class that contains method.
+   * @param  name  Method name.
+   * @param  parameters  Method parameters.
+   *
+   * @return  Method if found, otherwise null.
+   */
+  public static Method getMethod(final Class<?> target, final String name, final Class<?>... parameters)
+  {
+    final String key = target.getName() + '.' + name;
+    Method method = METHOD_CACHE.get(key);
+    if (method != null) {
+      return method;
+    }
+    try {
+      method = target.getMethod(name, parameters);
+      METHOD_CACHE.put(key, method);
+      return method;
+    } catch (NoSuchMethodException e) {
+      return null;
+    }
+  }
+
+
+  /**
+   * Invokes the method on the target object with the given parameters.
+   *
+   * @param  target  Target class that contains method.
+   * @param  method  Method to invoke on target.
+   * @param  parameters  Method parameters.
+   *
+   * @return  Method return value. A void method returns null.
+   */
+  public static Object invoke(final Object target, final Method method, final Object... parameters)
+  {
+    try {
+      return method.invoke(target, parameters);
+    } catch (Exception e) {
+      throw new RuntimeException("Failed invoking " + method, e);
+    }
+  }
+}
diff --git a/src/main/java/org/cryptacular/util/StreamUtil.java b/src/main/java/org/cryptacular/util/StreamUtil.java
new file mode 100644
index 0000000..bece540
--- /dev/null
+++ b/src/main/java/org/cryptacular/util/StreamUtil.java
@@ -0,0 +1,277 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.util;
+
+import java.io.BufferedInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.CharArrayWriter;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.io.Reader;
+import java.io.Writer;
+import org.bouncycastle.util.io.Streams;
+import org.cryptacular.StreamException;
+import org.cryptacular.io.ChunkHandler;
+
+/**
+ * Utility methods for stream handling.
+ *
+ * @author  Middleware Services
+ */
+public final class StreamUtil
+{
+
+  /**
+   * Buffer size of chunked operations, e.g. {@link #pipeAll(java.io.InputStream, java.io.OutputStream,
+   * org.cryptacular.io.ChunkHandler)}.
+   */
+  public static final int CHUNK_SIZE = 1024;
+
+  /** Private method of utility class. */
+  private StreamUtil() {}
+
+
+  /**
+   * Reads all the data from the file at the given path.
+   *
+   * @param  path  Path to file.
+   *
+   * @return  Byte array of data read from file.
+   *
+   * @throws  StreamException  on stream IO errors.
+   */
+  public static byte[] readAll(final String path) throws StreamException
+  {
+    return readAll(new File(path));
+  }
+
+
+  /**
+   * Reads all the data from the given file.
+   *
+   * @param  file  File to read.
+   *
+   * @return  Byte array of data read from file.
+   *
+   * @throws  StreamException  on stream IO errors.
+   */
+  public static byte[] readAll(final File file) throws StreamException
+  {
+    final InputStream input = makeStream(file);
+    try {
+      return readAll(input, (int) file.length());
+    } finally {
+      closeStream(input);
+    }
+  }
+
+
+  /**
+   * Reads all the data from the given input stream.
+   *
+   * @param  input  Input stream to read.
+   *
+   * @return  Byte array of data read from stream.
+   *
+   * @throws  StreamException  on stream IO errors.
+   */
+  public static byte[] readAll(final InputStream input) throws StreamException
+  {
+    return readAll(input, 1024);
+  }
+
+
+  /**
+   * Reads all the data from the given input stream.
+   *
+   * @param  input  Input stream to read.
+   * @param  sizeHint  Estimate of amount of data to be read in bytes.
+   *
+   * @return  Byte array of data read from stream.
+   *
+   * @throws  StreamException  on stream IO errors.
+   */
+  public static byte[] readAll(final InputStream input, final int sizeHint) throws StreamException
+  {
+    final ByteArrayOutputStream output = new ByteArrayOutputStream(sizeHint);
+    try {
+      Streams.pipeAll(input, output);
+    } catch (IOException e) {
+      throw new StreamException(e);
+    } finally {
+      closeStream(input);
+      closeStream(output);
+    }
+    return output.toByteArray();
+  }
+
+
+  /**
+   * Reads all data from the given reader.
+   *
+   * @param  reader  Reader over character data.
+   *
+   * @return  Data read from reader.
+   *
+   * @throws  StreamException  on stream IO errors.
+   */
+  public static String readAll(final Reader reader) throws StreamException
+  {
+    return readAll(reader, 1024);
+  }
+
+
+  /**
+   * Reads all data from the given reader.
+   *
+   * @param  reader  Reader over character data.
+   * @param  sizeHint  Estimate of amount of data to be read in number of characters.
+   *
+   * @return  Data read from reader.
+   *
+   * @throws  StreamException  on stream IO errors.
+   */
+  public static String readAll(final Reader reader, final int sizeHint) throws StreamException
+  {
+    final CharArrayWriter writer = new CharArrayWriter(sizeHint);
+    final char[] buffer = new char[CHUNK_SIZE];
+    int len;
+    try {
+      while ((len = reader.read(buffer)) > 0) {
+        writer.write(buffer, 0, len);
+      }
+    } catch (IOException e) {
+      throw new StreamException(e);
+    } finally {
+      closeReader(reader);
+      closeWriter(writer);
+    }
+    return writer.toString();
+  }
+
+
+  /**
+   * Pipes an input stream into an output stream with chunked processing.
+   *
+   * @param  in  Input stream providing data to process.
+   * @param  out  Output stream holding processed data.
+   * @param  handler  Arbitrary handler for processing input stream.
+   *
+   * @throws  StreamException  on stream IO errors.
+   */
+  public static void pipeAll(final InputStream in, final OutputStream out, final ChunkHandler handler)
+      throws StreamException
+  {
+    final byte[] buffer = new byte[CHUNK_SIZE];
+    int count;
+    try {
+      while ((count = in.read(buffer)) > 0) {
+        handler.handle(buffer, 0, count, out);
+      }
+    } catch (IOException e) {
+      throw new StreamException(e);
+    }
+  }
+
+
+  /**
+   * Creates an input stream around the given file.
+   *
+   * @param  file  Input stream source.
+   *
+   * @return  Input stream around file.
+   *
+   * @throws  StreamException  on stream IO errors.
+   */
+  public static InputStream makeStream(final File file) throws StreamException
+  {
+    try {
+      return new BufferedInputStream(new FileInputStream(file));
+    } catch (FileNotFoundException e) {
+      throw new StreamException(file + " does not exist");
+    }
+  }
+
+
+  /**
+   * Creates a reader around the given file that presumably contains character data.
+   *
+   * @param  file  Reader source.
+   *
+   * @return  Reader around file.
+   *
+   * @throws  StreamException  on stream IO errors.
+   */
+  public static Reader makeReader(final File file) throws StreamException
+  {
+    try {
+      return new InputStreamReader(new BufferedInputStream(new FileInputStream(file)));
+    } catch (FileNotFoundException e) {
+      throw new StreamException(file + " does not exist");
+    }
+  }
+
+
+  /**
+   * Closes the given stream and swallows exceptions that may arise during the process.
+   *
+   * @param  in  Input stream to close.
+   */
+  public static void closeStream(final InputStream in)
+  {
+    try {
+      in.close();
+    } catch (IOException e) {
+      System.err.println("Error closing " + in + ": " + e);
+    }
+  }
+
+
+  /**
+   * Closes the given stream and swallows exceptions that may arise during the process.
+   *
+   * @param  out  Output stream to close.
+   */
+  public static void closeStream(final OutputStream out)
+  {
+    try {
+      out.close();
+    } catch (IOException e) {
+      System.err.println("Error closing " + out + ": " + e);
+    }
+  }
+
+
+  /**
+   * Closes the given reader and swallows exceptions that may arise during the process.
+   *
+   * @param  reader  Reader to close.
+   */
+  public static void closeReader(final Reader reader)
+  {
+    try {
+      reader.close();
+    } catch (IOException e) {
+      System.err.println("Error closing " + reader + ": " + e);
+    }
+  }
+
+
+  /**
+   * Closes the given writer and swallows exceptions that may arise during the process.
+   *
+   * @param  writer  Writer to close.
+   */
+  public static void closeWriter(final Writer writer)
+  {
+    try {
+      writer.close();
+    } catch (IOException e) {
+      System.err.println("Error closing " + writer + ": " + e);
+    }
+  }
+}
diff --git a/src/main/java/org/cryptacular/x509/ExtensionReader.java b/src/main/java/org/cryptacular/x509/ExtensionReader.java
new file mode 100644
index 0000000..0b75dc8
--- /dev/null
+++ b/src/main/java/org/cryptacular/x509/ExtensionReader.java
@@ -0,0 +1,311 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.x509;
+
+import java.security.cert.X509Certificate;
+import java.util.ArrayList;
+import java.util.List;
+import org.bouncycastle.asn1.ASN1Encodable;
+import org.bouncycastle.asn1.ASN1OctetString;
+import org.bouncycastle.asn1.ASN1Primitive;
+import org.bouncycastle.asn1.ASN1Sequence;
+import org.bouncycastle.asn1.x509.AccessDescription;
+import org.bouncycastle.asn1.x509.AuthorityKeyIdentifier;
+import org.bouncycastle.asn1.x509.BasicConstraints;
+import org.bouncycastle.asn1.x509.DistributionPoint;
+import org.bouncycastle.asn1.x509.GeneralNames;
+import org.bouncycastle.asn1.x509.KeyPurposeId;
+import org.bouncycastle.asn1.x509.KeyUsage;
+import org.bouncycastle.asn1.x509.PolicyInformation;
+import org.bouncycastle.asn1.x509.SubjectKeyIdentifier;
+import org.cryptacular.EncodingException;
+
+/**
+ * Reads X.509v3 extended properties from an {@link java.security.cert.X509Certificate} object. The available properties
+ * are described in section 4.2 of RFC 2459, http://www.faqs.org/rfcs/rfc2459.html.
+ *
+ * @author  Middleware Services
+ */
+public final class ExtensionReader
+{
+
+  /** The X509Certificate whose extension fields will be read. */
+  private final X509Certificate certificate;
+
+
+  /**
+   * Creates a new instance that can read extension fields from the given X.509 certificate.
+   *
+   * @param  cert  Certificate to read.
+   */
+  public ExtensionReader(final X509Certificate cert)
+  {
+    certificate = cert;
+  }
+
+
+  /**
+   * Reads the value of the extension given by OID or name as defined in section 4.2 of RFC 2459.
+   *
+   * @param  extensionOidOrName  OID or extension name, e.g. 2.5.29.14 orSubjectK eyIdentifier. In the case of extension
+   *                             name, the name is case-sensitive and follows the conventions in RFC 2459.
+   *
+   * @return  Extension type containing data from requested extension field.
+   *
+   * @throws  EncodingException  On certificate field parse errors.
+   */
+  public ASN1Encodable read(final String extensionOidOrName) throws EncodingException
+  {
+    if (extensionOidOrName == null) {
+      throw new IllegalArgumentException("extensionOidOrName cannot be null.");
+    }
+    if (extensionOidOrName.contains(".")) {
+      return read(ExtensionType.fromOid(extensionOidOrName));
+    } else {
+      return read(ExtensionType.fromName(extensionOidOrName));
+    }
+  }
+
+
+  /**
+   * Reads the value of the given certificate extension field.
+   *
+   * @param  extension  Extension to read from certificate.
+   *
+   * @return  Extension type containing data from requested extension field.
+   *
+   * @throws  EncodingException  On certificate field parse errors.
+   */
+  public ASN1Encodable read(final ExtensionType extension)
+  {
+    byte[] data = certificate.getExtensionValue(extension.getOid());
+    if (data == null) {
+      return null;
+    }
+    try {
+      ASN1Encodable der = ASN1Primitive.fromByteArray(data);
+      if (der instanceof ASN1OctetString) {
+        // Strip off octet string "wrapper"
+        data = ((ASN1OctetString) der).getOctets();
+        der = ASN1Primitive.fromByteArray(data);
+      }
+      return der;
+    } catch (Exception e) {
+      throw new EncodingException("ASN.1 parse error", e);
+    }
+  }
+
+
+  /**
+   * Reads the value of the SubjectAlternativeName extension field of the certificate.
+   *
+   * @return  Collection of subject alternative names or null if the certificate does not define this extension field.
+   *          Note that an empty collection of names is different from a null return value; in the former case the field
+   *          is defined but empty, whereas in the latter the field is not defined on the certificate.
+   *
+   * @throws  EncodingException  On certificate field parse errors.
+   */
+  public GeneralNames readSubjectAlternativeName() throws EncodingException
+  {
+    try {
+      return GeneralNames.getInstance(read(ExtensionType.SubjectAlternativeName));
+    } catch (RuntimeException e) {
+      throw new EncodingException("GeneralNames parse error", e);
+    }
+  }
+
+
+  /**
+   * Reads the value of the <code>IssuerAlternativeName</code> extension field of the certificate.
+   *
+   * @return  Collection of issuer alternative names or null if the certificate does not define this extension field.
+   *          Note that an empty collection of names is different from a null return value; in the former case the field
+   *          is defined but empty, whereas in the latter the field is not defined on the certificate.
+   *
+   * @throws  EncodingException  On certificate field parse errors.
+   */
+  public GeneralNames readIssuerAlternativeName() throws EncodingException
+  {
+    try {
+      return GeneralNames.getInstance(read(ExtensionType.IssuerAlternativeName));
+    } catch (RuntimeException e) {
+      throw new EncodingException("GeneralNames parse error", e);
+    }
+  }
+
+
+  /**
+   * Reads the value of the <code>BasicConstraints</code> extension field of the certificate.
+   *
+   * @return  Basic constraints defined on certificate or null if the certificate does not define the field.
+   *
+   * @throws  EncodingException  On certificate field parse errors.
+   */
+  public BasicConstraints readBasicConstraints() throws EncodingException
+  {
+    try {
+      return BasicConstraints.getInstance(read(ExtensionType.BasicConstraints));
+    } catch (RuntimeException e) {
+      throw new EncodingException("BasicConstraints parse error", e);
+    }
+  }
+
+
+  /**
+   * Reads the value of the <code>CertificatePolicies</code> extension field of the certificate.
+   *
+   * @return  List of certificate policies defined on certificate or null if the certificate does not define the field.
+   *
+   * @throws  EncodingException  On certificate field parse errors.
+   */
+  public List<PolicyInformation> readCertificatePolicies() throws EncodingException
+  {
+    final ASN1Encodable data = read(ExtensionType.CertificatePolicies);
+    if (data == null) {
+      return null;
+    }
+
+    try {
+      final ASN1Sequence sequence = ASN1Sequence.getInstance(data);
+      final List<PolicyInformation> list = new ArrayList<>(sequence.size());
+      for (int i = 0; i < sequence.size(); i++) {
+        list.add(PolicyInformation.getInstance(sequence.getObjectAt(i)));
+      }
+      return list;
+    } catch (RuntimeException e) {
+      throw new EncodingException("PolicyInformation parse error", e);
+    }
+  }
+
+
+  /**
+   * Reads the value of the <code>SubjectKeyIdentifier</code> extension field of the certificate.
+   *
+   * @return  Subject key identifier.
+   *
+   * @throws  EncodingException  On certificate field parse errors.
+   */
+  public SubjectKeyIdentifier readSubjectKeyIdentifier() throws EncodingException
+  {
+    try {
+      return SubjectKeyIdentifier.getInstance(read(ExtensionType.SubjectKeyIdentifier));
+    } catch (RuntimeException e) {
+      throw new EncodingException("SubjectKeyIdentifier parse error", e);
+    }
+  }
+
+
+  /**
+   * Reads the value of the <code>AuthorityKeyIdentifier</code> extension field of the certificate.
+   *
+   * @return  Authority key identifier.
+   *
+   * @throws  EncodingException  On certificate field parse errors.
+   */
+  public AuthorityKeyIdentifier readAuthorityKeyIdentifier() throws EncodingException
+  {
+    try {
+      return AuthorityKeyIdentifier.getInstance(read(ExtensionType.AuthorityKeyIdentifier));
+    } catch (RuntimeException e) {
+      throw new EncodingException("AuthorityKeyIdentifier parse error", e);
+    }
+  }
+
+
+  /**
+   * Reads the value of the <code>KeyUsage</code> extension field of the certificate.
+   *
+   * @return  Key usage data or null if extension field is not defined.
+   *
+   * @throws  EncodingException  On certificate field parse errors.
+   */
+  public KeyUsage readKeyUsage() throws EncodingException
+  {
+    try {
+      return KeyUsage.getInstance(read(ExtensionType.KeyUsage));
+    } catch (RuntimeException e) {
+      throw new EncodingException("KeyUsage parse error", e);
+    }
+  }
+
+
+  /**
+   * Reads the value of the <code>ExtendedKeyUsage</code> extension field of the certificate.
+   *
+   * @return  List of supported extended key usages or null if extension is not defined.
+   *
+   * @throws  EncodingException  On certificate field parse errors.
+   */
+  public List<KeyPurposeId> readExtendedKeyUsage() throws EncodingException
+  {
+    final ASN1Encodable data = read(ExtensionType.ExtendedKeyUsage);
+    if (data == null) {
+      return null;
+    }
+
+    try {
+      final ASN1Sequence sequence = ASN1Sequence.getInstance(data);
+      final List<KeyPurposeId> list = new ArrayList<>(sequence.size());
+      for (int i = 0; i < sequence.size(); i++) {
+        list.add(KeyPurposeId.getInstance(sequence.getObjectAt(i)));
+      }
+      return list;
+    } catch (RuntimeException e) {
+      throw new EncodingException("KeyPurposeId parse error", e);
+    }
+  }
+
+
+  /**
+   * Reads the value of the <code>CRLDistributionPoints</code> extension field of the certificate.
+   *
+   * @return  List of CRL distribution points or null if extension is not defined.
+   *
+   * @throws  EncodingException  On certificate field parse errors.
+   */
+  public List<DistributionPoint> readCRLDistributionPoints() throws EncodingException
+  {
+    final ASN1Encodable data = read(ExtensionType.CRLDistributionPoints);
+    if (data == null) {
+      return null;
+    }
+
+    try {
+      final ASN1Sequence sequence = ASN1Sequence.getInstance(data);
+      final List<DistributionPoint> list = new ArrayList<>(sequence.size());
+      for (int i = 0; i < sequence.size(); i++) {
+        list.add(DistributionPoint.getInstance(sequence.getObjectAt(i)));
+      }
+      return list;
+    } catch (RuntimeException e) {
+      throw new EncodingException("DistributionPoint parse error", e);
+    }
+  }
+
+
+  /**
+   * Reads the value of the <code>AuthorityInformationAccess</code> extension field of the certificate.
+   *
+   * @return  List of access descriptions or null if extension is not defined.
+   *
+   * @throws  EncodingException  On certificate field parse errors.
+   */
+  public List<AccessDescription> readAuthorityInformationAccess() throws EncodingException
+  {
+    final ASN1Encodable data = read(ExtensionType.AuthorityInformationAccess);
+    if (data == null) {
+      return null;
+    }
+
+    try {
+      final ASN1Sequence sequence = ASN1Sequence.getInstance(data);
+      final List<AccessDescription> list = new ArrayList<>(sequence.size());
+      for (int i = 0; i < sequence.size(); i++) {
+        list.add(AccessDescription.getInstance(sequence.getObjectAt(i)));
+      }
+      return list;
+    } catch (RuntimeException e) {
+      throw new EncodingException("AccessDescription parse error", e);
+    }
+  }
+
+}
diff --git a/src/main/java/org/cryptacular/x509/ExtensionType.java b/src/main/java/org/cryptacular/x509/ExtensionType.java
new file mode 100644
index 0000000..a7d4074
--- /dev/null
+++ b/src/main/java/org/cryptacular/x509/ExtensionType.java
@@ -0,0 +1,134 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.x509;
+
+/**
+ * Enumeration of X.509v3 extension fields defined in section 4.2 of RFC 2459.
+ *
+ * @author  Middleware Services
+ * @version  $Revision: 2745 $
+ */
+public enum ExtensionType {
+
+  /** AuthorityInfoAccess extension field. */
+  AuthorityInformationAccess("1.3.6.1.5.5.7.1.1", false),
+
+  /** AuthorityKeyIdentifier extension field. */
+  AuthorityKeyIdentifier("2.5.29.35", false),
+
+  /** BasicConstraints extension field. */
+  BasicConstraints("2.5.29.19", true),
+
+  /** CertificatePolicies extension field. */
+  CertificatePolicies("2.5.29.32", false),
+
+  /** CRLDistributionPoints extension field. */
+  CRLDistributionPoints("2.5.29.31", false),
+
+  /** ExtendedKeyUsage extension field. */
+  ExtendedKeyUsage("2.5.29.37", false),
+
+  /** IssuerAlternativeName extension field. */
+  IssuerAlternativeName("2.5.29.18", false),
+
+  /** KeyUsage extension field. */
+  KeyUsage("2.5.29.15", true),
+
+  /** NameConstraints extension field. */
+  NameConstraints("2.5.29.30", true),
+
+  /** PolicyConstraints extension field. */
+  PolicyConstraints("2.5.29.36", false),
+
+  /** PolicyMappings extension field. */
+  PolicyMappings("2.5.29.33", false),
+
+  /** PrivateKeyUsage extension field. */
+  PrivateKeyUsagePeriod("2.5.29.16", false),
+
+  /** SubjectAlternativeName extension field. */
+  SubjectAlternativeName("2.5.29.17", false),
+
+  /** SubjectKeyIdentifier extension field. */
+  SubjectKeyIdentifier("2.5.29.14", false),
+
+  /** SubjectDirectoryAttributes extension field. */
+  SubjectDirectoryAttributes("2.5.29.9", false);
+
+
+  /** Oid value. */
+  private final String oid;
+
+  /** Whether this extension is critical according to RFC 2459. */
+  private final boolean critical;
+
+
+  /**
+   * Creates a new type with the given OID value.
+   *
+   * @param  oidString  Extension OID value.
+   * @param  criticality  True if extension MUST or SHOULD be marked critical under general circumstances, false
+   *                      otherwise.
+   */
+  ExtensionType(final String oidString, final boolean criticality)
+  {
+    oid = oidString;
+    critical = criticality;
+  }
+
+
+  /**
+   * Gets the extension by OID.
+   *
+   * @param  oid  Extension OID value.
+   *
+   * @return  Extension with given OID value.
+   *
+   * @throws  IllegalArgumentException  If no extension with given OID exists.
+   */
+  public static ExtensionType fromOid(final String oid)
+  {
+    for (ExtensionType ext : values()) {
+      if (ext.getOid().equals(oid)) {
+        return ext;
+      }
+    }
+    throw new IllegalArgumentException("Invalid X.509v3 extension OID " + oid);
+  }
+
+
+  /**
+   * Gets the extension by name.
+   *
+   * @param  name  Case-sensitive X.509v3 extension name. The acceptable case of extension names is governed by
+   *               conventions in RFC 2459.
+   *
+   * @return  Extension with given name.
+   *
+   * @throws  IllegalArgumentException  If no extension with given name exists.
+   */
+  public static ExtensionType fromName(final String name)
+  {
+    try {
+      return ExtensionType.valueOf(ExtensionType.class, name);
+    } catch (IllegalArgumentException e) {
+      throw new IllegalArgumentException("Invalid X.509v3 extension name " + name);
+    }
+  }
+
+
+  /**
+   * @return  True if extension MUST or SHOULD be marked critical under general circumstances according to RFC 2459,
+   *          false otherwise.
+   */
+  public boolean isCritical()
+  {
+    return critical;
+  }
+
+
+  /** @return  OID value of extension field. */
+  public String getOid()
+  {
+    return oid;
+  }
+}
diff --git a/src/main/java/org/cryptacular/x509/GeneralNameType.java b/src/main/java/org/cryptacular/x509/GeneralNameType.java
new file mode 100644
index 0000000..7129887
--- /dev/null
+++ b/src/main/java/org/cryptacular/x509/GeneralNameType.java
@@ -0,0 +1,68 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.x509;
+
+/**
+ * Representation of the options in the CHOICE element describing various categories of the <code>GeneralName</code>
+ * type defined in section 4.2.1.7 of RFC 2459.
+ *
+ * @author  Middleware Services
+ */
+public enum GeneralNameType {
+
+  /** otherName choice element. */
+  OtherName,
+
+  /** rfc822Name choice element. */
+  RFC822Name,
+
+  /** dNSName choice element. */
+  DNSName,
+
+  /** x400Address choice element. */
+  X400Address,
+
+  /** directoryName choice element. */
+  DirectoryName,
+
+  /** ediPartyName choice element. */
+  EdiPartyName,
+
+  /** uniformResourceIdentifier choice element. */
+  UniformResourceIdentifier,
+
+  /** iPAddress choice element. */
+  IPAddress,
+
+  /** registeredID choice element. */
+  RegisteredID;
+
+
+  /** Minimum tag number for items in CHOICE definition. */
+  public static final int MIN_TAG_NUMBER = 0;
+
+  /** Maximum tag number for items in CHOICE definition. */
+  public static final int MAX_TAG_NUMBER = 8;
+
+
+  /**
+   * Gets a name type from the value of the tag in the CHOICE element definition.
+   *
+   * @param  tagNo  Ordinal position of type in CHOICE definition in RFC 2459.
+   *
+   * @return  Type corresponding to given tag number.
+   *
+   * @throws  IllegalArgumentException  If there is not a general name type corresponding to the given tag number.
+   */
+  public static GeneralNameType fromTagNumber(final int tagNo)
+  {
+    if (tagNo < MIN_TAG_NUMBER || tagNo > MAX_TAG_NUMBER) {
+      throw new IllegalArgumentException("Invalid tag number " + tagNo);
+    }
+    for (GeneralNameType type : values()) {
+      if (type.ordinal() == tagNo) {
+        return type;
+      }
+    }
+    throw new IllegalArgumentException("Invalid tag number " + tagNo);
+  }
+}
diff --git a/src/main/java/org/cryptacular/x509/KeyUsageBits.java b/src/main/java/org/cryptacular/x509/KeyUsageBits.java
new file mode 100644
index 0000000..83782e6
--- /dev/null
+++ b/src/main/java/org/cryptacular/x509/KeyUsageBits.java
@@ -0,0 +1,120 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.x509;
+
+import java.util.BitSet;
+import org.bouncycastle.asn1.x509.KeyUsage;
+
+/**
+ * Representation of the bit meanings in the <code>KeyUsage</code> BIT STRING type defined in section 4.2.1.3 of RFC
+ * 2459.
+ *
+ * @author  Middleware Services
+ * @version  $Revision: 2745 $
+ */
+public enum KeyUsageBits {
+
+  /** digitalSignature bit. */
+  DigitalSignature(7),
+
+  /** nonRepudiation bit. */
+  NonRepudiation(6),
+
+  /** keyEncipherment bit. */
+  KeyEncipherment(5),
+
+  /** dataEncipherment bit. */
+  DataEncipherment(4),
+
+  /** keyAgreement bit. */
+  KeyAgreement(3),
+
+  /** keyCertSign bit. */
+  KeyCertSign(2),
+
+  /** cRLSign bit. */
+  CRLSign(1),
+
+  /** encipherOnly bit. */
+  EncipherOnly(0),
+
+  /** decipherOnly bit. */
+  DecipherOnly(15);
+
+
+  /** Bit mask offset. */
+  private final int offset;
+
+
+  /**
+   * Creates a bit flag with the given bit mask offset.
+   *
+   * @param  offset  Bit mask offset.
+   */
+  KeyUsageBits(final int offset)
+  {
+    this.offset = offset;
+  }
+
+
+  /** @return  Bit mask value. */
+  public int getMask()
+  {
+    return 1 << offset;
+  }
+
+
+  /**
+   * Determines whether this key usage bit is set in the given key usage value.
+   *
+   * @param  keyUsage  BC key usage object.
+   *
+   * @return  True if bit is set, false otherwise.
+   */
+  public boolean isSet(final KeyUsage keyUsage)
+  {
+    return isSet(keyUsage.getBytes());
+  }
+
+
+  /**
+   * Determines whether this key usage bit is set in the given key usage bit string.
+   *
+   * @param  bitString  Key usage bit string as a byte array.
+   *
+   * @return  True if bit is set, false otherwise.
+   */
+  public boolean isSet(final byte[] bitString)
+  {
+    return BitSet.valueOf(bitString).get(offset);
+  }
+
+
+  /**
+   * Determines whether this key usage bit is set in the given key usage bit string.
+   *
+   * @param  bitString  Key usage bit string as a big endian integer.
+   *
+   * @return  True if bit is set, false otherwise.
+   */
+  public boolean isSet(final int bitString)
+  {
+    return (bitString & getMask()) >> offset == 1;
+  }
+
+
+  /**
+   * Computes the key usage value from one or more key usage bits.
+   *
+   * @param  bits  One ore more key usage bits.
+   *
+   * @return  Key usage bit string as an integer.
+   */
+  public static int usage(final KeyUsageBits... bits)
+  {
+    int usage = 0;
+    for (KeyUsageBits bit : bits) {
+      usage |= bit.getMask();
+    }
+    return usage;
+  }
+}
diff --git a/src/main/java/org/cryptacular/x509/dn/Attribute.java b/src/main/java/org/cryptacular/x509/dn/Attribute.java
new file mode 100644
index 0000000..d6e7738
--- /dev/null
+++ b/src/main/java/org/cryptacular/x509/dn/Attribute.java
@@ -0,0 +1,51 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.x509.dn;
+
+/**
+ * Simple implementation of the X.501 AttributeTypeAndValue that makes up the RelativeDistinguishedName type described
+ * in section 4.1.2.4 of RFC 2459.
+ *
+ * @author  Middleware Services
+ */
+public class Attribute
+{
+
+  /** Attribute type. */
+  private final AttributeType type;
+
+  /** Attribute value. */
+  private final String value;
+
+
+  /**
+   * Creates a new instance of the given type and value.
+   *
+   * @param  type  Attribute type.
+   * @param  value  Attribute value.
+   */
+  public Attribute(final AttributeType type, final String value)
+  {
+    if (type == null) {
+      throw new IllegalArgumentException("Type cannot be null.");
+    }
+    this.type = type;
+    if (value == null) {
+      throw new IllegalArgumentException("Value cannot be null.");
+    }
+    this.value = value;
+  }
+
+
+  /** @return  Attribute type. */
+  public AttributeType getType()
+  {
+    return type;
+  }
+
+
+  /** @return  Attribute value. */
+  public String getValue()
+  {
+    return value;
+  }
+}
diff --git a/src/main/java/org/cryptacular/x509/dn/AttributeType.java b/src/main/java/org/cryptacular/x509/dn/AttributeType.java
new file mode 100644
index 0000000..2d53d11
--- /dev/null
+++ b/src/main/java/org/cryptacular/x509/dn/AttributeType.java
@@ -0,0 +1,18 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.x509.dn;
+
+/**
+ * Describes values of AttributeType that may appear in a RelativeDistinguishedName (RDN) as defined in section 2 of RFC
+ * 2253.
+ *
+ * @author  Middleware Services
+ */
+public interface AttributeType
+{
+
+  /** @return  Attribute OID. */
+  String getOid();
+
+  /** @return  Attribute name. */
+  String getName();
+}
diff --git a/src/main/java/org/cryptacular/x509/dn/Attributes.java b/src/main/java/org/cryptacular/x509/dn/Attributes.java
new file mode 100644
index 0000000..a4eef35
--- /dev/null
+++ b/src/main/java/org/cryptacular/x509/dn/Attributes.java
@@ -0,0 +1,117 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.x509.dn;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * Ordered list of {@link Attribute}s.
+ *
+ * @author  Middleware Services
+ */
+public class Attributes implements Iterable<Attribute>
+{
+
+  /** Underlying attributes. */
+  private final List<Attribute> attributes = new ArrayList<>(5);
+
+
+  /**
+   * Adds an attribute by type and value to the end of the attribute list.
+   *
+   * @param  typeOid  OID of attribute type.
+   * @param  value  Attribute value.
+   */
+  public void add(final String typeOid, final String value)
+  {
+    final StandardAttributeType type = StandardAttributeType.fromOid(typeOid);
+    if (type != null) {
+      add(new Attribute(type, value));
+    } else {
+      add(new Attribute(new UnknownAttributeType(typeOid), value));
+    }
+  }
+
+
+  /**
+   * Adds the given attribute to the end of the attribute list.
+   *
+   * @param  attr  Non-null attribute.
+   */
+  public void add(final Attribute attr)
+  {
+    if (attr == null) {
+      throw new IllegalArgumentException("Attribute cannot be null");
+    }
+    attributes.add(attr);
+  }
+
+
+  /**
+   * Gets the number of attributes contained in this instance.
+   *
+   * @return  Number of attributes.
+   */
+  public int size()
+  {
+    return attributes.size();
+  }
+
+
+  /**
+   * Gets an immutable list of attributes.
+   *
+   * @return  Non-null immutable attribute list.
+   */
+  public List<Attribute> getAll()
+  {
+    return Collections.unmodifiableList(attributes);
+  }
+
+
+  /**
+   * Gets an immutable list of all attributes of the given type. The order of the returned list reflects the ordering of
+   * the underlying attributes.
+   *
+   * @param  type  Attribute type.
+   *
+   * @return  Non-null list of attributes of given type. An empty list is returned if there are no attributes of the
+   *          given type.
+   */
+  public List<String> getValues(final AttributeType type)
+  {
+    final List<String> values = new ArrayList<>(attributes.size());
+    values.addAll(
+      attributes.stream().filter(
+        attr -> attr.getType().equals(type)).map(Attribute::getValue).collect(Collectors.toList()));
+    return Collections.unmodifiableList(values);
+  }
+
+
+  /**
+   * Gets the first value of the given type that appears in the attribute list.
+   *
+   * @param  type  Attribute type.
+   *
+   * @return  Value of first attribute of given type or null if no attributes of given type exist.
+   */
+  public String getValue(final AttributeType type)
+  {
+    for (Attribute attr : attributes) {
+      if (attr.getType().equals(type)) {
+        return attr.getValue();
+      }
+    }
+    return null;
+  }
+
+
+  @Override
+  public Iterator<Attribute> iterator()
+  {
+    return attributes.iterator();
+  }
+}
diff --git a/src/main/java/org/cryptacular/x509/dn/LdapNameFormatter.java b/src/main/java/org/cryptacular/x509/dn/LdapNameFormatter.java
new file mode 100644
index 0000000..632d031
--- /dev/null
+++ b/src/main/java/org/cryptacular/x509/dn/LdapNameFormatter.java
@@ -0,0 +1,110 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.x509.dn;
+
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import java.nio.charset.StandardCharsets;
+import javax.security.auth.x500.X500Principal;
+import org.cryptacular.codec.HexEncoder;
+
+/**
+ * Produces a string representation of an X.500 distinguished name using the process described in section 2 of RFC 2253,
+ * LADPv3 Distinguished Names.
+ *
+ * @author  Middleware Services
+ */
+public class LdapNameFormatter implements NameFormatter
+{
+
+  /** Separator character between RDN components. */
+  public static final char RDN_SEPARATOR = ',';
+
+  /** Separator character between ATV components in the same RDN element. */
+  public static final char ATV_SEPARATOR = '+';
+
+  /** Escape character. */
+  public static final char ESCAPE_CHAR = '\\';
+
+  /** String of characters that need to be escaped. */
+  public static final String RESERVED_CHARS = ",+\"\\<>;";
+
+  /** Handles hex encoding. */
+  private static final HexEncoder ENCODER = new HexEncoder();
+
+
+  @Override
+  public String format(final X500Principal dn)
+  {
+    final StringBuilder builder = new StringBuilder();
+    final RDNSequence sequence = NameReader.readX500Principal(dn);
+    int i = 0;
+    for (RDN rdn : sequence.backward()) {
+      if (i++ > 0) {
+        builder.append(RDN_SEPARATOR);
+      }
+
+      int j = 0;
+      for (Attribute attr : rdn.getAttributes()) {
+        if (j++ > 0) {
+          builder.append(ATV_SEPARATOR);
+        }
+        builder.append(attr.getType()).append('=');
+
+        final AttributeType type = attr.getType();
+        if (type instanceof StandardAttributeType) {
+          escape(attr.getValue(), builder);
+        } else {
+          encode(attr.getValue(), builder);
+        }
+      }
+    }
+    return builder.toString();
+  }
+
+
+  /**
+   * Appends the given value to the output with proper character escaping.
+   *
+   * @param  value  Value to escape.
+   * @param  output  String builder where escaped value is written.
+   */
+  private static void escape(final String value, final StringBuilder output)
+  {
+    char c = value.charAt(0);
+    if (c == ' ' || c == '#') {
+      output.append(ESCAPE_CHAR);
+    }
+    output.append(c);
+
+    final int nmax = value.length() - 1;
+    for (int n = 1; n < nmax; n++) {
+      c = value.charAt(n);
+      if (RESERVED_CHARS.indexOf(c) > -1) {
+        output.append(ESCAPE_CHAR);
+      }
+      output.append(c);
+    }
+    c = value.charAt(nmax);
+    if (c == ' ') {
+      output.append(ESCAPE_CHAR);
+    }
+    output.append(c);
+  }
+
+
+  /**
+   * Appends the given value to the output using the HEX encoding method described in section 2.4.
+   *
+   * @param  value  Value to encode.
+   * @param  output  String builder where encoded value is written.
+   */
+  private static void encode(final String value, final StringBuilder output)
+  {
+    output.append('#');
+
+    final byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
+    final CharBuffer out = CharBuffer.allocate(bytes.length * 2);
+    ENCODER.encode(ByteBuffer.wrap(bytes), out);
+    output.append(out.flip());
+  }
+}
diff --git a/src/main/java/org/cryptacular/x509/dn/NameFormatter.java b/src/main/java/org/cryptacular/x509/dn/NameFormatter.java
new file mode 100644
index 0000000..af045a8
--- /dev/null
+++ b/src/main/java/org/cryptacular/x509/dn/NameFormatter.java
@@ -0,0 +1,22 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.x509.dn;
+
+import javax.security.auth.x500.X500Principal;
+
+/**
+ * Strategy pattern interface for producing a string representation of an X.500 distinguished name.
+ *
+ * @author  Middleware Services
+ */
+public interface NameFormatter
+{
+
+  /**
+   * Produces a string representation of the given X.500 principal.
+   *
+   * @param  dn  Distinguished name as an X.500 principal.
+   *
+   * @return  String representation of DN.
+   */
+  String format(X500Principal dn);
+}
diff --git a/src/main/java/org/cryptacular/x509/dn/NameReader.java b/src/main/java/org/cryptacular/x509/dn/NameReader.java
new file mode 100644
index 0000000..7564aa5
--- /dev/null
+++ b/src/main/java/org/cryptacular/x509/dn/NameReader.java
@@ -0,0 +1,91 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.x509.dn;
+
+import java.security.cert.X509Certificate;
+import javax.security.auth.x500.X500Principal;
+import org.bouncycastle.asn1.x500.AttributeTypeAndValue;
+import org.bouncycastle.asn1.x500.X500Name;
+
+/**
+ * Reads X.509 subject and issuer DNs as a raw sequence of attributes to facilitate precise handling of name parsing.
+ *
+ * @author  Middleware Services
+ */
+public class NameReader
+{
+
+  /** Certificate to read. */
+  private final X509Certificate certificate;
+
+
+  /**
+   * Creates a new instance to support reading subject and issuer information on the given certificate.
+   *
+   * @param  cert  Certificate to read.
+   */
+  public NameReader(final X509Certificate cert)
+  {
+    if (cert == null) {
+      throw new IllegalArgumentException("Certificate cannot be null.");
+    }
+    this.certificate = cert;
+  }
+
+
+  /**
+   * Reads the subject field from the certificate.
+   *
+   * @return  Subject DN as an RDN sequence.
+   */
+  public RDNSequence readSubject()
+  {
+    return readX500Principal(certificate.getSubjectX500Principal());
+  }
+
+
+  /**
+   * Reads the issuer field from the certificate.
+   *
+   * @return  Issuer DN as an RDN sequence.
+   */
+  public RDNSequence readIssuer()
+  {
+    return readX500Principal(certificate.getIssuerX500Principal());
+  }
+
+
+  /**
+   * Converts the given X.500 principal to a list of relative distinguished names that contains the attributes
+   * comprising the DN.
+   *
+   * @param  principal  Principal to convert.
+   *
+   * @return  X500 principal as an RDN sequence.
+   */
+  public static RDNSequence readX500Principal(final X500Principal principal)
+  {
+    return readX500Name(X500Name.getInstance(principal.getEncoded()));
+  }
+
+
+  /**
+   * Converts the given X.500 name to a list of relative distinguished names that contains the attributes
+   * comprising the DN.
+   *
+   * @param  name  X.500 name.
+   *
+   * @return  X.500 name as an RDN sequence.
+   */
+  public static RDNSequence readX500Name(final X500Name name)
+  {
+    final RDNSequence sequence = new RDNSequence();
+    for (org.bouncycastle.asn1.x500.RDN rdn : name.getRDNs()) {
+      final Attributes attributes = new Attributes();
+      for (AttributeTypeAndValue tv : rdn.getTypesAndValues()) {
+        attributes.add(tv.getType().getId(), tv.getValue().toString());
+      }
+      sequence.add(new RDN(attributes));
+    }
+    return sequence;
+  }
+}
diff --git a/src/main/java/org/cryptacular/x509/dn/RDN.java b/src/main/java/org/cryptacular/x509/dn/RDN.java
new file mode 100644
index 0000000..be2a3e3
--- /dev/null
+++ b/src/main/java/org/cryptacular/x509/dn/RDN.java
@@ -0,0 +1,35 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.x509.dn;
+
+/**
+ * Simple implementation of the X.501 RelativeDistinguishedName type described in section 4.1.2.4 of RFC 2459.
+ *
+ * @author  Middleware Services
+ */
+public class RDN
+{
+
+  /** RDN attributes. */
+  private final Attributes attributes;
+
+
+  /**
+   * Creates a new instance with given attributes.
+   *
+   * @param  attributes  Container for one or more AttributeTypeAndValues.
+   */
+  public RDN(final Attributes attributes)
+  {
+    if (attributes == null) {
+      throw new IllegalArgumentException("Attributes cannot be null");
+    }
+    this.attributes = attributes;
+  }
+
+
+  /** @return  RDN attributes. */
+  public Attributes getAttributes()
+  {
+    return attributes;
+  }
+}
diff --git a/src/main/java/org/cryptacular/x509/dn/RDNSequence.java b/src/main/java/org/cryptacular/x509/dn/RDNSequence.java
new file mode 100644
index 0000000..53ba5aa
--- /dev/null
+++ b/src/main/java/org/cryptacular/x509/dn/RDNSequence.java
@@ -0,0 +1,126 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.x509.dn;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.ListIterator;
+
+/**
+ * Simple implementation of the X.501 RDNSequence type described in section 4.1.2.4 of RFC 2459.
+ *
+ * @author  Middleware Services
+ */
+public class RDNSequence implements Iterable<RDN>
+{
+
+  /** Maintains the list/sequence of RDNs. */
+  private final List<RDN> rdns = new ArrayList<>(10);
+
+
+  /**
+   * Adds an RDN to the sequence.
+   *
+   * @param  rdn  RDN to add.
+   */
+  public void add(final RDN rdn)
+  {
+    rdns.add(rdn);
+  }
+
+
+  @Override
+  public Iterator<RDN> iterator()
+  {
+    return rdns.iterator();
+  }
+
+
+  /** @return  Iterable that moves backward over the RDN sequence. */
+  public Iterable<RDN> backward()
+  {
+    return
+      () -> new Iterator<RDN>() {
+
+        /** List iterator. */
+        private final ListIterator<RDN> it = rdns.listIterator(rdns.size());
+
+        @Override
+        public boolean hasNext()
+        {
+          return it.hasPrevious();
+        }
+
+        @Override
+        public RDN next()
+        {
+          return it.previous();
+        }
+
+        @Override
+        public void remove()
+        {
+          throw new UnsupportedOperationException("Remove not supported");
+        }
+      };
+  }
+
+
+  /**
+   * Gets an immutable list of all attributes of the given type. The order of the returned list reflects the ordering of
+   * the RDNs and their attributes.
+   *
+   * @param  type  Attribute type.
+   *
+   * @return  Non-null list of attributes of given type. An empty list is returned if there are no attributes of the
+   *          given type.
+   */
+  public List<String> getValues(final AttributeType type)
+  {
+    final List<String> values = new ArrayList<>(rdns.size());
+    for (RDN rdn : rdns) {
+      values.addAll(rdn.getAttributes().getValues(type));
+    }
+    return Collections.unmodifiableList(values);
+  }
+
+
+  /**
+   * Gets the first value of the given type that appears in the attribute list of any RDN in the sequence.
+   *
+   * @param  type  Attribute type.
+   *
+   * @return  Value of first attribute of given type or null if no attributes of given type exist.
+   */
+  public String getValue(final AttributeType type)
+  {
+    final List<String> values = getValues(type);
+    if (!values.isEmpty()) {
+      return values.get(0);
+    }
+    return null;
+  }
+
+  /**
+   * Creates a comma-separated list of TYPE=VALUE tokens from the attributes in the list in order.
+   *
+   * @return  String representation that resembles an X.509 distinguished name, e.g. <code>CN=foo, OU=Bar, dc=example,
+   *          dc=com</code>.
+   */
+  @Override
+  public String toString()
+  {
+    final StringBuilder builder = new StringBuilder();
+    int i = 0;
+    for (RDN rdn : this) {
+      for (Attribute attr : rdn.getAttributes()) {
+        if (i++ > 0) {
+          builder.append(", ");
+        }
+        builder.append(attr.getType()).append('=').append(attr.getValue());
+      }
+    }
+    return builder.toString();
+  }
+}
diff --git a/src/main/java/org/cryptacular/x509/dn/StandardAttributeType.java b/src/main/java/org/cryptacular/x509/dn/StandardAttributeType.java
new file mode 100644
index 0000000..c9f81da
--- /dev/null
+++ b/src/main/java/org/cryptacular/x509/dn/StandardAttributeType.java
@@ -0,0 +1,179 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.x509.dn;
+
+/**
+ * Describes the registered values of AttributeType that may appear in a RelativeDistinguishedName (RDN) as defined in
+ * section 2 of RFC 2253.
+ *
+ * <p>Enumeration values include attributes likely to appear in an X.509 RDN, which were obtained from the following
+ * sources:</p>
+ *
+ * <ul>
+ *   <li>RFC 4519 Lightweight Directory Access Protocol (LDAP): Schema for User Applications</li>
+ *   <li>RFC 4524 COSINE LDAP/X.500 Schema</li>
+ *   <li>PKCS #9 v2.0: Selected Object Classes and Attribute Types</li>
+ * </ul>
+ *
+ * @author  Middleware Services
+ * @version  $Revision: 2745 $
+ */
+public enum StandardAttributeType implements AttributeType {
+
+  /** CN - RFC 4519 section 2.3. */
+  CommonName("2.5.4.3", "CN"),
+
+  /** C - RFC 4519 section 2.2. */
+  CountryName("2.5.4.6", "C"),
+
+  /** DESCRIPTION - RFC 4519 section 2.5. */
+  Description("2.5.4.13", "DESCRIPTION"),
+
+  /** DNQUALIFIER - RFC 4519 section 2.8. */
+  DnQualifier("2.5.4.46", "DNQUALIFIER"),
+
+  /** DC - RFC 4519 section 2.4. */
+  DomainComponent("0.9.2342.19200300.100.1.25", "DC"),
+
+  /** Email address - PKCS#9 v2.0 section B.3.5. */
+  EmailAddress("1.2.840.113549.1.9.1", "EMAILADDRESS"),
+
+  /** GenerationQualifier - RFC 4519 section 2.11. */
+  GenerationQualifier("2.5.4.44", "GENERATIONQUALIFIER"),
+
+  /** GIVENNAME - RFC 4519 section 2.12. */
+  GivenName("2.5.4.42", "GIVENNAME"),
+
+  /** INITIALS - RFC 4519 section 2.14. */
+  Initials("2.5.4.43", "INITIALS"),
+
+  /** L - RFC 4519 section 2.16. */
+  LocalityName("2.5.4.7", "L"),
+
+  /** MAIL - RFC 4524 section 2.16. */
+  Mail("0.9.2342.19200300.100.1.3", "MAIL"),
+
+  /** NAME - RFC 4519 section 2.18. */
+  Name("2.5.4.41", "NAME"),
+
+  /** O - RFC 4519 section 2.19. */
+  OrganizationName("2.5.4.10", "O"),
+
+  /** OU - RFC 4519 section 2.20. */
+  OrganizationalUnitName("2.5.4.11", "OU"),
+
+  /** POSTALADDRESS - RFC 4519 section 2.23. */
+  PostalAddress("2.5.4.16", "POSTALADDRESS"),
+
+  /** POSTALCODE - RFC 4519 section 2.24. */
+  PostalCode("2.5.4.17", "POSTALCODE"),
+
+  /** POSTOFFICEBOX - RFC 4519 section 2.25. */
+  PostOfficeBox("2.5.4.18", "POSTOFFICEBOX"),
+
+  /** SERIALNUMBER - RFC 4519 section 2.31. */
+  SerialNumber("2.5.4.5", "SERIALNUMBER"),
+
+  /** ST - RFC 4519 section 2.33. */
+  StateOrProvinceName("2.5.4.8", "ST"),
+
+  /** STREET - RFC 4519 section 2.34. */
+  StreetAddress("2.5.4.9", "STREET"),
+
+  /** SN - RFC 4519 section 2.32. */
+  Surname("2.5.4.4", "SN"),
+
+  /** TELEPHONENUMBER - RFC 4519 section 2.35. */
+  TelephoneNumber("2.5.4.20", "TELEPHONENUMBER"),
+
+  /** TITLE - RFC 4519 section 2.38. */
+  Title("2.5.4.12", "TITLE"),
+
+  /** UNIQUEIDENTIFIER - RFC 4524 section 2.24. */
+  UniqueIdentifier("0.9.2342.19200300.100.1.44", "UNIQUEIDENTIFIER"),
+
+  /** UID - RFC 4519 section 2.39. */
+  UserId("0.9.2342.19200300.100.1.1", "UID");
+
+
+  /** OID of RDN attribute type. */
+  private final String oid;
+
+  /** Display string of the type in an RDN. */
+  private final String name;
+
+
+  /**
+   * Creates a new type for the given OID.
+   *
+   * @param  attributeTypeOid  OID of attribute type.
+   * @param  shortName  Registered short name for the attribute type.
+   */
+  StandardAttributeType(final String attributeTypeOid, final String shortName)
+  {
+    oid = attributeTypeOid;
+    name = shortName;
+  }
+
+
+  /** @return  OID of attribute type. */
+  @Override
+  public String getOid()
+  {
+    return oid;
+  }
+
+
+  /** @return  Registered short name of attribute type. */
+  @Override
+  public String getName()
+  {
+    return name;
+  }
+
+
+  /** @return  Attribute name. */
+  @Override
+  public String toString()
+  {
+    return name;
+  }
+
+
+  /**
+   * Gets the attribute type whose OID is the given string.
+   *
+   * @param  oid  OID of attribute type to get.
+   *
+   * @return  Attribute type whose OID matches given value or none if there is no standard attribute type matching the
+   *          given OID.
+   */
+  public static StandardAttributeType fromOid(final String oid)
+  {
+    for (StandardAttributeType t : StandardAttributeType.values()) {
+      if (t.getOid().equals(oid)) {
+        return t;
+      }
+    }
+    return null;
+  }
+
+
+  /**
+   * Gets the attribute type whose name is the given string.
+   *
+   * @param  name  Name of attribute to get, where the name is the all-caps RFC/standard name that would be returned by
+   *               {@link #getName()} for the desired attribute.
+   *
+   * @return  Attribute type whose {@link #getName()} property matches the given value or null if there is no standard
+   *          attribute with the given name.
+   */
+  public static AttributeType fromName(final String name)
+  {
+    for (AttributeType t : StandardAttributeType.values()) {
+      if (t.getName().equals(name)) {
+        return t;
+      }
+    }
+    return null;
+  }
+}
diff --git a/src/main/java/org/cryptacular/x509/dn/UnknownAttributeType.java b/src/main/java/org/cryptacular/x509/dn/UnknownAttributeType.java
new file mode 100644
index 0000000..d9d5f3e
--- /dev/null
+++ b/src/main/java/org/cryptacular/x509/dn/UnknownAttributeType.java
@@ -0,0 +1,52 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.x509.dn;
+
+import java.util.regex.Pattern;
+
+/**
+ * Describes a non-standard AttributeType in dotted decimal form that may appear in a RelativeDistinguishedName (RDN) as
+ * defined in section 2 of RFC 2253.
+ *
+ * @author  Middleware Services
+ */
+public class UnknownAttributeType implements AttributeType
+{
+
+  /** Dotted decimal OID pattern. */
+  private static final Pattern PATTERN = Pattern.compile("[0-9]+(.[0-9]+)*");
+
+  /** Attribute type OID. */
+  private final String oid;
+
+
+  /**
+   * Creates a new instance from the given oid.
+   *
+   * @param  attributeTypeOid  Attribute type OID.
+   */
+  public UnknownAttributeType(final String attributeTypeOid)
+  {
+    if (!PATTERN.matcher(attributeTypeOid).matches()) {
+      throw new IllegalArgumentException(attributeTypeOid + " is not an OID");
+    }
+    this.oid = attributeTypeOid;
+  }
+
+  @Override
+  public String getOid()
+  {
+    return oid;
+  }
+
+  @Override
+  public String getName()
+  {
+    return oid;
+  }
+
+  @Override
+  public String toString()
+  {
+    return oid;
+  }
+}
diff --git a/src/main/spotbugs/exclude.xml b/src/main/spotbugs/exclude.xml
new file mode 100644
index 0000000..b614caa
--- /dev/null
+++ b/src/main/spotbugs/exclude.xml
@@ -0,0 +1,62 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<FindBugsFilter
+    xmlns="https://github.com/spotbugs/filter/4.8.4"
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xsi:schemaLocation="https://github.com/spotbugs/filter/4.8.4
+                        https://raw.githubusercontent.com/spotbugs/spotbugs/4.8.4/spotbugs/etc/findbugsfilter.xsd">
+
+  <!-- See https://spotbugs.readthedocs.io/en/latest/bugDescriptions.html -->
+
+  <!-- Allow platform specific encoding -->
+  <Match>
+    <Class name="org.cryptacular.CiphertextHeader" />
+    <Bug pattern="DM_DEFAULT_ENCODING" />
+  </Match>
+
+  <!-- Result of InputStream#read is ignored -->
+  <Match>
+    <Class name="org.cryptacular.CiphertextHeader" />
+    <Method name="decode" />
+    <Bug pattern="RR_NOT_CHECKED" />
+  </Match>
+
+  <!-- Allow constructors to throw exceptions -->
+  <Match>
+    <Bug pattern="CT_CONSTRUCTOR_THROW" />
+  </Match>
+
+  <!-- Allow platform specific encoding when reading and writing files -->
+  <Match>
+    <Or>
+      <Class name="org.cryptacular.util.StreamUtil" />
+      <Class name="org.cryptacular.io.DecodingInputStream" />
+      <Class name="org.cryptacular.io.EncodingOutputStream" />
+    </Or>
+    <Bug pattern="DM_DEFAULT_ENCODING" />
+  </Match>
+
+  <!-- This check appears broken, SecureRandom is not discarded -->
+  <Match>
+    <Or>
+      <Class name="org.cryptacular.generator.RandomIdGenerator" />
+      <Class name="org.cryptacular.util.NonceUtil" />
+    </Or>
+    <Bug pattern="DMI_RANDOM_USED_ONLY_ONCE" />
+  </Match>
+
+  <!-- Byte array streams do not need to be closed -->
+  <Match>
+    <Class name="org.cryptacular.asn.OpenSSLPrivateKeyDecoder" />
+    <Method name="decodeASN1" />
+    <Bug pattern="OS_OPEN_STREAM" />
+  </Match>
+
+  <!-- Internal representation is exposed throughout the API -->
+  <Match>
+    <Or>
+      <Bug pattern="EI_EXPOSE_REP" />
+      <Bug pattern="EI_EXPOSE_REP2" />
+    </Or>
+  </Match>
+
+</FindBugsFilter>
diff --git a/src/openssl/gen-test-cert.sh b/src/openssl/gen-test-cert.sh
new file mode 100755
index 0000000..360c511
--- /dev/null
+++ b/src/openssl/gen-test-cert.sh
@@ -0,0 +1,11 @@
+#!/bin/sh
+
+if [ $# -lt 1 ]; then
+  echo "USAGE: `basename $0` path/to/cert/file"
+  exit
+fi
+CSR=request.csr
+openssl req -config openssl.cnf -new -key test-key.pem -out $CSR
+openssl ca -config openssl.cnf -days 10000 -in request.csr -out $1
+rm -f $CSR
+
diff --git a/src/openssl/openssl.cnf b/src/openssl/openssl.cnf
new file mode 100644
index 0000000..15b2756
--- /dev/null
+++ b/src/openssl/openssl.cnf
@@ -0,0 +1,257 @@
+#

+# OpenSSL example configuration file.

+# This is mostly being used for generation of certificate requests.

+#

+

+# This definition stops the following lines choking if HOME isn't

+# defined.

+HOME			= .

+RANDFILE		= $ENV::HOME/.rnd

+

+# Extra OBJECT IDENTIFIER info:

+#oid_file		= $ENV::HOME/.oid

+oid_section		= new_oids

+

+# To use this configuration file with the "-extfile" option of the

+# "openssl x509" utility, name here the section containing the

+# X.509v3 extensions to use:

+# extensions		= 

+# (Alternatively, use a configuration file that has only

+# X.509v3 extensions in its main [= default] section.)

+

+[ new_oids ]

+

+# We can add new OIDs in here for use by 'ca' and 'req'.

+# Add a simple OID like this:

+# testoid1=1.2.3.4

+# Or use config file substitution like this:

+# testoid2=${testoid1}.5.6

+

+####################################################################

+[ ca ]

+default_ca	= CA_default		# The default ca section

+

+####################################################################

+[ CA_default ]

+

+dir		= ./testCA        # Where everything is kept

+certs		= $dir/certs		# Where the issued certs are kept

+crl_dir		= $dir/crl		# Where the issued crl are kept

+database	= $dir/index.txt	# database index file.

+new_certs_dir	= $dir/newcerts		# default place for new certs.

+

+certificate	= $dir/ca/cacert.pem 	# The CA certificate

+serial		= $dir/serial 		# The current serial number

+crl		= $dir/crl.pem 		# The current CRL

+private_key	= $dir/ca/cakey.pem # The private key

+RANDFILE	= $dir/private/.rand	# private random number file

+

+x509_extensions	= usr_cert		# The extentions to add to the cert

+

+# Comment out the following two lines for the "traditional"

+# (and highly broken) format.

+name_opt 	= ca_default		# Subject Name options

+cert_opt 	= ca_default		# Certificate field options

+

+# Extension copying option: use with caution.

+# copy_extensions = copy

+

+# Extensions to add to a CRL. Note: Netscape communicator chokes on V2 CRLs

+# so this is commented out by default to leave a V1 CRL.

+# crl_extensions	= crl_ext

+

+default_days	= 10000			# how long to certify for

+default_crl_days= 30			# how long before next CRL

+default_md	= sha256			# which md to use.

+preserve	= no		      	# keep passed DN ordering

+

+# A few difference way of specifying how similar the request should look

+# For type CA, the listed attributes must be the same, and the optional

+# and supplied fields are just that :-)

+policy		= policy_match

+

+# For the CA policy

+[ policy_match ]

+countryName		= match

+stateOrProvinceName	= match

+organizationName	= match

+organizationalUnitName	= optional

+commonName		= supplied

+emailAddress		= optional

+

+# For the 'anything' policy

+# At this point in time, you must list all acceptable 'object'

+# types.

+[ policy_anything ]

+countryName		= optional

+stateOrProvinceName	= optional

+localityName		= optional

+organizationName	= optional

+organizationalUnitName	= optional

+commonName		= supplied

+emailAddress		= optional

+

+####################################################################

+[ req ]

+default_bits		= 2048

+default_keyfile 	= serverkey.pem

+distinguished_name	= req_distinguished_name

+attributes		= req_attributes

+x509_extensions	= v3_ca	# The extentions to add to the self signed cert

+

+# Passwords for private keys if not present they will be prompted for

+# input_password = secret

+# output_password = secret

+

+# This sets a mask for permitted string types. There are several options. 

+# default: PrintableString, T61String, BMPString.

+# pkix	 : PrintableString, BMPString.

+# utf8only: only UTF8Strings.

+# nombstr : PrintableString, T61String (no BMPStrings or UTF8Strings).

+# MASK:XXXX a literal mask value.

+# WARNING: current versions of Netscape crash on BMPStrings or UTF8Strings

+# so use this option with caution!

+string_mask = nombstr

+

+# req_extensions = v3_req # The extensions to add to a certificate request

+

+[ req_distinguished_name ]

+

+0.domainComponent               = Domain Component 1 (eg, edu, com)

+0.domainComponent_default       = com

+

+1.domainComponent               = Domain Component 2 (eg, vt)

+1.domainComponent_default       = example

+

+countryName				= Country Name (2 letter code)

+countryName_default		= US

+countryName_min			= 2

+countryName_max			= 2

+

+stateOrProvinceName		= State or Province Name (full name)

+stateOrProvinceName_default	= New York

+

+localityName			= Locality Name (eg, city)

+localityName_default		= New York

+

+0.organizationName		= Organization Name (eg, company)

+0.organizationName_default	= Snake Oil Unlimited

+

+commonName			= Common Name (eg, YOUR server DNS name eg. server.dept.vt.edu  must be less than 64 charactors)

+commonName_max			= 64

+commonName_default	= something.example.com

+

+#emailAddress			= Email Address

+#emailAddress_max		= 64

+

+# SET-ex3			= SET extension number 3

+

+[ req_attributes ]

+#challengePassword		= A challenge password

+#challengePassword_min		= 4

+#challengePassword_max		= 20

+

+#unstructuredName		= An optional company name

+

+[ usr_cert ]

+

+# These extensions are added when 'ca' signs a request.

+

+# This goes against PKIX guidelines but some CAs do it and some software

+# requires this to avoid interpreting an end user certificate as a CA.

+

+basicConstraints=CA:FALSE

+

+# Here are some examples of the usage of nsCertType. If it is omitted

+# the certificate can be used for anything *except* object signing.

+

+# This is OK for an SSL server.

+# nsCertType			= server

+

+# For an object signing certificate this would be used.

+# nsCertType = objsign

+

+# For normal client use this is typical

+# nsCertType = client, email

+

+# and for everything including object signing:

+# nsCertType = client, email, objsign

+

+# This is typical in keyUsage for a client certificate.

+# keyUsage = nonRepudiation, digitalSignature, keyEncipherment

+

+# This will be displayed in Netscape's comment listbox.

+nsComment			= "OpenSSL Generated Certificate"

+

+# PKIX recommendations harmless if included in all certificates.

+subjectKeyIdentifier=hash

+authorityKeyIdentifier=keyid,issuer:always

+

+# This stuff is for subjectAltName and issuerAltname.

+# Import the email address.

+# subjectAltName=email:copy

+# An alternative to produce certificates that aren't

+# deprecated according to PKIX.

+# subjectAltName=email:move

+

+# Copy subject details

+issuerAltName=DNS:snake-1.example.com, DNS:snake-2.example.com

+

+nsCaRevocationUrl=http://crl.example.com/ca-crl.pem

+#nsBaseUrl

+#nsRevocationUrl

+#nsRenewalUrl

+#nsCaPolicyUrl

+#nsSslServerName

+

+[ v3_req ]

+

+# Extensions to add to a certificate request

+

+basicConstraints = CA:FALSE

+keyUsage = nonRepudiation, digitalSignature, keyEncipherment

+

+[ v3_ca ]

+

+

+# Extensions for a typical CA

+

+

+# PKIX recommendation.

+

+subjectKeyIdentifier=hash

+

+authorityKeyIdentifier=keyid:always,issuer:always

+

+# This is what PKIX recommends but some broken software chokes on critical

+# extensions.

+#basicConstraints = critical,CA:true

+# So we do this instead.

+basicConstraints = CA:true

+

+# Key usage: this is typical for a CA certificate. However since it will

+# prevent it being used as an test self-signed certificate it is best

+# left out by default.

+# keyUsage = cRLSign, keyCertSign

+

+# Some might want this also

+# nsCertType = sslCA, emailCA

+

+# Include email address in subject alt name: another PKIX recommendation

+# subjectAltName=email:copy

+# Copy issuer details

+# issuerAltName=issuer:copy

+

+# DER hex encoding of an extension: beware experts only!

+# obj=DER:02:03

+# Where 'obj' is a standard or added object

+# You can even override a supported extension:

+# basicConstraints= critical, DER:30:03:01:01:FF

+

+[ crl_ext ]

+

+# CRL extensions.

+# Only issuerAltName and authorityKeyIdentifier make any sense in a CRL.

+

+# issuerAltName=issuer:copy

+authorityKeyIdentifier=keyid:always,issuer:always

diff --git a/src/openssl/test-key.pem b/src/openssl/test-key.pem
new file mode 100644
index 0000000..ff29adf
--- /dev/null
+++ b/src/openssl/test-key.pem
@@ -0,0 +1,27 @@
+-----BEGIN RSA PRIVATE KEY-----
+MIIEpAIBAAKCAQEA3J6aPv2Zr+PvkUCxb1BL03wE6zTL/Jkqbfz+dItiCpDQ0395
+W/D2/nY67p8J5z47WKbdkH8W2E2FZB3PRH8D9Vd0hlXGw9J3Fgg/AIa4s44SHuub
+/rNXeAERviYHVAS+/PesQoxivhJsP50pX0P92AOot9WlcR/nYlo5TAvhDw4xSNg4
+Ya5IviTy9DX9x9qqv6PSIkY1wvALOAB6tnHq/xCwFsaWZJ9VjP7LyaKaeZmOgQ6v
+DZKUwJtiflVKsQYfUvChsFTJHbKZS3QUgLsuY7Ik5McgP8Ks5LpZZ+xF/R4SfTy1
+2qxkI6qPcoWyKcdh8/fQO1R61FNqYhOSkWYodQIDAQABAoIBAQCHd9Qa7bnbKUlH
+lcPeKB4HZFXY33iKSLqnAvx0L8op1raDx/iLHjFsGskhEQMRvULPstbGDWPHugI4
+cZ1938hcdDEW88CzKZ76JmIZPqBXkNtLpT0KbrE8/NsaOVuymZ900dgynOVc9Q8H
+GMf4uVU7uTN2fneyOPbpi6E3MuwlQ1urgo02WSyOfkMALtPBOvM/qajLv1LvIKxf
+lp7JfOrewZokMNX8NUraiGJmOOZsidgd66HLaxqjCALk/7UWTBh9vjur4jPn+qOm
+zukQSpmvoFMjtaoNSQXL7qqYTFKoLqfNNUhq/Ok533JHWSQhOM37YmfmfCEChxIE
+Qdlc0SSZAoGBAPbBRxbOOY6/55Qq5/uuAlMPDMpYIA8nas7P1XQhPd9Ai7RSkN9S
+wATZkOvr/KT07c0maFpWddIAxbgv27GN9VTF0G+HULItRRQ2C8LK/BhhnEVokMyl
+U4mnc3NTQCY1zIbaYJ0i/PEREOyR69WzIDJKl446SAqEgaTyVF6RGCHPAoGBAOTi
+pk92VJuc1vqDaxFoCUw1sUZtyrTCTX/jXxyEQhO8gZfq3m8dTVzW+3AKDlwvFNfj
+WPbJh5H36eHzXJBOomn4yIz9OV/+XmhBux8XESZODfeVF+UFepTc1V4q0TsrBUIf
+luK7ZFtaGWmho3Hn5RA1h8e0Z6c+4nKZ5i0Z3/Z7AoGANPEA/J6gcMUxvXN7NF+A
+Nivbdap0rmupmdezl2fua3DgyH6SgKezdRbs5gFKwmWeg86CwycbvkPWKA90lmK7
+yUVr1BH3OVNHJ+/0lAWTEvQWYDnwH0g1ZSpdNdgdwlT2ndRKuEwicuJTfD5OmBoH
+hWLFo4lTnZYSbr5jZarBv7cCgYA+i+Uwr8BdKdXhbUoz3n8z8TQ5b8VF8hbljMev
+7kB0Tj4HuqoAKTy70w+wxT65WDBU8o6cGeRPMjUahrtTv/lIBjEfvg8QuV0pFlVB
+ILeSBSBx+K8n6YBe753q9r5ocdAlCqbb3KOHBy8Mm5wjg2AoNsic/SKaJGgTMxUg
+XALEwwKBgQCpMQMlKkJniqwjdxqYVfsHNmnTZVWBlu7I5Pph2Os2VGGW+w10XWcX
+h0P3ys6abPQQjzh7STbXGnFYJH1oK1X98jahjI5iMwwpI+4KfcRThbn9nC8qftVL
+ZvD1krEMJbkvke7PnUX/idBmI7VNXhq4538WL47w29jmnmkYNQQFTA==
+-----END RSA PRIVATE KEY-----
diff --git a/src/openssl/testCA/ca/cacert.pem b/src/openssl/testCA/ca/cacert.pem
new file mode 100644
index 0000000..b2ee317
--- /dev/null
+++ b/src/openssl/testCA/ca/cacert.pem
@@ -0,0 +1,28 @@
+-----BEGIN CERTIFICATE-----
+MIIEvTCCA6WgAwIBAgIJALk9c4BcQN5DMA0GCSqGSIb3DQEBBQUAMIGaMRMwEQYK
+CZImiZPyLGQBGRYDY29tMRcwFQYKCZImiZPyLGQBGRYHZXhhbXBsZTELMAkGA1UE
+BhMCVVMxETAPBgNVBAgTCE5ldyBZb3JrMREwDwYDVQQHEwhOZXcgWW9yazEcMBoG
+A1UEChMTU25ha2UgT2lsIFVubGltaXRlZDEZMBcGA1UEAxMQcm9vdC5leGFtcGxl
+LmNvbTAeFw0xMzEyMDQxNjM3MjBaFw00MTA0MjExNjM3MjBaMIGaMRMwEQYKCZIm
+iZPyLGQBGRYDY29tMRcwFQYKCZImiZPyLGQBGRYHZXhhbXBsZTELMAkGA1UEBhMC
+VVMxETAPBgNVBAgTCE5ldyBZb3JrMREwDwYDVQQHEwhOZXcgWW9yazEcMBoGA1UE
+ChMTU25ha2UgT2lsIFVubGltaXRlZDEZMBcGA1UEAxMQcm9vdC5leGFtcGxlLmNv
+bTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANhHTFSyNtSs3AwtbvUe
+x3DNySOxmIXU3C5HUBNLRvRvW5uVhXg6xUd/vJkuy6vNIkXJUVqgft+NHI3OmyNX
+W0ElD4d5wK24/HuuA7iUkOtRQ2oqxrdQQQ+u12y1yLOE/+Dbt0wMaxxdU8LL/VEu
+oj3eRRoaWJsWLsctR/Y3b6nddhG1pAeB/S1x0BGq+k8EB09ijYQ6Lmxi4WaPnZqy
+vHXgbBgCT5K7pY0AxMy2Nctam71O+2D8W3m5DSx9eEPpxhqXBfp+fM/HYeG7lMX7
+sURD+6CZ5RaoHCdGFVh3TWzjs32WjV047QdwRQGzcJ6GIxfXjBjMECLwqKAyMMpb
+Bf0CAwEAAaOCAQIwgf8wHQYDVR0OBBYEFH1agQiTu1LPLGbN18FHsxFLAUNsMIHP
+BgNVHSMEgccwgcSAFH1agQiTu1LPLGbN18FHsxFLAUNsoYGgpIGdMIGaMRMwEQYK
+CZImiZPyLGQBGRYDY29tMRcwFQYKCZImiZPyLGQBGRYHZXhhbXBsZTELMAkGA1UE
+BhMCVVMxETAPBgNVBAgTCE5ldyBZb3JrMREwDwYDVQQHEwhOZXcgWW9yazEcMBoG
+A1UEChMTU25ha2UgT2lsIFVubGltaXRlZDEZMBcGA1UEAxMQcm9vdC5leGFtcGxl
+LmNvbYIJALk9c4BcQN5DMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEB
+AHtnPxtwM5wOu1utXlnwzTOepeM10OVIOIrJRNfGaz9cwxMEvlFp5PdhNBdlV460
+loAe9NtWDz6IieWJJXNx+h+CeMwOG9jnSYtCKpd2dfICyNypD3PUtHwyx81mxExz
+/fK87G6Wfo53j4kuUBL+wzI2qQfaPZ95oDpLySQ/i/l6nzD2+lDczYUCaynwPcRq
+MzMLyvpt6Km3gAZHXKB8VNtR+Sr655oMysOB0jcHetMnVvKm87ByZP/ErnCMWQZy
+rEZClf93OaTQCcMcOJSrnVWOgi0o31m90MQjRwdh09ZtikuRSutkA4xrU3rPOgmV
+c/9bYt2L/wzfV1v3JaBJEQs=
+-----END CERTIFICATE-----
diff --git a/src/openssl/testCA/ca/cakey.pem b/src/openssl/testCA/ca/cakey.pem
new file mode 100644
index 0000000..9f2764c
--- /dev/null
+++ b/src/openssl/testCA/ca/cakey.pem
@@ -0,0 +1,27 @@
+-----BEGIN RSA PRIVATE KEY-----
+MIIEogIBAAKCAQEA2EdMVLI21KzcDC1u9R7HcM3JI7GYhdTcLkdQE0tG9G9bm5WF
+eDrFR3+8mS7Lq80iRclRWqB+340cjc6bI1dbQSUPh3nArbj8e64DuJSQ61FDairG
+t1BBD67XbLXIs4T/4Nu3TAxrHF1Twsv9US6iPd5FGhpYmxYuxy1H9jdvqd12EbWk
+B4H9LXHQEar6TwQHT2KNhDoubGLhZo+dmrK8deBsGAJPkruljQDEzLY1y1qbvU77
+YPxbebkNLH14Q+nGGpcF+n58z8dh4buUxfuxREP7oJnlFqgcJ0YVWHdNbOOzfZaN
+XTjtB3BFAbNwnoYjF9eMGMwQIvCooDIwylsF/QIDAQABAoIBAEsVdYpx1FdBK6OO
+ola2uMaQqqOZpDnSDB6E42fPWnLBtivtXMjAnnyT/AWyGUMrlBpmKbgsv98cPi18
+7J74VNXo59tAiYPGFOFbKC+MZENNkvnon9REKFIpgOBcu7CXG74UiS39obHXNJ0L
+9IWaivivkY3eV6R/rv222qS/2iQ9+p8Y3vqCGIzRtPCfNq3mPK9/nzyKPnwq3sJX
+tq6hJuBaKuoJV6omCZG8FWK4EZXUVAaUXWXeCoA+cWuz1gmXe9mY4Xh1JOCfwzTh
+wVkWcg7CqSgjsBvrq8twVU47EF68Rqo6g/ntGuyb4Qe6UPjlDEliakYV+os8hNbs
+p6DLUAECgYEA9JUKXnHHUkSqQCsYLcr3h0PZ93///jomyc/t8vtdbE5f0T6e46d+
+KFoVsdJ6H9rFd2TGh+FythQ91Mbz0YQeEdIEK+dS9IVjBgXKaAgQC0XW3VtMWYa5
+hWoTNEy0y4eYxCuRDfIIkteiJwwabLLeLA0MFRKSol89Wd5D7M6dI90CgYEA4mAB
+WM9TeiJF7AyDO/xXogF+VrCWayxeWsUvm228xG+AemWVFxYc3LTVwC2SRU+SJpA/
+UKM8DMB5lNEip9u6XDzRjqDHG08FQshxdvizttugsn4MGiYfeDv7bAs3Lm6xIUuG
+9UmC7j2XPCnbtTZMz/5jL/9hY08ki2NmLjqJ2KECgYABwBNL67qGbzFctjI9Gae9
+0xF7QPI/CoF+jjtgssXPYZwz7iPK80bm2QYwuJXhJnqlSRZWoJlmjiyHGkliZXSl
+ogAfpE8mqtGzmFUDe5NJ0V0hRmb8eQdY2hJ7HFVq43SHatxl4iaHjn19lAuYXYtT
+e2Brwi9EdDQHMZ0A09WyDQKBgDCVLBTUQfUXP+xd7xhDmscRDP0r3sxXdFSEtyfj
+UDzUNT2PaYTP4RfY03rwDNFFN3eBQ6VZsvyFnlI64/YkaQV8o/i5NqH8voNLo1ZG
+H8OhtQY5mP1PqzdRoC7a5VfYt7kOjYM86JWasEdgMF/erHODA+R8KXl3tb8YcQiA
+1a6hAoGAdYwuHLeH+Nwr2q5+m3QEkCmfuAMo3oWCGccdOetrCUVViRuOM6IR8L4s
+46bG8cbnOQ90SWqyIwLH8RPEkMykcRpLw/1XGG5tmpXgvPgNSwPsZj2a33yJBAW1
+JtGXcSMQPfzROahNKBFlV9qcF+3FL0mf3B/y24rEyRKFuIiAvhQ=
+-----END RSA PRIVATE KEY-----
diff --git a/src/openssl/testCA/index.txt b/src/openssl/testCA/index.txt
new file mode 100644
index 0000000..edf5c7a
--- /dev/null
+++ b/src/openssl/testCA/index.txt
@@ -0,0 +1 @@
+V	410421165056Z		01	unknown	/C=US/ST=New York/O=Snake Oil Unlimited/CN=test.example.com
diff --git a/src/openssl/testCA/index.txt.attr b/src/openssl/testCA/index.txt.attr
new file mode 100644
index 0000000..8f7e63a
--- /dev/null
+++ b/src/openssl/testCA/index.txt.attr
@@ -0,0 +1 @@
+unique_subject = yes
diff --git a/src/openssl/testCA/index.txt.old b/src/openssl/testCA/index.txt.old
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/openssl/testCA/index.txt.old
diff --git a/src/openssl/testCA/newcerts/01.pem b/src/openssl/testCA/newcerts/01.pem
new file mode 100644
index 0000000..a7fb6b2
--- /dev/null
+++ b/src/openssl/testCA/newcerts/01.pem
@@ -0,0 +1,94 @@
+Certificate:
+    Data:
+        Version: 3 (0x2)
+        Serial Number: 1 (0x1)
+        Signature Algorithm: sha256WithRSAEncryption
+        Issuer: DC=com, DC=example, C=US, ST=New York, L=New York, O=Snake Oil Unlimited, CN=root.example.com
+        Validity
+            Not Before: Dec  4 16:50:56 2013 GMT
+            Not After : Apr 21 16:50:56 2041 GMT
+        Subject: C=US, ST=New York, O=Snake Oil Unlimited, CN=test.example.com
+        Subject Public Key Info:
+            Public Key Algorithm: rsaEncryption
+            RSA Public Key: (2048 bit)
+                Modulus (2048 bit):
+                    00:dc:9e:9a:3e:fd:99:af:e3:ef:91:40:b1:6f:50:
+                    4b:d3:7c:04:eb:34:cb:fc:99:2a:6d:fc:fe:74:8b:
+                    62:0a:90:d0:d3:7f:79:5b:f0:f6:fe:76:3a:ee:9f:
+                    09:e7:3e:3b:58:a6:dd:90:7f:16:d8:4d:85:64:1d:
+                    cf:44:7f:03:f5:57:74:86:55:c6:c3:d2:77:16:08:
+                    3f:00:86:b8:b3:8e:12:1e:eb:9b:fe:b3:57:78:01:
+                    11:be:26:07:54:04:be:fc:f7:ac:42:8c:62:be:12:
+                    6c:3f:9d:29:5f:43:fd:d8:03:a8:b7:d5:a5:71:1f:
+                    e7:62:5a:39:4c:0b:e1:0f:0e:31:48:d8:38:61:ae:
+                    48:be:24:f2:f4:35:fd:c7:da:aa:bf:a3:d2:22:46:
+                    35:c2:f0:0b:38:00:7a:b6:71:ea:ff:10:b0:16:c6:
+                    96:64:9f:55:8c:fe:cb:c9:a2:9a:79:99:8e:81:0e:
+                    af:0d:92:94:c0:9b:62:7e:55:4a:b1:06:1f:52:f0:
+                    a1:b0:54:c9:1d:b2:99:4b:74:14:80:bb:2e:63:b2:
+                    24:e4:c7:20:3f:c2:ac:e4:ba:59:67:ec:45:fd:1e:
+                    12:7d:3c:b5:da:ac:64:23:aa:8f:72:85:b2:29:c7:
+                    61:f3:f7:d0:3b:54:7a:d4:53:6a:62:13:92:91:66:
+                    28:75
+                Exponent: 65537 (0x10001)
+        X509v3 extensions:
+            X509v3 Basic Constraints: 
+                CA:FALSE
+            Netscape Comment: 
+                OpenSSL Generated Certificate
+            X509v3 Subject Key Identifier: 
+                44:88:EF:79:2B:51:0B:16:31:FE:62:C3:2E:D3:26:F6:A8:EF:CD:A3
+            X509v3 Authority Key Identifier: 
+                keyid:7D:5A:81:08:93:BB:52:CF:2C:66:CD:D7:C1:47:B3:11:4B:01:43:6C
+                DirName:/DC=com/DC=example/C=US/ST=New York/L=New York/O=Snake Oil Unlimited/CN=root.example.com
+                serial:B9:3D:73:80:5C:40:DE:43
+
+            X509v3 Issuer Alternative Name: 
+                DNS:snake-1.example.com, DNS:snake-2.example.com
+            Netscape CA Revocation Url: 
+                http://crl.example.com/ca-crl.pem
+    Signature Algorithm: sha256WithRSAEncryption
+        a3:f0:4a:d0:e6:2a:b2:f2:ab:4e:68:05:89:9d:d4:95:cc:7d:
+        7c:01:39:44:f0:9a:52:18:3c:49:78:3b:1b:95:00:7a:25:f0:
+        c7:e6:25:a0:41:20:5f:86:8d:e3:c5:59:79:fe:6d:99:81:23:
+        40:a7:52:ed:b8:18:dd:f8:37:b9:5b:99:39:c2:6f:0f:8f:4a:
+        5f:c3:dd:b4:fd:ca:be:a2:5d:f5:56:8a:d6:f2:cd:ce:e7:92:
+        01:0f:4c:9c:a8:63:5a:2a:53:ca:ce:fe:88:87:f1:76:7e:6f:
+        0d:d0:55:b3:c2:db:03:13:f2:ea:88:0a:1b:a7:0e:cf:54:a9:
+        02:63:fc:1a:0f:94:40:68:46:f5:e2:4a:77:d1:fa:a7:35:d3:
+        0e:ba:17:1c:55:08:ca:e4:30:39:0f:c9:39:0b:e6:a7:f9:f9:
+        25:2f:8e:0f:88:81:5c:16:04:e0:0f:69:9b:21:87:4f:92:dd:
+        ed:37:f6:a6:01:5d:7d:af:1d:fb:9f:53:67:2f:d2:8c:10:dd:
+        d7:fb:16:ea:18:7f:47:28:d0:91:d7:1e:d7:25:a0:ed:0b:8c:
+        29:94:d5:a8:43:e8:74:f4:bf:f6:bd:d4:78:fe:c5:bd:5a:87:
+        53:27:ad:70:2c:77:61:4c:98:50:c9:c6:db:c8:d6:74:0e:1b:
+        a9:04:2a:db
+-----BEGIN CERTIFICATE-----
+MIIFBjCCA+6gAwIBAgIBATANBgkqhkiG9w0BAQsFADCBmjETMBEGCgmSJomT8ixk
+ARkWA2NvbTEXMBUGCgmSJomT8ixkARkWB2V4YW1wbGUxCzAJBgNVBAYTAlVTMREw
+DwYDVQQIEwhOZXcgWW9yazERMA8GA1UEBxMITmV3IFlvcmsxHDAaBgNVBAoTE1Nu
+YWtlIE9pbCBVbmxpbWl0ZWQxGTAXBgNVBAMTEHJvb3QuZXhhbXBsZS5jb20wHhcN
+MTMxMjA0MTY1MDU2WhcNNDEwNDIxMTY1MDU2WjBZMQswCQYDVQQGEwJVUzERMA8G
+A1UECBMITmV3IFlvcmsxHDAaBgNVBAoTE1NuYWtlIE9pbCBVbmxpbWl0ZWQxGTAX
+BgNVBAMTEHRlc3QuZXhhbXBsZS5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw
+ggEKAoIBAQDcnpo+/Zmv4++RQLFvUEvTfATrNMv8mSpt/P50i2IKkNDTf3lb8Pb+
+djrunwnnPjtYpt2QfxbYTYVkHc9EfwP1V3SGVcbD0ncWCD8AhrizjhIe65v+s1d4
+ARG+JgdUBL7896xCjGK+Emw/nSlfQ/3YA6i31aVxH+diWjlMC+EPDjFI2Dhhrki+
+JPL0Nf3H2qq/o9IiRjXC8As4AHq2cer/ELAWxpZkn1WM/svJopp5mY6BDq8NkpTA
+m2J+VUqxBh9S8KGwVMkdsplLdBSAuy5jsiTkxyA/wqzkulln7EX9HhJ9PLXarGQj
+qo9yhbIpx2Hz99A7VHrUU2piE5KRZih1AgMBAAGjggGVMIIBkTAJBgNVHRMEAjAA
+MCwGCWCGSAGG+EIBDQQfFh1PcGVuU1NMIEdlbmVyYXRlZCBDZXJ0aWZpY2F0ZTAd
+BgNVHQ4EFgQURIjveStRCxYx/mLDLtMm9qjvzaMwgc8GA1UdIwSBxzCBxIAUfVqB
+CJO7Us8sZs3XwUezEUsBQ2yhgaCkgZ0wgZoxEzARBgoJkiaJk/IsZAEZFgNjb20x
+FzAVBgoJkiaJk/IsZAEZFgdleGFtcGxlMQswCQYDVQQGEwJVUzERMA8GA1UECBMI
+TmV3IFlvcmsxETAPBgNVBAcTCE5ldyBZb3JrMRwwGgYDVQQKExNTbmFrZSBPaWwg
+VW5saW1pdGVkMRkwFwYDVQQDExByb290LmV4YW1wbGUuY29tggkAuT1zgFxA3kMw
+MwYDVR0SBCwwKoITc25ha2UtMS5leGFtcGxlLmNvbYITc25ha2UtMi5leGFtcGxl
+LmNvbTAwBglghkgBhvhCAQQEIxYhaHR0cDovL2NybC5leGFtcGxlLmNvbS9jYS1j
+cmwucGVtMA0GCSqGSIb3DQEBCwUAA4IBAQCj8ErQ5iqy8qtOaAWJndSVzH18ATlE
+8JpSGDxJeDsblQB6JfDH5iWgQSBfho3jxVl5/m2ZgSNAp1LtuBjd+De5W5k5wm8P
+j0pfw920/cq+ol31VorW8s3O55IBD0ycqGNaKlPKzv6Ih/F2fm8N0FWzwtsDE/Lq
+iAobpw7PVKkCY/waD5RAaEb14kp30fqnNdMOuhccVQjK5DA5D8k5C+an+fklL44P
+iIFcFgTgD2mbIYdPkt3tN/amAV19rx37n1NnL9KMEN3X+xbqGH9HKNCR1x7XJaDt
+C4wplNWoQ+h09L/2vdR4/sW9WodTJ61wLHdhTJhQycbbyNZ0DhupBCrb
+-----END CERTIFICATE-----
diff --git a/src/openssl/testCA/serial b/src/openssl/testCA/serial
new file mode 100644
index 0000000..9e22bcb
--- /dev/null
+++ b/src/openssl/testCA/serial
@@ -0,0 +1 @@
+02
diff --git a/src/openssl/testCA/serial.old b/src/openssl/testCA/serial.old
new file mode 100644
index 0000000..8a0f05e
--- /dev/null
+++ b/src/openssl/testCA/serial.old
@@ -0,0 +1 @@
+01
diff --git a/src/test/java/org/cryptacular/CiphertextHeaderTest.java b/src/test/java/org/cryptacular/CiphertextHeaderTest.java
new file mode 100644
index 0000000..51abfae
--- /dev/null
+++ b/src/test/java/org/cryptacular/CiphertextHeaderTest.java
@@ -0,0 +1,55 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular;
+
+import java.util.Arrays;
+import org.cryptacular.util.CodecUtil;
+import org.testng.annotations.Test;
+import static org.testng.Assert.assertEquals;
+
+/**
+ * Unit test for {@link CiphertextHeader}.
+ *
+ * @author Middleware Services
+ */
+public class CiphertextHeaderTest
+{
+
+  @Test(
+      expectedExceptions = IllegalArgumentException.class,
+      expectedExceptionsMessageRegExp = "Nonce exceeds size limit in bytes.*")
+  public void testNonceLimitConstructor()
+  {
+    new CiphertextHeader(new byte[256], "key2");
+  }
+
+  @Test
+  public void testEncodeDecodeSuccess()
+  {
+    final byte[] nonce = new byte[255];
+    Arrays.fill(nonce, (byte) 7);
+    final CiphertextHeader expected = new CiphertextHeader(nonce, "aleph");
+    final byte[] encoded = expected.encode();
+    assertEquals(expected.getLength(), encoded.length);
+    final CiphertextHeader actual = CiphertextHeader.decode(encoded);
+    assertEquals(expected.getNonce(), actual.getNonce());
+    assertEquals(expected.getKeyName(), actual.getKeyName());
+    assertEquals(expected.getLength(), actual.getLength());
+  }
+
+  @Test(
+    expectedExceptions = EncodingException.class,
+    expectedExceptionsMessageRegExp = "Bad ciphertext header: maximum nonce length exceeded")
+  public void testDecodeFailNonceLengthExceeded()
+  {
+    // https://github.com/vt-middleware/cryptacular/issues/52
+    CiphertextHeader.decode(CodecUtil.hex("000000347ffffffd"));
+  }
+
+  @Test(
+      expectedExceptions = EncodingException.class,
+      expectedExceptionsMessageRegExp = "Bad ciphertext header: maximum key length exceeded")
+  public void testDecodeFailKeyLengthExceeded()
+  {
+    CiphertextHeader.decode(CodecUtil.hex("000000F300000004DEADBEEF00FFFFFF"));
+  }
+}
diff --git a/src/test/java/org/cryptacular/CiphertextHeaderV2Test.java b/src/test/java/org/cryptacular/CiphertextHeaderV2Test.java
new file mode 100644
index 0000000..7313d35
--- /dev/null
+++ b/src/test/java/org/cryptacular/CiphertextHeaderV2Test.java
@@ -0,0 +1,67 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular;
+
+import java.util.Arrays;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.SecretKeySpec;
+import org.cryptacular.generator.sp80038a.RBGNonce;
+import org.testng.annotations.Test;
+import static org.testng.Assert.assertEquals;
+
+/**
+ * Unit test for {@link CiphertextHeaderV2}.
+ *
+ * @author Middleware Services
+ */
+public class CiphertextHeaderV2Test
+{
+  /** Test HMAC key. */
+  private final SecretKey key = new SecretKeySpec(new RBGNonce().generate(), "AES");
+
+  @Test(
+      expectedExceptions = IllegalArgumentException.class,
+      expectedExceptionsMessageRegExp = "Nonce exceeds size limit in bytes.*")
+  public void testNonceLimitConstructor()
+  {
+    new CiphertextHeaderV2(new byte[256], "key2");
+  }
+
+  @Test
+  public void testEncodeDecodeSuccess()
+  {
+    final byte[] nonce = new byte[255];
+    Arrays.fill(nonce, (byte) 7);
+    final CiphertextHeaderV2 expected = new CiphertextHeaderV2(nonce, "aleph");
+    expected.setKeyLookup(this::getKey);
+    final byte[] encoded = expected.encode();
+    assertEquals(expected.getLength(), encoded.length);
+    final CiphertextHeaderV2 actual = CiphertextHeaderV2.decode(encoded, this::getKey);
+    assertEquals(expected.getNonce(), actual.getNonce());
+    assertEquals(expected.getKeyName(), actual.getKeyName());
+    assertEquals(expected.getLength(), actual.getLength());
+  }
+
+  @Test(
+      expectedExceptions = EncodingException.class,
+      expectedExceptionsMessageRegExp = "Ciphertext header HMAC verification failed")
+  public void testEncodeDecodeFailBadHMAC()
+  {
+    final byte[] nonce = new byte[16];
+    Arrays.fill(nonce, (byte) 3);
+    final CiphertextHeaderV2 expected = new CiphertextHeaderV2(nonce, "aleph");
+    // Tamper with computed HMAC
+    final byte[] encoded = expected.encode(key);
+    final int index = encoded.length - 3;
+    final byte b = encoded[index];
+    encoded[index] = (byte) (b + 1);
+    CiphertextHeaderV2.decode(encoded, this::getKey);
+  }
+
+  private SecretKey getKey(final String alias)
+  {
+    if ("aleph".equals(alias)) {
+      return key;
+    }
+    return null;
+  }
+}
diff --git a/src/test/java/org/cryptacular/FailListener.java b/src/test/java/org/cryptacular/FailListener.java
new file mode 100644
index 0000000..41e3768
--- /dev/null
+++ b/src/test/java/org/cryptacular/FailListener.java
@@ -0,0 +1,22 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular;
+
+import org.testng.ITestResult;
+import org.testng.TestListenerAdapter;
+
+/**
+ * TestNG listener that converts skipped results to failures when the cause of skip is an error.
+ * A common use case for this listener is triggering failures on <code>@DataProvider</code> errors.
+ *
+ * @author  Middleware Services
+ */
+public class FailListener extends TestListenerAdapter
+{
+  @Override
+  public void onTestSkipped(final ITestResult tr)
+  {
+    if (tr.getThrowable() != null) {
+      tr.setStatus(ITestResult.FAILURE);
+    }
+  }
+}
diff --git a/src/test/java/org/cryptacular/adapter/WrappedKeyTest.java b/src/test/java/org/cryptacular/adapter/WrappedKeyTest.java
new file mode 100644
index 0000000..339b77b
--- /dev/null
+++ b/src/test/java/org/cryptacular/adapter/WrappedKeyTest.java
@@ -0,0 +1,82 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.adapter;
+
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.security.KeyFactory;
+import java.security.KeyPair;
+import java.security.spec.PKCS8EncodedKeySpec;
+import java.security.spec.X509EncodedKeySpec;
+import org.cryptacular.FailListener;
+import org.cryptacular.util.KeyPairUtil;
+import org.cryptacular.util.StreamUtil;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Listeners;
+import org.testng.annotations.Test;
+import static org.testng.AssertJUnit.assertTrue;
+
+/**
+ * Test for {@link AbstractWrappedKey} classes.
+ *
+ * @author  Middleware Services
+ */
+@Listeners(FailListener.class)
+public class WrappedKeyTest
+{
+  private static final String KEY_PATH = "src/test/resources/keys/";
+
+  @DataProvider(name = "keypairs")
+  public Object[][] getKeyPairs()
+  {
+    return
+      new Object[][] {
+        {"DSA", KEY_PATH + "dsa-pub.der", KEY_PATH + "dsa-pkcs8-nopass.der", },
+        {"RSA", KEY_PATH + "rsa-pub.der", KEY_PATH + "rsa-pkcs8-nopass.der", },
+        // TODO: enable once BC gets support for writing EC named curves
+        // As of bcprov 1.50 only raw EC params can be written
+        // SunJCE only understands named curves
+        // {
+        // "EC",
+        // KEY_PATH + "ec-prime256v1-named-pub.der",
+        // KEY_PATH + "ec-pkcs8-prime256v1-named-nopass.der",
+        // },
+      };
+  }
+
+
+  @Test(dataProvider = "keypairs")
+  public void testKeyEquivalence(final String algorithm, final String pubKeyPath, final String privKeyPath)
+    throws Exception
+  {
+    final KeyPair wrappedPair = new KeyPair(
+      KeyPairUtil.readPublicKey(pubKeyPath),
+      KeyPairUtil.readPrivateKey(privKeyPath));
+    final String bcPubKeyPath = String.format("target/%s-%s.key", algorithm, "pub");
+    final String bcPrivKeyPath = String.format("target/%s-%s.key", algorithm, "priv");
+    writeFile(bcPubKeyPath, wrappedPair.getPublic().getEncoded());
+    writeFile(bcPrivKeyPath, wrappedPair.getPrivate().getEncoded());
+
+    final KeyPair jcePair = readJCEKeyPair(algorithm, bcPubKeyPath, bcPrivKeyPath);
+
+    assertTrue(KeyPairUtil.isKeyPair(wrappedPair.getPublic(), jcePair.getPrivate()));
+    assertTrue(KeyPairUtil.isKeyPair(jcePair.getPublic(), wrappedPair.getPrivate()));
+  }
+
+
+  private static void writeFile(final String path, final byte[] data)
+    throws IOException
+  {
+    try (FileOutputStream out = new FileOutputStream(path)) {
+      out.write(data);
+    }
+  }
+
+  private static KeyPair readJCEKeyPair(final String algorithm, final String pubKeyPath, final String privKeyPath)
+    throws Exception
+  {
+    final PKCS8EncodedKeySpec privSpec = new PKCS8EncodedKeySpec(StreamUtil.readAll(privKeyPath));
+    final X509EncodedKeySpec pubSpec = new X509EncodedKeySpec(StreamUtil.readAll(pubKeyPath));
+    final KeyFactory factory = KeyFactory.getInstance(algorithm);
+    return new KeyPair(factory.generatePublic(pubSpec), factory.generatePrivate(privSpec));
+  }
+}
diff --git a/src/test/java/org/cryptacular/bean/AEADBlockCipherBeanTest.java b/src/test/java/org/cryptacular/bean/AEADBlockCipherBeanTest.java
new file mode 100644
index 0000000..a26f341
--- /dev/null
+++ b/src/test/java/org/cryptacular/bean/AEADBlockCipherBeanTest.java
@@ -0,0 +1,151 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.bean;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.security.KeyStore;
+import org.cryptacular.FailListener;
+import org.cryptacular.generator.sp80038d.CounterNonce;
+import org.cryptacular.io.FileResource;
+import org.cryptacular.spec.AEADBlockCipherSpec;
+import org.cryptacular.util.ByteUtil;
+import org.cryptacular.util.CodecUtil;
+import org.cryptacular.util.StreamUtil;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Listeners;
+import org.testng.annotations.Test;
+import static org.testng.Assert.assertEquals;
+
+/**
+ * Unit test for {@link AEADBlockCipherBean}.
+ *
+ * @author  Middleware Services
+ */
+@Listeners(FailListener.class)
+public class AEADBlockCipherBeanTest
+{
+
+  @DataProvider(name = "test-arrays")
+  public Object[][] getTestArrays()
+  {
+    return
+      new Object[][] {
+        new Object[] {
+          // Plaintext is NOT multiple of block size
+          "Able was I ere I saw elba.",
+          "AES/GCM",
+        },
+        // Plaintext is multiple of block size
+        new Object[] {
+          "Four score and seven years ago, our forefathers ",
+          "Twofish/CCM",
+        },
+        // OCB
+        new Object[] {
+          "Have you passed through this night?",
+          "Twofish/OCB",
+        },
+        // EAX
+        new Object[] {
+          "I went to the woods because I wished to live deliberately, to front only the essential facts of life",
+          "AES/EAX",
+        },
+      };
+  }
+
+  @DataProvider(name = "test-streams")
+  public Object[][] getTestStreams()
+  {
+    return
+      new Object[][] {
+        new Object[] {
+          "src/test/resources/plaintexts/lorem-5000.txt",
+          "Twofish/GCM",
+        },
+        new Object[] {
+          "src/test/resources/plaintexts/lorem-1200.txt",
+          "AES/OCB",
+        },
+        new Object[] {
+          "src/test/resources/plaintexts/lorem-1200.txt",
+          "AES/EAX",
+        },
+      };
+  }
+
+
+  @Test(dataProvider = "test-arrays")
+  public void testEncryptDecryptArray(final String input, final String cipherSpecString)
+    throws Exception
+  {
+    final AEADBlockCipherBean cipherBean = newCipherBean(AEADBlockCipherSpec.parse(cipherSpecString));
+    final byte[] ciphertext = cipherBean.encrypt(ByteUtil.toBytes(input));
+    assertEquals(ByteUtil.toString(cipherBean.decrypt(ciphertext)), input);
+  }
+
+
+  @Test(dataProvider = "test-streams")
+  public void testEncryptDecryptStream(final String path, final String cipherSpecString)
+    throws Exception
+  {
+    final AEADBlockCipherBean cipherBean = newCipherBean(AEADBlockCipherSpec.parse(cipherSpecString));
+    final ByteArrayOutputStream tempOut = new ByteArrayOutputStream(8192);
+    cipherBean.encrypt(StreamUtil.makeStream(new File(path)), tempOut);
+
+    final ByteArrayInputStream tempIn = new ByteArrayInputStream(tempOut.toByteArray());
+    final ByteArrayOutputStream finalOut = new ByteArrayOutputStream(8192);
+    cipherBean.decrypt(tempIn, finalOut);
+    assertEquals(ByteUtil.toString(finalOut.toByteArray()), ByteUtil.toString(StreamUtil.readAll(path)));
+  }
+
+
+  @Test
+  public void testDecryptArrayBackwardCompatibleHeader()
+  {
+    final AEADBlockCipherBean cipherBean = newCipherBean(new AEADBlockCipherSpec("Twofish", "OCB"));
+    final String expected = "Have you passed through this night?";
+    final String v1CiphertextHex =
+        "0000001f0000000c76746d770002ba17043672d900000007767463727970745a38dee735266e3f5f7aafec8d1c9ed8a0830a2ff9" +
+        "c3a46c25f89e69b6eb39dbb82fd13da50e32b2544a73f1a4476677b377e6";
+    final byte[] plaintext = cipherBean.decrypt(CodecUtil.hex(v1CiphertextHex));
+    assertEquals(expected, ByteUtil.toString(plaintext));
+  }
+
+
+  @Test
+  public void testDecryptStreamBackwardCompatibleHeader()
+  {
+    final AEADBlockCipherBean cipherBean = newCipherBean(new AEADBlockCipherSpec("Twofish", "OCB"));
+    final String expected = "Have you passed through this night?";
+    final String v1CiphertextHex =
+      "0000001f0000000c76746d770002ba17043672d900000007767463727970745a38dee735266e3f5f7aafec8d1c9ed8a0830a2ff9" +
+        "c3a46c25f89e69b6eb39dbb82fd13da50e32b2544a73f1a4476677b377e6";
+    final ByteArrayInputStream in = new ByteArrayInputStream(CodecUtil.hex(v1CiphertextHex));
+    final ByteArrayOutputStream out = new ByteArrayOutputStream();
+    cipherBean.decrypt(in, out);
+    assertEquals(expected, ByteUtil.toString(out.toByteArray()));
+  }
+
+
+  private static KeyStore getTestKeyStore()
+  {
+    final KeyStoreFactoryBean bean = new KeyStoreFactoryBean();
+    bean.setPassword("vtcrypt");
+    bean.setResource(new FileResource(new File("src/test/resources/keystores/cipher-bean.jceks")));
+    bean.setType("JCEKS");
+    return bean.newInstance();
+  }
+
+
+  private static AEADBlockCipherBean newCipherBean(final AEADBlockCipherSpec cipherSpec)
+  {
+    final AEADBlockCipherBean cipherBean = new AEADBlockCipherBean();
+    cipherBean.setNonce(new CounterNonce("vtmw", System.nanoTime()));
+    cipherBean.setKeyAlias("vtcrypt");
+    cipherBean.setKeyPassword("vtcrypt");
+    cipherBean.setKeyStore(getTestKeyStore());
+    cipherBean.setBlockCipherSpec(cipherSpec);
+    return cipherBean;
+  }
+}
diff --git a/src/test/java/org/cryptacular/bean/BCryptHashBeanTest.java b/src/test/java/org/cryptacular/bean/BCryptHashBeanTest.java
new file mode 100644
index 0000000..0192062
--- /dev/null
+++ b/src/test/java/org/cryptacular/bean/BCryptHashBeanTest.java
@@ -0,0 +1,41 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.bean;
+
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertTrue;
+
+/**
+ * Unit test for {@link BCryptHashBean} class.
+ *
+ * @author Middleware Services
+ */
+public class BCryptHashBeanTest
+{
+  @DataProvider(name = "hashes")
+  public Object[][] getHashData()
+  {
+    return
+      new Object[][] {
+        {"password", "$2a$5$bvIG6Nmid91Mu9RcmmWZfO5HJIMCT8riNW0hEp8f6/FuA2/mHZFpe"},
+        {"x", "$2a$12$w6IdiZTAckGirKaH8LU8VOxEvP97cFLEW5ePVJzhZilSa5c.V/uMK"},
+        {"abcdefghijklmnopqrstuvwxyz", "$2a$6$.rCVZVOThsIa97pEDOxvGuRRgzG64bvtJ0938xuqzv18d3ZpQhstC"},
+        {"abcdefghijklmnopqrstuvwxyz", "$2a$8$aTsUwsyowQuzRrDqFflhgekJ8d9/7Z3GV3UcgvzQW3J5zMyrTvlz."},
+      };
+  }
+
+  @Test(dataProvider = "hashes")
+  public void testHash(final String password, final String expected)
+  {
+    final BCryptHashBean.BCryptParameters params = new BCryptHashBean.BCryptParameters(expected);
+    final String hash = new BCryptHashBean(params.getCost()).hash(params.getSalt(), password);
+    assertEquals(params.encode(hash), expected);
+  }
+
+  @Test(dataProvider = "hashes")
+  public void testCompare(final String password, final String expected)
+  {
+    assertTrue(new BCryptHashBean(10).compare(expected, password));
+  }
+}
diff --git a/src/test/java/org/cryptacular/bean/BufferedBlockCipherBeanTest.java b/src/test/java/org/cryptacular/bean/BufferedBlockCipherBeanTest.java
new file mode 100644
index 0000000..0a42a10
--- /dev/null
+++ b/src/test/java/org/cryptacular/bean/BufferedBlockCipherBeanTest.java
@@ -0,0 +1,138 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.bean;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.math.BigInteger;
+import java.security.KeyStore;
+import org.cryptacular.FailListener;
+import org.cryptacular.generator.Nonce;
+import org.cryptacular.generator.sp80038a.BigIntegerCounterNonce;
+import org.cryptacular.generator.sp80038a.LongCounterNonce;
+import org.cryptacular.generator.sp80038a.RBGNonce;
+import org.cryptacular.io.FileResource;
+import org.cryptacular.spec.BufferedBlockCipherSpec;
+import org.cryptacular.util.ByteUtil;
+import org.cryptacular.util.StreamUtil;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Listeners;
+import org.testng.annotations.Test;
+import static org.testng.Assert.assertEquals;
+
+/**
+ * Unit test for {@link BufferedBlockCipherBean}.
+ *
+ * @author  Middleware Services
+ */
+@Listeners(FailListener.class)
+public class BufferedBlockCipherBeanTest
+{
+  @DataProvider(name = "test-arrays")
+  public Object[][] getTestArrays()
+  {
+    return
+      new Object[][] {
+        new Object[] {
+          // Plaintext is NOT multiple of block size
+          "Able was I ere I saw elba.",
+          "AES/CBC/PKCS5",
+          new RBGNonce(16),
+        },
+        // Plaintext is multiple of block size
+        new Object[] {
+          "Four score and seven years ago, our forefathers ",
+          "Blowfish/CBC/None",
+          new RBGNonce(8),
+        },
+        // OFB
+        new Object[] {
+          "Have you passed through this night?",
+          "Blowfish/OFB/PKCS5Padding",
+          new LongCounterNonce(),
+        },
+        // CFB
+        new Object[] {
+          "I went to the woods because I wished to live deliberately, to front only the essential facts of life",
+          "AES/CFB/PKCS5Padding",
+          new RBGNonce(16),
+        },
+      };
+  }
+
+  @DataProvider(name = "test-streams")
+  public Object[][] getTestStreams()
+  {
+    return
+      new Object[][] {
+        new Object[] {
+          "src/test/resources/plaintexts/lorem-5000.txt",
+          "AES/CBC/PKCS7",
+          new RBGNonce(16),
+        },
+        new Object[] {
+          "src/test/resources/plaintexts/lorem-1200.txt",
+          "Twofish/OFB/NULL",
+          new BigIntegerCounterNonce(BigInteger.ONE, 16),
+        },
+        new Object[] {
+          "src/test/resources/plaintexts/lorem-1200.txt",
+          "AES/CFB/PKCS5",
+          new RBGNonce(16),
+        },
+        new Object[] {
+          "src/test/resources/plaintexts/lorem-1200.txt",
+          "AES/ECB/PKCS5",
+          new RBGNonce(16),
+        },
+      };
+  }
+
+
+  @Test(dataProvider = "test-arrays")
+  public void testEncryptDecryptArray(final String input, final String cipherSpecString, final Nonce nonce)
+    throws Exception
+  {
+    final BufferedBlockCipherBean cipherBean = new BufferedBlockCipherBean();
+    final BufferedBlockCipherSpec cipherSpec = BufferedBlockCipherSpec.parse(cipherSpecString);
+    cipherBean.setNonce(nonce);
+    cipherBean.setKeyAlias("vtcrypt");
+    cipherBean.setKeyPassword("vtcrypt");
+    cipherBean.setKeyStore(getTestKeyStore());
+    cipherBean.setBlockCipherSpec(cipherSpec);
+
+    final byte[] ciphertext = cipherBean.encrypt(ByteUtil.toBytes(input));
+    assertEquals(ByteUtil.toString(cipherBean.decrypt(ciphertext)), input);
+  }
+
+
+  @Test(dataProvider = "test-streams")
+  public void testEncryptDecryptStream(final String path, final String cipherSpecString, final Nonce nonce)
+    throws Exception
+  {
+    final BufferedBlockCipherBean cipherBean = new BufferedBlockCipherBean();
+    final BufferedBlockCipherSpec cipherSpec = BufferedBlockCipherSpec.parse(cipherSpecString);
+    cipherBean.setNonce(nonce);
+    cipherBean.setKeyAlias("vtcrypt");
+    cipherBean.setKeyPassword("vtcrypt");
+    cipherBean.setKeyStore(getTestKeyStore());
+    cipherBean.setBlockCipherSpec(cipherSpec);
+
+    final ByteArrayOutputStream tempOut = new ByteArrayOutputStream(8192);
+    cipherBean.encrypt(StreamUtil.makeStream(new File(path)), tempOut);
+
+    final ByteArrayInputStream tempIn = new ByteArrayInputStream(tempOut.toByteArray());
+    final ByteArrayOutputStream finalOut = new ByteArrayOutputStream(8192);
+    cipherBean.decrypt(tempIn, finalOut);
+    assertEquals(ByteUtil.toString(finalOut.toByteArray()), ByteUtil.toString(StreamUtil.readAll(path)));
+  }
+
+  private static KeyStore getTestKeyStore()
+  {
+    final KeyStoreFactoryBean bean = new KeyStoreFactoryBean();
+    bean.setPassword("vtcrypt");
+    bean.setResource(new FileResource(new File("src/test/resources/keystores/cipher-bean.jceks")));
+    bean.setType("JCEKS");
+    return bean.newInstance();
+  }
+}
diff --git a/src/test/java/org/cryptacular/bean/EncodingHashBeanTest.java b/src/test/java/org/cryptacular/bean/EncodingHashBeanTest.java
new file mode 100644
index 0000000..1653809
--- /dev/null
+++ b/src/test/java/org/cryptacular/bean/EncodingHashBeanTest.java
@@ -0,0 +1,98 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.bean;
+
+import org.cryptacular.FailListener;
+import org.cryptacular.spec.CodecSpec;
+import org.cryptacular.spec.DigestSpec;
+import org.cryptacular.util.ByteUtil;
+import org.cryptacular.util.CodecUtil;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Listeners;
+import org.testng.annotations.Test;
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertTrue;
+
+/**
+ * Unit test for {@link EncodingHashBean}.
+ *
+ * @author  Middleware Services
+ */
+@Listeners(FailListener.class)
+public class EncodingHashBeanTest
+{
+  @DataProvider(name = "hash-data")
+  public Object[][] getHashData()
+  {
+    return
+      new Object[][] {
+        {
+          new EncodingHashBean(CodecSpec.BASE64, new DigestSpec("SHA1"), 1, false),
+          new Object[] {
+            CodecUtil.b64("7FHsteHnm6XQsJT1TTKbxw=="),
+            CodecUtil.b64("ehp6PCnojSegFpRvStqQ9A=="),
+          },
+          "Oadnuuj7QsRPUuMBiu+dmlT6qzU=",
+        },
+        {
+          new EncodingHashBean(CodecSpec.BASE64, new DigestSpec("SHA1"), 1, true),
+          new Object[] {
+            CodecUtil.b64("7FHsteHnm6XQsJT1TTKbxw=="),
+            CodecUtil.b64("ehp6PCnojSegFpRvStqQ9A=="),
+            CodecUtil.b64("/siCJIPstwM="),
+          },
+          "uRt+VlmPzfGOPjSGoZLTxpvd1dP+yIIkg+y3Aw==",
+        },
+        {
+          new EncodingHashBean(CodecSpec.HEX, new DigestSpec("SHA256"), 3, false),
+          new Object[] {
+            CodecUtil.b64("7FHsteHnm6XQsJT1TTKbxw=="),
+            CodecUtil.b64("ehp6PCnojSegFpRvStqQ9A=="),
+          },
+          "3a1edec6aef6d1736bec63130755690c07f04d7e7139d8fd685cc2d989961b79",
+        },
+        {
+          new EncodingHashBean(CodecSpec.HEX, new DigestSpec("SHA256"), 3, true),
+          new Object[] {
+            CodecUtil.b64("7FHsteHnm6XQsJT1TTKbxw=="),
+            CodecUtil.b64("ehp6PCnojSegFpRvStqQ9A=="),
+            CodecUtil.b64("DH9M1lDibNU="),
+          },
+          "79f2868e7f72ed18cd67858e8ffe589c6090d696f7ff298e021faf5855fd41a10c7f4cd650e26cd5",
+        },
+      };
+  }
+
+  @DataProvider(name = "compare-data")
+  public Object[][] getCompareData()
+  {
+    return
+      new Object[][] {
+        {
+          new EncodingHashBean(CodecSpec.BASE64, new DigestSpec("SHA1"), 1, false),
+          "7fyOZXGp+gKMziV/2Px7RIMkxyI2O1H8",
+          new Object[] {ByteUtil.toBytes("password"), },
+        },
+        {
+          new EncodingHashBean(CodecSpec.BASE64, new DigestSpec("SHA1"), 1, true),
+          "lrb+YkKHqoGbFtxYd0B5567N6ZYwqwvWQwvoSg==",
+          new Object[] {ByteUtil.toBytes("password"), },
+        },
+      };
+  }
+
+
+  @Test(dataProvider = "hash-data")
+  public void testHash(final EncodingHashBean bean, final Object[] input, final String expected)
+    throws Exception
+  {
+    assertEquals(bean.hash(input), expected);
+  }
+
+
+  @Test(dataProvider = "compare-data")
+  public void testCompare(final EncodingHashBean bean, final String hash, final Object[] input)
+    throws Exception
+  {
+    assertTrue(bean.compare(hash, input));
+  }
+}
diff --git a/src/test/java/org/cryptacular/bean/KeyStoreBasedKeyFactoryBeanTest.java b/src/test/java/org/cryptacular/bean/KeyStoreBasedKeyFactoryBeanTest.java
new file mode 100644
index 0000000..e7c995d
--- /dev/null
+++ b/src/test/java/org/cryptacular/bean/KeyStoreBasedKeyFactoryBeanTest.java
@@ -0,0 +1,75 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.bean;
+
+import java.io.File;
+import java.security.Key;
+import java.security.interfaces.RSAPrivateKey;
+import javax.crypto.SecretKey;
+import org.cryptacular.FailListener;
+import org.cryptacular.io.FileResource;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Listeners;
+import org.testng.annotations.Test;
+import static org.testng.Assert.assertEquals;
+
+/**
+ * Unit test for {@link KeyStoreBasedKeyFactoryBean}.
+ *
+ * @author  Middleware Services
+ */
+@Listeners(FailListener.class)
+public class KeyStoreBasedKeyFactoryBeanTest
+{
+  private static final String KS_PATH = "src/test/resources/keystores/";
+
+  @DataProvider(name = "keys")
+  public Object[][] getKeys()
+  {
+    return
+      new Object[][] {
+        {
+          KS_PATH + "factory-bean.jceks",
+          "JCEKS",
+          "aes256",
+          "AES",
+          32,
+        },
+        {
+          KS_PATH + "factory-bean.jceks",
+          "JCEKS",
+          "rsa2048",
+          "RSA",
+          2048,
+        },
+      };
+  }
+
+
+  @Test(dataProvider = "keys")
+  public void testNewInstance(
+    final String keyStorePath,
+    final String keyStoreType,
+    final String alias,
+    final String expectedAlg,
+    final int expectedSize)
+    throws Exception
+  {
+    final KeyStoreFactoryBean keyStoreFactory = new KeyStoreFactoryBean();
+    keyStoreFactory.setResource(new FileResource(new File(keyStorePath)));
+    keyStoreFactory.setPassword("vtcrypt");
+    keyStoreFactory.setType(keyStoreType);
+
+    final KeyStoreBasedKeyFactoryBean<? extends Key> secretKeyFactory = new KeyStoreBasedKeyFactoryBean<>();
+    secretKeyFactory.setKeyStore(keyStoreFactory.newInstance());
+    secretKeyFactory.setAlias(alias);
+    secretKeyFactory.setPassword("vtcrypt");
+
+    final Key key = secretKeyFactory.newInstance();
+    assertEquals(key.getAlgorithm(), expectedAlg);
+    if (key instanceof SecretKey) {
+      assertEquals(key.getEncoded().length, expectedSize);
+    } else if (key instanceof RSAPrivateKey) {
+      assertEquals(((RSAPrivateKey) key).getModulus().bitLength(), expectedSize);
+    }
+  }
+}
diff --git a/src/test/java/org/cryptacular/bean/KeyStoreFactoryBeanTest.java b/src/test/java/org/cryptacular/bean/KeyStoreFactoryBeanTest.java
new file mode 100644
index 0000000..f1c782a
--- /dev/null
+++ b/src/test/java/org/cryptacular/bean/KeyStoreFactoryBeanTest.java
@@ -0,0 +1,57 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.bean;
+
+import java.io.File;
+import org.cryptacular.FailListener;
+import org.cryptacular.io.FileResource;
+import org.cryptacular.io.Resource;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Listeners;
+import org.testng.annotations.Test;
+import static org.testng.Assert.assertEquals;
+
+/**
+ * Unit test for {@link KeyStoreFactoryBean}.
+ *
+ * @author  Middleware Services
+ */
+@Listeners(FailListener.class)
+public class KeyStoreFactoryBeanTest
+{
+  private static final String KS_PATH = "src/test/resources/keystores/";
+
+  @DataProvider(name = "keystore-data")
+  public Object[][] getKeyStoreData()
+  {
+    return
+      new Object[][] {
+        new Object[] {
+          "JCEKS",
+          new FileResource(new File(KS_PATH + "keystore.jceks")),
+          1,
+        },
+        new Object[] {
+          "JKS",
+          new FileResource(new File(KS_PATH + "keystore.jks")),
+          1,
+        },
+        new Object[] {
+          "PKCS12",
+          new FileResource(new File(KS_PATH + "keystore.p12")),
+          1,
+        },
+      };
+  }
+
+
+  @Test(dataProvider = "keystore-data")
+  public void testNewInstance(final String type, final Resource resource, final int expectedSize)
+    throws Exception
+  {
+    final KeyStoreFactoryBean factory = new KeyStoreFactoryBean();
+    factory.setType(type);
+    factory.setResource(resource);
+    factory.setPassword("vtcrypt");
+    assertEquals(factory.newInstance().size(), expectedSize);
+  }
+}
diff --git a/src/test/java/org/cryptacular/bean/PemBasedPrivateKeyFactoryBeanTest.java b/src/test/java/org/cryptacular/bean/PemBasedPrivateKeyFactoryBeanTest.java
new file mode 100644
index 0000000..5bd07c1
--- /dev/null
+++ b/src/test/java/org/cryptacular/bean/PemBasedPrivateKeyFactoryBeanTest.java
@@ -0,0 +1,45 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.bean;
+
+import java.io.File;
+import java.security.PrivateKey;
+import org.cryptacular.FailListener;
+import org.cryptacular.util.ByteUtil;
+import org.cryptacular.util.StreamUtil;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Listeners;
+import org.testng.annotations.Test;
+import static org.testng.Assert.assertTrue;
+
+/**
+ * Unit test for {@link PemBasedPrivateKeyFactoryBean}.
+ *
+ * @author  Middleware Services
+ */
+@Listeners(FailListener.class)
+public class PemBasedPrivateKeyFactoryBeanTest
+{
+  private static final String KEY_PATH = "src/test/resources/keys/";
+
+  @DataProvider(name = "keys")
+  public Object[][] getKeys()
+  {
+    return
+      new Object[][] {
+        new Object[] {KEY_PATH + "dsa-pkcs8-nopass.pem"},
+        new Object[] {KEY_PATH + "dsa-openssl-nopass.pem"},
+        new Object[] {KEY_PATH + "rsa-pkcs8-nopass.pem"},
+        new Object[] {KEY_PATH + "rsa-openssl-nopass.pem"},
+        new Object[] {KEY_PATH + "ec-openssl-sect571r1-explicit-nopass.pem"},
+      };
+  }
+
+  @Test(dataProvider = "keys")
+  public void testNewInstance(final String path)
+    throws Exception
+  {
+    final String pem = ByteUtil.toString(StreamUtil.readAll(new File(path)));
+    final PemBasedPrivateKeyFactoryBean factory = new PemBasedPrivateKeyFactoryBean(pem);
+    assertTrue(factory.newInstance() instanceof PrivateKey);
+  }
+}
diff --git a/src/test/java/org/cryptacular/bean/PemBasedPublicKeyFactoryBeanTest.java b/src/test/java/org/cryptacular/bean/PemBasedPublicKeyFactoryBeanTest.java
new file mode 100644
index 0000000..806690d
--- /dev/null
+++ b/src/test/java/org/cryptacular/bean/PemBasedPublicKeyFactoryBeanTest.java
@@ -0,0 +1,43 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.bean;
+
+import java.io.File;
+import java.security.PublicKey;
+import org.cryptacular.FailListener;
+import org.cryptacular.util.ByteUtil;
+import org.cryptacular.util.StreamUtil;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Listeners;
+import org.testng.annotations.Test;
+import static org.testng.Assert.assertTrue;
+
+/**
+ * Unit test for {@link PemBasedPublicKeyFactoryBean}.
+ *
+ * @author  Middleware Services
+ */
+@Listeners(FailListener.class)
+public class PemBasedPublicKeyFactoryBeanTest
+{
+  private static final String KEY_PATH = "src/test/resources/keys/";
+
+  @DataProvider(name = "keys")
+  public Object[][] getKeys()
+  {
+    return
+      new Object[][] {
+        new Object[] {KEY_PATH + "dsa-pub.pem"},
+        new Object[] {KEY_PATH + "rsa-pub.pem"},
+        new Object[] {KEY_PATH + "ec-secp224k1-explicit-pub.pem"},
+      };
+  }
+
+  @Test(dataProvider = "keys")
+  public void testNewInstance(final String path)
+    throws Exception
+  {
+    final String pem = ByteUtil.toString(StreamUtil.readAll(new File(path)));
+    final PemBasedPublicKeyFactoryBean factory = new PemBasedPublicKeyFactoryBean(pem);
+    assertTrue(factory.newInstance() instanceof PublicKey);
+  }
+}
diff --git a/src/test/java/org/cryptacular/bean/ResourceBasedPrivateKeyFactoryBeanTest.java b/src/test/java/org/cryptacular/bean/ResourceBasedPrivateKeyFactoryBeanTest.java
new file mode 100644
index 0000000..4ade3fd
--- /dev/null
+++ b/src/test/java/org/cryptacular/bean/ResourceBasedPrivateKeyFactoryBeanTest.java
@@ -0,0 +1,60 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.bean;
+
+import java.io.File;
+import java.security.PrivateKey;
+import org.cryptacular.FailListener;
+import org.cryptacular.io.FileResource;
+import org.cryptacular.io.Resource;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Listeners;
+import org.testng.annotations.Test;
+import static org.testng.Assert.assertTrue;
+
+/**
+ * Unit test for {@link ResourceBasedPrivateKeyFactoryBean}.
+ *
+ * @author  Middleware Services
+ */
+@Listeners(FailListener.class)
+public class ResourceBasedPrivateKeyFactoryBeanTest
+{
+  private static final String KEY_PATH = "src/test/resources/keys/";
+
+  @DataProvider(name = "keys")
+  public Object[][] getKeys()
+  {
+    return
+      new Object[][] {
+        new Object[] {KEY_PATH + "dsa-pkcs8-nopass.pem", null},
+        new Object[] {KEY_PATH + "dsa-openssl-nopass.pem", null},
+        new Object[] {KEY_PATH + "rsa-pkcs8-nopass.pem", null},
+        new Object[] {KEY_PATH + "rsa-openssl-nopass.pem", null},
+        new Object[] {
+          KEY_PATH + "ec-openssl-sect571r1-explicit-nopass.pem",
+          null,
+        },
+        new Object[] {KEY_PATH + "dsa-openssl-des3.pem", "vtcrypt"},
+        new Object[] {KEY_PATH + "dsa-pkcs8-v2-des3.der", "vtcrypt"},
+        new Object[] {
+          KEY_PATH + "ec-pkcs8-sect571r1-explicit-v2-aes128.pem",
+          "vtcrypt",
+        },
+        new Object[] {
+          KEY_PATH + "ec-pkcs8-sect571r1-named-v1-sha1-rc2-64.der",
+          "vtcrypt",
+        },
+        new Object[] {KEY_PATH + "rsa-openssl-des.pem", "vtcrypt"},
+        new Object[] {KEY_PATH + "rsa-pkcs8-v2-aes256.der", "vtcrypt"},
+      };
+  }
+
+  @Test(dataProvider = "keys")
+  public void testNewInstance(final String path, final String password)
+    throws Exception
+  {
+    final Resource resource = new FileResource(new File(path));
+    final ResourceBasedPrivateKeyFactoryBean factory = new ResourceBasedPrivateKeyFactoryBean(resource, password);
+    assertTrue(factory.newInstance() instanceof PrivateKey);
+  }
+}
diff --git a/src/test/java/org/cryptacular/bean/ResourceBasedPublicKeyFactoryBeanTest.java b/src/test/java/org/cryptacular/bean/ResourceBasedPublicKeyFactoryBeanTest.java
new file mode 100644
index 0000000..1bf7ef1
--- /dev/null
+++ b/src/test/java/org/cryptacular/bean/ResourceBasedPublicKeyFactoryBeanTest.java
@@ -0,0 +1,42 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.bean;
+
+import java.security.PublicKey;
+import org.cryptacular.FailListener;
+import org.cryptacular.io.ClassPathResource;
+import org.cryptacular.io.Resource;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Listeners;
+import org.testng.annotations.Test;
+import static org.testng.Assert.assertTrue;
+
+/**
+ * Unit test for {@link ResourceBasedPublicKeyFactoryBean}.
+ *
+ * @author  Middleware Services
+ */
+@Listeners(FailListener.class)
+public class ResourceBasedPublicKeyFactoryBeanTest
+{
+  private static final String KEY_PATH = "/keys/";
+
+  @DataProvider(name = "keys")
+  public Object[][] getKeys()
+  {
+    return
+      new Object[][] {
+        new Object[] {KEY_PATH + "dsa-pub.pem"},
+        new Object[] {KEY_PATH + "rsa-pub.pem"},
+        new Object[] {KEY_PATH + "ec-secp224k1-explicit-pub.pem"},
+      };
+  }
+
+  @Test(dataProvider = "keys")
+  public void testNewInstance(final String path)
+    throws Exception
+  {
+    final Resource resource = new ClassPathResource(path);
+    final ResourceBasedPublicKeyFactoryBean factory = new ResourceBasedPublicKeyFactoryBean(resource);
+    assertTrue(factory.newInstance() instanceof PublicKey);
+  }
+}
diff --git a/src/test/java/org/cryptacular/bean/ResourceBasedSecretKeyFactoryBeanTest.java b/src/test/java/org/cryptacular/bean/ResourceBasedSecretKeyFactoryBeanTest.java
new file mode 100644
index 0000000..d01fa12
--- /dev/null
+++ b/src/test/java/org/cryptacular/bean/ResourceBasedSecretKeyFactoryBeanTest.java
@@ -0,0 +1,45 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.bean;
+
+import java.io.File;
+import org.cryptacular.FailListener;
+import org.cryptacular.io.FileResource;
+import org.cryptacular.io.Resource;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Listeners;
+import org.testng.annotations.Test;
+import static org.testng.Assert.assertEquals;
+
+/**
+ * Unit test for {@link ResourceBasedSecretKeyFactoryBean}.
+ *
+ * @author  Middleware Services
+ */
+@Listeners(FailListener.class)
+public class ResourceBasedSecretKeyFactoryBeanTest
+{
+  private static final String KEY_PATH = "src/test/resources/keys/";
+
+  @DataProvider(name = "keys")
+  public Object[][] getKeys()
+  {
+    return new Object[][] {
+      new Object[] {
+        "AES",
+        new FileResource(new File(KEY_PATH + "aes-128.key")),
+        16,
+      },
+    };
+  }
+
+
+  @Test(dataProvider = "keys")
+  public void testNewInstance(final String algorithm, final Resource resource, final int expectedSize)
+    throws Exception
+  {
+    final ResourceBasedSecretKeyFactoryBean factory = new ResourceBasedSecretKeyFactoryBean();
+    factory.setAlgorithm(algorithm);
+    factory.setResource(resource);
+    assertEquals(factory.newInstance().getEncoded().length, expectedSize);
+  }
+}
diff --git a/src/test/java/org/cryptacular/bean/SimpleHashBeanTest.java b/src/test/java/org/cryptacular/bean/SimpleHashBeanTest.java
new file mode 100644
index 0000000..3f19a30
--- /dev/null
+++ b/src/test/java/org/cryptacular/bean/SimpleHashBeanTest.java
@@ -0,0 +1,55 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.bean;
+
+import org.cryptacular.FailListener;
+import org.cryptacular.spec.DigestSpec;
+import org.cryptacular.util.CodecUtil;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Listeners;
+import org.testng.annotations.Test;
+import static org.testng.Assert.assertEquals;
+
+/**
+ * Unit test for {@link SimpleHashBean}.
+ *
+ * @author  Middleware Services
+ */
+@Listeners(FailListener.class)
+public class SimpleHashBeanTest
+{
+  @DataProvider(name = "test-data")
+  public Object[][] getTestData()
+  {
+    return
+      new Object[][] {
+        {
+          new DigestSpec("SHA1"),
+          new Object[] {
+            CodecUtil.b64("7FHsteHnm6XQsJT1TTKbxw=="),
+            CodecUtil.b64("ehp6PCnojSegFpRvStqQ9A=="),
+          },
+          1,
+          "Oadnuuj7QsRPUuMBiu+dmlT6qzU=",
+        },
+        {
+          new DigestSpec("SHA256"),
+          new Object[] {
+            CodecUtil.b64("7FHsteHnm6XQsJT1TTKbxw=="),
+            CodecUtil.b64("ehp6PCnojSegFpRvStqQ9A=="),
+          },
+          3,
+          "Oh7exq720XNr7GMTB1VpDAfwTX5xOdj9aFzC2YmWG3k=",
+        },
+      };
+  }
+
+  @Test(dataProvider = "test-data")
+  public void testHash(final DigestSpec digest, final Object[] input, final int iterations, final String expectedBase64)
+    throws Exception
+  {
+    final SimpleHashBean bean = new SimpleHashBean();
+    bean.setDigestSpec(digest);
+    bean.setIterations(iterations);
+    assertEquals(CodecUtil.b64(bean.hash(input)), expectedBase64);
+  }
+}
diff --git a/src/test/java/org/cryptacular/codec/Base32DecoderTest.java b/src/test/java/org/cryptacular/codec/Base32DecoderTest.java
new file mode 100644
index 0000000..de382ff
--- /dev/null
+++ b/src/test/java/org/cryptacular/codec/Base32DecoderTest.java
@@ -0,0 +1,74 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.codec;
+
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import org.cryptacular.FailListener;
+import org.cryptacular.util.ByteUtil;
+import org.cryptacular.util.CodecUtil;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Listeners;
+import org.testng.annotations.Test;
+import static org.testng.Assert.assertEquals;
+
+/**
+ * Unit test for {@link Base64Decoder}.
+ *
+ * @author  Middleware Services
+ */
+@Listeners(FailListener.class)
+public class Base32DecoderTest
+{
+  @DataProvider(name = "encoded-data")
+  public Object[][] getEncodedData()
+  {
+    final Base32Decoder unpadded = new Base32Decoder();
+    unpadded.setPaddedInput(false);
+    return
+      new Object[][] {
+        // Multiple of 40 bits
+        new Object[] {
+          new Base32Decoder(),
+          "TQSN7XJ4",
+          CodecUtil.hex("9c24dfdd3c"),
+        },
+        // Final quantum of encoding input is exactly 8 bits
+        new Object[] {
+          unpadded,
+          "43H7CNN2EI",
+          CodecUtil.hex("e6cff135ba22"),
+        },
+        // Final quantum of encoding input is exactly 16 bits
+        new Object[] {
+          new Base32Decoder(),
+          "2NEK2FDJHXDQ====",
+          CodecUtil.hex("d348ad14693dc7"),
+        },
+        // Final quantum of encoding input is exactly 24 bits
+        new Object[] {
+          new Base32Decoder(),
+          "LVVECZIT6F3MU===",
+          CodecUtil.hex("5d6a416513f176ca"),
+        },
+        // Final quantum of encoding input is exactly 32 bits
+        new Object[] {
+          new Base32Decoder(),
+          "QN5Z7HN4PBY4G5Q=",
+          CodecUtil.hex("837b9f9dbc7871c376"),
+        },
+      };
+  }
+
+
+  @Test(dataProvider = "encoded-data")
+  public void testDecode(final Base32Decoder decoder, final String data, final byte[] expected)
+    throws Exception
+  {
+    final CharBuffer input = CharBuffer.wrap(data);
+    final ByteBuffer output = ByteBuffer.allocate(decoder.outputSize(input.length()));
+    decoder.decode(input, output);
+    decoder.finalize(output);
+    output.flip();
+    assertEquals(ByteUtil.toArray(output), expected);
+  }
+}
diff --git a/src/test/java/org/cryptacular/codec/Base32EncoderTest.java b/src/test/java/org/cryptacular/codec/Base32EncoderTest.java
new file mode 100644
index 0000000..2738c43
--- /dev/null
+++ b/src/test/java/org/cryptacular/codec/Base32EncoderTest.java
@@ -0,0 +1,72 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.codec;
+
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import org.cryptacular.FailListener;
+import org.cryptacular.util.CodecUtil;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Listeners;
+import org.testng.annotations.Test;
+import static org.testng.Assert.assertEquals;
+
+/**
+ * Unit test for {@link Base64Encoder}.
+ *
+ * @author  Middleware Services
+ */
+@Listeners(FailListener.class)
+public class Base32EncoderTest
+{
+  @DataProvider(name = "byte-data")
+  public Object[][] getByteData()
+  {
+    final Base32Encoder unpadded = new Base32Encoder();
+    unpadded.setPaddedOutput(false);
+    return
+      new Object[][] {
+        // Multiple of 40 bits
+        new Object[] {
+          new Base32Encoder(),
+          CodecUtil.hex("9c24dfdd3c"),
+          "TQSN7XJ4",
+        },
+        // Final quantum of encoding input is exactly 8 bits
+        new Object[] {
+          new Base32Encoder(),
+          CodecUtil.hex("e6cff135ba22"),
+          "43H7CNN2EI======",
+        },
+        // Final quantum of encoding input is exactly 16 bits
+        new Object[] {
+          new Base32Encoder(),
+          CodecUtil.hex("d348ad14693dc7"),
+          "2NEK2FDJHXDQ====",
+        },
+        // Final quantum of encoding input is exactly 24 bits
+        new Object[] {
+          unpadded,
+          CodecUtil.hex("5d6a416513f176ca"),
+          "LVVECZIT6F3MU",
+        },
+        // Final quantum of encoding input is exactly 32 bits
+        new Object[] {
+          new Base32Encoder(),
+          CodecUtil.hex("837b9f9dbc7871c376"),
+          "QN5Z7HN4PBY4G5Q=",
+        },
+      };
+  }
+
+  @Test(dataProvider = "byte-data")
+  public void testEncode(final Base32Encoder encoder, final byte[] inBytes, final String expected)
+    throws Exception
+  {
+    final ByteBuffer input = ByteBuffer.wrap(inBytes);
+    final CharBuffer output = CharBuffer.allocate(encoder.outputSize(input.limit()));
+    encoder.encode(input, output);
+    encoder.finalize(output);
+    assertEquals(output.flip().toString(), expected);
+  }
+
+}
diff --git a/src/test/java/org/cryptacular/codec/Base64DecoderTest.java b/src/test/java/org/cryptacular/codec/Base64DecoderTest.java
new file mode 100644
index 0000000..641ed39
--- /dev/null
+++ b/src/test/java/org/cryptacular/codec/Base64DecoderTest.java
@@ -0,0 +1,119 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.codec;
+
+import java.io.File;
+import java.io.Reader;
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import org.cryptacular.FailListener;
+import org.cryptacular.util.ByteUtil;
+import org.cryptacular.util.CodecUtil;
+import org.cryptacular.util.HashUtil;
+import org.cryptacular.util.StreamUtil;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Listeners;
+import org.testng.annotations.Test;
+import static org.testng.Assert.assertEquals;
+
+/**
+ * Unit test for {@link Base64Decoder} class.
+ *
+ * @author  Middleware Services
+ */
+@Listeners(FailListener.class)
+public class Base64DecoderTest
+{
+
+  @DataProvider(name = "encoded-data")
+  public Object[][] getEncodedData()
+  {
+    return
+      new Object[][] {
+        new Object[] {
+          new Base64Decoder(),
+          "QWJsZSB3YXMgSSBlcmUgSSBzYXcgZWxiYQ==",
+          ByteUtil.toBytes("Able was I ere I saw elba"),
+        },
+        new Object[] {
+          new Base64Decoder(),
+          "QWJsZSB3YXMgSSBlcmUgSSBzYXcgZWxiYS4=",
+          ByteUtil.toBytes("Able was I ere I saw elba."),
+        },
+        new Object[] {
+          new Base64Decoder(),
+          "safx/LW8+SsSy/o3PmCNy4VEm5s=",
+          HashUtil.sha1(ByteUtil.toBytes("t3stUs3r01")),
+        },
+        new Object[] {
+          new Base64Decoder.Builder().setUrlSafe(true).build(),
+          "safx_LW8-SsSy_o3PmCNy4VEm5s=",
+          HashUtil.sha1(ByteUtil.toBytes("t3stUs3r01")),
+        },
+        new Object[] {
+          new Base64Decoder.Builder().setUrlSafe(true).setPadding(false).build(),
+          "FPu_A9l-",
+          CodecUtil.hex("14FBBF03D97E"),
+        },
+        new Object[] {
+          new Base64Decoder.Builder().setUrlSafe(true).setPadding(false).build(),
+          "FPu_A9k",
+          CodecUtil.hex("14FBBF03D9"),
+        },
+      };
+  }
+
+
+  @DataProvider(name = "plaintext-files")
+  public Object[][] getPlaintextFiles()
+  {
+    return
+      new Object[][] {
+        new Object[] {"src/test/resources/plaintexts/lorem-1190.txt"},
+        new Object[] {"src/test/resources/plaintexts/lorem-1200.txt"},
+        new Object[] {"src/test/resources/plaintexts/lorem-5000.txt"},
+      };
+  }
+
+
+  @Test(dataProvider = "encoded-data")
+  public void testDecode(final Base64Decoder decoder, final String data, final byte[] expected)
+    throws Exception
+  {
+    final CharBuffer input = CharBuffer.wrap(data);
+    final ByteBuffer output = ByteBuffer.allocate(decoder.outputSize(input.length()));
+    decoder.decode(input, output);
+    decoder.finalize(output);
+    output.flip();
+    assertEquals(ByteUtil.toArray(output), expected);
+  }
+
+
+  @Test(dataProvider = "plaintext-files")
+  public void testDecodeFile(final String path)
+    throws Exception
+  {
+    final String expected = StreamUtil.readAll(StreamUtil.makeReader(new File(path)));
+    final File file = new File(path + ".b64");
+    final StringBuilder actual = new StringBuilder(expected.length());
+    final Reader reader = StreamUtil.makeReader(file);
+    final Base64Decoder decoder = new Base64Decoder();
+    try {
+      final CharBuffer bufIn = CharBuffer.allocate(1024);
+      final ByteBuffer bufOut = ByteBuffer.allocate(decoder.outputSize(bufIn.capacity()));
+      while (reader.read(bufIn) > 0) {
+        bufIn.flip();
+        decoder.decode(bufIn, bufOut);
+        bufOut.flip();
+        actual.append(ByteUtil.toCharBuffer(bufOut));
+        bufOut.clear();
+        bufIn.clear();
+      }
+      decoder.finalize(bufOut);
+      bufOut.flip();
+      actual.append(ByteUtil.toCharBuffer(bufOut));
+    } finally {
+      StreamUtil.closeReader(reader);
+    }
+    assertEquals(actual.toString(), expected);
+  }
+}
diff --git a/src/test/java/org/cryptacular/codec/Base64EncoderTest.java b/src/test/java/org/cryptacular/codec/Base64EncoderTest.java
new file mode 100644
index 0000000..201a0bd
--- /dev/null
+++ b/src/test/java/org/cryptacular/codec/Base64EncoderTest.java
@@ -0,0 +1,133 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.codec;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import java.nio.channels.FileChannel;
+import org.cryptacular.FailListener;
+import org.cryptacular.util.ByteUtil;
+import org.cryptacular.util.CodecUtil;
+import org.cryptacular.util.HashUtil;
+import org.cryptacular.util.StreamUtil;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Listeners;
+import org.testng.annotations.Test;
+import static org.testng.Assert.assertEquals;
+
+/**
+ * Unit test for {@link Base64Encoder} class.
+ *
+ * @author  Middleware Services
+ */
+@Listeners(FailListener.class)
+public class Base64EncoderTest
+{
+  @DataProvider(name = "byte-data")
+  public Object[][] getByteData()
+  {
+    final Base64Encoder unpadded = new Base64Encoder();
+    unpadded.setPaddedOutput(false);
+    return
+      new Object[][] {
+        new Object[] {
+          new Base64Encoder(),
+          ByteUtil.toBytes("Able was I ere I saw elba"),
+          "QWJsZSB3YXMgSSBlcmUgSSBzYXcgZWxiYQ==",
+        },
+        new Object[] {
+          new Base64Encoder.Builder().setPadding(false).build(),
+          ByteUtil.toBytes("Able was I ere I saw elba"),
+          "QWJsZSB3YXMgSSBlcmUgSSBzYXcgZWxiYQ",
+        },
+        new Object[] {
+          new Base64Encoder(),
+          ByteUtil.toBytes("Able was I ere I saw elba."),
+          "QWJsZSB3YXMgSSBlcmUgSSBzYXcgZWxiYS4=",
+        },
+        new Object[] {
+          new Base64Encoder(),
+          HashUtil.sha1(ByteUtil.toBytes("t3stUs3r01")),
+          "safx/LW8+SsSy/o3PmCNy4VEm5s=",
+        },
+        new Object[] {
+          new Base64Encoder(true),
+          HashUtil.sha1(ByteUtil.toBytes("t3stUs3r01")),
+          "safx_LW8-SsSy_o3PmCNy4VEm5s=",
+        },
+        new Object[] {
+          new Base64Encoder(),
+          CodecUtil.hex("3f1c435a244f7a8be1572a1bf2a196f4958cc00c17b96e"),
+          "PxxDWiRPeovhVyob8qGW9JWMwAwXuW4=",
+        },
+        new Object[] {
+          new Base64Encoder.Builder().setUrlSafe(true).setPadding(false).build(),
+          CodecUtil.hex("14FBBF03D97E"),
+          "FPu_A9l-",
+        },
+        new Object[] {
+          new Base64Encoder.Builder().setUrlSafe(true).setPadding(false).build(),
+          CodecUtil.hex("14FBBF03D9"),
+          "FPu_A9k",
+        },
+      };
+  }
+
+
+  @DataProvider(name = "plaintext-files")
+  public Object[][] getPlaintextFiles()
+  {
+    return
+      new Object[][] {
+        new Object[] {"src/test/resources/plaintexts/lorem-1190.txt"},
+        new Object[] {"src/test/resources/plaintexts/lorem-1200.txt"},
+        new Object[] {"src/test/resources/plaintexts/lorem-5000.txt"},
+      };
+  }
+
+
+  @Test(dataProvider = "byte-data")
+  public void testEncode(final Base64Encoder encoder, final byte[] inBytes, final String expected)
+    throws Exception
+  {
+    final ByteBuffer input = ByteBuffer.wrap(inBytes);
+    final CharBuffer output = CharBuffer.allocate(encoder.outputSize(input.limit()));
+    encoder.encode(input, output);
+    encoder.finalize(output);
+    assertEquals(output.flip().toString(), expected);
+  }
+
+
+  @Test(dataProvider = "plaintext-files")
+  public void testEncodeFile(final String path)
+    throws Exception
+  {
+    final File file = new File(path);
+    String expectedPath = path + ".b64";
+    if ("\r\n".equals(System.lineSeparator())) {
+      expectedPath += ".crlf";
+    }
+
+    final String expected = new String(StreamUtil.readAll(expectedPath));
+    final StringBuilder actual = new StringBuilder(expected.length());
+    final Base64Encoder encoder = new Base64Encoder(64);
+    try (FileInputStream input = new FileInputStream(file)) {
+      final ByteBuffer bufIn = ByteBuffer.allocate(512);
+      final CharBuffer bufOut = CharBuffer.allocate(encoder.outputSize(512));
+      final FileChannel chIn = input.getChannel();
+      while (chIn.read(bufIn) > 0) {
+        bufIn.flip();
+        encoder.encode(bufIn, bufOut);
+        bufOut.flip();
+        actual.append(bufOut);
+        bufOut.clear();
+        bufIn.clear();
+      }
+      encoder.finalize(bufOut);
+      bufOut.flip();
+      actual.append(bufOut);
+    }
+    assertEquals(actual.toString(), expected);
+  }
+}
diff --git a/src/test/java/org/cryptacular/codec/HexDecoderTest.java b/src/test/java/org/cryptacular/codec/HexDecoderTest.java
new file mode 100644
index 0000000..17246a6
--- /dev/null
+++ b/src/test/java/org/cryptacular/codec/HexDecoderTest.java
@@ -0,0 +1,72 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.codec;
+
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import org.cryptacular.FailListener;
+import org.cryptacular.util.ByteUtil;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Listeners;
+import org.testng.annotations.Test;
+import static org.testng.Assert.assertEquals;
+
+/**
+ * Unit test for {@link HexDecoder} class.
+ *
+ * @author  Middleware Services
+ */
+@Listeners(FailListener.class)
+public class HexDecoderTest
+{
+  @DataProvider(name = "hex-data")
+  public Object[][] getHexData()
+  {
+    return
+      new Object[][] {
+        new Object[] {
+          "41626C652077617320492065726520492073617720656C6261",
+          "Able was I ere I saw elba",
+        },
+        new Object[] {
+          "41626c652 077617320492065726520492073617720656c626\n1",
+          "Able was I ere I saw elba",
+        },
+        new Object[] {
+          "41626c652 077617320492065726520492073617720656c626\n",
+          "Able was I ere I saw elb",
+        },
+        new Object[] {
+          "41:62:6c:65:20:77:61:73:20:49:20:65:72:65:20:49:20:73:61:77:20:65:6c:62:61",
+          "Able was I ere I saw elba",
+        },
+        new Object[] {
+          "9c63b0547798b60d5e04",
+          ByteUtil.toString(
+            new byte[] {
+              (byte) -100,
+              (byte) 99,
+              (byte) -80,
+              (byte) 84,
+              (byte) 119,
+              (byte) -104,
+              (byte) -74,
+              (byte) 13,
+              (byte) 94,
+              (byte) 4,
+            }),
+        },
+      };
+  }
+
+  @Test(dataProvider = "hex-data")
+  public void testDecode(final String encoded, final String expected)
+    throws Exception
+  {
+    final HexDecoder decoder = new HexDecoder();
+    final ByteBuffer output = ByteBuffer.allocate(decoder.outputSize(encoded.length()));
+    decoder.decode(CharBuffer.wrap(encoded), output);
+    decoder.finalize(output);
+    output.flip();
+    assertEquals(ByteUtil.toString(output), expected);
+  }
+}
diff --git a/src/test/java/org/cryptacular/codec/HexEncoderTest.java b/src/test/java/org/cryptacular/codec/HexEncoderTest.java
new file mode 100644
index 0000000..c30a02c
--- /dev/null
+++ b/src/test/java/org/cryptacular/codec/HexEncoderTest.java
@@ -0,0 +1,74 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.codec;
+
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import org.cryptacular.FailListener;
+import org.cryptacular.util.ByteUtil;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Listeners;
+import org.testng.annotations.Test;
+import static org.testng.Assert.assertEquals;
+
+/**
+ * Unit test for {@link HexEncoder} class.
+ *
+ * @author  Middleware Services
+ */
+@Listeners(FailListener.class)
+public class HexEncoderTest
+{
+  @DataProvider(name = "text-data")
+  public Object[][] getTextData()
+  {
+    return
+      new Object[][] {
+        new Object[] {
+          new HexEncoder(false),
+          ByteUtil.toBytes("Able was I ere I saw elba"),
+          "41626c652077617320492065726520492073617720656c6261",
+        },
+        new Object[] {
+          new HexEncoder(false, true),
+          ByteUtil.toBytes("Able was I ere I saw elba\n"),
+          "41626C652077617320492065726520492073617720656C62610A",
+        },
+        new Object[] {
+          new HexEncoder(true),
+          ByteUtil.toBytes("Able was I ere I saw elba"),
+          "41:62:6c:65:20:77:61:73:20:49:20:65:72:65:20:49:20:73:61:77:20:65:6c:62:61",
+        },
+        new Object[] {
+          new HexEncoder(true, true),
+          ByteUtil.toBytes("Able was I ere I saw elba"),
+          "41:62:6C:65:20:77:61:73:20:49:20:65:72:65:20:49:20:73:61:77:20:65:6C:62:61",
+        },
+        new Object[] {
+          new HexEncoder(),
+          new byte[] {
+            (byte) -100,
+            (byte) 99,
+            (byte) -80,
+            (byte) 84,
+            (byte) 119,
+            (byte) -104,
+            (byte) -74,
+            (byte) 13,
+            (byte) 94,
+            (byte) 4,
+          },
+          "9c63b0547798b60d5e04",
+        },
+      };
+  }
+
+  @Test(dataProvider = "text-data")
+  public void testEncode(final HexEncoder encoder, final byte[] data, final String expected)
+    throws Exception
+  {
+    final CharBuffer output = CharBuffer.allocate(encoder.outputSize(data.length));
+    encoder.encode(ByteBuffer.wrap(data), output);
+    encoder.finalize(output);
+    assertEquals(output.flip().toString(), expected);
+  }
+}
diff --git a/src/test/java/org/cryptacular/generator/AESP12GeneratorTest.java b/src/test/java/org/cryptacular/generator/AESP12GeneratorTest.java
new file mode 100644
index 0000000..e5a496a
--- /dev/null
+++ b/src/test/java/org/cryptacular/generator/AESP12GeneratorTest.java
@@ -0,0 +1,75 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.generator;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.security.KeyStore;
+import java.security.cert.X509Certificate;
+import java.security.interfaces.RSAPrivateKey;
+import org.bouncycastle.asn1.ASN1ObjectIdentifier;
+import org.bouncycastle.asn1.nist.NISTObjectIdentifiers;
+import org.bouncycastle.pkcs.PKCS12PfxPdu;
+import org.cryptacular.bean.KeyStoreFactoryBean;
+import org.cryptacular.io.ClassPathResource;
+import org.cryptacular.io.FileResource;
+import org.cryptacular.io.Resource;
+import org.testng.Assert;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+/**
+ * Unit test for {@link AESP12Generator} class.
+ *
+ * @author Marvin S. Addison
+ */
+public class AESP12GeneratorTest
+{
+  @DataProvider(name = "p12-params")
+  public Object[][] getP12Params()
+  {
+    return new Object[][] {
+      new Object[] {"aes256-sha256-2048", "/keystores/alpha.p12", NISTObjectIdentifiers.id_sha256, 2048},
+      new Object[] {"aes256-sha512-4196", "/keystores/alpha.p12", NISTObjectIdentifiers.id_sha512, 4196},
+    };
+  }
+
+  @Test(dataProvider = "p12-params")
+  public void testGenerate(
+    final String testCaseName,
+    final String keystorePath,
+    final ASN1ObjectIdentifier digestAlgId,
+    final int iterations) throws Exception
+  {
+    final String password = "vtcrypt";
+    final char[] passwordChars = password.toCharArray();
+    final KeyStore keyStore = loadP12KeyStore(new ClassPathResource(keystorePath), password);
+    final RSAPrivateKey privateKey = (RSAPrivateKey) keyStore.getKey("1", passwordChars);
+    final X509Certificate cert = (X509Certificate) keyStore.getCertificate("1");
+    final AESP12Generator generator = new AESP12Generator(digestAlgId, iterations);
+    final PKCS12PfxPdu p12 = generator.generate(passwordChars, privateKey, cert);
+    Assert.assertEquals(p12.getContentInfos().length, 2);
+    // Encrypted bag (certificate)
+    Assert.assertEquals(p12.getContentInfos()[0].getContentType().toString(), "1.2.840.113549.1.7.6");
+    // Shrouded bag (key)
+    Assert.assertEquals(p12.getContentInfos()[1].getContentType().toString(), "1.2.840.113549.1.7.1");
+    final File outFile = new File("target/keystores/" + testCaseName + ".p12");
+    outFile.getParentFile().mkdirs();
+    try (FileOutputStream out = new FileOutputStream(outFile)) {
+      out.write(p12.getEncoded());
+    }
+    final KeyStore generated = loadP12KeyStore(new FileResource(outFile), password);
+    final RSAPrivateKey genKey = (RSAPrivateKey) generated.getKey("end-entity-cert", passwordChars);
+    Assert.assertEquals(genKey.getPrivateExponent(), privateKey.getPrivateExponent());
+    final X509Certificate genCert = (X509Certificate) generated.getCertificate("end-entity-cert");
+    Assert.assertEquals(genCert.getSubjectX500Principal(), cert.getSubjectX500Principal());
+  }
+
+  private KeyStore loadP12KeyStore(final Resource resource, final String password)
+  {
+    final KeyStoreFactoryBean factory = new KeyStoreFactoryBean();
+    factory.setResource(resource);
+    factory.setType("PKCS12");
+    factory.setPassword(password);
+    return factory.newInstance();
+  }
+}
diff --git a/src/test/java/org/cryptacular/generator/HOTPGeneratorTest.java b/src/test/java/org/cryptacular/generator/HOTPGeneratorTest.java
new file mode 100644
index 0000000..7183176
--- /dev/null
+++ b/src/test/java/org/cryptacular/generator/HOTPGeneratorTest.java
@@ -0,0 +1,45 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.generator;
+
+import org.cryptacular.FailListener;
+import org.cryptacular.util.CodecUtil;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Listeners;
+import org.testng.annotations.Test;
+import static org.testng.Assert.assertEquals;
+
+/**
+ * Unit test for {@link HOTPGenerator}.
+ *
+ * @author  Middleware Services
+ */
+@Listeners(FailListener.class)
+public class HOTPGeneratorTest
+{
+  @DataProvider(name = "test-data")
+  public Object[][] getTestData()
+  {
+    return
+      new Object[][] {
+        {"0x3132333435363738393031323334353637383930", 0, 755224},
+        {"0x3132333435363738393031323334353637383930", 1, 287082},
+        {"0x3132333435363738393031323334353637383930", 2, 359152},
+        {"0x3132333435363738393031323334353637383930", 3, 969429},
+        {"0x3132333435363738393031323334353637383930", 4, 338314},
+        {"0x3132333435363738393031323334353637383930", 5, 254676},
+        {"0x3132333435363738393031323334353637383930", 6, 287922},
+        {"0x3132333435363738393031323334353637383930", 7, 162583},
+        {"0x3132333435363738393031323334353637383930", 8, 399871},
+        {"0x3132333435363738393031323334353637383930", 9, 520489},
+      };
+  }
+
+
+  @Test(dataProvider = "test-data")
+  public void testGenerate(final String hexKey, final int count, final int expected)
+    throws Exception
+  {
+    final HOTPGenerator generator = new HOTPGenerator();
+    assertEquals(generator.generate(CodecUtil.hex(hexKey), count), expected);
+  }
+}
diff --git a/src/test/java/org/cryptacular/generator/LegacyP12GeneratorTest.java b/src/test/java/org/cryptacular/generator/LegacyP12GeneratorTest.java
new file mode 100644
index 0000000..4d93c08
--- /dev/null
+++ b/src/test/java/org/cryptacular/generator/LegacyP12GeneratorTest.java
@@ -0,0 +1,66 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.generator;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.security.KeyStore;
+import java.security.cert.X509Certificate;
+import java.security.interfaces.RSAPrivateKey;
+import org.bouncycastle.pkcs.PKCS12PfxPdu;
+import org.cryptacular.bean.KeyStoreFactoryBean;
+import org.cryptacular.io.ClassPathResource;
+import org.cryptacular.io.Resource;
+import org.testng.Assert;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+/**
+ * Unit test for {@link LegacyP12Generator}.
+ *
+ * @author Marvin S. Addison
+ */
+public class LegacyP12GeneratorTest
+{
+  @DataProvider(name = "p12-params")
+  public Object[][] getP12Params()
+  {
+    return new Object[][] {
+      new Object[] {"legacy-1024", "/keystores/alpha.p12", 1024},
+      new Object[] {"legacy-2048", "/keystores/alpha.p12", 2048},
+    };
+  }
+
+  @Test(dataProvider = "p12-params")
+  public void testGenerate(
+    final String testCaseName,
+    final String keystorePath,
+    final int iterations) throws Exception
+  {
+    final String password = "vtcrypt";
+    final char[] passwordChars = password.toCharArray();
+    final KeyStore keyStore = loadP12KeyStore(new ClassPathResource(keystorePath), password);
+    final RSAPrivateKey privateKey = (RSAPrivateKey) keyStore.getKey("1", passwordChars);
+    final X509Certificate cert = (X509Certificate) keyStore.getCertificate("1");
+    final LegacyP12Generator generator = new LegacyP12Generator(iterations);
+    final PKCS12PfxPdu p12 = generator.generate(passwordChars, privateKey, cert);
+    Assert.assertEquals(p12.getContentInfos().length, 2);
+    // Encrypted bag (certificate)
+    Assert.assertEquals(p12.getContentInfos()[0].getContentType().toString(), "1.2.840.113549.1.7.6");
+    // Shrouded bag (key)
+    Assert.assertEquals(p12.getContentInfos()[1].getContentType().toString(), "1.2.840.113549.1.7.1");
+    final File outFile = new File("target/keystores/" + testCaseName + ".p12");
+    outFile.getParentFile().mkdirs();
+    try (FileOutputStream out = new FileOutputStream(outFile)) {
+      out.write(p12.getEncoded());
+    }
+  }
+
+  private KeyStore loadP12KeyStore(final Resource resource, final String password)
+  {
+    final KeyStoreFactoryBean factory = new KeyStoreFactoryBean();
+    factory.setResource(resource);
+    factory.setType("PKCS12");
+    factory.setPassword(password);
+    return factory.newInstance();
+  }
+}
diff --git a/src/test/java/org/cryptacular/generator/RandomIdGeneratorTest.java b/src/test/java/org/cryptacular/generator/RandomIdGeneratorTest.java
new file mode 100644
index 0000000..d511d29
--- /dev/null
+++ b/src/test/java/org/cryptacular/generator/RandomIdGeneratorTest.java
@@ -0,0 +1,113 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.generator;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.cryptacular.FailListener;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Listeners;
+import org.testng.annotations.Test;
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertTrue;
+
+/**
+ * Unit test for {@link RandomIdGenerator}.
+ *
+ * @author  Middleware Services
+ */
+@Listeners(FailListener.class)
+public class RandomIdGeneratorTest
+{
+  @DataProvider(name = "generators")
+  public Object[][] getGenerators()
+  {
+    return
+      new Object[][] {
+        {
+          new RandomIdGenerator(10),
+          Pattern.compile("\\w{10}"),
+        },
+        {
+          new RandomIdGenerator(128),
+          Pattern.compile("\\w{128}"),
+        },
+        {
+          new RandomIdGenerator(20, "abcdefg"),
+          Pattern.compile("[abcdefg]{20}"),
+        },
+      };
+  }
+
+  @Test(dataProvider = "generators")
+  public void testGenerate(final RandomIdGenerator generator, final Pattern expected)
+  {
+    for (int i = 0; i < 100; i++) {
+      final Matcher m = expected.matcher(generator.generate());
+      assertTrue(m.matches());
+    }
+  }
+
+  /**
+   * Test concurrent random ID generation on a shared instance.
+   *
+   * @throws Exception on test errors
+   */
+  @Test
+  public void testConcurrentGeneration()
+    throws Exception
+  {
+    final int poolSize = 100;
+    final ExecutorService executor = Executors.newFixedThreadPool(poolSize);
+    final RandomIdGenerator generator = new RandomIdGenerator(50);
+    final Collection<Callable<String>> tasks = new ArrayList<>();
+    for (int i = 0; i < poolSize; i++) {
+      tasks.add(generator::generate);
+    }
+    // Ensure all generated IDs are unique
+    final Set<String> identifiers = new HashSet<>(poolSize);
+    final List<Future<String>> results = executor.invokeAll(tasks);
+    for (Future<String> result : results) {
+      final String id = result.get(1, TimeUnit.SECONDS);
+      assertNotNull(id);
+      identifiers.add(id);
+    }
+    assertEquals(poolSize, identifiers.size());
+  }
+
+  /**
+   * Test creating new instances and calling generate on them concurrently.
+   *
+   * @throws Exception on test errors
+   */
+  @Test
+  public void testConcurrentGeneration2()
+      throws Exception
+  {
+    final int poolSize = 100;
+    final ExecutorService executor = Executors.newFixedThreadPool(poolSize);
+    final Collection<Callable<String>> tasks = new ArrayList<>();
+    for (int i = 0; i < poolSize; i++) {
+      tasks.add(() -> new RandomIdGenerator(50).generate());
+    }
+    // Ensure all generated IDs are unique
+    final Set<String> identifiers = new HashSet<>(poolSize);
+    final List<Future<String>> results = executor.invokeAll(tasks);
+    for (Future<String> result : results) {
+      final String id = result.get(1, TimeUnit.SECONDS);
+      assertNotNull(id);
+      identifiers.add(id);
+    }
+    assertEquals(poolSize, identifiers.size());
+  }
+}
diff --git a/src/test/java/org/cryptacular/generator/TOTPGeneratorTest.java b/src/test/java/org/cryptacular/generator/TOTPGeneratorTest.java
new file mode 100644
index 0000000..298420d
--- /dev/null
+++ b/src/test/java/org/cryptacular/generator/TOTPGeneratorTest.java
@@ -0,0 +1,77 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.generator;
+
+import java.nio.charset.StandardCharsets;
+import org.cryptacular.FailListener;
+import org.cryptacular.spec.DigestSpec;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Listeners;
+import org.testng.annotations.Test;
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertTrue;
+
+/**
+ * Unit test for {@link HOTPGenerator}.
+ *
+ * @author  Middleware Services
+ */
+@Listeners(FailListener.class)
+public class TOTPGeneratorTest
+{
+  /** Test vectors from RFC 6238, appendix B. */
+  @DataProvider(name = "test-data-rfc6238")
+  public Object[][] getTestDataRfc6238()
+  {
+    // Key size is equal to hash length for test vectors in RFC-6238
+    // (via careful review of the main method in the reference implementation under Appendix A)
+    final String sha1Key = "12345678901234567890";
+    final String sha256Key = "12345678901234567890123456789012";
+    final String sha512Key = "1234567890123456789012345678901234567890123456789012345678901234";
+    return
+      new Object[][] {
+        {new DigestSpec("SHA1"), sha1Key, 59, 8, 94287082},
+        {new DigestSpec("SHA256"), sha256Key, 59, 8, 46119246},
+        {new DigestSpec("SHA512"), sha512Key, 59, 8, 90693936},
+        {new DigestSpec("SHA1"), sha1Key, 1111111109, 8, 7081804},
+        {new DigestSpec("SHA256"), sha256Key, 1111111109, 8, 68084774},
+        {new DigestSpec("SHA512"), sha512Key, 1111111109, 8, 25091201},
+        {new DigestSpec("SHA1"), sha1Key, 1111111111, 8, 14050471},
+        {new DigestSpec("SHA256"), sha256Key, 1111111111, 8, 67062674},
+        {new DigestSpec("SHA512"), sha512Key, 1111111111, 8, 99943326},
+        {new DigestSpec("SHA1"), sha1Key, 1234567890, 8, 89005924},
+        {new DigestSpec("SHA256"), sha256Key, 1234567890, 8, 91819424},
+        {new DigestSpec("SHA512"), sha512Key, 1234567890, 8, 93441116},
+        {new DigestSpec("SHA1"), sha1Key, 2000000000, 8, 69279037},
+        {new DigestSpec("SHA256"), sha256Key, 2000000000, 8, 90698825},
+        {new DigestSpec("SHA512"), sha512Key, 2000000000, 8, 38618901},
+        {new DigestSpec("SHA1"), sha1Key, 20000000000L, 8, 65353130},
+        {new DigestSpec("SHA256"), sha256Key, 20000000000L, 8, 77737706},
+        {new DigestSpec("SHA512"), sha512Key, 20000000000L, 8, 47863826},
+      };
+  }
+
+
+  @Test(dataProvider = "test-data-rfc6238")
+  public void testGenerate(
+      final DigestSpec digestSpec, final String asciiKey, final long currentTime, final int otpSize, final int expected)
+  {
+    final TOTPGenerator generator = new TOTPGenerator();
+    generator.setDigestSpecification(digestSpec);
+    generator.setStartTime(0);
+    generator.setTimeStep(30);
+    generator.setCurrentTime(currentTime);
+    generator.setNumberOfDigits(otpSize);
+    assertEquals(generator.generate(asciiKey.getBytes(StandardCharsets.US_ASCII)), expected);
+  }
+
+  /** Ensure the system time is used by default. */
+  @Test
+  public void testTimeBehavior() throws Exception
+  {
+    final TOTPGenerator generator = new TOTPGenerator();
+    final long t1 = generator.currentTime();
+    Thread.sleep(1001);
+    final long t2 = generator.currentTime();
+    assertTrue(t2 > t1);
+  }
+}
diff --git a/src/test/java/org/cryptacular/generator/sp80038a/BigIntegerCounterNonceTest.java b/src/test/java/org/cryptacular/generator/sp80038a/BigIntegerCounterNonceTest.java
new file mode 100644
index 0000000..118abec
--- /dev/null
+++ b/src/test/java/org/cryptacular/generator/sp80038a/BigIntegerCounterNonceTest.java
@@ -0,0 +1,40 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.generator.sp80038a;
+
+import java.math.BigInteger;
+import org.cryptacular.FailListener;
+import org.cryptacular.util.ByteUtil;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Listeners;
+import org.testng.annotations.Test;
+import static org.testng.Assert.assertEquals;
+
+/**
+ * Unit test for {@link BigIntegerCounterNonce}.
+ *
+ * @author  Middleware Services
+ */
+@Listeners(FailListener.class)
+public class BigIntegerCounterNonceTest
+{
+  @DataProvider(name = "test-data")
+  public Object[][] getTestData()
+  {
+    return new Object[][] {
+      new Object[] {1, 8},
+      new Object[] {2199023255552L, 16},
+    };
+  }
+
+  @Test(dataProvider = "test-data")
+  public void testGenerate(final long start, final int expectedLength)
+    throws Exception
+  {
+    final BigIntegerCounterNonce nonce = new BigIntegerCounterNonce(
+      new BigInteger(ByteUtil.toBytes(start)),
+      expectedLength);
+    final byte[] value = nonce.generate();
+    assertEquals(value.length, expectedLength);
+    assertEquals(new BigInteger(value), new BigInteger(ByteUtil.toBytes(start + 1)));
+  }
+}
diff --git a/src/test/java/org/cryptacular/io/DecodingInputStreamTest.java b/src/test/java/org/cryptacular/io/DecodingInputStreamTest.java
new file mode 100644
index 0000000..ce727ab
--- /dev/null
+++ b/src/test/java/org/cryptacular/io/DecodingInputStreamTest.java
@@ -0,0 +1,49 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.io;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import org.bouncycastle.util.io.Streams;
+import org.cryptacular.FailListener;
+import org.cryptacular.util.ByteUtil;
+import org.cryptacular.util.StreamUtil;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Listeners;
+import org.testng.annotations.Test;
+import static org.testng.Assert.assertEquals;
+
+/**
+ * Unit test for {@link DecodingInputStream} class.
+ *
+ * @author  Middleware Services
+ */
+@Listeners(FailListener.class)
+public class DecodingInputStreamTest
+{
+  @DataProvider(name = "plaintext-files")
+  public Object[][] getPlaintextFiles()
+  {
+    return
+      new Object[][] {
+        new Object[] {"src/test/resources/plaintexts/lorem-1200.txt"},
+        new Object[] {"src/test/resources/plaintexts/lorem-5000.txt"},
+      };
+  }
+
+  @Test(dataProvider = "plaintext-files")
+  public void testDecode(final String path)
+    throws Exception
+  {
+    final String expected = StreamUtil.readAll(StreamUtil.makeReader(new File(path)));
+    final File file = new File(path + ".b64");
+    final DecodingInputStream input = DecodingInputStream.base64(StreamUtil.makeStream(file));
+    final ByteArrayOutputStream output = new ByteArrayOutputStream(expected.length());
+    try {
+      Streams.pipeAll(input, output);
+    } finally {
+      StreamUtil.closeStream(input);
+      StreamUtil.closeStream(output);
+    }
+    assertEquals(ByteUtil.toString(output.toByteArray()), expected);
+  }
+}
diff --git a/src/test/java/org/cryptacular/io/EncodingOutputStreamTest.java b/src/test/java/org/cryptacular/io/EncodingOutputStreamTest.java
new file mode 100644
index 0000000..28ba857
--- /dev/null
+++ b/src/test/java/org/cryptacular/io/EncodingOutputStreamTest.java
@@ -0,0 +1,53 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.io;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import org.bouncycastle.util.io.Streams;
+import org.cryptacular.FailListener;
+import org.cryptacular.util.ByteUtil;
+import org.cryptacular.util.StreamUtil;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Listeners;
+import org.testng.annotations.Test;
+import static org.testng.Assert.assertEquals;
+
+/**
+ * Unit test for {@link EncodingOutputStream} class.
+ *
+ * @author  Middleware Services
+ */
+@Listeners(FailListener.class)
+public class EncodingOutputStreamTest
+{
+  @DataProvider(name = "plaintext-files")
+  public Object[][] getPlaintextFiles()
+  {
+    return
+      new Object[][] {
+        new Object[] {"src/test/resources/plaintexts/lorem-1200.txt"},
+        new Object[] {"src/test/resources/plaintexts/lorem-5000.txt"},
+      };
+  }
+
+  @Test(dataProvider = "plaintext-files")
+  public void testEncode(final String path)
+    throws Exception
+  {
+    final File file = new File(path);
+    String expectedPath = path + ".b64";
+    if ("\r\n".equals(System.lineSeparator())) {
+      expectedPath += ".crlf";
+    }
+
+    final String expected = new String(StreamUtil.readAll(expectedPath));
+    final ByteArrayOutputStream bufOut = new ByteArrayOutputStream((int) file.length() * 4 / 3);
+    final EncodingOutputStream output = EncodingOutputStream.base64(bufOut, 64);
+    try {
+      Streams.pipeAll(StreamUtil.makeStream(file), output);
+    } finally {
+      StreamUtil.closeStream(output);
+    }
+    assertEquals(ByteUtil.toString(bufOut.toByteArray()), expected);
+  }
+}
diff --git a/src/test/java/org/cryptacular/util/ByteUtilTest.java b/src/test/java/org/cryptacular/util/ByteUtilTest.java
new file mode 100644
index 0000000..d7956f2
--- /dev/null
+++ b/src/test/java/org/cryptacular/util/ByteUtilTest.java
@@ -0,0 +1,55 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.util;
+
+import org.cryptacular.FailListener;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Listeners;
+import org.testng.annotations.Test;
+import static org.testng.Assert.assertEquals;
+
+/** @author  Middleware Services */
+@Listeners(FailListener.class)
+public class ByteUtilTest
+{
+  @DataProvider(name = "integers")
+  public Object[][] getIntegers()
+  {
+    return
+      new Object[][] {
+        new Object[] {64},
+        new Object[] {-89},
+        new Object[] {255},
+        new Object[] {256},
+        new Object[] {210983498},
+        new Object[] {-417234198},
+      };
+  }
+
+  @DataProvider(name = "longs")
+  public Object[][] getLongs()
+  {
+    return new Object[][] {
+      new Object[] {128},
+      new Object[] {110374187198L},
+      new Object[] {-8987189751341L},
+    };
+  }
+
+  @Test(dataProvider = "integers")
+  public void testIntToBytesAndBack(final int value)
+    throws Exception
+  {
+    final byte[] bytes = new byte[4];
+    ByteUtil.toBytes(value, bytes, 0);
+    assertEquals(ByteUtil.toInt(bytes), value);
+  }
+
+  @Test(dataProvider = "longs")
+  public void testLongToBytesAndBack(final long value)
+    throws Exception
+  {
+    final byte[] bytes = new byte[8];
+    ByteUtil.toBytes(value, bytes, 0);
+    assertEquals(ByteUtil.toLong(bytes), value);
+  }
+}
diff --git a/src/test/java/org/cryptacular/util/CertUtilTest.java b/src/test/java/org/cryptacular/util/CertUtilTest.java
new file mode 100644
index 0000000..1978c0d
--- /dev/null
+++ b/src/test/java/org/cryptacular/util/CertUtilTest.java
@@ -0,0 +1,503 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.util;
+
+import java.io.File;
+import java.nio.file.Files;
+import java.security.KeyPair;
+import java.security.PrivateKey;
+import java.security.SecureRandom;
+import java.security.cert.X509Certificate;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.util.Date;
+import java.util.List;
+import org.bouncycastle.asn1.x509.GeneralNames;
+import org.bouncycastle.asn1.x509.KeyPurposeId;
+import org.cryptacular.FailListener;
+import org.cryptacular.generator.KeyPairGenerator;
+import org.cryptacular.x509.GeneralNameType;
+import org.cryptacular.x509.KeyUsageBits;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Listeners;
+import org.testng.annotations.Test;
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNull;
+import static org.testng.Assert.assertTrue;
+
+/**
+ * Unit test for {@link CertUtil} class.
+ *
+ * @author  Middleware Services
+ */
+@Listeners(FailListener.class)
+public class CertUtilTest
+{
+  private static final String CRT_PATH = "src/test/resources/certs/";
+
+  @DataProvider(name = "subject-cn")
+  public Object[][] getSubjectCommonNames()
+  {
+    return
+      new Object[][] {
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "ed.middleware.vt.edu.crt"),
+          "ed.middleware.vt.edu",
+        },
+      };
+  }
+
+  @DataProvider(name = "subject-dn")
+  public Object[][] getSubjectDN()
+  {
+    return
+      new Object[][] {
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "ed.middleware.vt.edu.crt"),
+          "C=US,DC=edu,DC=vt,ST=Virginia,L=Blacksburg,O=Virginia Polytechnic Institute and State University," +
+            "OU=Middleware-Server-with-saltr,OU=Middleware Services,CN=ed.middleware.vt.edu",
+        },
+      };
+  }
+
+  @DataProvider(name = "subject-dn-spaces")
+  public Object[][] getSubjectDNWithSpaces()
+  {
+    return
+      new Object[][] {
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "ed.middleware.vt.edu.crt"),
+          "C=US, DC=edu, DC=vt, ST=Virginia, L=Blacksburg, O=Virginia Polytechnic Institute and State University, " +
+            "OU=Middleware-Server-with-saltr, OU=Middleware Services, CN=ed.middleware.vt.edu",
+        },
+      };
+  }
+
+
+  @DataProvider(name = "encode-cert-p7")
+  public Object[][] getP7EncodedCert() throws Exception
+  {
+    return
+      new Object[][] {
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "ed.middleware.vt.edu.crt"),
+          new String(Files.readAllBytes(new File(CRT_PATH + "ed.middleware.vt.edu.p7b").toPath())),
+        },
+      };
+  }
+
+  @DataProvider(name = "encode-cert-x509")
+  public Object[][] getX509Cert() throws Exception
+  {
+    return
+      new Object[][] {
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "ed.middleware.vt.edu.crt"),
+          new String(Files.readAllBytes(new File(CRT_PATH + "ed.middleware.vt.edu.crt").toPath())),
+        },
+      };
+  }
+
+  @DataProvider(name = "encode-cert-der")
+  public Object[][] getDERCert() throws Exception
+  {
+    return
+      new Object[][] {
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "ed.middleware.vt.edu.crt"),
+          Files.readAllBytes(new File(CRT_PATH + "ed.middleware.vt.edu.der").toPath()),
+        },
+      };
+  }
+
+  @DataProvider(name = "subject-alt-names")
+  public Object[][] getSubjectAltNames()
+  {
+    return
+      new Object[][] {
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "ed.middleware.vt.edu.crt"),
+          new String[] {
+            "ed.middleware.vt.edu",
+            "directory.vt.edu",
+            "id.directory.vt.edu",
+            "authn.directory.vt.edu",
+            "ldap.vt.edu",
+          },
+        },
+      };
+  }
+
+  @DataProvider(name = "subject-alt-names-by-type")
+  public Object[][] getSubjectAltNamesByType()
+  {
+    return
+      new Object[][] {
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "ed.middleware.vt.edu.crt"),
+          new GeneralNameType[] {GeneralNameType.DNSName},
+          new String[] {
+            "ed.middleware.vt.edu",
+            "directory.vt.edu",
+            "id.directory.vt.edu",
+            "authn.directory.vt.edu",
+            "ldap.vt.edu",
+          },
+        },
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "ed.middleware.vt.edu.crt"),
+          new GeneralNameType[] {GeneralNameType.RFC822Name},
+          new String[0],
+        },
+      };
+  }
+
+  @DataProvider(name = "subject-names")
+  public Object[][] getSubjectNames()
+  {
+    return
+      new Object[][] {
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "serac-dev-test.crt"),
+          new String[] {"Marvin S Addison", "eprov@vt.edu"},
+        },
+      };
+  }
+
+  @DataProvider(name = "subject-names-by-type")
+  public Object[][] getSubjectNamesByType()
+  {
+    return
+      new Object[][] {
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "serac-dev-test.crt"),
+          new GeneralNameType[] {GeneralNameType.RFC822Name},
+          new String[] {"Marvin S Addison", "eprov@vt.edu"},
+        },
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "serac-dev-test.crt"),
+          new GeneralNameType[] {GeneralNameType.OtherName},
+          new String[] {"Marvin S Addison"},
+        },
+      };
+  }
+
+  @DataProvider(name = "entity-certificate")
+  public Object[][] getEntityCertificates()
+    throws Exception
+  {
+    return
+      new Object[][] {
+        new Object[] {
+          KeyPairUtil.readPrivateKey(CRT_PATH + "entity.key"),
+          new X509Certificate[] {
+            CertUtil.readCertificate(CRT_PATH + "glider.cc.vt.edu.crt"),
+            CertUtil.readCertificate(CRT_PATH + "login.live.com.crt"),
+            CertUtil.readCertificate(CRT_PATH + "entity.crt"),
+          },
+          CertUtil.readCertificate(CRT_PATH + "entity.crt"),
+        },
+      };
+  }
+
+  @DataProvider(name = "basic-usage")
+  public Object[][] getBasicUsage()
+    throws Exception
+  {
+    return
+      new Object[][] {
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "serac-dev-test.crt"),
+          new KeyUsageBits[] {
+            KeyUsageBits.DigitalSignature,
+            KeyUsageBits.NonRepudiation,
+          },
+        },
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "login.live.com.crt"),
+          new KeyUsageBits[] {
+            KeyUsageBits.DigitalSignature,
+            KeyUsageBits.KeyEncipherment,
+          },
+        },
+      };
+  }
+
+  @DataProvider(name = "extended-usage")
+  public Object[][] getExtendedUsage()
+    throws Exception
+  {
+    return
+      new Object[][] {
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "serac-dev-test.crt"),
+          new KeyPurposeId[] {
+            KeyPurposeId.id_kp_clientAuth,
+            KeyPurposeId.id_kp_emailProtection,
+            KeyPurposeId.id_kp_smartcardlogon,
+          },
+        },
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "login.live.com.crt"),
+          new KeyPurposeId[] {
+            KeyPurposeId.id_kp_clientAuth,
+            KeyPurposeId.id_kp_serverAuth,
+          },
+        },
+      };
+  }
+
+  @DataProvider(name = "has-policies")
+  public Object[][] getHasPolicies()
+    throws Exception
+  {
+    return
+      new Object[][] {
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "serac-dev-test.crt"),
+          new String[] {
+            "1.3.6.1.4.1.6760.5.2.2.1.1",
+            "1.3.6.1.4.1.6760.5.2.2.2.1",
+            "1.3.6.1.4.1.6760.5.2.2.3.1",
+            "1.3.6.1.4.1.6760.5.2.2.4.1",
+          },
+        },
+      };
+  }
+
+  @DataProvider(name = "subject-keyid")
+  public Object[][] getSubjectKeyId()
+    throws Exception
+  {
+    return
+      new Object[][] {
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "serac-dev-test.crt"),
+          "25:48:2F:28:EC:5D:19:BB:1D:25:AE:94:93:B1:7B:B5:35:96:24:66",
+        },
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "login.live.com.crt"),
+          "31:AE:F1:7C:98:67:E9:1F:19:69:A2:A7:84:1E:67:5C:AA:C3:6B:75",
+        },
+      };
+  }
+
+  @DataProvider(name = "authority-keyid")
+  public Object[][] getAuthorityKeyId()
+    throws Exception
+  {
+    return
+      new Object[][] {
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "serac-dev-test.crt"),
+          "38:E0:6F:AE:48:ED:5E:23:F6:22:9B:1E:E7:9C:19:16:47:B8:7E:92",
+        },
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "login.live.com.crt"),
+          "FC:8A:50:BA:9E:B9:25:5A:7B:55:85:4F:95:00:63:8F:E9:58:6B:43",
+        },
+      };
+  }
+
+  @DataProvider(name = "cert-chains")
+  public Object[][] getCertificateChains()
+    throws Exception
+  {
+    return new Object[][] {
+        {CRT_PATH + "vtgsca_chain.pem", 4},
+        {CRT_PATH + "vtuca_chain.p7b", 2},
+      };
+  }
+
+
+  @Test(dataProvider = "subject-cn")
+  public void testSubjectCN(final X509Certificate cert, final String expected)
+  {
+    assertEquals(CertUtil.subjectCN(cert), expected);
+  }
+
+  @Test(dataProvider = "subject-alt-names")
+  public void testSubjectAltNames(final X509Certificate cert, final String[] expected)
+    throws Exception
+  {
+    final GeneralNames names = CertUtil.subjectAltNames(cert);
+    if (expected.length == 0) {
+      assertNull(names);
+      return;
+    }
+    assertEquals(names.getNames().length, expected.length);
+    for (int i = 0; i < expected.length; i++) {
+      assertEquals(names.getNames()[i].getName().toString(), expected[i]);
+    }
+  }
+
+  @Test(dataProvider = "subject-alt-names-by-type")
+  public void testSubjectAltNamesByType(
+    final X509Certificate cert,
+    final GeneralNameType[] types,
+    final String[] expected)
+    throws Exception
+  {
+    final GeneralNames names = CertUtil.subjectAltNames(cert, types);
+    if (expected.length == 0) {
+      assertNull(names);
+      return;
+    }
+    assertEquals(names.getNames().length, expected.length);
+    for (int i = 0; i < expected.length; i++) {
+      assertEquals(names.getNames()[i].getName().toString(), expected[i]);
+    }
+  }
+
+  @Test(dataProvider = "subject-names")
+  public void testSubjectNames(final X509Certificate cert, final String[] expected)
+    throws Exception
+  {
+    final List<String> names = CertUtil.subjectNames(cert);
+    assertEquals(names.size(), expected.length);
+    for (int i = 0; i < expected.length; i++) {
+      assertEquals(names.get(i), expected[i]);
+    }
+  }
+
+  @Test(dataProvider = "subject-names-by-type")
+  public void testSubjectNamesByType(final X509Certificate cert, final GeneralNameType[] types, final String[] expected)
+    throws Exception
+  {
+    final List<String> names = CertUtil.subjectNames(cert, types);
+    assertEquals(names.size(), expected.length);
+    for (int i = 0; i < expected.length; i++) {
+      assertEquals(names.get(i), expected[i]);
+    }
+  }
+
+  @Test(dataProvider = "entity-certificate")
+  public void testFindEntityCertificate(
+    final PrivateKey key,
+    final X509Certificate[] candidates,
+    final X509Certificate expected)
+    throws Exception
+  {
+    assertEquals(CertUtil.findEntityCertificate(key, candidates), expected);
+  }
+
+  @Test(dataProvider = "basic-usage")
+  public void testAllowsBasicUsage(final X509Certificate cert, final KeyUsageBits[] expectedUses)
+    throws Exception
+  {
+    assertTrue(CertUtil.allowsUsage(cert, expectedUses));
+  }
+
+  @Test(dataProvider = "extended-usage")
+  public void testAllowsExtendedUsage(final X509Certificate cert, final KeyPurposeId[] expectedPurposes)
+    throws Exception
+  {
+    assertTrue(CertUtil.allowsUsage(cert, expectedPurposes));
+  }
+
+  @Test(dataProvider = "has-policies")
+  public void testHasPolicies(final X509Certificate cert, final String[] expectedPolicies)
+    throws Exception
+  {
+    assertTrue(CertUtil.hasPolicies(cert, expectedPolicies));
+  }
+
+  @Test(dataProvider = "subject-keyid")
+  public void testSubjectKeyId(final X509Certificate cert, final String expectedKeyId)
+    throws Exception
+  {
+    assertEquals(CertUtil.subjectKeyId(cert).toUpperCase(), expectedKeyId);
+  }
+
+  @Test(dataProvider = "authority-keyid")
+  public void testAuthorityKeyId(final X509Certificate cert, final String expectedKeyId)
+    throws Exception
+  {
+    assertEquals(CertUtil.authorityKeyId(cert).toUpperCase(), expectedKeyId);
+  }
+
+
+  @Test(dataProvider = "cert-chains")
+  public void testReadCertificateChains(final String path, final int expectedCount)
+    throws Exception
+  {
+    assertEquals(CertUtil.readCertificateChain(path).length, expectedCount);
+  }
+
+  @Test(dataProvider = "encode-cert-p7")
+  public void certEncodedAsPkcs7(final X509Certificate certificate, final String expectedEncodedCert)
+  {
+    final String actualEncodedCertString = CertUtil.encodeCert(certificate, CertUtil.EncodeType.PKCS7);
+    final X509Certificate decodedCert = CertUtil.decodeCertificate(CertUtil.encodeCert(certificate,
+      CertUtil.EncodeType.PKCS7).getBytes());
+    assertEquals(actualEncodedCertString, expectedEncodedCert);
+    assertEquals(certificate, decodedCert);
+  }
+
+  @Test(dataProvider = "encode-cert-x509")
+  public void certEncodedAsX509(final X509Certificate certificate, final String x509Cert)
+  {
+    final String encodedCert = CertUtil.encodeCert(certificate, CertUtil.EncodeType.X509);
+    assertEquals(encodedCert, x509Cert);
+  }
+
+  @Test(dataProvider = "encode-cert-der")
+  public void certEncodedAsDER(final X509Certificate certificate, final byte[] derCert)
+  {
+    final byte[] encodedCert = CertUtil.encodeCert(certificate, CertUtil.EncodeType.DER);
+    assertEquals(encodedCert, derCert);
+  }
+
+  @Test(dataProvider = "subject-dn")
+  public void testSubjectDN(final X509Certificate certificate, final String expectedResponse)
+  {
+    assertEquals(CertUtil.subjectDN(certificate, CertUtil.X500PrincipalFormat.RFC2253), expectedResponse);
+  }
+
+  @Test(dataProvider = "subject-dn-spaces")
+  public void testSubjectDNWithSpaces(final X509Certificate certificate, final String expectedResponse)
+  {
+    assertEquals(CertUtil.subjectDN(certificate, CertUtil.X500PrincipalFormat.READABLE), expectedResponse);
+  }
+
+  @Test
+  public void testGenX509()
+  {
+    final KeyPair keyPair = KeyPairGenerator.generateRSA(new SecureRandom(), 2048);
+    final String dn = "C=US, DC=edu, DC=vt, ST=Virginia, " +
+      "L=Blacksburg, O=Virginia Polytechnic Institute and State University, OU=Middleware-Server-with-saltr, " +
+      "OU=Middleware Services, CN=ed.middleware.vt.edu";
+
+    final Instant expectedNotBefore = Instant.now();
+    final Instant expectedNotAfter = Instant.now().plus(Duration.ofDays(365));
+
+    final X509Certificate x509Certificate = CertUtil.generateX509Certificate(keyPair, dn,
+      Date.from(expectedNotBefore), Date.from(expectedNotAfter), "SHA256WithRSA");
+
+    assertEquals(truncateToSeconds(expectedNotBefore), truncateToSeconds(x509Certificate.getNotBefore().toInstant()));
+    assertEquals(truncateToSeconds(expectedNotAfter), truncateToSeconds(x509Certificate.getNotAfter().toInstant()));
+  }
+
+  @Test(expectedExceptions = RuntimeException.class,
+    expectedExceptionsMessageRegExp = "Unknown signature type requested: UNSUPPORTEDALGO")
+  public void testGenX509UnSupportedAlgo()
+  {
+    final KeyPair keyPair = KeyPairGenerator.generateRSA(new SecureRandom(), 2048);
+    final String dn = "C=US, DC=edu, DC=vt, ST=Virginia, " +
+      "L=Blacksburg, O=Virginia Polytechnic Institute and State University, OU=Middleware-Server-with-saltr, " +
+      "OU=Middleware Services, CN=ed.middleware.vt.edu";
+
+    final Instant expectedNotBefore = Instant.now();
+    final Instant expectedNotAfter = Instant.now().plus(Duration.ofDays(365));
+
+    CertUtil.generateX509Certificate(keyPair, dn,
+        Date.from(expectedNotBefore), Date.from(expectedNotAfter), "UNSUPPORTEDALGO");
+  }
+
+
+  private OffsetDateTime truncateToSeconds(final Instant instant)
+  {
+    return instant.atOffset(ZoneOffset.UTC).withNano(0);
+  }
+}
diff --git a/src/test/java/org/cryptacular/util/CipherUtilTest.java b/src/test/java/org/cryptacular/util/CipherUtilTest.java
new file mode 100644
index 0000000..8c5e10c
--- /dev/null
+++ b/src/test/java/org/cryptacular/util/CipherUtilTest.java
@@ -0,0 +1,215 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.util;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import javax.crypto.SecretKey;
+import org.bouncycastle.crypto.BlockCipher;
+import org.bouncycastle.crypto.engines.AESEngine;
+import org.bouncycastle.crypto.engines.BlowfishEngine;
+import org.bouncycastle.crypto.engines.TwofishEngine;
+import org.bouncycastle.crypto.modes.AEADBlockCipher;
+import org.bouncycastle.crypto.modes.CBCBlockCipher;
+import org.bouncycastle.crypto.modes.CCMBlockCipher;
+import org.bouncycastle.crypto.modes.CFBBlockCipher;
+import org.bouncycastle.crypto.modes.GCMBlockCipher;
+import org.bouncycastle.crypto.modes.OCBBlockCipher;
+import org.bouncycastle.crypto.modes.OFBBlockCipher;
+import org.cryptacular.FailListener;
+import org.cryptacular.bean.KeyStoreBasedKeyFactoryBean;
+import org.cryptacular.bean.KeyStoreFactoryBean;
+import org.cryptacular.generator.Nonce;
+import org.cryptacular.generator.SecretKeyGenerator;
+import org.cryptacular.generator.sp80038a.LongCounterNonce;
+import org.cryptacular.generator.sp80038a.RBGNonce;
+import org.cryptacular.generator.sp80038d.CounterNonce;
+import org.cryptacular.io.FileResource;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Listeners;
+import org.testng.annotations.Test;
+import static org.testng.Assert.assertEquals;
+
+/**
+ * Unit test for {@link CipherUtil} class.
+ *
+ * @author  Middleware Services
+ */
+@Listeners(FailListener.class)
+public class CipherUtilTest
+{
+  /** Static key derived from keystore on resource classpath. */
+  private static final SecretKey STATIC_KEY;
+
+  static
+  {
+    final KeyStoreFactoryBean keyStoreFactory = new KeyStoreFactoryBean();
+    keyStoreFactory.setPassword("vtcrypt");
+    keyStoreFactory.setResource(new FileResource(new File("src/test/resources/keystores/cipher-bean.jceks")));
+    keyStoreFactory.setType("JCEKS");
+    final KeyStoreBasedKeyFactoryBean<SecretKey> keyFactory = new KeyStoreBasedKeyFactoryBean<>();
+    keyFactory.setKeyStore(keyStoreFactory.newInstance());
+    keyFactory.setAlias("vtcrypt");
+    keyFactory.setPassword("vtcrypt");
+    STATIC_KEY = keyFactory.newInstance();
+  }
+
+  @DataProvider(name = "block-cipher")
+  public Object[][] getBlockCipherData()
+  {
+    return
+      new Object[][] {
+        new Object[] {
+          // Plaintext is NOT multiple of block size
+          "Able was I ere I saw elba.",
+          new CBCBlockCipher(new AESEngine()),
+          new RBGNonce(16),
+        },
+        // Plaintext is multiple of block size
+        new Object[] {
+          "Four score and seven years ago, our forefathers ",
+          new CBCBlockCipher(new BlowfishEngine()),
+          new RBGNonce(8),
+        },
+        // OFB
+        new Object[] {
+          "Have you passed through this night?",
+          new OFBBlockCipher(new BlowfishEngine(), 64),
+          new LongCounterNonce(),
+        },
+        // CFB
+        new Object[] {
+          "I went to the woods because I wished to live deliberately, to front only the essential facts of life",
+          new CFBBlockCipher(new AESEngine(), 128),
+          new RBGNonce(16),
+        },
+      };
+  }
+
+
+  @DataProvider(name = "aead-block-cipher")
+  public Object[][] getAeadBlockCipherData()
+  {
+    return
+      new Object[][] {
+        new Object[] {
+          // Plaintext is NOT multiple of block size
+          "I never picked cotton like my mother did",
+          new GCMBlockCipher(new AESEngine()),
+        },
+        new Object[] {
+          // Plaintext is multiple of block size
+          "Cogito ergo sum.",
+          new GCMBlockCipher(new AESEngine()),
+        },
+        // CCM
+        new Object[] {
+          "Thousands of candles can be lit from a single candle and the life of the candle will not be shortened.",
+          new CCMBlockCipher(new TwofishEngine()),
+        },
+        // OCB
+        new Object[] {
+          "I slept and dreamt life was joy. I awoke and saw that life was service. " +
+            "I acted and behold: service was joy.",
+          new OCBBlockCipher(new AESEngine(), new AESEngine()),
+        },
+      };
+  }
+
+
+  @DataProvider(name = "plaintext-files")
+  public Object[][] getPlaintextFiles()
+  {
+    return
+      new Object[][] {
+        new Object[] {"src/test/resources/plaintexts/lorem-1200.txt"},
+        new Object[] {"src/test/resources/plaintexts/lorem-5000.txt"},
+      };
+  }
+
+
+  @Test(dataProvider = "block-cipher")
+  public void testBlockCipherEncryptDecrypt(final String plaintext, final BlockCipher cipher, final Nonce nonce)
+  {
+    final SecretKey key = SecretKeyGenerator.generate(cipher);
+    final byte[] ciphertext = CipherUtil.encrypt(cipher, key, nonce, plaintext.getBytes());
+    final byte[] result = CipherUtil.decrypt(cipher, key, ciphertext);
+    assertEquals(new String(result), plaintext);
+  }
+
+
+  @Test(dataProvider = "aead-block-cipher")
+  public void testAeadBlockCipherEncryptDecrypt(final String plaintext, final AEADBlockCipher cipher)
+  {
+    final BlockCipher under = cipher.getUnderlyingCipher();
+    final SecretKey key = SecretKeyGenerator.generate(under);
+    final byte[] ciphertext = CipherUtil.encrypt(cipher, key, new RBGNonce(12), plaintext.getBytes());
+    final byte[] result = CipherUtil.decrypt(cipher, key, ciphertext);
+    assertEquals(new String(result), plaintext);
+  }
+
+
+  @Test(dataProvider = "plaintext-files")
+  public void testBlockCipherEncryptDecryptStream(final String path)
+    throws Exception
+  {
+    final BlockCipher cipher = new CBCBlockCipher(new AESEngine());
+    final SecretKey key = SecretKeyGenerator.generate(cipher);
+    final Nonce nonce = new CounterNonce("vt-crypt", 1);
+    final File file = new File(path);
+    final String expected = new String(StreamUtil.readAll(file));
+    final ByteArrayOutputStream tempOut = new ByteArrayOutputStream();
+    CipherUtil.encrypt(cipher, key, nonce, StreamUtil.makeStream(file), tempOut);
+
+    final ByteArrayInputStream tempIn = new ByteArrayInputStream(tempOut.toByteArray());
+    final ByteArrayOutputStream actual = new ByteArrayOutputStream();
+    CipherUtil.decrypt(cipher, key, tempIn, actual);
+    assertEquals(actual.toString(), expected);
+  }
+
+
+  @Test(dataProvider = "plaintext-files")
+  public void testAeadBlockCipherEncryptDecryptStream(final String path)
+    throws Exception
+  {
+    final AEADBlockCipher cipher = new GCMBlockCipher(new AESEngine());
+    final SecretKey key = SecretKeyGenerator.generate(cipher.getUnderlyingCipher());
+    final File file = new File(path);
+    final String expected = new String(StreamUtil.readAll(file));
+    final ByteArrayOutputStream tempOut = new ByteArrayOutputStream();
+    CipherUtil.encrypt(cipher, key, new RBGNonce(), StreamUtil.makeStream(file), tempOut);
+
+    final ByteArrayInputStream tempIn = new ByteArrayInputStream(tempOut.toByteArray());
+    final ByteArrayOutputStream actual = new ByteArrayOutputStream();
+    CipherUtil.decrypt(cipher, key, tempIn, actual);
+    assertEquals(actual.toString(), expected);
+  }
+
+
+  @Test
+  public void testDecryptArrayBackwardCompatibleHeader()
+  {
+    final AEADBlockCipher cipher = new OCBBlockCipher(new TwofishEngine(), new TwofishEngine());
+    final String expected = "Have you passed through this night?";
+    final String v1CiphertextHex =
+      "0000001f0000000c76746d770002ba17043672d900000007767463727970745a38dee735266e3f5f7aafec8d1c9ed8a0830a2ff9" +
+        "c3a46c25f89e69b6eb39dbb82fd13da50e32b2544a73f1a4476677b377e6";
+    final byte[] plaintext = CipherUtil.decrypt(cipher, STATIC_KEY, CodecUtil.hex(v1CiphertextHex));
+    assertEquals(expected, ByteUtil.toString(plaintext));
+  }
+
+
+  @Test
+  public void testDecryptStreamBackwardCompatibleHeader()
+  {
+    final AEADBlockCipher cipher = new OCBBlockCipher(new TwofishEngine(), new TwofishEngine());
+    final String expected = "Have you passed through this night?";
+    final String v1CiphertextHex =
+      "0000001f0000000c76746d770002ba17043672d900000007767463727970745a38dee735266e3f5f7aafec8d1c9ed8a0830a2ff9" +
+        "c3a46c25f89e69b6eb39dbb82fd13da50e32b2544a73f1a4476677b377e6";
+    final ByteArrayInputStream in = new ByteArrayInputStream(CodecUtil.hex(v1CiphertextHex));
+    final ByteArrayOutputStream out = new ByteArrayOutputStream();
+    CipherUtil.decrypt(cipher, STATIC_KEY, in, out);
+    assertEquals(expected, ByteUtil.toString(out.toByteArray()));
+  }
+}
diff --git a/src/test/java/org/cryptacular/util/CsrUtilTest.java b/src/test/java/org/cryptacular/util/CsrUtilTest.java
new file mode 100644
index 0000000..3e9743e
--- /dev/null
+++ b/src/test/java/org/cryptacular/util/CsrUtilTest.java
@@ -0,0 +1,126 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.util;
+
+import java.io.IOException;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.util.Arrays;
+import java.util.List;
+import org.bouncycastle.asn1.pkcs.CertificationRequest;
+import org.bouncycastle.pkcs.PKCS10CertificationRequest;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+import static org.testng.Assert.assertEquals;
+
+/**
+ * Test class for {@link CsrUtil}.
+ *
+ * @author Marvin S. Addison
+ */
+public class CsrUtilTest
+{
+
+  @DataProvider(name = "csr-files")
+  public Object[][] getCsrFiles()
+  {
+    return new Object[][] {
+      new Object[] {"/csrs/simple-ec-prime256v1.csr"},
+      new Object[] {"/csrs/simple-ec-secp384r1.csr"},
+      new Object[] {"/csrs/simple-rsa-1024.csr"},
+      new Object[] {"/csrs/with-sans-rsa-2048.csr"},
+    };
+  }
+
+  @DataProvider(name = "key-lengths")
+  public Object[][] getKeyLengths()
+  {
+    return new Object[][] {
+      new Object[] {"/csrs/simple-ec-prime256v1.csr", 256},
+      new Object[] {"/csrs/simple-ec-secp384r1.csr", 384},
+      new Object[] {"/csrs/simple-rsa-1024.csr", 1024},
+      new Object[] {"/csrs/with-sans-rsa-2048.csr", 2048},
+    };
+  }
+
+  @DataProvider(name = "sig-alg-names")
+  public Object[][] getSigAlgNames()
+  {
+    return new Object[][] {
+      new Object[] {"/csrs/simple-ec-secp384r1.csr", "SHA256withECDSA"},
+      new Object[] {"/csrs/simple-ec-prime256v1.csr", "SHA256withECDSA"},
+      new Object[] {"/csrs/simple-rsa-1024.csr", "SHA256withRSA"},
+      new Object[] {"/csrs/with-sans-rsa-2048.csr", "SHA256withRSA"},
+    };
+  }
+
+  @DataProvider(name = "names")
+  public Object[][] getNames()
+  {
+    return new Object[][] {
+      new Object[] {"/csrs/simple-ec-prime256v1.csr", "simple.example.com"},
+      new Object[] {"/csrs/simple-ec-secp384r1.csr", "simple.example.com"},
+      new Object[] {"/csrs/simple-rsa-1024.csr", "simple.example.com"},
+      new Object[] {
+        "/csrs/with-sans-rsa-2048.csr",
+        "host.example.com",
+        "dev.host.example.com",
+        "pprd.host.example.com",
+      },
+    };
+  }
+
+  @DataProvider(name = "key-algs")
+  public Object[][] getKeyAlgs()
+  {
+    return new Object[][] {
+      new Object[] {"RSA"},
+      new Object[] {"EC"},
+    };
+  }
+
+  @Test(dataProvider = "csr-files")
+  public void testEncodeCsr(final String classPath) throws IOException
+  {
+    final CertificationRequest csr1 = CsrUtil.readCsr(getClass().getResourceAsStream(classPath));
+    final String encoded = CsrUtil.encodeCsr(new PKCS10CertificationRequest(csr1));
+    final CertificationRequest csr2 = CsrUtil.decodeCsr(encoded);
+    assertEquals(csr1.getEncoded(), csr2.getEncoded());
+  }
+
+  @Test(dataProvider = "names")
+  public void testNames(final String classPath, final String... names)
+  {
+    final CertificationRequest csr = CsrUtil.readCsr(getClass().getResourceAsStream(classPath));
+    assertEquals(CsrUtil.commonNames(csr).get(0), names[0]);
+    final List<String> sans = CsrUtil.subjectAltNames(csr);
+    for (int i = 1; i < names.length; i++) {
+      assertEquals(sans.get(i - 1), names[i]);
+    }
+  }
+
+  @Test(dataProvider = "key-lengths")
+  public void testKeyLength(final String classPath, final int length)
+  {
+    final CertificationRequest csr = CsrUtil.readCsr(getClass().getResourceAsStream(classPath));
+    assertEquals(CsrUtil.keyLength(csr), length);
+  }
+
+  @Test(dataProvider = "sig-alg-names")
+  public void testSigAlgName(final String classPath, final String sigAlgName)
+  {
+    final CertificationRequest csr = CsrUtil.readCsr(getClass().getResourceAsStream(classPath));
+    assertEquals(CsrUtil.sigAlgName(csr), sigAlgName);
+  }
+
+  @Test(dataProvider = "key-algs")
+  public void testGenerateCsr(final String keyAlg) throws Exception
+  {
+    final KeyPair keyPair = KeyPairGenerator.getInstance(keyAlg).generateKeyPair();
+    final String hostname = keyAlg.toLowerCase() + ".example.org";
+    final String dn = "CN=" + hostname + ",DC=example,DC=org";
+    final String[] sans = {"dev." + hostname, "pprd." + hostname};
+    final CertificationRequest csr = CsrUtil.generateCsr(keyPair, dn, sans).toASN1Structure();
+    assertEquals(CsrUtil.commonNames(csr).get(0), hostname);
+    assertEquals(CsrUtil.subjectAltNames(csr), Arrays.asList(sans));
+  }
+}
diff --git a/src/test/java/org/cryptacular/util/HashUtilTest.java b/src/test/java/org/cryptacular/util/HashUtilTest.java
new file mode 100644
index 0000000..16b8f40
--- /dev/null
+++ b/src/test/java/org/cryptacular/util/HashUtilTest.java
@@ -0,0 +1,150 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.util;
+
+import java.io.File;
+import java.io.InputStream;
+import org.bouncycastle.crypto.Digest;
+import org.bouncycastle.crypto.digests.SHA1Digest;
+import org.bouncycastle.crypto.digests.SHA256Digest;
+import org.bouncycastle.util.encoders.Hex;
+import org.cryptacular.FailListener;
+import org.cryptacular.SaltedHash;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Listeners;
+import org.testng.annotations.Test;
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertTrue;
+
+/**
+ * Unit test for {@link HashUtil}.
+ *
+ * @author  Middleware Services
+ */
+@Listeners(FailListener.class)
+public class HashUtilTest
+{
+  private static final byte[] SALT = new byte[] {0, 1, 2, 3, 4, 5, 6, 7};
+
+  @DataProvider(name = "salted-hashes")
+  public Object[][] getSaltedHashData()
+  {
+    return
+      new Object[][] {
+        {
+          new SHA1Digest(),
+          new Object[] {ByteUtil.toBytes("deoxyribonucleic acid"), },
+          1,
+          "0aDM5g/qqfVV/8MIqkTKQaklWSg=",
+        },
+        {
+          new SHA1Digest(),
+          new Object[] {
+            ByteUtil.toBytes("protoporphyrin-9"),
+            SALT,
+          },
+          1,
+          "6SafHIoTusYN6dnK1pxx7udaBLA=",
+        },
+        {
+          new SHA256Digest(),
+          new Object[] {
+            SALT,
+            ByteUtil.toBytes("N-arachidonoylethanolamine"),
+          },
+          5,
+          "RWIg3BIXdqZPI9C7PFvSn62miU3L9ponVZLvKmC9XlQ=",
+        },
+      };
+  }
+
+  @DataProvider(name = "hash-compare")
+  public Object[][] getHashCompareData()
+  {
+    return
+      new Object[][] {
+        {
+          new SHA1Digest(),
+          CodecUtil.b64("7fyOZXGp+gKMziV/2Px7RIMkxyI2O1H8"),
+          1,
+          ByteUtil.toBytes("password"),
+        },
+        {
+          new SHA1Digest(),
+          CodecUtil.b64("0aDM5g/qqfVV/8MIqkTKQaklWSg="),
+          1,
+          ByteUtil.toBytes("deoxyribonucleic acid"),
+        },
+      };
+  }
+
+  @DataProvider(name = "salted-hash-compare")
+  public Object[][] getSaltedHashCompareData()
+  {
+    return
+      new Object[][] {
+        {
+          new SHA1Digest(),
+          new SaltedHash(CodecUtil.b64("7fyOZXGp+gKMziV/2Px7RIMkxyI2O1H8"), 20, true),
+          1,
+          true,
+          ByteUtil.toBytes("password"),
+        },
+      };
+  }
+
+
+  @DataProvider(name = "file-hashes")
+  public Object[][] getFileHashes()
+  {
+    return
+      new Object[][] {
+        new Object[] {
+          "src/test/resources/plaintexts/lorem-1200.txt",
+          "f0746e8978b3eccca05284dd12f098fdea32c8bc",
+        },
+        new Object[] {
+          "src/test/resources/plaintexts/lorem-5000.txt",
+          "1142d7a2661760624fa41b002be6c66c23b50602",
+        },
+      };
+  }
+
+
+  @Test(dataProvider = "salted-hashes")
+  public void testSaltedHash(final Digest digest, final Object[] data, final int iterations, final String expected)
+    throws Exception
+  {
+    assertEquals(CodecUtil.b64(HashUtil.hash(digest, iterations, data)), expected);
+  }
+
+
+  @Test(dataProvider = "hash-compare")
+  public void testCompareHash(final Digest digest, final byte[] hash, final int iterations, final byte[] data)
+    throws Exception
+  {
+    assertTrue(HashUtil.compareHash(digest, hash, iterations, data));
+  }
+
+
+  @Test(dataProvider = "salted-hash-compare")
+  public void testCompareSaltedHash(
+    final Digest digest,
+    final SaltedHash saltedHash,
+    final int iterations,
+    final boolean saltAfterData,
+    final byte[] data)
+    throws Exception
+  {
+    assertTrue(HashUtil.compareHash(digest, saltedHash, iterations, saltAfterData, data));
+  }
+
+
+  @Test(dataProvider = "file-hashes")
+  public void testHashStream(final String path, final String expected)
+    throws Exception
+  {
+    try (InputStream in = StreamUtil.makeStream(new File(path))) {
+      assertEquals(Hex.toHexString(HashUtil.sha1(in)), expected);
+    }
+  }
+}
diff --git a/src/test/java/org/cryptacular/util/KeyPairUtilTest.java b/src/test/java/org/cryptacular/util/KeyPairUtilTest.java
new file mode 100644
index 0000000..7a28e78
--- /dev/null
+++ b/src/test/java/org/cryptacular/util/KeyPairUtilTest.java
@@ -0,0 +1,433 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.util;
+
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.security.KeyPair;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.security.SecureRandom;
+import java.security.interfaces.DSAPrivateKey;
+import java.security.interfaces.DSAPublicKey;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.ECPublicKey;
+import java.security.interfaces.RSAPrivateCrtKey;
+import java.security.interfaces.RSAPublicKey;
+import org.cryptacular.FailListener;
+import org.cryptacular.generator.KeyPairGenerator;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Listeners;
+import org.testng.annotations.Test;
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertTrue;
+
+/**
+ * Unit test for {@link KeyPairUtil} class.
+ *
+ * @author  Middleware Services
+ */
+@Listeners(FailListener.class)
+public class KeyPairUtilTest
+{
+  private static final String KEY_PATH = "src/test/resources/keys/";
+
+  private final SecureRandom random = new SecureRandom();
+
+  private final KeyPair rsa512 = KeyPairGenerator.generateRSA(random, 512);
+
+  private final KeyPair dsa1024 = KeyPairGenerator.generateDSA(random, 1024);
+
+  private final KeyPair ec256 = KeyPairGenerator.generateEC(random, 256);
+
+  private final KeyPair ec224 = KeyPairGenerator.generateEC(random, "P-224");
+
+
+  @DataProvider(name = "public-keys")
+  public Object[][] getPublicKeys()
+  {
+    return
+      new Object[][] {
+        new Object[] {dsa1024.getPublic(), 1024},
+        new Object[] {rsa512.getPublic(), 512},
+        new Object[] {ec256.getPublic(), 256},
+      };
+  }
+
+  @DataProvider(name = "private-keys")
+  public Object[][] getPrivateKeys()
+  {
+    return
+      new Object[][] {
+        new Object[] {dsa1024.getPrivate(), 160},
+        new Object[] {rsa512.getPrivate(), 512},
+        new Object[] {ec224.getPrivate(), 224},
+      };
+  }
+
+  @DataProvider(name = "key-pairs")
+  public Object[][] getKeyPairs()
+  {
+    final KeyPair rsa512p2 = KeyPairGenerator.generateRSA(random, 512);
+    return
+      new Object[][] {
+        new Object[] {rsa512.getPublic(), rsa512.getPrivate(), true},
+        new Object[] {rsa512p2.getPublic(), rsa512p2.getPrivate(), true},
+        new Object[] {rsa512.getPublic(), rsa512p2.getPrivate(), false},
+        new Object[] {ec256.getPublic(), ec256.getPrivate(), true},
+        new Object[] {ec224.getPublic(), ec224.getPrivate(), true},
+        new Object[] {
+          KeyPairUtil.readPublicKey(KEY_PATH + "ec-openssl-prime256v1-named-pub.pem"),
+          KeyPairUtil.readPrivateKey(KEY_PATH + "ec-openssl-prime256v1-named-nopass.pem"),
+          true,
+        },
+        new Object[] {
+          KeyPairUtil.readPublicKey(KEY_PATH + "ec-openssl-secp112r1-named-pub.pem"),
+          KeyPairUtil.readPrivateKey(KEY_PATH + "ec-openssl-secp112r1-named-nopass.pem"),
+          true,
+        },
+        new Object[] {
+          KeyPairUtil.readPublicKey(KEY_PATH + "ec-openssl-secp224k1-explicit-pub.pem"),
+          KeyPairUtil.readPrivateKey(KEY_PATH + "ec-openssl-secp224k1-explicit-nopass.pem"),
+          true,
+        },
+        new Object[] {
+          KeyPairUtil.readPublicKey(KEY_PATH + "ec-openssl-secp256k1-explicit-pub.pem"),
+          KeyPairUtil.readPrivateKey(KEY_PATH + "ec-openssl-secp256k1-explicit-nopass.pem"),
+          true,
+        },
+        new Object[] {
+          KeyPairUtil.readPublicKey(KEY_PATH + "ec-openssl-sect409k1-named-pub.pem"),
+          KeyPairUtil.readPrivateKey(KEY_PATH + "ec-openssl-sect409k1-named-nopass.pem"),
+          true,
+        },
+        new Object[] {
+          KeyPairUtil.readPublicKey(KEY_PATH + "ec-openssl-sect571r1-explicit-pub.pem"),
+          KeyPairUtil.readPrivateKey(KEY_PATH + "ec-openssl-sect571r1-explicit-nopass.pem"),
+          true,
+        },
+        new Object[] {
+          KeyPairUtil.readPublicKey(KEY_PATH + "ec-openssl-sect571r1-explicit-pub.pem"),
+          KeyPairUtil.readPrivateKey(KEY_PATH + "ec-openssl-sect571r1-explicit-nopass.pem"),
+          true,
+        },
+      };
+  }
+
+  @DataProvider(name = "private-key-files")
+  public Object[][] getPrivateKeyFiles()
+  {
+    return
+      new Object[][] {
+        new Object[] {KEY_PATH + "dsa-openssl-nopass.der", DSAPrivateKey.class},
+        new Object[] {KEY_PATH + "dsa-openssl-nopass.pem", DSAPrivateKey.class},
+        new Object[] {
+          KEY_PATH + "rsa-openssl-nopass.der",
+          RSAPrivateCrtKey.class,
+        },
+        new Object[] {
+          KEY_PATH + "rsa-openssl-nopass.pem",
+          RSAPrivateCrtKey.class,
+        },
+        new Object[] {
+          KEY_PATH + "ec-openssl-prime256v1-named-nopass.der",
+          ECPrivateKey.class,
+        },
+        new Object[] {
+          KEY_PATH + "ec-openssl-prime256v1-named-nopass.pem",
+          ECPrivateKey.class,
+        },
+        new Object[] {
+          KEY_PATH + "ec-openssl-secp112r1-named-nopass.der",
+          ECPrivateKey.class,
+        },
+        new Object[] {
+          KEY_PATH + "ec-openssl-secp112r1-named-nopass.pem",
+          ECPrivateKey.class,
+        },
+        new Object[] {
+          KEY_PATH + "ec-openssl-secp224k1-explicit-nopass.der",
+          ECPrivateKey.class,
+        },
+        new Object[] {
+          KEY_PATH + "ec-openssl-secp224k1-explicit-nopass.pem",
+          ECPrivateKey.class,
+        },
+        new Object[] {
+          KEY_PATH + "ec-openssl-sect571r1-explicit-nopass.der",
+          ECPrivateKey.class,
+        },
+        new Object[] {
+          KEY_PATH + "ec-openssl-sect571r1-explicit-nopass.pem",
+          ECPrivateKey.class,
+        },
+        new Object[] {KEY_PATH + "dsa-pkcs8-nopass.der", DSAPrivateKey.class},
+        new Object[] {KEY_PATH + "dsa-pkcs8-nopass.pem", DSAPrivateKey.class},
+        new Object[] {
+          KEY_PATH + "rsa-pkcs8-nopass.der",
+          RSAPrivateCrtKey.class,
+        },
+        new Object[] {
+          KEY_PATH + "rsa-pkcs8-nopass.pem",
+          RSAPrivateCrtKey.class,
+        },
+        new Object[] {
+          KEY_PATH + "rsa-pkcs8-nopass-noheader.pem",
+          RSAPrivateCrtKey.class,
+        },
+        new Object[] {
+          KEY_PATH + "ec-pkcs8-secp224k1-explicit-nopass.der",
+          ECPrivateKey.class,
+        },
+        new Object[] {
+          KEY_PATH + "ec-pkcs8-secp224k1-explicit-nopass.pem",
+          ECPrivateKey.class,
+        },
+      };
+  }
+
+  @DataProvider(name = "encrypted-private-key-files")
+  public Object[][] getEncryptedPrivateKeyFiles()
+  {
+    return
+      new Object[][] {
+        new Object[] {
+          KEY_PATH + "dsa-openssl-des3.pem",
+          "vtcrypt",
+          DSAPrivateKey.class,
+        },
+        new Object[] {
+          KEY_PATH + "rsa-openssl-des.pem",
+          "vtcrypt",
+          RSAPrivateCrtKey.class,
+        },
+        new Object[] {
+          KEY_PATH + "rsa-openssl-des-noheader.pem",
+          "vtcrypt",
+          RSAPrivateCrtKey.class,
+        },
+        new Object[] {
+          KEY_PATH + "rsa-openssl-des3.pem",
+          "vtcrypt",
+          RSAPrivateCrtKey.class,
+        },
+        new Object[] {
+          KEY_PATH + "ec-openssl-secp224k1-explicit-des.pem",
+          "vtcrypt",
+          ECPrivateKey.class,
+        },
+        new Object[] {
+          KEY_PATH + "ec-openssl-sect571r1-explicit-des.pem",
+          "vtcrypt",
+          ECPrivateKey.class,
+        },
+        new Object[] {
+          KEY_PATH + "dsa-pkcs8-priv.der",
+          "vtcrypt",
+          DSAPrivateKey.class,
+        },
+        new Object[] {
+          KEY_PATH + "dsa-pkcs8-priv.pem",
+          "vtcrypt",
+          DSAPrivateKey.class,
+        },
+        new Object[] {
+          KEY_PATH + "dsa-pkcs8-v2-des3.der",
+          "vtcrypt",
+          DSAPrivateKey.class,
+        },
+        new Object[] {
+          KEY_PATH + "dsa-pkcs8-v2-des3.pem",
+          "vtcrypt",
+          DSAPrivateKey.class,
+        },
+        new Object[] {
+          KEY_PATH + "rsa-pkcs8-v1-md5-des.der",
+          "vtcrypt",
+          RSAPrivateCrtKey.class,
+        },
+        new Object[] {
+          KEY_PATH + "rsa-pkcs8-v1-md5-des.pem",
+          "vtcrypt",
+          RSAPrivateCrtKey.class,
+        },
+        new Object[] {
+          KEY_PATH + "rsa-pkcs8-v1-md5-rc2-64.der",
+          "vtcrypt",
+          RSAPrivateCrtKey.class,
+        },
+        new Object[] {
+          KEY_PATH + "rsa-pkcs8-v2-aes256.der",
+          "vtcrypt",
+          RSAPrivateCrtKey.class,
+        },
+        new Object[] {
+          KEY_PATH + "rsa-pkcs8-v2-aes256.pem",
+          "vtcrypt",
+          RSAPrivateCrtKey.class,
+        },
+        new Object[] {
+          KEY_PATH + "rsa-pkcs8-v2-aes256-noheader.pem",
+          "vtcrypt",
+          RSAPrivateCrtKey.class,
+        },
+        new Object[] {
+          KEY_PATH + "ec-pkcs8-secp224k1-explicit-sha1-rc4-128.der",
+          "vtcrypt",
+          ECPrivateKey.class,
+        },
+        new Object[] {
+          KEY_PATH + "ec-pkcs8-secp224k1-explicit-v1-sha1-rc2-64.der",
+          "vtcrypt",
+          ECPrivateKey.class,
+        },
+        new Object[] {
+          KEY_PATH + "ec-pkcs8-secp224k1-explicit-v2-des3.pem",
+          "vtcrypt",
+          ECPrivateKey.class,
+        },
+        new Object[] {
+          KEY_PATH + "ec-pkcs8-sect571r1-explicit-v2-aes128.pem",
+          "vtcrypt",
+          ECPrivateKey.class,
+        },
+        new Object[] {
+          KEY_PATH + "ec-pkcs8-sect571r1-named-v1-sha1-rc2-64.der",
+          "vtcrypt",
+          ECPrivateKey.class,
+        },
+      };
+  }
+
+  @DataProvider(name = "public-key-files")
+  public Object[][] getPublicKeyFiles()
+  {
+    return
+      new Object[][] {
+        new Object[] {KEY_PATH + "dsa-pub.der", DSAPublicKey.class},
+        new Object[] {KEY_PATH + "dsa-pub.pem", DSAPublicKey.class},
+        new Object[] {
+          KEY_PATH + "ec-secp224k1-explicit-pub.der",
+          ECPublicKey.class,
+        },
+        new Object[] {
+          KEY_PATH + "ec-secp224k1-explicit-pub.pem",
+          ECPublicKey.class,
+        },
+        new Object[] {KEY_PATH + "rsa-pub.der", RSAPublicKey.class},
+        new Object[] {KEY_PATH + "rsa-pub.pem", RSAPublicKey.class},
+      };
+  }
+
+
+  @Test(dataProvider = "public-keys")
+  public void testLengthPublicKey(final PublicKey key, final int expectedLength)
+    throws Exception
+  {
+    assertEquals(KeyPairUtil.length(key), expectedLength);
+  }
+
+  @Test(dataProvider = "private-keys")
+  public void testLengthPrivateKey(final PrivateKey key, final int expectedLength)
+    throws Exception
+  {
+    assertEquals(KeyPairUtil.length(key), expectedLength);
+  }
+
+  @Test(dataProvider = "key-pairs")
+  public void testIsKeyPair(final PublicKey pubKey, final PrivateKey privKey, final boolean expected)
+    throws Exception
+  {
+    assertEquals(KeyPairUtil.isKeyPair(pubKey, privKey), expected);
+  }
+
+  @Test(dataProvider = "private-key-files")
+  public void testReadPrivateKey(final String path, final Class<?> expectedType)
+    throws Exception
+  {
+    final PrivateKey key = KeyPairUtil.readPrivateKey(path);
+    assertNotNull(key);
+    assertTrue(expectedType.isAssignableFrom(key.getClass()));
+  }
+
+  @Test(dataProvider = "encrypted-private-key-files")
+  public void testReadEncryptedPrivateKey(final String path, final String password, final Class<?> expectedType)
+    throws Exception
+  {
+    final PrivateKey key = KeyPairUtil.readPrivateKey(path, password.toCharArray());
+    assertNotNull(key);
+    assertTrue(expectedType.isAssignableFrom(key.getClass()));
+  }
+
+  @Test(dataProvider = "public-key-files")
+  public void testReadPublicKey(final String path, final Class<?> expectedType)
+    throws Exception
+  {
+    final PublicKey key = KeyPairUtil.readPublicKey(path);
+    assertNotNull(key);
+    assertTrue(expectedType.isAssignableFrom(key.getClass()));
+  }
+
+  @Test(dataProvider = "private-key-files")
+  public void testClosePrivateKey(final String path, final Class<?> expectedType)
+    throws Exception
+  {
+    final TestableFileInputStream is = new TestableFileInputStream(path);
+    final PrivateKey key = KeyPairUtil.readPrivateKey(is);
+    assertNotNull(key);
+    assertTrue(is.isClosed());
+  }
+
+  @Test(dataProvider = "public-key-files")
+  public void testClosePublicKey(final String path, final Class<?> expectedType)
+    throws Exception
+  {
+    final TestableFileInputStream is = new TestableFileInputStream(path);
+    final PublicKey key = KeyPairUtil.readPublicKey(is);
+    assertNotNull(key);
+    assertTrue(is.isClosed());
+  }
+
+
+  /**
+   * Class for testing usage of {@link FileInputStream}.
+   */
+  private static class TestableFileInputStream extends FileInputStream
+  {
+
+    /** Whether {@link #close()} has been invoked. */
+    private boolean isClosed;
+
+    /**
+     * Default constructor.
+     *
+     * @param  name  of the file to open
+     *
+     * @throws  FileNotFoundException  if an error occurs
+     */
+    TestableFileInputStream(final String name)
+      throws FileNotFoundException
+    {
+      super(name);
+    }
+
+    @Override
+    public void close()
+      throws IOException
+    {
+      super.close();
+      isClosed = true;
+    }
+
+    /**
+     * Returns whether {@link #close()} has been invoked.
+     *
+     * @return  whether {@link #close()} has been invoked
+     */
+    public boolean isClosed()
+    {
+      return isClosed;
+    }
+  }
+}
diff --git a/src/test/java/org/cryptacular/x509/ExtensionReaderTest.java b/src/test/java/org/cryptacular/x509/ExtensionReaderTest.java
new file mode 100644
index 0000000..f75e15b
--- /dev/null
+++ b/src/test/java/org/cryptacular/x509/ExtensionReaderTest.java
@@ -0,0 +1,352 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.x509;
+
+import java.security.cert.X509Certificate;
+import java.util.List;
+import org.bouncycastle.asn1.ASN1ObjectIdentifier;
+import org.bouncycastle.asn1.DERSequence;
+import org.bouncycastle.asn1.x509.AccessDescription;
+import org.bouncycastle.asn1.x509.AuthorityKeyIdentifier;
+import org.bouncycastle.asn1.x509.DistributionPoint;
+import org.bouncycastle.asn1.x509.DistributionPointName;
+import org.bouncycastle.asn1.x509.GeneralName;
+import org.bouncycastle.asn1.x509.GeneralNames;
+import org.bouncycastle.asn1.x509.KeyPurposeId;
+import org.bouncycastle.asn1.x509.KeyUsage;
+import org.bouncycastle.asn1.x509.PolicyInformation;
+import org.bouncycastle.asn1.x509.PolicyQualifierInfo;
+import org.bouncycastle.asn1.x509.SubjectKeyIdentifier;
+import org.cryptacular.FailListener;
+import org.cryptacular.util.CertUtil;
+import org.cryptacular.util.CodecUtil;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Listeners;
+import org.testng.annotations.Test;
+import static org.testng.Assert.assertEquals;
+
+/**
+ * Unit test for {@link ExtensionReader}.
+ *
+ * @author  Middleware Services
+ */
+@Listeners(FailListener.class)
+public class ExtensionReaderTest
+{
+  private static final String CRT_PATH = "src/test/resources/certs/";
+
+  @DataProvider(name = "subject-alt-name")
+  public Object[][] getSubjectAltNames()
+  {
+    return
+      new Object[][] {
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "serac-dev-test.crt"),
+          new String[] {"eprov@vt.edu"},
+        },
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "ed.middleware.vt.edu.crt"),
+          new String[] {
+            "ed.middleware.vt.edu",
+            "directory.vt.edu",
+            "id.directory.vt.edu",
+            "authn.directory.vt.edu",
+            "ldap.vt.edu",
+          },
+        },
+      };
+  }
+
+  @DataProvider(name = "issuer-alt-name")
+  public Object[][] getIssuerAltNames()
+  {
+    return
+      new Object[][] {
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "test.example.com.crt"),
+          new String[] {"snake-1.example.com", "snake-2.example.com"},
+        },
+      };
+  }
+
+  @DataProvider(name = "basic-constraints")
+  public Object[][] getBasicConstraints()
+  {
+    return
+      new Object[][] {
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "thawte-premium-server-ca.crt"),
+          true,
+        },
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "login.live.com.crt"),
+          false,
+        },
+      };
+  }
+
+  @DataProvider(name = "certificate-policies")
+  public Object[][] getCertificatePolicies()
+  {
+    return
+      new Object[][] {
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "serac-dev-test.crt"),
+          new PolicyInformation[] {
+            new PolicyInformation(new ASN1ObjectIdentifier("1.3.6.1.4.1.6760.5.2.2.2.1")),
+            new PolicyInformation(new ASN1ObjectIdentifier("1.3.6.1.4.1.6760.5.2.2.1.1")),
+            new PolicyInformation(
+              new ASN1ObjectIdentifier("1.3.6.1.4.1.6760.5.2.2.4.1"),
+              new DERSequence(new PolicyQualifierInfo("http://www.pki.vt.edu/vtuca/cps/index.html"))),
+            new PolicyInformation(new ASN1ObjectIdentifier("1.3.6.1.4.1.6760.5.2.2.3.1")),
+          },
+        },
+      };
+  }
+
+  @DataProvider(name = "subject-key-id")
+  public Object[][] getSubjectKeyIds()
+  {
+    return
+      new Object[][] {
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "serac-dev-test.crt"),
+          "25:48:2F:28:EC:5D:19:BB:1D:25:AE:94:93:B1:7B:B5:35:96:24:66",
+        },
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "login.live.com.crt"),
+          "31:AE:F1:7C:98:67:E9:1F:19:69:A2:A7:84:1E:67:5C:AA:C3:6B:75",
+        },
+      };
+  }
+
+  @DataProvider(name = "authority-key-id")
+  public Object[][] getAuthorityKeyIds()
+  {
+    return
+      new Object[][] {
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "serac-dev-test.crt"),
+          "38:E0:6F:AE:48:ED:5E:23:F6:22:9B:1E:E7:9C:19:16:47:B8:7E:92",
+        },
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "login.live.com.crt"),
+          "FC:8A:50:BA:9E:B9:25:5A:7B:55:85:4F:95:00:63:8F:E9:58:6B:43",
+        },
+      };
+  }
+
+  @DataProvider(name = "key-usage")
+  public Object[][] getKeyUsage()
+  {
+    return
+      new Object[][] {
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "serac-dev-test.crt"),
+          KeyUsageBits.usage(KeyUsageBits.DigitalSignature, KeyUsageBits.NonRepudiation),
+        },
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "login.live.com.crt"),
+          KeyUsageBits.usage(KeyUsageBits.DigitalSignature, KeyUsageBits.KeyEncipherment),
+        },
+      };
+  }
+
+  @DataProvider(name = "extended-key-usage")
+  public Object[][] getExtendedKeyUsage()
+  {
+    return
+      new Object[][] {
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "serac-dev-test.crt"),
+          new KeyPurposeId[] {
+            KeyPurposeId.id_kp_clientAuth,
+            KeyPurposeId.id_kp_emailProtection,
+            KeyPurposeId.id_kp_smartcardlogon,
+          },
+        },
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "login.live.com.crt"),
+          new KeyPurposeId[] {
+            KeyPurposeId.id_kp_serverAuth,
+            KeyPurposeId.id_kp_clientAuth,
+          },
+        },
+      };
+  }
+
+  @DataProvider(name = "crl-distribution-points")
+  public Object[][] getCrlDistributionPoints()
+  {
+    return
+      new Object[][] {
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "login.live.com.crt"),
+          new DistributionPoint[] {
+            new DistributionPoint(
+              new DistributionPointName(new GeneralNames(uri("http://EVSecure-crl.verisign.com/EVSecure2006.crl"))),
+              null,
+              null),
+          },
+        },
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "glider.cc.vt.edu.crt"),
+          new DistributionPoint[] {
+            new DistributionPoint(
+              new DistributionPointName(
+                new GeneralNames(
+                  uri(
+                    "http://vtca-p.eprov.seti.vt.edu:8080/ejbca/publicweb/" +
+                    "webdist/certdist?cmd=crl&" +
+                    "issuer=CN=Virginia+Tech+Middleware+CA,O=Virginia+" +
+                    "Polytechnic+Institute+and+State+University," +
+                    "DC=vt,DC=edu,C=US"))),
+              null,
+              new GeneralNames(
+                dirName(
+                  "CN=Virginia Tech Middleware CA,O=Virginia Polytechnic " +
+                  "Institute and State University,DC=vt,DC=edu,C=US"))),
+          },
+        },
+      };
+  }
+
+  @DataProvider(name = "authority-information-access")
+  public Object[][] getAuthorityInformationAccess()
+  {
+    return
+      new Object[][] {
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "login.live.com.crt"),
+          new AccessDescription[] {
+            new AccessDescription(AccessDescription.id_ad_ocsp, uri("http://EVSecure-ocsp.verisign.com")),
+            new AccessDescription(
+              AccessDescription.id_ad_caIssuers,
+              uri("http://EVSecure-aia.verisign.com/EVSecure2006.cer")),
+          },
+        },
+      };
+  }
+
+
+  @Test(dataProvider = "subject-alt-name")
+  public void testReadSubjectAlternativeName(final X509Certificate cert, final String[] expected)
+    throws Exception
+  {
+    final GeneralNames names = new ExtensionReader(cert).readSubjectAlternativeName();
+    assertEquals(names.getNames().length, expected.length);
+    for (int i = 0; i < expected.length; i++) {
+      assertEquals(names.getNames()[i].getName().toString(), expected[i]);
+    }
+  }
+
+  @Test(dataProvider = "issuer-alt-name")
+  public void testReadIssuerAlternativeName(final X509Certificate cert, final String[] expected)
+    throws Exception
+  {
+    final GeneralNames names = new ExtensionReader(cert).readIssuerAlternativeName();
+    assertEquals(names.getNames().length, expected.length);
+    for (int i = 0; i < expected.length; i++) {
+      assertEquals(names.getNames()[i].getName().toString(), expected[i]);
+    }
+  }
+
+  @Test(dataProvider = "basic-constraints")
+  public void testReadBasicConstraints(final X509Certificate cert, final boolean expected)
+    throws Exception
+  {
+    assertEquals(new ExtensionReader(cert).readBasicConstraints().isCA(), expected);
+  }
+
+  @Test(dataProvider = "certificate-policies")
+  public void testReadCertificatePolicies(final X509Certificate cert, final PolicyInformation[] expected)
+    throws Exception
+  {
+    final List<PolicyInformation> policies = new ExtensionReader(cert).readCertificatePolicies();
+    assertEquals(policies.size(), expected.length);
+
+    PolicyInformation current;
+    for (int i = 0; i < expected.length; i++) {
+      current = policies.get(i);
+      assertEquals(current.getPolicyIdentifier(), expected[i].getPolicyIdentifier());
+      if (expected[i].getPolicyQualifiers() != null) {
+        for (int j = 0; j < expected[i].getPolicyQualifiers().size(); j++) {
+          assertEquals(current.getPolicyQualifiers().getObjectAt(j), expected[i].getPolicyQualifiers().getObjectAt(j));
+        }
+      }
+    }
+  }
+
+  @Test(dataProvider = "subject-key-id")
+  public void testReadSubjectKeyIdentifier(final X509Certificate cert, final String expected)
+    throws Exception
+  {
+    final SubjectKeyIdentifier keyId = new ExtensionReader(cert).readSubjectKeyIdentifier();
+    assertEquals(CodecUtil.hex(keyId.getKeyIdentifier(), true).toUpperCase(), expected);
+  }
+
+  @Test(dataProvider = "authority-key-id")
+  public void testReadAuthorityKeyIdentifier(final X509Certificate cert, final String expected)
+    throws Exception
+  {
+    final AuthorityKeyIdentifier keyId = new ExtensionReader(cert).readAuthorityKeyIdentifier();
+    assertEquals(CodecUtil.hex(keyId.getKeyIdentifier(), true).toUpperCase(), expected);
+  }
+
+  @Test(dataProvider = "key-usage")
+  public void testReadKeyUsage(final X509Certificate cert, final int expected)
+    throws Exception
+  {
+    final KeyUsage usage = new ExtensionReader(cert).readKeyUsage();
+    final byte[] bytes = usage.getBytes();
+    final int result;
+    if (bytes.length == 1) {
+      result = bytes[0] & 0xff;
+    } else {
+      result = (bytes[1] & 0xff) << 8 | (bytes[0] & 0xff);
+    }
+    assertEquals(result, expected);
+  }
+
+  @Test(dataProvider = "extended-key-usage")
+  public void testReadExtendedKeyUsage(final X509Certificate cert, final KeyPurposeId[] expected)
+    throws Exception
+  {
+    final List<KeyPurposeId> purposes = new ExtensionReader(cert).readExtendedKeyUsage();
+    assertEquals(purposes.size(), expected.length);
+    for (int i = 0; i < expected.length; i++) {
+      assertEquals(purposes.get(i), expected[i]);
+    }
+  }
+
+  @Test(dataProvider = "crl-distribution-points")
+  public void testReadCRLDistributionPoints(final X509Certificate cert, final DistributionPoint[] expected)
+    throws Exception
+  {
+    final List<DistributionPoint> points = new ExtensionReader(cert).readCRLDistributionPoints();
+    assertEquals(points.size(), expected.length);
+    for (int i = 0; i < expected.length; i++) {
+      assertEquals(points.get(i), expected[i]);
+    }
+  }
+
+  @Test(dataProvider = "authority-information-access")
+  public void testReadAuthorityInformationAccess(final X509Certificate cert, final AccessDescription[] expected)
+    throws Exception
+  {
+    final List<AccessDescription> descriptions = new ExtensionReader(cert).readAuthorityInformationAccess();
+    assertEquals(descriptions.size(), expected.length);
+    for (int i = 0; i < expected.length; i++) {
+      assertEquals(descriptions.get(i), expected[i]);
+    }
+  }
+
+  private GeneralName uri(final String uri)
+  {
+    return new GeneralName(GeneralName.uniformResourceIdentifier, uri);
+  }
+
+  private GeneralName dirName(final String dn)
+  {
+    return new GeneralName(GeneralName.directoryName, dn);
+  }
+}
diff --git a/src/test/java/org/cryptacular/x509/dn/LdapNameFormatterTest.java b/src/test/java/org/cryptacular/x509/dn/LdapNameFormatterTest.java
new file mode 100644
index 0000000..b6546a3
--- /dev/null
+++ b/src/test/java/org/cryptacular/x509/dn/LdapNameFormatterTest.java
@@ -0,0 +1,83 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.x509.dn;
+
+import javax.security.auth.x500.X500Principal;
+import org.cryptacular.FailListener;
+import org.cryptacular.util.CertUtil;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Listeners;
+import org.testng.annotations.Test;
+import static org.testng.Assert.assertEquals;
+
+/**
+ * Unit test for {@link LdapNameFormatter} class.
+ *
+ * @author  Middleware Services
+ */
+@Listeners(FailListener.class)
+public class LdapNameFormatterTest
+{
+  private static final String CRT_PATH = "src/test/resources/certs/";
+
+  @DataProvider(name = "distinguished-names")
+  public Object[][] getDistinguishedNames()
+  {
+    return
+      new Object[][] {
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "serac-dev-test.crt").getSubjectX500Principal(),
+          "C=US,DC=vt,DC=edu,O=Virginia Polytechnic Institute and State University,CN=Marvin S Addison,UID=1145718",
+        },
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "serac-dev-test.crt").getIssuerX500Principal(),
+          "SERIALNUMBER=12,CN=DEV Virginia Tech Class 1 Server CA,O=Virginia " +
+            "Polytechnic Institute and State University,C=US,DC=vt,DC=edu",
+        },
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "glider.cc.vt.edu.crt").getSubjectX500Principal(),
+          "C=US,DC=edu,DC=vt,ST=Virginia,L=Blacksburg,O=Virginia Polytechnic Institute and State University," +
+            "OU=Middleware-Client,OU=SETI,SERIALNUMBER=1248110657961,CN=glider.cc.vt.edu",
+        },
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "glider.cc.vt.edu.crt").getIssuerX500Principal(),
+          "CN=Virginia Tech Middleware CA,O=Virginia Polytechnic Institute and State University,C=US,DC=vt,DC=edu",
+        },
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "multi-value-rdn-1.crt").getSubjectX500Principal(),
+          "CN=b.foo.com,CN=a.foo.com,DC=ldaptive,DC=org",
+        },
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "multi-value-rdn-2.crt").getSubjectX500Principal(),
+          "DC=org,DC=ldaptive,CN=a.foo.com+CN=b.foo.com",
+        },
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "needs-escaping-1.crt").getSubjectX500Principal(),
+          "CN=DC=example\\, DC=com,O=VPI&SU,L=Blacksburg,ST=Virginia,C=US,DC=vt,DC=edu",
+        },
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "needs-escaping-2.crt").getSubjectX500Principal(),
+          "CN=\\#DEADBEEF,O=VPI&SU,L=Blacksburg,ST=Virginia,C=US,DC=vt,DC=edu",
+        },
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "needs-escaping-3.crt").getSubjectX500Principal(),
+          "CN=\\ space,O=VPI&SU,L=Blacksburg,ST=Virginia,C=US,DC=vt,DC=edu",
+        },
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "needs-escaping-4.crt").getSubjectX500Principal(),
+          "CN=space2 \\ ,O=VPI&SU,L=Blacksburg,ST=Virginia,C=US,DC=vt,DC=edu",
+        },
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "unknown-dn-attr.crt").getSubjectX500Principal(),
+          "DC=org,DC=example,1.2.3.4.5=#6e6f6e73656e7365,CN=marzipan",
+        },
+      };
+  }
+
+
+  @Test(dataProvider = "distinguished-names")
+  public void testFormat(final X500Principal dn, final String expected)
+    throws Exception
+  {
+    assertEquals(new LdapNameFormatter().format(dn), expected);
+  }
+}
diff --git a/src/test/java/org/cryptacular/x509/dn/NameReaderTest.java b/src/test/java/org/cryptacular/x509/dn/NameReaderTest.java
new file mode 100644
index 0000000..3ed0fc2
--- /dev/null
+++ b/src/test/java/org/cryptacular/x509/dn/NameReaderTest.java
@@ -0,0 +1,97 @@
+/* See LICENSE for licensing and NOTICE for copyright. */
+package org.cryptacular.x509.dn;
+
+import java.security.cert.X509Certificate;
+import org.cryptacular.FailListener;
+import org.cryptacular.util.CertUtil;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Listeners;
+import org.testng.annotations.Test;
+import static org.testng.Assert.assertEquals;
+
+/**
+ * Unit test for {@link NameReader}.
+ *
+ * @author  Middleware Services
+ */
+@Listeners(FailListener.class)
+public class NameReaderTest
+{
+  private static final String CRT_PATH = "src/test/resources/certs/";
+
+  @DataProvider(name = "subjects")
+  public Object[][] getSubjects()
+  {
+    return
+      new Object[][] {
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "serac-dev-test.crt"),
+          "UID=1145718, CN=Marvin S Addison, O=Virginia Polytechnic " +
+            "Institute and State University, DC=edu, DC=vt, C=US",
+        },
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "glider.cc.vt.edu.crt"),
+          "CN=glider.cc.vt.edu, SERIALNUMBER=1248110657961, OU=SETI, " +
+            "OU=Middleware-Client, O=Virginia Polytechnic Institute and " +
+            "State University, L=Blacksburg, ST=Virginia, DC=vt, DC=edu, C=US",
+        },
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "multi-value-rdn-1.crt"),
+          "DC=org, DC=ldaptive, CN=a.foo.com, CN=b.foo.com",
+        },
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "multi-value-rdn-2.crt"),
+          "CN=a.foo.com, CN=b.foo.com, DC=ldaptive, DC=org",
+        },
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "scantor-dn-description.crt"),
+          "DESCRIPTION=6MtpJS1dcC7t254v, CN=cantor.2@osu.edu, EMAILADDRESS=cantor.2@osu.edu",
+        },
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "unknown-dn-attr.crt"),
+          "CN=marzipan, 1.2.3.4.5=nonsense, DC=example, DC=org",
+        },
+      };
+  }
+
+  @DataProvider(name = "issuers")
+  public Object[][] getIssuers()
+  {
+    return
+      new Object[][] {
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "serac-dev-test.crt"),
+          "DC=edu, DC=vt, C=US, O=Virginia Polytechnic Institute and State " +
+            "University, CN=DEV Virginia Tech Class 1 Server CA, SERIALNUMBER=12",
+        },
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "glider.cc.vt.edu.crt"),
+          "DC=edu, DC=vt, C=US, O=Virginia Polytechnic Institute and State University, CN=Virginia Tech Middleware CA",
+        },
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "multi-value-rdn-1.crt"),
+          "DC=org, DC=ldaptive, CN=a.foo.com, CN=b.foo.com",
+        },
+        new Object[] {
+          CertUtil.readCertificate(CRT_PATH + "multi-value-rdn-2.crt"),
+          "CN=a.foo.com, CN=b.foo.com, DC=ldaptive, DC=org",
+        },
+      };
+  }
+
+  @Test(dataProvider = "subjects")
+  public void testReadSubject(final X509Certificate cert, final String expected)
+    throws Exception
+  {
+    final RDNSequence sequence = new NameReader(cert).readSubject();
+    assertEquals(sequence.toString(), expected);
+  }
+
+  @Test(dataProvider = "issuers")
+  public void testReadIssuer(final X509Certificate cert, final String expected)
+    throws Exception
+  {
+    final RDNSequence sequence = new NameReader(cert).readIssuer();
+    assertEquals(sequence.toString(), expected);
+  }
+}
diff --git a/src/test/resources/certs/ed.middleware.vt.edu.crt b/src/test/resources/certs/ed.middleware.vt.edu.crt
new file mode 100644
index 0000000..446eaeb
--- /dev/null
+++ b/src/test/resources/certs/ed.middleware.vt.edu.crt
@@ -0,0 +1,42 @@
+-----BEGIN CERTIFICATE-----
+MIIHTjCCBTagAwIBAgIITROLEwW3lyYwDQYJKoZIhvcNAQEFBQAwgZoxEzARBgoJ
+kiaJk/IsZAEZEwNlZHUxEjAQBgoJkiaJk/IsZAEZEwJ2dDELMAkGA1UEBhMCVVMx
+PDA6BgNVBAoTM1ZpcmdpbmlhIFBvbHl0ZWNobmljIEluc3RpdHV0ZSBhbmQgU3Rh
+dGUgVW5pdmVyc2l0eTEkMCIGA1UEAxMbVmlyZ2luaWEgVGVjaCBNaWRkbGV3YXJl
+IENBMB4XDTEyMDUyNTE2NTMxN1oXDTE0MDUyNTE2NTMxN1owggEAMR0wGwYDVQQD
+DBRlZC5taWRkbGV3YXJlLnZ0LmVkdTEcMBoGA1UECwwTTWlkZGxld2FyZSBTZXJ2
+aWNlczElMCMGA1UECwwcTWlkZGxld2FyZS1TZXJ2ZXItd2l0aC1zYWx0cjE8MDoG
+A1UECgwzVmlyZ2luaWEgUG9seXRlY2huaWMgSW5zdGl0dXRlIGFuZCBTdGF0ZSBV
+bml2ZXJzaXR5MRMwEQYDVQQHDApCbGFja3NidXJnMREwDwYDVQQIDAhWaXJnaW5p
+YTESMBAGCgmSJomT8ixkARkWAnZ0MRMwEQYKCZImiZPyLGQBGRYDZWR1MQswCQYD
+VQQGEwJVUzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAN2+UJtj+Bhd
+um+GQe9zciP6nZKBgNWDLPLHo2s+beL0R9n9qIVapqUaB6Rqssgaf0K3WegX+ULR
+9m6Dz0ljh9FOgfOtzDrPTAtqkGvz+HQZtKaAZzHW4WFAtjUrhhY1JI4zIvJiEkSg
+db26IhSqb0+CB15XPZDE+ra5j4QlygOqfUs8fF+SrjkynAQ1ASWsnT+aHplg2nEY
+VJPaN2LhxWjr6Uo2iVCuzc1vR3Tua/veZACztb2x8QK6sgUcsrH/OY02yFcJ7qyo
+NhgxnMJSpLB/Mo6BLFjMn2fetePt96RgsO4FDwzDT7h1iGf5vXBBiId9QDk7fvZw
+Z9wcJkLa1R0CAwEAAaOCAi0wggIpMB0GA1UdDgQWBBSrVN4+XHdiFIwgwiAFIWRX
+UFRxPzAJBgNVHRMEAjAAMB8GA1UdIwQYMBaAFP02QCKa1fxb8ATJkOZPT4hLRPhO
+MHgGA1UdIARxMG8wDgYMKwYBBAG0aAUCAgIBMA4GDCsGAQQBtGgFAgIBATA9Bgwr
+BgEEAbRoBQICBAEwLTArBggrBgEFBQcCARYfaHR0cDovL3d3dy5wa2kudnQuZWR1
+L3Z0bXcvY3BzLzAOBgwrBgEEAbRoBQICAwEwgdIGA1UdHwSByjCBxzCBxKCBwaCB
+voaBu2h0dHA6Ly92dGNhLXAuZXByb3Yuc2V0aS52dC5lZHU6ODA4MC9lamJjYS9w
+dWJsaWN3ZWIvd2ViZGlzdC9jZXJ0ZGlzdD9jbWQ9Y3JsJmlzc3Vlcj1DTj1WaXJn
+aW5pYStUZWNoK01pZGRsZXdhcmUrQ0EsTz1WaXJnaW5pYStQb2x5dGVjaG5pYytJ
+bnN0aXR1dGUrYW5kK1N0YXRlK1VuaXZlcnNpdHksREM9dnQsREM9ZWR1LEM9VVMw
+CwYDVR0PBAQDAgTwMBMGA1UdJQQMMAoGCCsGAQUFBwMBMGsGA1UdEQRkMGKCFGVk
+Lm1pZGRsZXdhcmUudnQuZWR1ghBkaXJlY3RvcnkudnQuZWR1ghNpZC5kaXJlY3Rv
+cnkudnQuZWR1ghZhdXRobi5kaXJlY3RvcnkudnQuZWR1ggtsZGFwLnZ0LmVkdTAN
+BgkqhkiG9w0BAQUFAAOCAgEABs6vLEbm28l3tpZOy1iWJEZbaXsKwVdMZXQKlQWx
+QzXNe99ktfzsq1Rf99YhNefcxpwqIb4TKxc9e8hjSH39ySLso3PpjjywSjZzFkVn
+I/gC+R8Lq6tgFJnEGeRfu6z699ej7YaX21SKqy4+qh+pXTKV9yppch8Wiz440Pbx
+qTg0/422nFjBoDcfSby9g4GaFcQKsx26MZ3cY+bFZUSVZ9skFlv8hXQ60NnHL23L
+TzYTje+/7gZAYMRWKQpbBvXGj/cu7cpX56qYNRV9My+xmDxkhv2aJaEyY6kePIY4
+4Ziu8PUfSLLK7CZexOyNTbVbbyL31s7ao07v1iXiY8rIJieCOrzEpqMHZ7qaGZVJ
+26WL4fgVfGQu8mIWnlcE7R2sa6RtmLBqAHttXozYCuGVxMYYsyoFLg+I8p1wnFAF
+4dS6rARiI/dbOrz5z8UokDDaAAfP8FfpyTBzi0mZo85XPZeB2Vy0zQjiZFAuzq8f
+9XShx3VnLNz7FIvy2wNQwmLM3LXijL4NzCKo5GAE9KniFr4x9d+iAdzcOyZe5a9i
+6KtfZD+9Z7HyV45gzKWR6WmNL9lS44afYNW1dTxQCY7dOo+27nW3eS59rcxHlifc
+maJA9izvFEMxGDBzu5isUWyj+qp7VFpAm63uHKwy8+wmemm7C8xF04f+QnHpw5MM
+Bl0=
+-----END CERTIFICATE-----
\ No newline at end of file
diff --git a/src/test/resources/certs/ed.middleware.vt.edu.der b/src/test/resources/certs/ed.middleware.vt.edu.der
new file mode 100644
index 0000000..eec76f3
--- /dev/null
+++ b/src/test/resources/certs/ed.middleware.vt.edu.der
Binary files differ
diff --git a/src/test/resources/certs/ed.middleware.vt.edu.p7b b/src/test/resources/certs/ed.middleware.vt.edu.p7b
new file mode 100644
index 0000000..074bc4b
--- /dev/null
+++ b/src/test/resources/certs/ed.middleware.vt.edu.p7b
@@ -0,0 +1,42 @@
+-----BEGIN PKCS7-----
+MIIHTjCCBTagAwIBAgIITROLEwW3lyYwDQYJKoZIhvcNAQEFBQAwgZoxEzARBgoJ
+kiaJk/IsZAEZEwNlZHUxEjAQBgoJkiaJk/IsZAEZEwJ2dDELMAkGA1UEBhMCVVMx
+PDA6BgNVBAoTM1ZpcmdpbmlhIFBvbHl0ZWNobmljIEluc3RpdHV0ZSBhbmQgU3Rh
+dGUgVW5pdmVyc2l0eTEkMCIGA1UEAxMbVmlyZ2luaWEgVGVjaCBNaWRkbGV3YXJl
+IENBMB4XDTEyMDUyNTE2NTMxN1oXDTE0MDUyNTE2NTMxN1owggEAMR0wGwYDVQQD
+DBRlZC5taWRkbGV3YXJlLnZ0LmVkdTEcMBoGA1UECwwTTWlkZGxld2FyZSBTZXJ2
+aWNlczElMCMGA1UECwwcTWlkZGxld2FyZS1TZXJ2ZXItd2l0aC1zYWx0cjE8MDoG
+A1UECgwzVmlyZ2luaWEgUG9seXRlY2huaWMgSW5zdGl0dXRlIGFuZCBTdGF0ZSBV
+bml2ZXJzaXR5MRMwEQYDVQQHDApCbGFja3NidXJnMREwDwYDVQQIDAhWaXJnaW5p
+YTESMBAGCgmSJomT8ixkARkWAnZ0MRMwEQYKCZImiZPyLGQBGRYDZWR1MQswCQYD
+VQQGEwJVUzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAN2+UJtj+Bhd
+um+GQe9zciP6nZKBgNWDLPLHo2s+beL0R9n9qIVapqUaB6Rqssgaf0K3WegX+ULR
+9m6Dz0ljh9FOgfOtzDrPTAtqkGvz+HQZtKaAZzHW4WFAtjUrhhY1JI4zIvJiEkSg
+db26IhSqb0+CB15XPZDE+ra5j4QlygOqfUs8fF+SrjkynAQ1ASWsnT+aHplg2nEY
+VJPaN2LhxWjr6Uo2iVCuzc1vR3Tua/veZACztb2x8QK6sgUcsrH/OY02yFcJ7qyo
+NhgxnMJSpLB/Mo6BLFjMn2fetePt96RgsO4FDwzDT7h1iGf5vXBBiId9QDk7fvZw
+Z9wcJkLa1R0CAwEAAaOCAi0wggIpMB0GA1UdDgQWBBSrVN4+XHdiFIwgwiAFIWRX
+UFRxPzAJBgNVHRMEAjAAMB8GA1UdIwQYMBaAFP02QCKa1fxb8ATJkOZPT4hLRPhO
+MHgGA1UdIARxMG8wDgYMKwYBBAG0aAUCAgIBMA4GDCsGAQQBtGgFAgIBATA9Bgwr
+BgEEAbRoBQICBAEwLTArBggrBgEFBQcCARYfaHR0cDovL3d3dy5wa2kudnQuZWR1
+L3Z0bXcvY3BzLzAOBgwrBgEEAbRoBQICAwEwgdIGA1UdHwSByjCBxzCBxKCBwaCB
+voaBu2h0dHA6Ly92dGNhLXAuZXByb3Yuc2V0aS52dC5lZHU6ODA4MC9lamJjYS9w
+dWJsaWN3ZWIvd2ViZGlzdC9jZXJ0ZGlzdD9jbWQ9Y3JsJmlzc3Vlcj1DTj1WaXJn
+aW5pYStUZWNoK01pZGRsZXdhcmUrQ0EsTz1WaXJnaW5pYStQb2x5dGVjaG5pYytJ
+bnN0aXR1dGUrYW5kK1N0YXRlK1VuaXZlcnNpdHksREM9dnQsREM9ZWR1LEM9VVMw
+CwYDVR0PBAQDAgTwMBMGA1UdJQQMMAoGCCsGAQUFBwMBMGsGA1UdEQRkMGKCFGVk
+Lm1pZGRsZXdhcmUudnQuZWR1ghBkaXJlY3RvcnkudnQuZWR1ghNpZC5kaXJlY3Rv
+cnkudnQuZWR1ghZhdXRobi5kaXJlY3RvcnkudnQuZWR1ggtsZGFwLnZ0LmVkdTAN
+BgkqhkiG9w0BAQUFAAOCAgEABs6vLEbm28l3tpZOy1iWJEZbaXsKwVdMZXQKlQWx
+QzXNe99ktfzsq1Rf99YhNefcxpwqIb4TKxc9e8hjSH39ySLso3PpjjywSjZzFkVn
+I/gC+R8Lq6tgFJnEGeRfu6z699ej7YaX21SKqy4+qh+pXTKV9yppch8Wiz440Pbx
+qTg0/422nFjBoDcfSby9g4GaFcQKsx26MZ3cY+bFZUSVZ9skFlv8hXQ60NnHL23L
+TzYTje+/7gZAYMRWKQpbBvXGj/cu7cpX56qYNRV9My+xmDxkhv2aJaEyY6kePIY4
+4Ziu8PUfSLLK7CZexOyNTbVbbyL31s7ao07v1iXiY8rIJieCOrzEpqMHZ7qaGZVJ
+26WL4fgVfGQu8mIWnlcE7R2sa6RtmLBqAHttXozYCuGVxMYYsyoFLg+I8p1wnFAF
+4dS6rARiI/dbOrz5z8UokDDaAAfP8FfpyTBzi0mZo85XPZeB2Vy0zQjiZFAuzq8f
+9XShx3VnLNz7FIvy2wNQwmLM3LXijL4NzCKo5GAE9KniFr4x9d+iAdzcOyZe5a9i
+6KtfZD+9Z7HyV45gzKWR6WmNL9lS44afYNW1dTxQCY7dOo+27nW3eS59rcxHlifc
+maJA9izvFEMxGDBzu5isUWyj+qp7VFpAm63uHKwy8+wmemm7C8xF04f+QnHpw5MM
+Bl0=
+-----END PKCS7-----
\ No newline at end of file
diff --git a/src/test/resources/certs/entity.crt b/src/test/resources/certs/entity.crt
new file mode 100644
index 0000000..5577edd
--- /dev/null
+++ b/src/test/resources/certs/entity.crt
@@ -0,0 +1,31 @@
+-----BEGIN CERTIFICATE-----
+MIIFSzCCBDOgAwIBAgIJAJlvk9MRIyGCMA0GCSqGSIb3DQEBBQUAMIHJMQswCQYD
+VQQGEwJVUzERMA8GA1UECBMIVmlyZ2luaWExEzARBgNVBAcTCkJsYWNrc2J1cmcx
+PDA6BgNVBAoTM1ZpcmdpbmlhIFBvbHl0ZWNobmljIEluc3RpdHV0ZSBhbmQgU3Rh
+dGUgVW5pdmVyc2l0eTETMBEGA1UECxMKTWlkZGxld2FyZTEeMBwGA1UEAxMVQmVh
+dXJlZ2FyZCBCdW1ibGVmb290MR8wHQYJKoZIhvcNAQkBFhBiZWF1QGV4YW1wbGUu
+Y29tMB4XDTEyMDIyMzE1MDc0NloXDTM5MDcxMTE1MDc0NlowgckxCzAJBgNVBAYT
+AlVTMREwDwYDVQQIEwhWaXJnaW5pYTETMBEGA1UEBxMKQmxhY2tzYnVyZzE8MDoG
+A1UEChMzVmlyZ2luaWEgUG9seXRlY2huaWMgSW5zdGl0dXRlIGFuZCBTdGF0ZSBV
+bml2ZXJzaXR5MRMwEQYDVQQLEwpNaWRkbGV3YXJlMR4wHAYDVQQDExVCZWF1cmVn
+YXJkIEJ1bWJsZWZvb3QxHzAdBgkqhkiG9w0BCQEWEGJlYXVAZXhhbXBsZS5jb20w
+ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDJqxu5wkvp2xIViqPYaqPI
+LIojitnspRsPHBe7JjPW+rDHx68RNzkht/uCOTvvDPYvKbf7aZyaFQnTQBQMaWqX
+1FkwcMRoVHeJpvi8pS5pyuvcu3kpS3U3aT7t6Joi/qhPBZMc9iu8Pe4jIcf8CPQU
+SCMJoSl1llbM0EBxqxTa9vKTsryt1xkVPM0Of1i5RpC4Y9gipSyKffWH8M0RX2p2
+iVu79te0GAwDBmKw2lNLkPodDj8yF78CRnJJw07R8kMVoS0Hi91Sx6NEhbmvc+zi
+jpF2/RFsOjDBh3Im6V6ejnDwFu8+5P/kuL04nL1I0lG5Urv0js3Jlgn36DuYomXf
+AgMBAAGjggEyMIIBLjAdBgNVHQ4EFgQUOOaPw5TKHJs6XEi1u13t0oHrVNAwgf4G
+A1UdIwSB9jCB84AUOOaPw5TKHJs6XEi1u13t0oHrVNChgc+kgcwwgckxCzAJBgNV
+BAYTAlVTMREwDwYDVQQIEwhWaXJnaW5pYTETMBEGA1UEBxMKQmxhY2tzYnVyZzE8
+MDoGA1UEChMzVmlyZ2luaWEgUG9seXRlY2huaWMgSW5zdGl0dXRlIGFuZCBTdGF0
+ZSBVbml2ZXJzaXR5MRMwEQYDVQQLEwpNaWRkbGV3YXJlMR4wHAYDVQQDExVCZWF1
+cmVnYXJkIEJ1bWJsZWZvb3QxHzAdBgkqhkiG9w0BCQEWEGJlYXVAZXhhbXBsZS5j
+b22CCQCZb5PTESMhgjAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4IBAQAS
+WHk3oP64Uat1gBodTpOEPTUvvemEvVvjGGBDZbg+SOA2OYvMrfJTIEYlVcFlGLHU
+es82gCFdG7BMrqnZlssMNnvGdSS1SIarsfdv82wizBzuqVCHfMQXJ3ar5XfbY5hJ
+PgnJs/LIRLncB8u0UdmZ2xcUDR5RbFtreXplMboRmzv2MXK7m0PhoctLSDFoMAld
+RLxXOfD+Geq/ZJC8LLnNaSD483sTltDh4bu1RaQueZ5sb84a2agVnP//LkHwGvxE
+MfctVFDTeHng7oCA55Jel8nSoPunp4MG5fUwfMb3yp0Dc+C/EJejCpH5dqO5lflw
+76/LBZIpF8V3s1jWNbYL
+-----END CERTIFICATE-----
diff --git a/src/test/resources/certs/entity.key b/src/test/resources/certs/entity.key
new file mode 100644
index 0000000..987e03d
--- /dev/null
+++ b/src/test/resources/certs/entity.key
@@ -0,0 +1,27 @@
+-----BEGIN RSA PRIVATE KEY-----
+MIIEowIBAAKCAQEAyasbucJL6dsSFYqj2GqjyCyKI4rZ7KUbDxwXuyYz1vqwx8ev
+ETc5Ibf7gjk77wz2Lym3+2mcmhUJ00AUDGlql9RZMHDEaFR3iab4vKUuacrr3Lt5
+KUt1N2k+7eiaIv6oTwWTHPYrvD3uIyHH/Aj0FEgjCaEpdZZWzNBAcasU2vbyk7K8
+rdcZFTzNDn9YuUaQuGPYIqUsin31h/DNEV9qdolbu/bXtBgMAwZisNpTS5D6HQ4/
+Mhe/AkZyScNO0fJDFaEtB4vdUsejRIW5r3Ps4o6Rdv0RbDowwYdyJuleno5w8Bbv
+PuT/5Li9OJy9SNJRuVK79I7NyZYJ9+g7mKJl3wIDAQABAoIBADwQMUbHHpL9A0rV
+Ku1m/Xa+BTqGvVck6YU7ibncq+3oZkRqLbMD7okjYc4sO7R7+MqdM0W288RUZcO8
+PvxfXTbxMMsjmuuz1JJz33tX+xXZMRxh3bk11yh0uSBkeZvYmspGT8V9cBM1orpl
+8kkXZZKw1XalwFJcP2fq0nbITILWKC25vc5QSesuOcpP9rs2f+W/d80eF11EPLXw
+/cBgqHc9YCk/IAX1vZnXrPep27OzrOmwT7XIQUWQvGA0VjwT3jGE3fF4ZgEbIz3g
+gZz346tHkIQzjibKiMjxnq1RO+f0leqfaTyomLD0YeSIg14giwS0i3OwvCOhC4bB
+S0Q7k5ECgYEA9fe3ZDKjfKukYRhEwfoJmD/z4ea7mA+9U4N6Ax/WUa1eRGkLFXDf
+FCfEIt3/HWW90QN3R2jC7Cc2fZ+QWRzBSuVdvdUZPxEDAvvlWYbIDwblR1rvWeqX
+wVw2fudKse/1Sr5iAPw+d1vSSUHFkexsxTP0v3iCJQZppe64JeysTAcCgYEA0eTW
+zJCu9WGh55FSbp/nArk1q3Ezqaf+KFs9VY1oMTigE1DHhHLO8ueBRXwtwRWczVpP
+uD2xHb9FSKgkyhSXb88rS8E3v/uww8l2bRJDfFe3j87a8T/ChWJSEenH0JA3aisv
+IQQhqAfh/uj/l6jjEyJqvtojFr5bjUKiwKaPUWkCgYByT/AhVw94D2VT4q2B4Sy4
+X3B+2nbw0s/QklgQP6mhSAt5i8Ak9NIYUerrsXSxOumezBeRTnTYv9ipRZEWeTC0
+GCka4oDbOJLHvj32/5bWtQO1x+NZTJe+u5ZwIBos3DKJzDVL8+8sFbaDaVfi25gp
+hl4G5oDFqFdNUMawiXAB3QKBgBTUiRy0HyjrD45TtcKUy/BRQSpKib4ElgybQXME
+HZsE653/Hk3etvsUTpf+wuuuoWkf1VmLhdBV8yJKzZvgf0bxYHKcMlQzPk+v5rjc
+XyYv7l+vP7tBgKSMJWjxsorYRSecMYktR8nNPnh11yfN8vsrJzzZmTHgomVaf5xu
+6zpBAoGBANyebxHmQwIINzQ8N5zhVo/+jYfLJslNzxg9nb9t2mbO5OAmuZWPrLKt
+iw17lA6aLc3YORqs0UntQKRRY2Jqv643Uluo9XvHFpITNzpSkjkcGc1TEfuZJD7m
+k782JevhsWkEaMtiQflAOq3lFH3Go1u+qP/A2klt9jbdexfNu4v2
+-----END RSA PRIVATE KEY-----
diff --git a/src/test/resources/certs/glider.cc.vt.edu.crt b/src/test/resources/certs/glider.cc.vt.edu.crt
new file mode 100644
index 0000000..0c77bdb
--- /dev/null
+++ b/src/test/resources/certs/glider.cc.vt.edu.crt
@@ -0,0 +1,43 @@
+-----BEGIN CERTIFICATE-----
+MIIHgTCCBWmgAwIBAgIIM9zF+iOD1NIwDQYJKoZIhvcNAQEFBQAwgZoxEzARBgoJ
+kiaJk/IsZAEZEwNlZHUxEjAQBgoJkiaJk/IsZAEZEwJ2dDELMAkGA1UEBhMCVVMx
+PDA6BgNVBAoTM1ZpcmdpbmlhIFBvbHl0ZWNobmljIEluc3RpdHV0ZSBhbmQgU3Rh
+dGUgVW5pdmVyc2l0eTEkMCIGA1UEAxMbVmlyZ2luaWEgVGVjaCBNaWRkbGV3YXJl
+IENBMB4XDTA5MDcyMDE4MTYzOVoXDTExMDcyMDE4MTYzOVowgfoxGTAXBgNVBAMM
+EGdsaWRlci5jYy52dC5lZHUxFjAUBgNVBAUTDTEyNDgxMTA2NTc5NjExDTALBgNV
+BAsMBFNFVEkxGjAYBgNVBAsMEU1pZGRsZXdhcmUtQ2xpZW50MTwwOgYDVQQKDDNW
+aXJnaW5pYSBQb2x5dGVjaG5pYyBJbnN0aXR1dGUgYW5kIFN0YXRlIFVuaXZlcnNp
+dHkxEzARBgNVBAcMCkJsYWNrc2J1cmcxETAPBgNVBAgMCFZpcmdpbmlhMRIwEAYK
+CZImiZPyLGQBGRYCdnQxEzARBgoJkiaJk/IsZAEZFgNlZHUxCzAJBgNVBAYTAlVT
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAs5UbcNcLgGVmyzzrz/rR
+jnvelOjb6KZVYH+7twTNQ8nrjfKz09xeRGUyYEeeJd4Ma0l+i0kW6i68LsI8Mshm
+mKQTbMXdGodXrGmqw468+Az9nbp/fsn6N/PYRJedar9i1IqiVzu638RZpvVuo3Vi
+uZjuj0mHC89NNhUQ0NJaefgsKQ3ksAvvyhHtmk6UZyx4S5bQWQ8rVmGuBRAqUjjT
+HLde4k2Rid/TVjqT7bAjsIMUKnWx2+tVrx19ePJ2c4XUB9aBIwZ/IMmhVwtpQESn
+c32G5t8tY7W/vPiHK1dupYkeOhiyLXAClHIipbOBdUnvIzEmuBmv1Ln1t2Z64O0D
+zQIDAQABo4ICZzCCAmMwHQYDVR0OBBYEFMc2newZJGEJXQtUFUlg6d4+MFpnMAkG
+A1UdEwQCMAAwHwYDVR0jBBgwFoAU/TZAIprV/FvwBMmQ5k9PiEtE+E4weAYDVR0g
+BHEwbzAOBgwrBgEEAbRoBQICAgEwDgYMKwYBBAG0aAUCAgEBMD0GDCsGAQQBtGgF
+AgIEATAtMCsGCCsGAQUFBwIBFh9odHRwOi8vd3d3LnBraS52dC5lZHUvdnRtdy9j
+cHMvMA4GDCsGAQQBtGgFAgIDATCCAXgGA1UdHwSCAW8wggFrMIIBZ6CBwaCBvoaB
+u2h0dHA6Ly92dGNhLXAuZXByb3Yuc2V0aS52dC5lZHU6ODA4MC9lamJjYS9wdWJs
+aWN3ZWIvd2ViZGlzdC9jZXJ0ZGlzdD9jbWQ9Y3JsJmlzc3Vlcj1DTj1WaXJnaW5p
+YStUZWNoK01pZGRsZXdhcmUrQ0EsTz1WaXJnaW5pYStQb2x5dGVjaG5pYytJbnN0
+aXR1dGUrYW5kK1N0YXRlK1VuaXZlcnNpdHksREM9dnQsREM9ZWR1LEM9VVOigaCk
+gZ0wgZoxJDAiBgNVBAMMG1ZpcmdpbmlhIFRlY2ggTWlkZGxld2FyZSBDQTE8MDoG
+A1UECgwzVmlyZ2luaWEgUG9seXRlY2huaWMgSW5zdGl0dXRlIGFuZCBTdGF0ZSBV
+bml2ZXJzaXR5MRIwEAYKCZImiZPyLGQBGRYCdnQxEzARBgoJkiaJk/IsZAEZFgNl
+ZHUxCzAJBgNVBAYTAlVTMAsGA1UdDwQEAwIE8DATBgNVHSUEDDAKBggrBgEFBQcD
+AjANBgkqhkiG9w0BAQUFAAOCAgEAoTqj6tu88MnnQ5FzjWnUw4qVtLF9A6Cm/Ku/
+g49/LNSvccGoe55OazE1fYjLLGmPkwICey8ijkvj+kzYEHqPKcNCSqPEW2ALOclF
+obTpUQvxSWieYoLSsJ8oBCjQ4RY03uSVnVvnSx0mhXQZffW0q7qfx8+OhDe4JFKo
+4/iizuC8OZuvWvUwV3CkufVUpFNQJZ5ClXqkhad265sw8fTGkqZ8e0ccfzTdlomk
+UUhsykFagVM071nQ4FtxdCS+Wml5e36ysXqraMVWBv3XGWWI0SiFY1Ol/cewIdZt
+elHyZYOxBzBLXqfep2GjEVqhqqkBQLiw1WqDJ6FXlz7Lt3V6AQHtT1QT/lcjD7c4
+Stj25/Xh89tWVV5ApccdDGT83JHN6dI2rTTkNwFS+PuDY1ogjUyM4O52olGWY7Ws
+5xmOf+cDJAzGCdF94OY7c+Z9uyso65r+QpGhCsLnC/dKw6YXrPKz4LC4vJ2g2Cc1
+Gb3ZqOCkSt9OHcqLfzaNul7tJx82b7sDdgoTjyVroY5M0lsXEsKh1gFwp3LWiz5N
+BHI8sSqwAuQP2R5xPij8Lp/0hasWONZNULURisQKFsswyE+6UTbFoEjkFEMrx/pM
+OcWT490JNHRxcb+fb0UGXZ0Wdm9NKIy1L98EHpkPnNDuiaXs/lhpu56LipUAWdiL
+4swBcCA=
+-----END CERTIFICATE-----
diff --git a/src/test/resources/certs/login.live.com.crt b/src/test/resources/certs/login.live.com.crt
new file mode 100644
index 0000000..108311d
--- /dev/null
+++ b/src/test/resources/certs/login.live.com.crt
@@ -0,0 +1,33 @@
+-----BEGIN CERTIFICATE-----

+MIIFxzCCBK+gAwIBAgIQWfjHqoYW0XMXfFBcAW44GzANBgkqhkiG9w0BAQUFADCB

+ujELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDlZlcmlTaWduLCBJbmMuMR8wHQYDVQQL

+ExZWZXJpU2lnbiBUcnVzdCBOZXR3b3JrMTswOQYDVQQLEzJUZXJtcyBvZiB1c2Ug

+YXQgaHR0cHM6Ly93d3cudmVyaXNpZ24uY29tL3JwYSAoYykwNjE0MDIGA1UEAxMr

+VmVyaVNpZ24gQ2xhc3MgMyBFeHRlbmRlZCBWYWxpZGF0aW9uIFNTTCBDQTAeFw0w

+OTA2MTYwMDAwMDBaFw0xMDA2MTYyMzU5NTlaMIIBDzETMBEGCysGAQQBgjc8AgED

+EwJVUzEbMBkGCysGAQQBgjc8AgECEwpXYXNoaW5ndG9uMRswGQYDVQQPExJWMS4w

+LCBDbGF1c2UgNS4oYikxEjAQBgNVBAUTCTYwMDQxMzQ4NTELMAkGA1UEBhMCVVMx

+DjAMBgNVBBEUBTk4MDUyMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHFAdS

+ZWRtb25kMRowGAYDVQQJFBFPbmUgTWljcm9zb2Z0IFdheTEeMBwGA1UEChQVTWlj

+cm9zb2Z0IENvcnBvcmF0aW9uMREwDwYDVQQLFAhQYXNzcG9ydDEXMBUGA1UEAxQO

+bG9naW4ubGl2ZS5jb20wgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAKuYQtRg

++njn4QEUtC2TkeJj+LWH2rgmkpEWUE4df1j8/eFzKPnJmdsviZA+KLslQp9EALTM

+Y6G2wKwDxR9hRz5xmOlvnZB5z4uUfcXYsONETqm7HtWqHzHTyKHJKDv88Vva4eGr

+1AgS+M3hzFAaUl/DnpG8sI0Lz2X7dx5xSwSfAgMBAAGjggHzMIIB7zAJBgNVHRME

+AjAAMB0GA1UdDgQWBBQxrvF8mGfpHxlpoqeEHmdcqsNrdTALBgNVHQ8EBAMCBaAw

+QgYDVR0fBDswOTA3oDWgM4YxaHR0cDovL0VWU2VjdXJlLWNybC52ZXJpc2lnbi5j

+b20vRVZTZWN1cmUyMDA2LmNybDBEBgNVHSAEPTA7MDkGC2CGSAGG+EUBBxcGMCow

+KAYIKwYBBQUHAgEWHGh0dHBzOi8vd3d3LnZlcmlzaWduLmNvbS9ycGEwHQYDVR0l

+BBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMB8GA1UdIwQYMBaAFPyKULqeuSVae1WF

+T5UAY4/pWGtDMHwGCCsGAQUFBwEBBHAwbjAtBggrBgEFBQcwAYYhaHR0cDovL0VW

+U2VjdXJlLW9jc3AudmVyaXNpZ24uY29tMD0GCCsGAQUFBzAChjFodHRwOi8vRVZT

+ZWN1cmUtYWlhLnZlcmlzaWduLmNvbS9FVlNlY3VyZTIwMDYuY2VyMG4GCCsGAQUF

+BwEMBGIwYKFeoFwwWjBYMFYWCWltYWdlL2dpZjAhMB8wBwYFKw4DAhoEFEtruSiW

+Bgy70FI4mymsSweLIQUYMCYWJGh0dHA6Ly9sb2dvLnZlcmlzaWduLmNvbS92c2xv

+Z28xLmdpZjANBgkqhkiG9w0BAQUFAAOCAQEAdjzxBfnaVmlbOWci+BnXY9hZZqSK

+sqk0CrsdBQXot+PcPpzfi4cdUc8/8Pr6SvLAqi/koHc6ez0SxK1QycfuwAUVCKsd

+/np79v2K94UHuKb+NTzHUStBg3KoSe7/mK3gP1CSSpgr9u+wHjQhjdIYrozHJD2C

+T/cjX4A1EZy5ChV719L7UiganmT6jOY2+ogP3ppd1WgZHnCGHciUaiR6kjw9p6UD

+jM19Lg3DHnw9zxjFHTj+MFc9qLBb3m7QEdEOO1hwQdWYS08bjEXvuuISO0tEr28X

+5ir3zGxe66Ge0Koi2AM1y9gq8+bOVqDudU2poyIXfFXcQ13HRNtgvnH3fQ==

+-----END CERTIFICATE-----

diff --git a/src/test/resources/certs/multi-value-rdn-1.crt b/src/test/resources/certs/multi-value-rdn-1.crt
new file mode 100644
index 0000000..1c510c2
--- /dev/null
+++ b/src/test/resources/certs/multi-value-rdn-1.crt
@@ -0,0 +1,18 @@
+-----BEGIN CERTIFICATE-----
+MIIC2zCCAkSgAwIBAgIDAVJ9MA0GCSqGSIb3DQEBBQUAMFcxEzARBgoJkiaJk/Is
+ZAEZFgNvcmcxGDAWBgoJkiaJk/IsZAEZFghsZGFwdGl2ZTESMBAGA1UEAxMJYS5m
+b28uY29tMRIwEAYDVQQDEwliLmZvby5jb20wHhcNMTQwODI5MTk1MTE5WhcNMTQw
+OTI4MTk1MTE5WjBXMRMwEQYKCZImiZPyLGQBGRYDb3JnMRgwFgYKCZImiZPyLGQB
+GRYIbGRhcHRpdmUxEjAQBgNVBAMTCWEuZm9vLmNvbTESMBAGA1UEAxMJYi5mb28u
+Y29tMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCrFV0ARzYvBJLXMLo8yex7
+aNATrAANh4S3utE/ce+xj2qTi+hl9xm0EU6Zal+iYGpsKqnpTPfNE8HVMbzOrrPB
+6fRMGS1AyRV3WOy+2mgdzi1P068PqpTkm+MjXF6El8OBnuGaIwLzvFMno0rV7lse
+UOLDYcEIl3BdVsIlH27KpQIDAQABo4G0MIGxMB0GA1UdDgQWBBSHRs4AN3PGdL/i
+OkPq/Cjjc6f8EDCBgQYDVR0jBHoweIAUh0bOADdzxnS/4jpD6vwo43On/BChW6RZ
+MFcxEzARBgoJkiaJk/IsZAEZFgNvcmcxGDAWBgoJkiaJk/IsZAEZFghsZGFwdGl2
+ZTESMBAGA1UEAxMJYS5mb28uY29tMRIwEAYDVQQDEwliLmZvby5jb22CAwFSfTAM
+BgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4GBAC++ms/hrIOiY4Gdyie8qiIW
+FAU/IZLkbFSPwFpVQrYLdN7m+xCIcq2+viaZdXG6QYOC8dYr2URoEoVm+DPfx2Hj
+TokXEIsNS7ODx8r/sBmJ2UHvRdPROtqwY4tCgYlf7LWD/s27eRVYCTZbcwMF1hBf
+aNe1VTBZ5MLkzyewZ6tW
+-----END CERTIFICATE-----
diff --git a/src/test/resources/certs/multi-value-rdn-2.crt b/src/test/resources/certs/multi-value-rdn-2.crt
new file mode 100644
index 0000000..29e71f5
--- /dev/null
+++ b/src/test/resources/certs/multi-value-rdn-2.crt
@@ -0,0 +1,18 @@
+-----BEGIN CERTIFICATE-----
+MIIC1DCCAj2gAwIBAgIDAVJ9MA0GCSqGSIb3DQEBBQUAMFUxJDAQBgNVBAMTCWEu
+Zm9vLmNvbTAQBgNVBAMTCWIuZm9vLmNvbTEYMBYGCgmSJomT8ixkARkWCGxkYXB0
+aXZlMRMwEQYKCZImiZPyLGQBGRYDb3JnMB4XDTE0MDgyOTE5MjY1OVoXDTE0MDky
+ODE5MjY1OVowVTEkMBAGA1UEAxMJYS5mb28uY29tMBAGA1UEAxMJYi5mb28uY29t
+MRgwFgYKCZImiZPyLGQBGRYIbGRhcHRpdmUxEzARBgoJkiaJk/IsZAEZFgNvcmcw
+gZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAOC2KBN8MDiKHWEuv1pnIEcWYjHb
+D+NgAGVnZh7i8jEDRIVpWUzFj7FxNROEsAitZAanzpwo6jYeGmT60Vl4DpliuoVu
+Vt1Reem96Dp9/J7BL0QBv0fJErv/YRhNor4wSOuWI96TWHvCDEL4oDNuxEK46Nsn
+dAw10DFBRMWt1VcFAgMBAAGjgbEwga4wHQYDVR0OBBYEFB7EPqv9y/GqxBrJAMS4
+pEh2ktyTMH8GA1UdIwR4MHaAFB7EPqv9y/GqxBrJAMS4pEh2ktyToVmkVzBVMSQw
+EAYDVQQDEwlhLmZvby5jb20wEAYDVQQDEwliLmZvby5jb20xGDAWBgoJkiaJk/Is
+ZAEZFghsZGFwdGl2ZTETMBEGCgmSJomT8ixkARkWA29yZ4IDAVJ9MAwGA1UdEwQF
+MAMBAf8wDQYJKoZIhvcNAQEFBQADgYEALU4SluqREjvyztZDZRsVnKn0Wy5kQqh3
+wVN/U2Sv82+N6ulzqOttmEY/dq8UGH5QbIioGUTgWxycidYwzWCIT/+Gg+pwBcmz
+oTYxJY0aUKfvfy4p25dcaG360DMycUpmZHM+HpgEGOrMsLCewKshuR+D03pE9eH5
+AK1FbieXQtM=
+-----END CERTIFICATE-----
diff --git a/src/test/resources/certs/needs-escaping-1.crt b/src/test/resources/certs/needs-escaping-1.crt
new file mode 100644
index 0000000..0e92a3f
--- /dev/null
+++ b/src/test/resources/certs/needs-escaping-1.crt
@@ -0,0 +1,22 @@
+-----BEGIN CERTIFICATE-----
+MIIDjTCCAvagAwIBAgIJAJ9OE8QRbtsRMA0GCSqGSIb3DQEBBQUAMIGMMRMwEQYK
+CZImiZPyLGQBGRYDZWR1MRIwEAYKCZImiZPyLGQBGRYCdnQxCzAJBgNVBAYTAlVT
+MREwDwYDVQQIEwhWaXJnaW5pYTETMBEGA1UEBxMKQmxhY2tzYnVyZzEPMA0GA1UE
+ChQGVlBJJlNVMRswGQYDVQQDExJEQz1leGFtcGxlLCBEQz1jb20wHhcNMTQxMDI5
+MTE0MzUzWhcNNDIwMzE2MTE0MzUzWjCBjDETMBEGCgmSJomT8ixkARkWA2VkdTES
+MBAGCgmSJomT8ixkARkWAnZ0MQswCQYDVQQGEwJVUzERMA8GA1UECBMIVmlyZ2lu
+aWExEzARBgNVBAcTCkJsYWNrc2J1cmcxDzANBgNVBAoUBlZQSSZTVTEbMBkGA1UE
+AxMSREM9ZXhhbXBsZSwgREM9Y29tMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKB
+gQDkYYrsisNd/+lNvWR3ridkbPg0VJKc9juQAnSrdh3rV99wBtg0SQeao5MUPwsm
+LG3/aQmzNhaFNPeWHcQZDmqZO1QLTB7rFX2xZn3t4uD2+yn764lnL10qliZmGCbs
+6wB4LLrnR50hWYx3MgisdeHe4uOc18FVGvGs8+2k0Im/yQIDAQABo4H0MIHxMB0G
+A1UdDgQWBBQVpddz37M6vpI2uk5lLDByhWTNxTCBwQYDVR0jBIG5MIG2gBQVpddz
+37M6vpI2uk5lLDByhWTNxaGBkqSBjzCBjDETMBEGCgmSJomT8ixkARkWA2VkdTES
+MBAGCgmSJomT8ixkARkWAnZ0MQswCQYDVQQGEwJVUzERMA8GA1UECBMIVmlyZ2lu
+aWExEzARBgNVBAcTCkJsYWNrc2J1cmcxDzANBgNVBAoUBlZQSSZTVTEbMBkGA1UE
+AxMSREM9ZXhhbXBsZSwgREM9Y29tggkAn04TxBFu2xEwDAYDVR0TBAUwAwEB/zAN
+BgkqhkiG9w0BAQUFAAOBgQDDyLykXoVgJroG5iSbebWrZALT0XzEN/gfxYxAYEh7
+YUrjM3GUHpF+dKroh/lreWn+fbO1oALuqSODnDmYPIYGeemS22P798ab8ar7ltNt
+pVGjaQk5w+GPvXA1LTAFWlb/2kHQAFEE0+oKz6PEZxtJy0StDQvfc4bQ3dW7ddOC
+Kg==
+-----END CERTIFICATE-----
diff --git a/src/test/resources/certs/needs-escaping-2.crt b/src/test/resources/certs/needs-escaping-2.crt
new file mode 100644
index 0000000..898ec8d
--- /dev/null
+++ b/src/test/resources/certs/needs-escaping-2.crt
@@ -0,0 +1,21 @@
+-----BEGIN CERTIFICATE-----
+MIIDcjCCAtugAwIBAgIJALpZTTXEfhcxMA0GCSqGSIb3DQEBBQUAMIGDMRMwEQYK
+CZImiZPyLGQBGRYDZWR1MRIwEAYKCZImiZPyLGQBGRYCdnQxCzAJBgNVBAYTAlVT
+MREwDwYDVQQIEwhWaXJnaW5pYTETMBEGA1UEBxMKQmxhY2tzYnVyZzEPMA0GA1UE
+ChQGVlBJJlNVMRIwEAYDVQQDFAkjREVBREJFRUYwHhcNMTQxMDI5MTMxNDIxWhcN
+NDIwMzE2MTMxNDIxWjCBgzETMBEGCgmSJomT8ixkARkWA2VkdTESMBAGCgmSJomT
+8ixkARkWAnZ0MQswCQYDVQQGEwJVUzERMA8GA1UECBMIVmlyZ2luaWExEzARBgNV
+BAcTCkJsYWNrc2J1cmcxDzANBgNVBAoUBlZQSSZTVTESMBAGA1UEAxQJI0RFQURC
+RUVGMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDkYYrsisNd/+lNvWR3ridk
+bPg0VJKc9juQAnSrdh3rV99wBtg0SQeao5MUPwsmLG3/aQmzNhaFNPeWHcQZDmqZ
+O1QLTB7rFX2xZn3t4uD2+yn764lnL10qliZmGCbs6wB4LLrnR50hWYx3MgisdeHe
+4uOc18FVGvGs8+2k0Im/yQIDAQABo4HrMIHoMB0GA1UdDgQWBBQVpddz37M6vpI2
+uk5lLDByhWTNxTCBuAYDVR0jBIGwMIGtgBQVpddz37M6vpI2uk5lLDByhWTNxaGB
+iaSBhjCBgzETMBEGCgmSJomT8ixkARkWA2VkdTESMBAGCgmSJomT8ixkARkWAnZ0
+MQswCQYDVQQGEwJVUzERMA8GA1UECBMIVmlyZ2luaWExEzARBgNVBAcTCkJsYWNr
+c2J1cmcxDzANBgNVBAoUBlZQSSZTVTESMBAGA1UEAxQJI0RFQURCRUVGggkAullN
+NcR+FzEwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQUFAAOBgQAOxmoFF9gRDiuA
+l+z0/stcxChGAkdKYGXL62U/L6POvwxLUnpvWgBX21KcIub2wICCMo/JA81s2QFk
+LwaxBpXp6awCN0H+H5HH60hr9Q3optPxFxzeAb8L/TlP4ClcsnKrpY9Ge69Hp8GD
+yqBVXG9iHAz3RpE10pJA4f/NFD7ZLQ==
+-----END CERTIFICATE-----
diff --git a/src/test/resources/certs/needs-escaping-3.crt b/src/test/resources/certs/needs-escaping-3.crt
new file mode 100644
index 0000000..151247a
--- /dev/null
+++ b/src/test/resources/certs/needs-escaping-3.crt
@@ -0,0 +1,21 @@
+-----BEGIN CERTIFICATE-----
+MIIDaTCCAtKgAwIBAgIJALRAX7xeRlE+MA0GCSqGSIb3DQEBBQUAMIGAMRMwEQYK
+CZImiZPyLGQBGRYDZWR1MRIwEAYKCZImiZPyLGQBGRYCdnQxCzAJBgNVBAYTAlVT
+MREwDwYDVQQIEwhWaXJnaW5pYTETMBEGA1UEBxMKQmxhY2tzYnVyZzEPMA0GA1UE
+ChQGVlBJJlNVMQ8wDQYDVQQDEwYgc3BhY2UwHhcNMTQxMDI5MTMxNjM4WhcNNDIw
+MzE2MTMxNjM4WjCBgDETMBEGCgmSJomT8ixkARkWA2VkdTESMBAGCgmSJomT8ixk
+ARkWAnZ0MQswCQYDVQQGEwJVUzERMA8GA1UECBMIVmlyZ2luaWExEzARBgNVBAcT
+CkJsYWNrc2J1cmcxDzANBgNVBAoUBlZQSSZTVTEPMA0GA1UEAxMGIHNwYWNlMIGf
+MA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDkYYrsisNd/+lNvWR3ridkbPg0VJKc
+9juQAnSrdh3rV99wBtg0SQeao5MUPwsmLG3/aQmzNhaFNPeWHcQZDmqZO1QLTB7r
+FX2xZn3t4uD2+yn764lnL10qliZmGCbs6wB4LLrnR50hWYx3MgisdeHe4uOc18FV
+GvGs8+2k0Im/yQIDAQABo4HoMIHlMB0GA1UdDgQWBBQVpddz37M6vpI2uk5lLDBy
+hWTNxTCBtQYDVR0jBIGtMIGqgBQVpddz37M6vpI2uk5lLDByhWTNxaGBhqSBgzCB
+gDETMBEGCgmSJomT8ixkARkWA2VkdTESMBAGCgmSJomT8ixkARkWAnZ0MQswCQYD
+VQQGEwJVUzERMA8GA1UECBMIVmlyZ2luaWExEzARBgNVBAcTCkJsYWNrc2J1cmcx
+DzANBgNVBAoUBlZQSSZTVTEPMA0GA1UEAxMGIHNwYWNlggkAtEBfvF5GUT4wDAYD
+VR0TBAUwAwEB/zANBgkqhkiG9w0BAQUFAAOBgQB14f+JlFDdZPIxYpNXGCDNaemw
+zPDUs7YDzXAfVgkbw/mt36155d7jXnmzkPnDZbCeIc/CGmgOURmY5WOS5m1eVywu
+cliBoYA5xQBfNUVMa+68wvLzY7jQbGyKm9CFIwNjNMhDF5uenpyv/DxwvMd7Wneq
+XluBRl3MN4DV9aULOg==
+-----END CERTIFICATE-----
diff --git a/src/test/resources/certs/needs-escaping-4.crt b/src/test/resources/certs/needs-escaping-4.crt
new file mode 100644
index 0000000..533c191
--- /dev/null
+++ b/src/test/resources/certs/needs-escaping-4.crt
@@ -0,0 +1,21 @@
+-----BEGIN CERTIFICATE-----
+MIIDbzCCAtigAwIBAgIJALu4vu81OOfsMA0GCSqGSIb3DQEBBQUAMIGCMRMwEQYK
+CZImiZPyLGQBGRYDZWR1MRIwEAYKCZImiZPyLGQBGRYCdnQxCzAJBgNVBAYTAlVT
+MREwDwYDVQQIEwhWaXJnaW5pYTETMBEGA1UEBxMKQmxhY2tzYnVyZzEPMA0GA1UE
+ChQGVlBJJlNVMREwDwYDVQQDEwhzcGFjZTIgIDAeFw0xNDEwMjkxMzE3MzJaFw00
+MjAzMTYxMzE3MzJaMIGCMRMwEQYKCZImiZPyLGQBGRYDZWR1MRIwEAYKCZImiZPy
+LGQBGRYCdnQxCzAJBgNVBAYTAlVTMREwDwYDVQQIEwhWaXJnaW5pYTETMBEGA1UE
+BxMKQmxhY2tzYnVyZzEPMA0GA1UEChQGVlBJJlNVMREwDwYDVQQDEwhzcGFjZTIg
+IDCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEA5GGK7IrDXf/pTb1kd64nZGz4
+NFSSnPY7kAJ0q3Yd61ffcAbYNEkHmqOTFD8LJixt/2kJszYWhTT3lh3EGQ5qmTtU
+C0we6xV9sWZ97eLg9vsp++uJZy9dKpYmZhgm7OsAeCy650edIVmMdzIIrHXh3uLj
+nNfBVRrxrPPtpNCJv8kCAwEAAaOB6jCB5zAdBgNVHQ4EFgQUFaXXc9+zOr6SNrpO
+ZSwwcoVkzcUwgbcGA1UdIwSBrzCBrIAUFaXXc9+zOr6SNrpOZSwwcoVkzcWhgYik
+gYUwgYIxEzARBgoJkiaJk/IsZAEZFgNlZHUxEjAQBgoJkiaJk/IsZAEZFgJ2dDEL
+MAkGA1UEBhMCVVMxETAPBgNVBAgTCFZpcmdpbmlhMRMwEQYDVQQHEwpCbGFja3Ni
+dXJnMQ8wDQYDVQQKFAZWUEkmU1UxETAPBgNVBAMTCHNwYWNlMiAgggkAu7i+7zU4
+5+wwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQUFAAOBgQC5fERd6NiAzDWY/Rlc
+YETf6pHrWrGsnB29Mj/PNuSj04pEJ00/LuVEzV56q7vFjjrPh7PkaOH7dAedtDRZ
+g4YEJY4hS8WZa5NavBtDjidQUQknoL6vQDi4rHEAXoJF/Ol2Zb+XCJCPyJjoR+Ae
+28EAHhfJKxysA9t16aI7UN/MqA==
+-----END CERTIFICATE-----
diff --git a/src/test/resources/certs/scantor-dn-description.crt b/src/test/resources/certs/scantor-dn-description.crt
new file mode 100644
index 0000000..ee771bd
--- /dev/null
+++ b/src/test/resources/certs/scantor-dn-description.crt
@@ -0,0 +1,36 @@
+-----BEGIN CERTIFICATE-----

+MIIGOTCCBSGgAwIBAgIDC9q0MA0GCSqGSIb3DQEBBQUAMIGMMQswCQYDVQQGEwJJ

+TDEWMBQGA1UEChMNU3RhcnRDb20gTHRkLjErMCkGA1UECxMiU2VjdXJlIERpZ2l0

+YWwgQ2VydGlmaWNhdGUgU2lnbmluZzE4MDYGA1UEAxMvU3RhcnRDb20gQ2xhc3Mg

+MSBQcmltYXJ5IEludGVybWVkaWF0ZSBDbGllbnQgQ0EwHhcNMTQxMTEwMDI1MzE2

+WhcNMTUxMTExMTM0ODI4WjBXMRkwFwYDVQQNExA2TXRwSlMxZGNDN3QyNTR2MRkw

+FwYDVQQDDBBjYW50b3IuMkBvc3UuZWR1MR8wHQYJKoZIhvcNAQkBFhBjYW50b3Iu

+MkBvc3UuZWR1MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyIP4AzEq

+67t3w/K3qKRp+8DYzPUFCA2tkuxBHEZRGeOK2xx2mZm9anw0lO1ihNm2Td6xaOnP

+RKeIdl2eMX7AEzVazMaDYAB8n+qxQLC5+2R98wrqsp685YjE0siczBDTKkReXtea

+yhxP6vuJTtnAfSf7caa+U5RqWxfPf9bGAb378P9eNQ4xXpaR3VjVBL+Bnh5pSYzT

+bxj2fpGzNUAh5pi/QD6pxZ+cXyc3SB2hmX1QoFPDlEjuXBrOJKW2rBJ/58Mah+gS

+V0cCt9YVgeONNytpr4IbjcdZj356VJav07gfrhwdlIDOhFsrq/1aHvwttUeaUQ+E

+AWuRwR5XfOrU6QIDAQABo4IC1jCCAtIwCQYDVR0TBAIwADALBgNVHQ8EBAMCBLAw

+HQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMEMB0GA1UdDgQWBBTE8bu/7pUs

+M37CLGlLPPfH/tTtcDAfBgNVHSMEGDAWgBRTcu2SnODaywFcfH6WNU7y1LhRgjAb

+BgNVHREEFDASgRBjYW50b3IuMkBvc3UuZWR1MIIBTAYDVR0gBIIBQzCCAT8wggE7

+BgsrBgEEAYG1NwECAzCCASowLgYIKwYBBQUHAgEWImh0dHA6Ly93d3cuc3RhcnRz

+c2wuY29tL3BvbGljeS5wZGYwgfcGCCsGAQUFBwICMIHqMCcWIFN0YXJ0Q29tIENl

+cnRpZmljYXRpb24gQXV0aG9yaXR5MAMCAQEagb5UaGlzIGNlcnRpZmljYXRlIHdh

+cyBpc3N1ZWQgYWNjb3JkaW5nIHRvIHRoZSBDbGFzcyAxIFZhbGlkYXRpb24gcmVx

+dWlyZW1lbnRzIG9mIHRoZSBTdGFydENvbSBDQSBwb2xpY3ksIHJlbGlhbmNlIG9u

+bHkgZm9yIHRoZSBpbnRlbmRlZCBwdXJwb3NlIGluIGNvbXBsaWFuY2Ugb2YgdGhl

+IHJlbHlpbmcgcGFydHkgb2JsaWdhdGlvbnMuMDYGA1UdHwQvMC0wK6ApoCeGJWh0

+dHA6Ly9jcmwuc3RhcnRzc2wuY29tL2NydHUxLWNybC5jcmwwgY4GCCsGAQUFBwEB

+BIGBMH8wOQYIKwYBBQUHMAGGLWh0dHA6Ly9vY3NwLnN0YXJ0c3NsLmNvbS9zdWIv

+Y2xhc3MxL2NsaWVudC9jYTBCBggrBgEFBQcwAoY2aHR0cDovL2FpYS5zdGFydHNz

+bC5jb20vY2VydHMvc3ViLmNsYXNzMS5jbGllbnQuY2EuY3J0MCMGA1UdEgQcMBqG

+GGh0dHA6Ly93d3cuc3RhcnRzc2wuY29tLzANBgkqhkiG9w0BAQUFAAOCAQEAnk+/

+4X15nDhpqiLPkfH0jg4QMkWsniANJtfPoLcbjlhpTxRgIpEHlqY2hdw/gwcMDL0y

+CjCs2/yhZINITU6YmOnOkalXqKkgy7RxGRYIpjb5XYJbQv+RR+qpzl5S38HQzUDu

++Z3r83lYrZFM5scqvl2bzK+2jUchQoM3iClr4ZmmgdJyBM5SEVgHjeQOQI5p7NhO

+B4eLZ5USBFLK5gVQEkqJjAxZTz72uCHsjPaoiyraZnuZyrct75g/adSfyoJvXuHw

+rd/RrbJJcx96Eqf1v26StKcU0eXTc7hIkSshNef3LSKqPbBOojp0JaPJ0AIyvQcs

+rZoJ0wA8Ifva+t2QvA==

+-----END CERTIFICATE-----

diff --git a/src/test/resources/certs/serac-dev-test.crt b/src/test/resources/certs/serac-dev-test.crt
new file mode 100644
index 0000000..c2cbb6c
--- /dev/null
+++ b/src/test/resources/certs/serac-dev-test.crt
@@ -0,0 +1,117 @@
+Bag Attributes
+    localKeyID: 01 00 00 00 
+subject=/UID=1145718/CN=Marvin S Addison/O=Virginia Polytechnic Institute and State University/DC=edu/DC=vt/C=US
+issuer=/DC=edu/DC=vt/C=US/O=Virginia Polytechnic Institute and State University/CN=DEV Virginia Tech Class 1 Server CA/serialNumber=12
+-----BEGIN CERTIFICATE-----
+MIIFfzCCA2egAwIBAgIIL1b8rywTgSMwDQYJKoZIhvcNAQEFBQAwga8xEzARBgoJ
+kiaJk/IsZAEZFgNlZHUxEjAQBgoJkiaJk/IsZAEZFgJ2dDELMAkGA1UEBhMCVVMx
+PDA6BgNVBAoTM1ZpcmdpbmlhIFBvbHl0ZWNobmljIEluc3RpdHV0ZSBhbmQgU3Rh
+dGUgVW5pdmVyc2l0eTEsMCoGA1UEAxMjREVWIFZpcmdpbmlhIFRlY2ggQ2xhc3Mg
+MSBTZXJ2ZXIgQ0ExCzAJBgNVBAUTAjEyMB4XDTA5MDEyNzE0MzkwMloXDTExMDEy
+NzE0MzkwMlowgagxFzAVBgoJkiaJk/IsZAEBDAcxMTQ1NzE4MRkwFwYDVQQDDBBN
+YXJ2aW4gUyBBZGRpc29uMTwwOgYDVQQKDDNWaXJnaW5pYSBQb2x5dGVjaG5pYyBJ
+bnN0aXR1dGUgYW5kIFN0YXRlIFVuaXZlcnNpdHkxEzARBgoJkiaJk/IsZAEZFgNl
+ZHUxEjAQBgoJkiaJk/IsZAEZFgJ2dDELMAkGA1UEBhMCVVMwgZ8wDQYJKoZIhvcN
+AQEBBQADgY0AMIGJAoGBAJqNBYMDpUcDTAI9Srrj473GzqUPB2GFyJMNUy8ebWPE
+iwsMJlLZRHpC2igDRvDuS/70Jm5YpuVdqr5rnotCgVpczGCP8mE0QBziktLTuLdf
+Vzbu06/F77GHGUkSJ5TNL0iUynk+zxVvyZA5nAZVpaPqCkKG1xs+Y1Mm34F4EA5t
+AgMBAAGjggEmMIIBIjAdBgNVHQ4EFgQUJUgvKOxdGbsdJa6Uk7F7tTWWJGYwCQYD
+VR0TBAIwADAfBgNVHSMEGDAWgBQ44G+uSO1eI/Yimx7nnBkWR7h+kjCBgwYDVR0g
+BHwwejAOBgwrBgEEAbRoBQICAgEwDgYMKwYBBAG0aAUCAgEBMEgGDCsGAQQBtGgF
+AgIEATA4MDYGCCsGAQUFBwIBFipodHRwOi8vd3d3LnBraS52dC5lZHUvdnR1Y2Ev
+Y3BzL2luZGV4Lmh0bWwwDgYMKwYBBAG0aAUCAgMBMAsGA1UdDwQEAwIGwDApBgNV
+HSUEIjAgBggrBgEFBQcDAgYIKwYBBQUHAwQGCisGAQQBgjcUAgIwFwYDVR0RBBAw
+DoEMZXByb3ZAdnQuZWR1MA0GCSqGSIb3DQEBBQUAA4ICAQBzUOeoIl4dN89hx5c1
+rCQ0zxRn1y3MGWvTLuJnAItnruTbqZWXlMjlGMlW7PY4ZacLcsDC8O2dUYojXiDr
+vjxykMNf+upCRUpKdlwQNeE7NaB99GnZCK2tavVVXF8B1hfoucvLghN9jPtI0mtI
+NoSVpUAaU81fTgkkEDP4P8TeBFokj7ygahRGBq6+6ogXQovZqn1jqH+GG/rUUO21
+flHw0fQ4MsRWOPXSkiubEn/JbRXsN4dznaoxpAsudl8r3qCf+A3ka6Q1P1ITQxLS
+aL6dP5GdipBB1Wq+cx/wZ4W2ZdAj9sFY1M8WXDo92i1KsobaRXtbuKgJMTSS8I54
+sRdpNzTxVnfrm5ZKm3IoLIqGbaV7HBs26qj+LMbb9XaDVnubRp+cLD4moTcEbEFI
+jOmykuTsfvZryfZF3Db82GZ0lrLdVr+fnc16PY/943cX7ZhjBUPTjlklwzQs7/VK
+6tAlzkfk6Qi0yJlZXGLp2+KZP0eTICB6h2/8udoqI6t6fVxm92JqWNfvFURt2m9t
+ayrNXSbTXq/KY2DcrMoYk3slqMpGnKlD+iIJQsbjOMmK8a1BMgEAZEDhzCabtQt6
+TtJq/v9XFvRPzDiHHmAMkbrJLA0DzpIKICdtovVsskPGov5zv7MalSFJ3+1Cwi0w
+phTkNox9q4S8XAaRUPS7swqefQ==
+-----END CERTIFICATE-----
+Bag Attributes: <Empty Attributes>
+subject=/C=US/ST=Virginia /L=Blacksburg/O=DEV Virginia Tech Root CA
+issuer=/C=US/ST=Virginia /L=Blacksburg/O=DEV Virginia Tech Root CA
+-----BEGIN CERTIFICATE-----
+MIIGQzCCBCugAwIBAgIBADANBgkqhkiG9w0BAQUFADBaMQswCQYDVQQGEwJVUzES
+MBAGA1UECBMJVmlyZ2luaWEgMRMwEQYDVQQHEwpCbGFja3NidXJnMSIwIAYDVQQK
+ExlERVYgVmlyZ2luaWEgVGVjaCBSb290IENBMB4XDTAzMDgyNzE5Mzk0M1oXDTMz
+MDgxOTE5Mzk0M1owWjELMAkGA1UEBhMCVVMxEjAQBgNVBAgTCVZpcmdpbmlhIDET
+MBEGA1UEBxMKQmxhY2tzYnVyZzEiMCAGA1UEChMZREVWIFZpcmdpbmlhIFRlY2gg
+Um9vdCBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANnl4lsx9H3P
+ktcm1B6b4nRaRZ1MiBjr3BVLH/GQbhC7rS9hq5B03j9LbN0/ld1aWa+ot6gibBvF
+cCGweAHkU878CVTEUr/CrvesY0FOggaj7ucCWPOxCxv6Dob+FT1RLO1Bd7YUVLN7
+WlMHwi/KX4ADytbNyu7DBDG9PQE4ZmI/9JKBIjpiY6EfjDDIMyRfqK9Gz1mpsVlo
+kYJBZDa37bNsZ+tIjdm61n2ramdjQxnH9DN1BPvQsm1IoRFD2JeVmYq6jJcmV+Wy
+Gy1U4WNUe2MxX/y5IEhUmerOnJs1tz7e9qoPJ5MqwSC/ujZ+J/3NmrZK1kc/o3AF
+5dZSejFZBIL2hdXVzFeblBYgwMYiLEqdykYo5Y/fFPe6k9VVDNAMraJsFpR7/2ks
+fENyS6bTZgX3bHeq2WJiQ92BDC5F32QZKFsCArdP8Cdy2J1O2BnFjVB9s3vlLy6X
+sPjtDtdyCsbuxYHRZT2wWDKKDcIrcSs+3fai5j0MpKPGdwjoK/uxBqjcwAai5XAW
+NG9ZY2K1+l0oLcw3exi3RUXLLWCu4KjIwQXh0dA1O0hSp+iHaCgW4S7s7EA37zG5
+/dOpNVFyZlJB4zOYxmrKt1XtOubOZ+GubeuQiS2xjvZKgyhulV6hdORO7VAgTgKA
+NMbKnbGCXMW7nU4InkXnPtD0X7j6/HnvAgMBAAGjggESMIIBDjAPBgNVHRMBAf8E
+BTADAQH/MB0GA1UdDgQWBBTwC6Mn1xo3SrY1FN+8p796K1cdCzCBggYDVR0jBHsw
+eYAU8AujJ9caN0q2NRTfvKe/eitXHQuhXqRcMFoxCzAJBgNVBAYTAlVTMRIwEAYD
+VQQIEwlWaXJnaW5pYSAxEzARBgNVBAcTCkJsYWNrc2J1cmcxIjAgBgNVBAoTGURF
+ViBWaXJnaW5pYSBUZWNoIFJvb3QgQ0GCAQAwCwYDVR0PBAQDAgEGMEoGA1UdIARD
+MEEwPwYMKwYBBAG0aAUCAQEBMC8wLQYIKwYBBQUHAgEWIWh0dHA6Ly93d3cucGtp
+LnZ0LmVkdS9yb290Y2EvY3BzLzANBgkqhkiG9w0BAQUFAAOCAgEAE1YKsusecsvV
+igS4vIlxuSVjxNVA2nyr25G6+jW5ZGwn4osLGh3wRGi6omPWRufctmXcj1K47Scl
+/wdUmYgprKNlQ3ZwnC03aDFrMBePdT3Iyc6tdS4s4WTH+YxUvHDOht5hGd8yjOEP
+1Kh3Iv+WKA3Y4Lt3k5dvYVyz/5Z1vnGFZ/0WU6ICz8rPewKHB0oGiGR2vuJ6g/pH
+QgSeR0bcUEubo3rBEb0E5GOGrjG4JIzZfH08tq82dGRfxODRgAZV8mMLfbkUluBV
+ilafwXHLWSgD0CooqmnIDnQJrmsZQssG5wjI2cclwdRZJvVB+Gxy9W7LvLQcRPvf
+8ojL6BCC7qkd3zXCdRcpBITTKf1xz1JcUmit2NJHFhmgv1JWwsmGRjGRCjtgLhkG
+4fEKP8eY8WeiOGsRzmcZzxe8jWjeUO6ghF3T2R1vdcMCUdn21DO/l62lhR2qC+Ji
+EDQZvciLZbltTxc0kbHNLXiET1wMHrq50WVM6F6pG7OzEwjXiQPXTPZqRU54ZCuA
+x3IIJ7pJTzmYGQSISn8QEFDJMmeX8/lWdUJuCAUf1ERO958VD6bSvgK1TasPHtzb
+xdPfyWvXAvetCNPJLBQAWs4V8SKx71brRn5/ZXBCMd8tqdtwhUyDQqlPpe7kBeGA
+STDp+xbGH4LUxg/5gUXPiYrqaVCM7GA=
+-----END CERTIFICATE-----
+Bag Attributes: <Empty Attributes>
+subject=/DC=edu/DC=vt/C=US/O=Virginia Polytechnic Institute and State University/CN=DEV Virginia Tech Class 1 Server CA/serialNumber=12
+issuer=/C=US/ST=Virginia /L=Blacksburg/O=DEV Virginia Tech Root CA
+-----BEGIN CERTIFICATE-----
+MIIG1DCCBLygAwIBAgIBDDANBgkqhkiG9w0BAQUFADBaMQswCQYDVQQGEwJVUzES
+MBAGA1UECBMJVmlyZ2luaWEgMRMwEQYDVQQHEwpCbGFja3NidXJnMSIwIAYDVQQK
+ExlERVYgVmlyZ2luaWEgVGVjaCBSb290IENBMB4XDTA1MDEwMzE0Mjg0MVoXDTE1
+MDEwMTE0Mjg0MVowga8xEzARBgoJkiaJk/IsZAEZFgNlZHUxEjAQBgoJkiaJk/Is
+ZAEZFgJ2dDELMAkGA1UEBhMCVVMxPDA6BgNVBAoTM1ZpcmdpbmlhIFBvbHl0ZWNo
+bmljIEluc3RpdHV0ZSBhbmQgU3RhdGUgVW5pdmVyc2l0eTEsMCoGA1UEAxMjREVW
+IFZpcmdpbmlhIFRlY2ggQ2xhc3MgMSBTZXJ2ZXIgQ0ExCzAJBgNVBAUTAjEyMIIC
+IDANBgkqhkiG9w0BAQEFAAOCAg0AMIICCAKCAgEA6Gbe+Q+Lx5uphTbuXVAriztF
+OOLU8/MUtidl1O7aifyiliUq7YqYbZ4Ma1twNEYxRSkrO9L+ljihOBPDOlEwgnAl
+yXxXIeRobyWxKuDwzC2DggLx1ufmj45iM0TrJ/+XAyo0jgnIxE4cvbFxMgA4AQYP
+YcxNmPtMdGSdLS5PgEp2DekA5a3K6dD4G6RxSW1YHfZMAzz7k3Mm30usHOw0qKgJ
+Hvdm50wBXZ+Epd+UUOvHvH8IuDyezvW11Ci/skRrvRYV2aYABy5WFZxVPNMUC5lI
+gHYuE0Q+gaVp9NJrHi0M/Ks6EFPeMlgKFoevxqluQHzHdOcfgOHD0Hzi5Urqspju
+mg+KJ5RzzsohLYsjXO0c0nMyzI3aaviZJ9vux7UhsJC8+sxX1hc/C5QubSi1NCEA
+t1GbH4tsWzhZwja++fTEz8cdOQ2M04PdZj2AltqmbKzse0Mn54XKg5h58hyxxx5z
+VEURnjV6lYVSxmWlVPviXkYO4i1+/UGmtAgNXdDCajS8PQGHCm4yukbhGFQxiwKQ
+xi+UaylVK3djr2+emEjGfHIRC5gLAyI687YExUIRbTM8UUfwirieGlEbEqYK9TQw
+8jDKp9S/P+hU/AA42QouM3jso1VV63J27cXZSUMsIcwUov6MIUccj/Vaf1PN9K/h
+Dz3ebY66xX9eT/VrXqcCAQOjggFPMIIBSzAPBgNVHRMBAf8EBTADAQH/MAsGA1Ud
+DwQEAwIBBjAdBgNVHQ4EFgQUOOBvrkjtXiP2Ipse55wZFke4fpIwgYIGA1UdIwR7
+MHmAFPALoyfXGjdKtjUU37ynv3orVx0LoV6kXDBaMQswCQYDVQQGEwJVUzESMBAG
+A1UECBMJVmlyZ2luaWEgMRMwEQYDVQQHEwpCbGFja3NidXJnMSIwIAYDVQQKExlE
+RVYgVmlyZ2luaWEgVGVjaCBSb290IENBggEAMDsGA1UdHwQ0MDIwMKAuoCyGKmh0
+dHA6Ly93d3cucGtpLnZ0LmVkdS9yb290Y2EvY3JsL2NhY3JsLmNybDBKBgNVHSAE
+QzBBMD8GDCsGAQQBtGgFAgEBATAvMC0GCCsGAQUFBwIBFiFodHRwOi8vd3d3LnBr
+aS52dC5lZHUvcm9vdGNhL2Nwcy8wDQYJKoZIhvcNAQEFBQADggIBAIylwmlnOevb
+PGL6TuRsHtXfN9Ext+HarJ+MZrH0zoDCpC3yOytNiuazDJA4fwD8Q3Km5OrYF2SL
+KAy6AaeTffEB9w4/MoNmr4qNMc0yx4IxcQuGuNq47L/jDCp332WNFkmR4LwmLYfo
+nb8O7KulYZxu0dqxY8PeglSbgAc8jff+xx41+V5bhxdMZUT/oSTMv70Xs+ovmqX7
+sBK5mPc4zcz28PmRxf1gQ5a69ouTFg+7xBm6+wluKjC5UItNSkYsUeXBvI6Kjf8X
+kjeO9t6eQ85iqR1SxD6KLhIE4eQ/gu3tCNCiUUTonA8kxJyHWtNmMjLs5Q7NtnmB
+h3MJo0Bjwf609ztIpAxp7+gRbkjRfLEWAsKyDrYzF2R9EQ06nxGwj05YsEYYXncw
+NnFutRNF/+CT0LYhEgGmbtrbnCcA3ZV6HmilsLHcDTPcdD1UyIhsx/qFxXD6Jawb
+2MO8wyDuaHy171EXN9elUBLqG7+64mSuphj7fuHRKEf+ntvcao7CZpwyuVO1NA9m
+L84JltJ5MuK14Bm/bIJQE/0pduRJtMZkuKoInRuFtnT7vqAt03rb6SaKUwurjzIE
+2nns1A0ddGcKQsn+zq0jyWAEPRVnLJTfk/hXjbUqk3Tw++h8r5GtvbdDrRU74r4h
+/1+7GR+cUUXCGG7HpuwthXCiXmhA9Vmz
+-----END CERTIFICATE-----
diff --git a/src/test/resources/certs/test.example.com.crt b/src/test/resources/certs/test.example.com.crt
new file mode 100644
index 0000000..a7fb6b2
--- /dev/null
+++ b/src/test/resources/certs/test.example.com.crt
@@ -0,0 +1,94 @@
+Certificate:
+    Data:
+        Version: 3 (0x2)
+        Serial Number: 1 (0x1)
+        Signature Algorithm: sha256WithRSAEncryption
+        Issuer: DC=com, DC=example, C=US, ST=New York, L=New York, O=Snake Oil Unlimited, CN=root.example.com
+        Validity
+            Not Before: Dec  4 16:50:56 2013 GMT
+            Not After : Apr 21 16:50:56 2041 GMT
+        Subject: C=US, ST=New York, O=Snake Oil Unlimited, CN=test.example.com
+        Subject Public Key Info:
+            Public Key Algorithm: rsaEncryption
+            RSA Public Key: (2048 bit)
+                Modulus (2048 bit):
+                    00:dc:9e:9a:3e:fd:99:af:e3:ef:91:40:b1:6f:50:
+                    4b:d3:7c:04:eb:34:cb:fc:99:2a:6d:fc:fe:74:8b:
+                    62:0a:90:d0:d3:7f:79:5b:f0:f6:fe:76:3a:ee:9f:
+                    09:e7:3e:3b:58:a6:dd:90:7f:16:d8:4d:85:64:1d:
+                    cf:44:7f:03:f5:57:74:86:55:c6:c3:d2:77:16:08:
+                    3f:00:86:b8:b3:8e:12:1e:eb:9b:fe:b3:57:78:01:
+                    11:be:26:07:54:04:be:fc:f7:ac:42:8c:62:be:12:
+                    6c:3f:9d:29:5f:43:fd:d8:03:a8:b7:d5:a5:71:1f:
+                    e7:62:5a:39:4c:0b:e1:0f:0e:31:48:d8:38:61:ae:
+                    48:be:24:f2:f4:35:fd:c7:da:aa:bf:a3:d2:22:46:
+                    35:c2:f0:0b:38:00:7a:b6:71:ea:ff:10:b0:16:c6:
+                    96:64:9f:55:8c:fe:cb:c9:a2:9a:79:99:8e:81:0e:
+                    af:0d:92:94:c0:9b:62:7e:55:4a:b1:06:1f:52:f0:
+                    a1:b0:54:c9:1d:b2:99:4b:74:14:80:bb:2e:63:b2:
+                    24:e4:c7:20:3f:c2:ac:e4:ba:59:67:ec:45:fd:1e:
+                    12:7d:3c:b5:da:ac:64:23:aa:8f:72:85:b2:29:c7:
+                    61:f3:f7:d0:3b:54:7a:d4:53:6a:62:13:92:91:66:
+                    28:75
+                Exponent: 65537 (0x10001)
+        X509v3 extensions:
+            X509v3 Basic Constraints: 
+                CA:FALSE
+            Netscape Comment: 
+                OpenSSL Generated Certificate
+            X509v3 Subject Key Identifier: 
+                44:88:EF:79:2B:51:0B:16:31:FE:62:C3:2E:D3:26:F6:A8:EF:CD:A3
+            X509v3 Authority Key Identifier: 
+                keyid:7D:5A:81:08:93:BB:52:CF:2C:66:CD:D7:C1:47:B3:11:4B:01:43:6C
+                DirName:/DC=com/DC=example/C=US/ST=New York/L=New York/O=Snake Oil Unlimited/CN=root.example.com
+                serial:B9:3D:73:80:5C:40:DE:43
+
+            X509v3 Issuer Alternative Name: 
+                DNS:snake-1.example.com, DNS:snake-2.example.com
+            Netscape CA Revocation Url: 
+                http://crl.example.com/ca-crl.pem
+    Signature Algorithm: sha256WithRSAEncryption
+        a3:f0:4a:d0:e6:2a:b2:f2:ab:4e:68:05:89:9d:d4:95:cc:7d:
+        7c:01:39:44:f0:9a:52:18:3c:49:78:3b:1b:95:00:7a:25:f0:
+        c7:e6:25:a0:41:20:5f:86:8d:e3:c5:59:79:fe:6d:99:81:23:
+        40:a7:52:ed:b8:18:dd:f8:37:b9:5b:99:39:c2:6f:0f:8f:4a:
+        5f:c3:dd:b4:fd:ca:be:a2:5d:f5:56:8a:d6:f2:cd:ce:e7:92:
+        01:0f:4c:9c:a8:63:5a:2a:53:ca:ce:fe:88:87:f1:76:7e:6f:
+        0d:d0:55:b3:c2:db:03:13:f2:ea:88:0a:1b:a7:0e:cf:54:a9:
+        02:63:fc:1a:0f:94:40:68:46:f5:e2:4a:77:d1:fa:a7:35:d3:
+        0e:ba:17:1c:55:08:ca:e4:30:39:0f:c9:39:0b:e6:a7:f9:f9:
+        25:2f:8e:0f:88:81:5c:16:04:e0:0f:69:9b:21:87:4f:92:dd:
+        ed:37:f6:a6:01:5d:7d:af:1d:fb:9f:53:67:2f:d2:8c:10:dd:
+        d7:fb:16:ea:18:7f:47:28:d0:91:d7:1e:d7:25:a0:ed:0b:8c:
+        29:94:d5:a8:43:e8:74:f4:bf:f6:bd:d4:78:fe:c5:bd:5a:87:
+        53:27:ad:70:2c:77:61:4c:98:50:c9:c6:db:c8:d6:74:0e:1b:
+        a9:04:2a:db
+-----BEGIN CERTIFICATE-----
+MIIFBjCCA+6gAwIBAgIBATANBgkqhkiG9w0BAQsFADCBmjETMBEGCgmSJomT8ixk
+ARkWA2NvbTEXMBUGCgmSJomT8ixkARkWB2V4YW1wbGUxCzAJBgNVBAYTAlVTMREw
+DwYDVQQIEwhOZXcgWW9yazERMA8GA1UEBxMITmV3IFlvcmsxHDAaBgNVBAoTE1Nu
+YWtlIE9pbCBVbmxpbWl0ZWQxGTAXBgNVBAMTEHJvb3QuZXhhbXBsZS5jb20wHhcN
+MTMxMjA0MTY1MDU2WhcNNDEwNDIxMTY1MDU2WjBZMQswCQYDVQQGEwJVUzERMA8G
+A1UECBMITmV3IFlvcmsxHDAaBgNVBAoTE1NuYWtlIE9pbCBVbmxpbWl0ZWQxGTAX
+BgNVBAMTEHRlc3QuZXhhbXBsZS5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw
+ggEKAoIBAQDcnpo+/Zmv4++RQLFvUEvTfATrNMv8mSpt/P50i2IKkNDTf3lb8Pb+
+djrunwnnPjtYpt2QfxbYTYVkHc9EfwP1V3SGVcbD0ncWCD8AhrizjhIe65v+s1d4
+ARG+JgdUBL7896xCjGK+Emw/nSlfQ/3YA6i31aVxH+diWjlMC+EPDjFI2Dhhrki+
+JPL0Nf3H2qq/o9IiRjXC8As4AHq2cer/ELAWxpZkn1WM/svJopp5mY6BDq8NkpTA
+m2J+VUqxBh9S8KGwVMkdsplLdBSAuy5jsiTkxyA/wqzkulln7EX9HhJ9PLXarGQj
+qo9yhbIpx2Hz99A7VHrUU2piE5KRZih1AgMBAAGjggGVMIIBkTAJBgNVHRMEAjAA
+MCwGCWCGSAGG+EIBDQQfFh1PcGVuU1NMIEdlbmVyYXRlZCBDZXJ0aWZpY2F0ZTAd
+BgNVHQ4EFgQURIjveStRCxYx/mLDLtMm9qjvzaMwgc8GA1UdIwSBxzCBxIAUfVqB
+CJO7Us8sZs3XwUezEUsBQ2yhgaCkgZ0wgZoxEzARBgoJkiaJk/IsZAEZFgNjb20x
+FzAVBgoJkiaJk/IsZAEZFgdleGFtcGxlMQswCQYDVQQGEwJVUzERMA8GA1UECBMI
+TmV3IFlvcmsxETAPBgNVBAcTCE5ldyBZb3JrMRwwGgYDVQQKExNTbmFrZSBPaWwg
+VW5saW1pdGVkMRkwFwYDVQQDExByb290LmV4YW1wbGUuY29tggkAuT1zgFxA3kMw
+MwYDVR0SBCwwKoITc25ha2UtMS5leGFtcGxlLmNvbYITc25ha2UtMi5leGFtcGxl
+LmNvbTAwBglghkgBhvhCAQQEIxYhaHR0cDovL2NybC5leGFtcGxlLmNvbS9jYS1j
+cmwucGVtMA0GCSqGSIb3DQEBCwUAA4IBAQCj8ErQ5iqy8qtOaAWJndSVzH18ATlE
+8JpSGDxJeDsblQB6JfDH5iWgQSBfho3jxVl5/m2ZgSNAp1LtuBjd+De5W5k5wm8P
+j0pfw920/cq+ol31VorW8s3O55IBD0ycqGNaKlPKzv6Ih/F2fm8N0FWzwtsDE/Lq
+iAobpw7PVKkCY/waD5RAaEb14kp30fqnNdMOuhccVQjK5DA5D8k5C+an+fklL44P
+iIFcFgTgD2mbIYdPkt3tN/amAV19rx37n1NnL9KMEN3X+xbqGH9HKNCR1x7XJaDt
+C4wplNWoQ+h09L/2vdR4/sW9WodTJ61wLHdhTJhQycbbyNZ0DhupBCrb
+-----END CERTIFICATE-----
diff --git a/src/test/resources/certs/thawte-premium-server-ca.crt b/src/test/resources/certs/thawte-premium-server-ca.crt
new file mode 100644
index 0000000..51285e3
--- /dev/null
+++ b/src/test/resources/certs/thawte-premium-server-ca.crt
@@ -0,0 +1,19 @@
+-----BEGIN CERTIFICATE-----
+MIIDJzCCApCgAwIBAgIBATANBgkqhkiG9w0BAQQFADCBzjELMAkGA1UEBhMCWkEx
+FTATBgNVBAgTDFdlc3Rlcm4gQ2FwZTESMBAGA1UEBxMJQ2FwZSBUb3duMR0wGwYD
+VQQKExRUaGF3dGUgQ29uc3VsdGluZyBjYzEoMCYGA1UECxMfQ2VydGlmaWNhdGlv
+biBTZXJ2aWNlcyBEaXZpc2lvbjEhMB8GA1UEAxMYVGhhd3RlIFByZW1pdW0gU2Vy
+dmVyIENBMSgwJgYJKoZIhvcNAQkBFhlwcmVtaXVtLXNlcnZlckB0aGF3dGUuY29t
+MB4XDTk2MDgwMTAwMDAwMFoXDTIwMTIzMTIzNTk1OVowgc4xCzAJBgNVBAYTAlpB
+MRUwEwYDVQQIEwxXZXN0ZXJuIENhcGUxEjAQBgNVBAcTCUNhcGUgVG93bjEdMBsG
+A1UEChMUVGhhd3RlIENvbnN1bHRpbmcgY2MxKDAmBgNVBAsTH0NlcnRpZmljYXRp
+b24gU2VydmljZXMgRGl2aXNpb24xITAfBgNVBAMTGFRoYXd0ZSBQcmVtaXVtIFNl
+cnZlciBDQTEoMCYGCSqGSIb3DQEJARYZcHJlbWl1bS1zZXJ2ZXJAdGhhd3RlLmNv
+bTCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEA0jY2aovXwlue2oFBYo847kkE
+VdbQ7xwblRZH7xhINTpS9CtqBo87L+pW46+GjZ4X9560ZXUCTe/LCaIhUdib0GfQ
+ug2SBhRz1JPLlyoAnFxODLz6FVL88kRu2hFKbgifLy3j+ao6hnO2RlNYyIkFvYMR
+uHM/qgeN9EJN50CdHDcCAwEAAaMTMBEwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG
+9w0BAQQFAAOBgQAmSCwWwlj66BZ0DKqqX1Q/8tfJeGBeXm43YyJ3Nn6yF8Q0ufUI
+hfzJATj/Tb7yFkJD57taRvvBxhEf8UqwKEbJw8RCfbz6q1lu1bdRiBHjpIUZa4JM
+pAwSremkrj/xw0llmozFyD4lt5SZu5IycQfwhl7tUCemDaYj+bvLpgcUQg==
+-----END CERTIFICATE-----
diff --git a/src/test/resources/certs/unknown-dn-attr.crt b/src/test/resources/certs/unknown-dn-attr.crt
new file mode 100644
index 0000000..c3caa2e
--- /dev/null
+++ b/src/test/resources/certs/unknown-dn-attr.crt
@@ -0,0 +1,23 @@
+-----BEGIN CERTIFICATE-----
+MIID5jCCAs6gAwIBAgIJAKezwvQ9w/frMA0GCSqGSIb3DQEBBQUAMFUxETAPBgNV
+BAMTCG1hcnppcGFuMRIwEAYEKgMEBRMIbm9uc2Vuc2UxFzAVBgoJkiaJk/IsZAEZ
+FgdleGFtcGxlMRMwEQYKCZImiZPyLGQBGRYDb3JnMB4XDTE0MTExOTE2Mzg1MVoX
+DTE0MTIxOTE2Mzg1MVowVTERMA8GA1UEAxMIbWFyemlwYW4xEjAQBgQqAwQFEwhu
+b25zZW5zZTEXMBUGCgmSJomT8ixkARkWB2V4YW1wbGUxEzARBgoJkiaJk/IsZAEZ
+FgNvcmcwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDJqxu5wkvp2xIV
+iqPYaqPILIojitnspRsPHBe7JjPW+rDHx68RNzkht/uCOTvvDPYvKbf7aZyaFQnT
+QBQMaWqX1FkwcMRoVHeJpvi8pS5pyuvcu3kpS3U3aT7t6Joi/qhPBZMc9iu8Pe4j
+Icf8CPQUSCMJoSl1llbM0EBxqxTa9vKTsryt1xkVPM0Of1i5RpC4Y9gipSyKffWH
+8M0RX2p2iVu79te0GAwDBmKw2lNLkPodDj8yF78CRnJJw07R8kMVoS0Hi91Sx6NE
+hbmvc+zijpF2/RFsOjDBh3Im6V6ejnDwFu8+5P/kuL04nL1I0lG5Urv0js3Jlgn3
+6DuYomXfAgMBAAGjgbgwgbUwHQYDVR0OBBYEFDjmj8OUyhybOlxItbtd7dKB61TQ
+MIGFBgNVHSMEfjB8gBQ45o/DlMocmzpcSLW7Xe3SgetU0KFZpFcwVTERMA8GA1UE
+AxMIbWFyemlwYW4xEjAQBgQqAwQFEwhub25zZW5zZTEXMBUGCgmSJomT8ixkARkW
+B2V4YW1wbGUxEzARBgoJkiaJk/IsZAEZFgNvcmeCCQCns8L0PcP36zAMBgNVHRME
+BTADAQH/MA0GCSqGSIb3DQEBBQUAA4IBAQB7gDp+vpIFNoAACztjrr5UXeRwdHYS
+L1J5FepfBcsjvlx1rwDWS7B0GiHxO7RRG4H/NzErRX6Jnoks4qz3pdX4sT358hn+
+7YeSnQ8pnl8WRWqrk968YJtn4GKdVIZcH2szuzJ+cuvsnfSBb9Hn9OpLe7PU8naS
+b7xWclvWXS87nWvR4i8Yq3uhBNIl70NON3es7SledzdvySWJ8HpKW2zxdFqAGKOY
+VGUROtBKMrjc8Or2A+ocApAVMfm5HpGrVJvipRCenb9VgLTlBDA32lZVnPDapHtP
+RhBSt1uuroqcofUod+MHoy2LDtYIch66iz9CGs4gZ9xmvFSdBvsMp07I
+-----END CERTIFICATE-----
diff --git a/src/test/resources/certs/vtgsca_chain.pem b/src/test/resources/certs/vtgsca_chain.pem
new file mode 100644
index 0000000..5d90c5a
--- /dev/null
+++ b/src/test/resources/certs/vtgsca_chain.pem
@@ -0,0 +1,117 @@
+-----BEGIN CERTIFICATE-----
+MIIG3jCCBMagAwIBAgIIPHK7keZq/ZUwDQYJKoZIhvcNAQEFBQAwZTELMAkGA1UE
+BhMCVVMxFjAUBgNVBAoTDVZpcmdpbmlhIFRlY2gxFzAVBgNVBAsTDkdsb2JhbCBS
+b290IENBMSUwIwYDVQQDExxWaXJnaW5pYSBUZWNoIEdsb2JhbCBSb290IENBMB4X
+DTExMDExODE2MzYyMVoXDTIwMTExODEwMDAwMFowaTEnMCUGA1UEAwweVmlyZ2lu
+aWEgVGVjaCBHbG9iYWwgU2VydmVyIENBMRkwFwYDVQQLDBBHbG9iYWwgU2VydmVy
+IENBMRYwFAYDVQQKDA1WaXJnaW5pYSBUZWNoMQswCQYDVQQGEwJVUzCCAiIwDQYJ
+KoZIhvcNAQEBBQADggIPADCCAgoCggIBALZZbbiCLqUuFETVAoVvKlVqoGq4Ex7l
+gWR3lhZrDJo2XN7Q8rE6O9R0u53Tb39UuFiG+rJFgevykZq/bk06xRxtvpo0dzEn
+0Xt7e2RjLLWAwDMb6WJjCJ9XvHJZfP6z1XXSpcohQetrUf7oYP2twpOmQbRBqvjz
+RkCcz3u5iRUgGn3f75LWkFPjeNEdLoWWF8iTp0QcTbQ8uPiSJFkQMxXkZdTsqUsF
+Kq54tEh75Lt5OHLrpcbwe2Rq3J8NYKVp6mGlpVRL2hmbZaEIvZkhj52Am1uPb5lX
+h1zpLfwk6vZwuo+Kmsv9sWi331DUEEWagDvtP2AhQUFmfGphMx78me0eMqASOk8I
+2FN0EnrXl5p4wGiXw2tK65UKqZUz8wwDgNbbA/UqNqgxC0OFaZwjkMei3pI8p4uA
+rgdZDhyL3V6zA1byQP3HAmEdKNXqvAE8j30I4xwdyu3m14jjMzmvTSZcn2HTqESi
+6UC5yuDEC8pSmCdUK5VdbbI1s63uQvkzLUxXHhhgiUcOY9PzVLT9edw+ymwoYoVC
+IlqrG7i/o+CIy5t7lE7yQ9We8XnR5d7cbtxHgeGe/dfCmzqvZpAxiV8f0bFMdB+c
+zdawPVaoL4DySDmVMO7iY8jBtk0vB05xPgOmhFJkkMMp2eVYcNVIzxTqJAVM45Gu
+dBq8uNm8xUY9AgMBAAGjggGMMIIBiDBSBggrBgEFBQcBAQRGMEQwQgYIKwYBBQUH
+MAKGNmh0dHA6Ly93d3cucGtpLnZ0LmVkdS9nbG9iYWxyb290L2NhY2VydC9nbG9i
+YWxyb290LmNydDAdBgNVHQ4EFgQUCyOn9eYxh71E5K/HEMx0mh1H8fYwEgYDVR0T
+AQH/BAgwBgEB/wIBADAfBgNVHSMEGDAWgBR4w+1F/pkMoulXrlhSWS/yr8TAYjCB
+jAYDVR0gBIGEMIGBMA4GDCsGAQQBtGgFAgICATAOBgwrBgEEAbRoBQICAQEwDgYM
+KwYBBAG0aAUCAgQBMD8GDCsGAQQBtGgFAgIFATAvMC0GCCsGAQUFBwIBFiFodHRw
+Oi8vd3d3LnBraS52dC5lZHUvcm9vdGNhL2Nwcy8wDgYMKwYBBAG0aAUCAgMBMD8G
+A1UdHwQ4MDYwNKAyoDCGLmh0dHA6Ly93d3cucGtpLnZ0LmVkdS9nbG9iYWxyb290
+L2NybC9jYWNybC5jcmwwDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3DQEBBQUAA4IC
+AQBgU3s9/+KRBFdjEUbWcMWUAhEqWWWCkuvSwyZzEoWnkwbisTBwXzvkiiuKPdH6
+BhSu/IL8CFaRyovsjU+Qh/TknYUA8E9NmqHssuf5QA2haHI72mum7MY9CXPq0HNy
+tOesWKk6Ci5L59DdwrQ/DZ9kkwzYEzb3noUSOGmiTqwGHF9+hiJdlDPEqq4Az33B
+E1HOUH/YLjvBFx6HXFa0ZdqJWJ9sLrX2odTdHmLt9rAOTXCZOvowCud1Vw2BIdGU
+qnJo1f3oZmvPH0zkgEzwK3HYWEwz6nrIMlDDN2NqIoyqdAd0+R+3I+N0y9ceh/0A
+BAzR4fvTTSEexVr+ZQIU78E8PNt4GuQ5usFaOElrn/sivWXWRj53g+efJlEUcdRD
+AZqEZl5YuSRn2GTIFNRaOkASggyGzTTdRinWiqUWfxZu4C5WxNZztmWaUKp2VCvQ
+u9Bxsgr8ZTZy/rPNvy985jLbm4n8LB6J590xb/oAzWdp02Ge5PVLJqRLVQjcEUtg
+8EPxtXj1h1eKTVDdxkfQGaXbAOLQTyO4IsDmYbpPoAtKTpNBeflZo9qDjUSekY4b
+Y/zZpp//4TqVgVvpNLsA+KY4PHWk4iLwgzE6EZtjeLUln+nTck9xlGzwYjJQy0De
+bKEd1GaYa6ofxDz/IJU8zu5P0aSi6Bd1iA0jJTl+aF80CQ==
+-----END CERTIFICATE-----
+-----BEGIN CERTIFICATE-----
+MIIF3TCCBMWgAwIBAgILBAAAAAABLF5/HXYwDQYJKoZIhvcNAQEFBQAwcTEoMCYG
+A1UEAxMfR2xvYmFsU2lnbiBSb290U2lnbiBQYXJ0bmVycyBDQTEdMBsGA1UECxMU
+Um9vdFNpZ24gUGFydG5lcnMgQ0ExGTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYtc2Ex
+CzAJBgNVBAYTAkJFMB4XDTEwMTExODEwMDAwMFoXDTIwMTExODEwMDAwMFowZTEL
+MAkGA1UEBhMCVVMxFjAUBgNVBAoTDVZpcmdpbmlhIFRlY2gxFzAVBgNVBAsTDkds
+b2JhbCBSb290IENBMSUwIwYDVQQDExxWaXJnaW5pYSBUZWNoIEdsb2JhbCBSb290
+IENBMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAot80+Dl5XoWNCsPy
+MG0JTJonOCeDwh92+XJ9mza+n+gMCjmXzmqzoDSgI0RwVMPMAizEyRZc044GBvw6
+blQHIlTTAWmxr904ABfgRTPnt5o9gGk55VcezfYZ0zT2I8OPEbPjOyNiJkNhKc7j
+ibef9C5DAxqYV2+kcwnx3edqnl5zHRK3EFAvHHoHVvrkfgd92hPqJO4XiIF4LSaX
+ZDDwSnHlJfpjJS3Qez2fl2skp6IV08Za3T5wZ8et4bPXpQiNsMTyOtPpGff9Qd2M
+wzlecgXYPqa117m2l4RGtapHQ4ei3mjjpbz6yEaT8O1wqeCItsG4roSDyb865LUX
+G719T0UZkr8HPyWPtglMURERsc03x/gLOE/1oSnoHU+9z0g6wMyOioBrkXKHdJoL
+oZklV3CddVJF+3AbjXUw9MyYGpKonJ6KC2M6OyvzMKpgvo3W3VIr7YdyBtQRsF4Z
+bQHSkTi3lce+HmXxGB03/w0oyxSbC31wQaE7lcDzq8TIcEl5w6YrR1KfaqvruO4g
+8a+SnzeA2rz8K8ya19Y/0WWK8SsWLzLe/TnNO0sOt5MqynBbjuiQzxyT5nJQiz3O
+Enm1yS4ipImrtmINTpMv+/dWHcRkqCjDLe5nhphF3WaE9T2ELsqQUES5mYanEG3/
+1I0hUdxOx1hnsvuxsvjWTCVbJdUCAwEAAaOCAYAwggF8MA4GA1UdDwEB/wQEAwIB
+BjASBgNVHRMBAf8ECDAGAQH/AgEBMB0GA1UdDgQWBBR4w+1F/pkMoulXrlhSWS/y
+r8TAYjB8BgNVHSAEdTBzMHEGCSsGAQQBoDIBPDBkMDEGCCsGAQUFBwIBFiVodHRw
+Oi8vd3d3Lmdsb2JhbHNpZ24uY29tL3JlcG9zaXRvcnkvMC8GCCsGAQUFBwICMCMa
+IWh0dHA6Ly93d3cucGtpLnZ0LmVkdS9yb290Y2EvY3BzLzA/BgNVHR8EODA2MDSg
+MqAwhi5odHRwOi8vY3JsLmdsb2JhbHNpZ24ubmV0L1Jvb3RTaWduUGFydG5lcnMu
+Y3JsMFcGCCsGAQUFBwEBBEswSTBHBggrBgEFBQcwAoY7aHR0cDovL3NlY3VyZS5n
+bG9iYWxzaWduLm5ldC9jYWNlcnQvUm9vdFNpZ25QYXJ0bmVycy1SMS5jcnQwHwYD
+VR0jBBgwFoAUVoTstXGl52PY21EE1vrm8EhSSc4wDQYJKoZIhvcNAQEFBQADggEB
+AHyR8iWGLs9Wzs+vELu8d0O1X0NYSpfupMP/TpkpyuAyFGl52TnVJvi7p46D4U/H
+sm7WhEnFm9g24Kv7OCoccS5ST41QZR56AivFXcJh0zXuWpJ8lJGFnvtDrzK2vo9A
+whFnZHiq99gfBOZmpgxqi5+/B5nt4c7bX2fD+JQrUATjuyji+32qdKb88otOtmY3
+G2ymLw+t33WdpBi+E3TSsELbDrBoJSJRHxGrArDRXZBdSQyKcOJINH6o3d2gAnyU
+idbweTuj7eftXM5VBJpQ/r4TMtfSx8RQOnmftEXWLxVv7xCpy+DegB7Jmz6xgvZ9
+LYeKy8z9lQXkXBBpnStGGsg=
+-----END CERTIFICATE-----
+-----BEGIN CERTIFICATE-----
+MIID5zCCAs+gAwIBAgILBAAAAAABFUtaxacwDQYJKoZIhvcNAQEFBQAwVzELMAkG
+A1UEBhMCQkUxGTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYtc2ExEDAOBgNVBAsTB1Jv
+b3QgQ0ExGzAZBgNVBAMTEkdsb2JhbFNpZ24gUm9vdCBDQTAeFw0wMzEyMTYxMzAw
+MDBaFw0yODAxMjgxMTAwMDBaMHExKDAmBgNVBAMTH0dsb2JhbFNpZ24gUm9vdFNp
+Z24gUGFydG5lcnMgQ0ExHTAbBgNVBAsTFFJvb3RTaWduIFBhcnRuZXJzIENBMRkw
+FwYDVQQKExBHbG9iYWxTaWduIG52LXNhMQswCQYDVQQGEwJCRTCCASIwDQYJKoZI
+hvcNAQEBBQADggEPADCCAQoCggEBAL3vMPEw8TSpiWV3TUanjZD9rk+OyigXulnj
+qJIKRQMqio/lCVBVUoHwo5Gx2RIqgfbCAxw8gsByzfGnANf1VJwKR+6alUGSjqCt
+CT3T66J0rZ8ZIAm2faZeNZ9POWoDtYqtH5Ziaxe5q4dg1V1t2ZLJ0BOu1IjZUKhE
+kQSw6kfqX7LtBMHXAXwh+MRxI/xrTGVEM8ONHebSZhxSKUbEBucLNfBZAWYAic+c
+43t4qlPi7qw1lef9XddClJXTGm4xVUfX663HTJ9UcYMaF8j5585YAfQ2v64/WZ9l
+fEAHXHMgNKISw0n0aEBpHonghek6t5dju0ewOWtBAH71S7h/4yECAwEAAaOBmTCB
+ljAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUVoTs
+tXGl52PY21EE1vrm8EhSSc4wMwYDVR0fBCwwKjAooCagJIYiaHR0cDovL2NybC5n
+bG9iYWxzaWduLm5ldC9Sb290LmNybDAfBgNVHSMEGDAWgBRge2YaRQ2XyolQL30E
+zTSo//z9SzANBgkqhkiG9w0BAQUFAAOCAQEAzeN0ltyXbKD5p2QRBKst9isZPl/R
+xbiw5Ijr9WLDCfJXza7b2o2kUOxkJ9ivz/BE+0DJc1i5G02aZf5bVq/CltFmJyS7
+lToQ+cO3jmPhA1++FLfHUz/C19/6NisbYPNo4713x0CDcqpOhWu7M8CcdF7gu3JT
+66Ra4nqoWPZJugW97gBm8cThETDunuYnd6I2fA3FoNdMhs1CdiVkOy1xFKIZS/ZK
+IV159Qmxa8xiP6uTrJ28t4lqRr0Ewf6DFpzuDSm3t6Rm4OvMIrfE0uDaJDl5E5Px
+4dTMhbD6kRoWFMpj+z63jhFxBPwF2DbBKv5UAlLuOWCQwmnjYEa6AyJzCw==
+-----END CERTIFICATE-----
+-----BEGIN CERTIFICATE-----
+MIIDdTCCAl2gAwIBAgILBAAAAAABFUtaw5QwDQYJKoZIhvcNAQEFBQAwVzELMAkG
+A1UEBhMCQkUxGTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYtc2ExEDAOBgNVBAsTB1Jv
+b3QgQ0ExGzAZBgNVBAMTEkdsb2JhbFNpZ24gUm9vdCBDQTAeFw05ODA5MDExMjAw
+MDBaFw0yODAxMjgxMjAwMDBaMFcxCzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9i
+YWxTaWduIG52LXNhMRAwDgYDVQQLEwdSb290IENBMRswGQYDVQQDExJHbG9iYWxT
+aWduIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDaDuaZ
+jc6j40+Kfvvxi4Mla+pIH/EqsLmVEQS98GPR4mdmzxzdzxtIK+6NiY6arymAZavp
+xy0Sy6scTHAHoT0KMM0VjU/43dSMUBUc71DuxC73/OlS8pF94G3VNTCOXkNz8kHp
+1Wrjsok6Vjk4bwY8iGlbKk3Fp1S4bInMm/k8yuX9ifUSPJJ4ltbcdG6TRGHRjcdG
+snUOhugZitVtbNV4FpWi6cgKOOvyJBNPc1STE4U6G7weNLWLBYy5d4ux2x8gkasJ
+U26Qzns3dLlwR5EiUWMWea6xrkEmCMgZK9FGqkjWZCrXgzT/LCrBbBlDSgeF59N8
+9iFo7+ryUp9/k5DPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8E
+BTADAQH/MB0GA1UdDgQWBBRge2YaRQ2XyolQL30EzTSo//z9SzANBgkqhkiG9w0B
+AQUFAAOCAQEA1nPnfE920I2/7LqivjTFKDK1fPxsnCwrvQmeU79rXqoRSLblCKOz
+yj1hTdNGCbM+w6DjY1Ub8rrvrTnhQ7k4o+YviiY776BQVvnGCv04zcQLcFGUl5gE
+38NflNUVyRRBnMRddWQVDf9VMOyGj/8N7yy5Y0b2qvzfvGn9LhJIZJrglfCm7ymP
+AbEVtQwdpf5pLGkkeB6zpxxxYu7KyJesF12KwvhHhm4qxFYxldBniYUr+WymXUad
+DKqC5JlR3XC321Y9YeRq4VzW9v493kHMB65jUr9TU/Qr6cf9tveCX4XSQRjbgbME
+HMUfpIBvFSDJ3gyICh3WZlXi/EjJKSZp4A==
+-----END CERTIFICATE-----
diff --git a/src/test/resources/certs/vtuca_chain.p7b b/src/test/resources/certs/vtuca_chain.p7b
new file mode 100644
index 0000000..bf00ed4
--- /dev/null
+++ b/src/test/resources/certs/vtuca_chain.p7b
@@ -0,0 +1,74 @@
+-----BEGIN PKCS7-----
+MIINVwYJKoZIhvcNAQcCoIINSDCCDUQCAQExADALBgkqhkiG9w0BBwGggg0qMIIG
+8TCCBNmgAwIBAgIBBTANBgkqhkiG9w0BAQUFADBVMQswCQYDVQQGEwJVUzERMA8G
+A1UECBMIVmlyZ2luaWExEzARBgNVBAcTCkJsYWNrc2J1cmcxHjAcBgNVBAoTFVZp
+cmdpbmlhIFRlY2ggUm9vdCBDQTAeFw0wNjA5MjAxNjI4MzZaFw0xNjA5MTcxNjI4
+MzZaMIGUMRMwEQYKCZImiZPyLGQBGRMDZWR1MRIwEAYKCZImiZPyLGQBGRMCdnQx
+CzAJBgNVBAYTAlVTMTwwOgYDVQQKEzNWaXJnaW5pYSBQb2x5dGVjaG5pYyBJbnN0
+aXR1dGUgYW5kIFN0YXRlIFVuaXZlcnNpdHkxHjAcBgNVBAMTFVZpcmdpbmlhIFRl
+Y2ggVXNlciBDQTCCAiAwDQYJKoZIhvcNAQEBBQADggINADCCAggCggIBALtuDADJ
+vOwqLAdiorNhINDADF6T3/xUGKGRiIGmjgy2COLGlxFHAgq8Hi8pPk3CzhUbBDia
+r1sFo0BfzozoticTzlF2tIuftSTMCbQWPB0bPZlYp5zSt7WBgHhfVeBnWrgn5eVk
+CJEfiwPjtOtlNvCykFKgM+4OTfC+ocCZwWmLR1PBmJ+CAf12xBbKoDIhPDOmTWOg
+lu85oJXPTPwd8caLHR4cKeznuxq+i8Y0uqgtXc6+nhWZ6zUcXO65Q0y5aRwJgt2L
+8Q6Pr34qFWR3GF+e6iIAY4UT951X5UdyfvSSNjN5BUhJv8n1mzHOqpdyiZf5qDEt
+DNFYGx/o9sXVcqaLR0S61Z7UkcZQKZLZQDwZVthT9i69+N2D1ckaME9dto0ApIgr
+gz0sUneg3hVs8un1bpXtJcsGfuUgWIp+qyjVZZQYN71wYXZ+HPEkmbRuXdrXEvC5
+63bL7gT/2/m5UqWoW3u8yaAO+DcuY9w0eh37mapsF2IuD3q4htFAM4cVUSCa0AQl
+PdXnhu2JM4VR4S4aKkHCQQMS1lTr9A3DEdEvoqAeNNOLhP430CrULChYf1/0FGiu
+PV1SADe6vrXf8y87W7RsWhuTFLyHxRq4eBHzBVnPrHk1ac8XslxcXz1qPm+Nshfa
+sGX26wHSbXJ8lddnkUPQKiOFrGnCtNGoHdntAgEDo4IBjDCCAYgwDwYDVR0TAQH/
+BAUwAwEB/zALBgNVHQ8EBAMCAQYwHQYDVR0OBBYEFN67bqLJNHXY8VeVeRD5A8DU
+aQqMMH0GA1UdIwR2MHSAFESRFC84mijaPkomPJiAo/z3iG2noVmkVzBVMQswCQYD
+VQQGEwJVUzERMA8GA1UECBMIVmlyZ2luaWExEzARBgNVBAcTCkJsYWNrc2J1cmcx
+HjAcBgNVBAoTFVZpcmdpbmlhIFRlY2ggUm9vdCBDQYIBADA7BgNVHR8ENDAyMDCg
+LqAshipodHRwOi8vd3d3LnBraS52dC5lZHUvcm9vdGNhL2NybC9jYWNybC5jcmww
+gYwGA1UdIASBhDCBgTAOBgwrBgEEAbRoBQICAQEwDgYMKwYBBAG0aAUCAgIBMA4G
+DCsGAQQBtGgFAgIDATAOBgwrBgEEAbRoBQICBAEwPwYMKwYBBAG0aAUCAgUBMC8w
+LQYIKwYBBQUHAgEWIWh0dHA6Ly93d3cucGtpLnZ0LmVkdS9yb290Y2EvY3BzLzAN
+BgkqhkiG9w0BAQUFAAOCAgEAY1QvqZ9ozwQPrPQmqz2XhXCCDZxriy0dapk537wE
+SYq+D4Ptkpuu+P8HDSi0j6QaksM6h7otm4k1znD1fcGt7uo2YzDoO1hyORjan4ZK
+FgsR906LgexvTreYGSi1+4VKcfALDkqd/snkoPMFI3/KQnEoKbqwCXVQL908WrD6
+PH8gdNb5M8Es+TzH49iA4J+jQrE6CqVMAq2e/QwyC7EJ3yUHknBV0hpaWEvkUtty
+XxFaF3ZVfOo0j2xxXcCihR4PGyUHlkPj5U3ggjyDZ7TTS0mtwmluNuhaJva2trW6
+NbscmZ0vdoyQLxjGVkBeeEFyNcm4OPMEc3uMzvhacxvN4NJF6Mhyhj+Sc/97KTYU
+CZNPiP9fE8Ev6klgjbf6AA9ARHARDWpQ/F7CbN710Ft2iHZb0uldzZRMBevW3M4y
+9Q62YQtr7WhuJ+FZFA3JnjJjOfYaR8c6zOXsWiyPFQ8Eq3qMdcO9V4UmDKeYK0hD
+YI6BHpoKXNEikf7ygwKVVmOjLzSJI1G/knj9UffiusmlSQ7Cx5nz5HwciBtAxYp1
+6IyL0YNi1Con8YbB0EKlW8LS9+4d1R+cKtx/tPTQLJv9jjqlp/sw+NdDEkJ0Q4YA
+TGJXklx3Q1QpyuA1TiI+7CXEyO9zkPQVptXAzB+kePb/TvwtaDI71LwYjg52d7nb
+/wgwggYxMIIEGaADAgECAgEAMA0GCSqGSIb3DQEBBQUAMFUxCzAJBgNVBAYTAlVT
+MREwDwYDVQQIEwhWaXJnaW5pYTETMBEGA1UEBxMKQmxhY2tzYnVyZzEeMBwGA1UE
+ChMVVmlyZ2luaWEgVGVjaCBSb290IENBMB4XDTAzMDQxMDE5Mjc0OVoXDTMzMDQw
+MjE5Mjc0OVowVTELMAkGA1UEBhMCVVMxETAPBgNVBAgTCFZpcmdpbmlhMRMwEQYD
+VQQHEwpCbGFja3NidXJnMR4wHAYDVQQKExVWaXJnaW5pYSBUZWNoIFJvb3QgQ0Ew
+ggIgMA0GCSqGSIb3DQEBAQUAA4ICDQAwggIIAoICAQCgRDSdz9FCKV9+KKohQDcO
+tcANOrvANxnCPJMsykQCRvGTKlhoSxhjApRkwJn4a60130Alm4AUD3iQANUYUnFq
+mCbjmmhieFTNGZkMBMVOIRIsupcMZLZsbYqSJSykxonejb9/WlTDEahVXh87p349
+9Pc/m/pZjfUjYAYrBzR/QwuFNq8eLdOlDcwlZS/PXfhtL5LjWm07DrAzKwUiUUCS
++IZrQqbquHNYUB+16IwtKv9ceInMwN+8nIBmhYPWQjKaX27Zvrj1x+FgpkXdv9A/
+b3ilioJZafiLx7/q2V9A01zb6uU18Sjxj3bqPlXXl7cK7BenQ8dDEwV/5DcCnaoQ
+ZJDT1PYYXiqdUoGgsFypZbuMZ0f2+6pKGI3nmiz5vOpmKvrE7+aX70gyNa0irQJM
+7OYailArddrOd7ostPZRMX1wcRrBoXh6IvTTMohrSo+yKVd9TIW60IebN0GPJZa/
+P9JUbsfYH1RjUOUOtvH7cTAYr9mNPaKiW4NpHVEz8o+RE24kifa46c2+cMe5PFR9
+mVXsiwdaFEJ4BhF4DRTMfGU7R19hDR7vr5FiqPU36i5jLGTgAmmzrJWPTmi42l79
+65UxNyL4GjdJPPZzFilKw2Yk/B/7Kky+fkpdyBqnwtHRr5X/u7gW1w3eYnqIUa/5
+askh5S5yqD8u64e1lLBXCQIBA6OCAQwwggEIMA8GA1UdEwEB/wQFMAMBAf8wHQYD
+VR0OBBYEFESRFC84mijaPkomPJiAo/z3iG2nMH0GA1UdIwR2MHSAFESRFC84mija
+PkomPJiAo/z3iG2noVmkVzBVMQswCQYDVQQGEwJVUzERMA8GA1UECBMIVmlyZ2lu
+aWExEzARBgNVBAcTCkJsYWNrc2J1cmcxHjAcBgNVBAoTFVZpcmdpbmlhIFRlY2gg
+Um9vdCBDQYIBADALBgNVHQ8EBAMCAQYwSgYDVR0gBEMwQTA/BgwrBgEEAbRoBQIB
+AQEwLzAtBggrBgEFBQcCARYhaHR0cDovL3d3dy5wa2kudnQuZWR1L3Jvb3RjYS9j
+cHMvMA0GCSqGSIb3DQEBBQUAA4ICAQB9k2F2LPLGh0BN9l2ER3sYSpWwEGEfWCrB
+Nl2Hhh75qmIFhOGHLiEdnbG87v/1fv2phIiFkSliq2s8LZJJJb6uMpKjKWmovSaf
+iQWXMfzm/266M6W7cxwSwymuaVZLIDqpfC0BsiIRixbuWB6qIy3wcBjTC4t4mBpV
+b//zjpk18TrbFbw5ee/ISuI/LMw86XbvKlhfU6Q4l4WGOyhUPw2lpVcmaKNkiVKe
+AG1CSa9L4XTCz7VJ0dm/kJXwfzw4cnRerZRjRBTKOa1geMvP5eG7mdx5bLwEIC4W
+qpxQ0a4S63NkMZShMzouXOiwwZk//DHOKrPTAtbEfrQ0cowL0cwmq5rrGmcqQQbK
+Dlu9GJO9aFSlVz0EpKtn4RazRe9aJwVb1m8u/uoZUQQDKDgVp4SNQnMlgYexyYRP
+E4RBRv6azJvJw19izG9m18iCaAb3oCHcmuAHCKr8/3nqsvdoWqrDMETuuxaLW2F4
+E/rWDHECGYmAbh8IMmoLY1cIB5hH1Qy6yjM4jfiz9bMQdIZ7vhM1JCSkrfUnBbOV
+6VipDH/YaMPnnmZ5Ay8gPRMm/iS7c5SQP/HcYcmvWAmKY/JTUeBTnSAF34hPBl0G
+6YwvGUgcbd556rqzS6498+5mEVTWE/jC0NzdHzrI2uTFiGFZMhaJgHMatOZixCB9
+IOE8OVlB9qEAMQA=
+-----END PKCS7-----
diff --git a/src/test/resources/csrs/simple-ec-prime256v1.csr b/src/test/resources/csrs/simple-ec-prime256v1.csr
new file mode 100644
index 0000000..b44f13a
--- /dev/null
+++ b/src/test/resources/csrs/simple-ec-prime256v1.csr
@@ -0,0 +1,8 @@
+-----BEGIN CERTIFICATE REQUEST-----
+MIIBBjCBrQIBADBLMRswGQYDVQQDExJzaW1wbGUuZXhhbXBsZS5jb20xFzAVBgoJ
+kiaJk/IsZAEZFgdleGFtcGxlMRMwEQYKCZImiZPyLGQBGRYDY29tMFkwEwYHKoZI
+zj0CAQYIKoZIzj0DAQcDQgAEAdnqPEISFMGKKZG5/y1vfZ7m+nQ3JPEdoFeg6uef
+ChzF2ds7yNR/e9nCl+vf4HT3m4S35oYvK1vI9uZDDrfaNKAAMAoGCCqGSM49BAMC
+A0gAMEUCIChj+FrmNlYWK/8ywpSzdLzPUMoOLSBQxXX6pKMDNsYoAiEAgybN/k5c
+9ZqMlpTQ+szfl4UlYuxeAZ9BL7uzMPgqjjY=
+-----END CERTIFICATE REQUEST-----
diff --git a/src/test/resources/csrs/simple-ec-secp384r1.csr b/src/test/resources/csrs/simple-ec-secp384r1.csr
new file mode 100644
index 0000000..4abe51b
--- /dev/null
+++ b/src/test/resources/csrs/simple-ec-secp384r1.csr
@@ -0,0 +1,9 @@
+-----BEGIN CERTIFICATE REQUEST-----
+MIIBRDCBygIBADBLMRswGQYDVQQDExJzaW1wbGUuZXhhbXBsZS5jb20xFzAVBgoJ
+kiaJk/IsZAEZFgdleGFtcGxlMRMwEQYKCZImiZPyLGQBGRYDY29tMHYwEAYHKoZI
+zj0CAQYFK4EEACIDYgAEI/OEPzzsE8kS5u2+CEVi4pWLM41B6jMJ00VKG4bR0mvK
+MQpmJmOAnJ13pxU308GX6kGqhBYD63ZwsJuTUyOHNUDli2bqrtDc3zi/Skwb41YO
+cMzLqxrzcA4OJKuYwV5ooAAwCgYIKoZIzj0EAwIDaQAwZgIxALRCHUNt6ualwJt7
+zfmkfIo66+ZNyIu67KugJcT+LhoBYgjgbrjubYHeN5YmjlUHEwIxAICo8t02z14r
+LGztxBFHTbZlMDtx/8PsK14Lx7wDQi0GhWNHQiqQKkmt8iigzAuBRA==
+-----END CERTIFICATE REQUEST-----
diff --git a/src/test/resources/csrs/simple-rsa-1024.csr b/src/test/resources/csrs/simple-rsa-1024.csr
new file mode 100644
index 0000000..d33424b
--- /dev/null
+++ b/src/test/resources/csrs/simple-rsa-1024.csr
@@ -0,0 +1,11 @@
+-----BEGIN CERTIFICATE REQUEST-----
+MIIBijCB9AIBADBLMRswGQYDVQQDExJzaW1wbGUuZXhhbXBsZS5jb20xFzAVBgoJ
+kiaJk/IsZAEZFgdleGFtcGxlMRMwEQYKCZImiZPyLGQBGRYDY29tMIGfMA0GCSqG
+SIb3DQEBAQUAA4GNADCBiQKBgQC7BZZm8prhDaplqevZs+8Le8IVQ/0H4Rz2rC5E
+p0lF6o/w0KddvLZkVh6ONKtwxf+oVFT/Aa87/Uib9fjR/XKrU1ektL9cabL9qiCC
+0bdvFlPJJvZL5ftPMaVAFlZh7ZqQ07tf4MOtPAhbCuwJbV9I0TEJBjM/4cDl6sgK
+d54xYQIDAQABoAAwDQYJKoZIhvcNAQELBQADgYEAQZ+j3jBhC4XSNYGx7bWovRPM
+O7v85X7oovjR0y0aqrYiIbajGG/AsiorwYuvfsM1ielPW3Jd1YZXN2SrFIvQUrre
+B4Pa6kAAAK7eWVFG7n5xZKyUi43JUIpRC+Q2LvRnREWYCLBggxgRBdz1cB/HNbOq
+LirPEroYI/+W6An6zKE=
+-----END CERTIFICATE REQUEST-----
diff --git a/src/test/resources/csrs/with-sans-rsa-2048.csr b/src/test/resources/csrs/with-sans-rsa-2048.csr
new file mode 100644
index 0000000..cd9eab4
--- /dev/null
+++ b/src/test/resources/csrs/with-sans-rsa-2048.csr
@@ -0,0 +1,18 @@
+-----BEGIN CERTIFICATE REQUEST-----
+MIIC1zCCAb8CAQAwSTEZMBcGA1UEAxMQaG9zdC5leGFtcGxlLmNvbTEXMBUGCgmS
+JomT8ixkARkWB2V4YW1wbGUxEzARBgoJkiaJk/IsZAEZFgNjb20wggEiMA0GCSqG
+SIb3DQEBAQUAA4IBDwAwggEKAoIBAQC5CZnRypPvyo2tN1uU143tYj7LG0Ymn3gN
+s8FlZfpRAqJc12W8FfPDeK1xCcuWtxtLjwQw7wH11I4XCX8/wfGEpLUkrvd3Ve+D
+3XXwi9ei/8vljJBVPzcLQcd+Kgm0jfvGT7UczZGVkcpHD1h3TGnajKmwN8hfcm9K
+9O97/NSyYrgS9Tdb8epsX0gH4VuUKcLLB7t4LMfwXZUFV8gxfQpGQ99VN0xmj+54
+34RhEbTsNbZxQchi9DJ3sDF7YDo5yOAbqWQTqfwaSHkjQIMettuy331IJ2lF3ju/
+1s9hlL/naZOq9Vp+R1MWhACc/kX6mVU23n8vF1V5MrOTNjgYjj21AgMBAAGgSTBH
+BgkqhkiG9w0BCQ4xOjA4MDYGA1UdEQQvMC2CFGRldi5ob3N0LmV4YW1wbGUuY29t
+ghVwcHJkLmhvc3QuZXhhbXBsZS5jb20wDQYJKoZIhvcNAQELBQADggEBAII2xRyP
+Gu8cEDUfjYOSEq7kTzKvajj/DQaBeEjsW1tU2dEFZndK/LoZy9IY/F3H7lYB5I9W
+AtmmoE0lmIlAZC1XFYTB++kY8Yh65l831Cie17FY1Z6NaqVmHyYZWXbzJRVLxLvf
+tczqXCLwC4dpXnhB5qEPwQ2jZbwHIohsMViK/L/DApgKDgtxqtxDqOv32Yo0g6xO
+r5KctgCpH4fr5zaZ4I2IjB9ZanEzBQP6w689vJGUiQOrTKoaDDMH/WaxEHoJsQjg
+0KxKrlKvORZGhK3voOh6maRhgiPCOLagK3DGxcXY6a/b4eS0giLX+Y6pGXfpR/Ps
+arHLrOFnzGfVfb0=
+-----END CERTIFICATE REQUEST-----
diff --git a/src/test/resources/keys/aes-128.key b/src/test/resources/keys/aes-128.key
new file mode 100644
index 0000000..db24e71
--- /dev/null
+++ b/src/test/resources/keys/aes-128.key
@@ -0,0 +1 @@
+Ü|âÑË¢oU•2ÿõ'>õ
\ No newline at end of file
diff --git a/src/test/resources/keys/dsa-openssl-des3.pem b/src/test/resources/keys/dsa-openssl-des3.pem
new file mode 100644
index 0000000..4a85bb1
--- /dev/null
+++ b/src/test/resources/keys/dsa-openssl-des3.pem
@@ -0,0 +1,15 @@
+-----BEGIN DSA PRIVATE KEY-----
+Proc-Type: 4,ENCRYPTED
+DEK-Info: DES-EDE3-CBC,5E562BC405BB14E8
+
+SUa/8UvU4GuanQJU8F2HQpizTuDdn93kk/h0HlQL8d9YFF1eHqxRfwzTWbw99/n3
+VZ6WC5xaP3w0nxD034Za56ujKL5GcmCQsNuVljFlOUqoFxDIUfxkFx5lX/zXIC8w
+3jSnYA2ga29bA+bUTVGuCBuzyqumh3zdpMXx2LpJGtTh7DGcDbCu5GBPF3SPHUhi
+iCXubJJJLuB15siChYVx4+w+74n2Y1Ga+FN6CL1svX+cpnMIH8jhlPQNmNySlEbW
+yBaaXUWoCFUeZZXt4rvzkTnXm5idKr0zYwuCp871rxQj8g2zqy8WGoJmjIj08vjD
+hSO0lRSlg8LQY2zNiv/D+PyXoODoS5AcSExjFsHs8RGynndgiGV2HpmgJ3d3Db/M
+ZdvCsHf8ldwmHhIAwjVbO30b93syaVhV+/O+pQ16XjEawgS9Rzz1POrAOr3/C8as
+WsYxT+5LDI6TYq+ng2JTiHx10VfSNbHZddXgt0E2utIt6kd7GKDbGBGKes8DJjqI
+8LrmVafdfBSea2R5i03YAMzOIpTxgyeJsiK0THm+XtjpTZ1yLSYN5ee9uvOz9TQd
+0MlWsFwB+9tFlLAV7J0yjKM4kzykrVHS
+-----END DSA PRIVATE KEY-----
diff --git a/src/test/resources/keys/dsa-openssl-nopass.der b/src/test/resources/keys/dsa-openssl-nopass.der
new file mode 100644
index 0000000..5be1a0d
--- /dev/null
+++ b/src/test/resources/keys/dsa-openssl-nopass.der
Binary files differ
diff --git a/src/test/resources/keys/dsa-openssl-nopass.pem b/src/test/resources/keys/dsa-openssl-nopass.pem
new file mode 100644
index 0000000..ed096e1
--- /dev/null
+++ b/src/test/resources/keys/dsa-openssl-nopass.pem
@@ -0,0 +1,12 @@
+-----BEGIN DSA PRIVATE KEY-----
+MIIBvAIBAAKBgQDrpYnxvgBhgVRlhI7pxuOuta8nQe3hckXx828FQFWj1NB98xXD
+mN+c9Zej5OGQDVR6u4Qm1YiQUe18q5GCmlHGgdJ0jy4IN71ljprxqobkerVkRm8+
+H8h5dbqwkZPZqybpuJgCXdzpS7oAsriKqM7GFLbyeZpRnr4q9c5sYpynewIVAO3w
+RMkmD+d4OxSzpXBMroSI70ABAoGASfW2qBs59MyqXrXguI32xlbiqxZLK9nnmNAB
+STnk7chZAp1aqzZxeu+HjdUQDsANrc84VmfehtABrsfGoZhrDUW4/6mlEwzUVq+w
+TwSLw3fvC6TIp+Am+Y+HPwO7jmPuVsYVJt0zYn7ngUaIcIdTZ3sWEKqXJXLk0QP6
+LoC1+PICgYEA6Tw2khtb1g0vcHu6JRgggWPZVTuj/HOH3FyjufsfHogWKrlKebZ6
+hnQ73qAcEgLLYKctPdCX6wnpXN+BsQGYdTkc0FsUNZD4VW5L5kaWRiLVfE8x55wX
+dMZtXKWqg1vL6aXYZw7RFe9U9Ck+/AG90knThDC+xrX2FTDm6uC25rkCFQDi5v1v
+S/zsr0up+AudMj9WvQTIuw==
+-----END DSA PRIVATE KEY-----
diff --git a/src/test/resources/keys/dsa-pkcs8-nopass.der b/src/test/resources/keys/dsa-pkcs8-nopass.der
new file mode 100644
index 0000000..8e7d2a8
--- /dev/null
+++ b/src/test/resources/keys/dsa-pkcs8-nopass.der
Binary files differ
diff --git a/src/test/resources/keys/dsa-pkcs8-nopass.pem b/src/test/resources/keys/dsa-pkcs8-nopass.pem
new file mode 100644
index 0000000..9da8e84
--- /dev/null
+++ b/src/test/resources/keys/dsa-pkcs8-nopass.pem
@@ -0,0 +1,9 @@
+-----BEGIN PRIVATE KEY-----
+MIIBSwIBADCCASsGByqGSM44BAEwggEeAoGBAOulifG+AGGBVGWEjunG4661rydB
+7eFyRfHzbwVAVaPU0H3zFcOY35z1l6Pk4ZANVHq7hCbViJBR7XyrkYKaUcaB0nSP
+Lgg3vWWOmvGqhuR6tWRGbz4fyHl1urCRk9mrJum4mAJd3OlLugCyuIqozsYUtvJ5
+mlGevir1zmxinKd7AhUA7fBEySYP53g7FLOlcEyuhIjvQAECgYBJ9baoGzn0zKpe
+teC4jfbGVuKrFksr2eeY0AFJOeTtyFkCnVqrNnF674eN1RAOwA2tzzhWZ96G0AGu
+x8ahmGsNRbj/qaUTDNRWr7BPBIvDd+8LpMin4Cb5j4c/A7uOY+5WxhUm3TNifueB
+Rohwh1NnexYQqpclcuTRA/ougLX48gQXAhUA4ub9b0v87K9LqfgLnTI/Vr0EyLs=
+-----END PRIVATE KEY-----
diff --git a/src/test/resources/keys/dsa-pkcs8-priv.der b/src/test/resources/keys/dsa-pkcs8-priv.der
new file mode 100644
index 0000000..5456df7
--- /dev/null
+++ b/src/test/resources/keys/dsa-pkcs8-priv.der
Binary files differ
diff --git a/src/test/resources/keys/dsa-pkcs8-priv.pem b/src/test/resources/keys/dsa-pkcs8-priv.pem
new file mode 100644
index 0000000..5c72a2c
--- /dev/null
+++ b/src/test/resources/keys/dsa-pkcs8-priv.pem
@@ -0,0 +1,10 @@
+-----BEGIN ENCRYPTED PRIVATE KEY-----
+MIIBcTAbBgkqhkiG9w0BBQMwDgQIcn2fFCBFuV8CAggABIIBUG8ceCq/p5CYZBt8
+8a7KNx2FmPt5LHrSuonpS6WbB8eZJWRl7QiAL3Ui7ooFoDkl+aa1vvolBpGjXvdd
+24hLgJCCSsAw6zIfcEaWkogu9wNOlOffL5CZgkFCg/JPrBnSJ/IoQ489hA4DWBDV
+CLc0ZYkFI1h0LmmjjU56uvfTU5hH5Ew+cIMCiBkFfaWP9micPqjXkl4h2jP0FoQ9
+25m99SNxX3ORtyA5PMTLVTWxlAoFdf91B/f36sYJzB95uDulz/sOhAEZyYuKuYi4
+KLqtdfV5jL9b/K7hFRBsHlnGSjzf7bQ5GloBeUtdZjmMs1Cg7wR+38GIp2xJMvJB
+T3Ja/XlDYzmYVYg31BTbIwQgsfDwdCFrsPJWUgjhLF4TR+DQRVGREuHCw/KWEyBz
+1ktXBYkZtnl5NzK6WxapCwaMn4v4UEvvLGLgaaqHErGFmkZS2A==
+-----END ENCRYPTED PRIVATE KEY-----
diff --git a/src/test/resources/keys/dsa-pkcs8-v2-des3.der b/src/test/resources/keys/dsa-pkcs8-v2-des3.der
new file mode 100644
index 0000000..9a5a6ef
--- /dev/null
+++ b/src/test/resources/keys/dsa-pkcs8-v2-des3.der
Binary files differ
diff --git a/src/test/resources/keys/dsa-pkcs8-v2-des3.pem b/src/test/resources/keys/dsa-pkcs8-v2-des3.pem
new file mode 100644
index 0000000..d19fc4a
--- /dev/null
+++ b/src/test/resources/keys/dsa-pkcs8-v2-des3.pem
@@ -0,0 +1,11 @@
+-----BEGIN ENCRYPTED PRIVATE KEY-----
+MIIBljBABgkqhkiG9w0BBQ0wMzAbBgkqhkiG9w0BBQwwDgQIe5NvINrToPcCAggA
+MBQGCCqGSIb3DQMHBAiBg4mBYvzq1QSCAVDzYM/oK1flkdze+6mW0JF5f8EFRU9O
+5bn94oEoum0EWz43OFEW9y8lBTuErzUUxXBZ3CXK5IT8AtiVTTUMnPzpMtnkSiWu
+iLR8bujoyfSEIQ4kewkZG4DFT2tUPOqE1O6kiiOzCiuCD1wRHH+tznGQb+mj4Afj
+GSD7jRyX9l5TWYWjQw3+w9Fiu12LHEDTXc7I5IotXOyOWJ+A6G4iLg7q4r7L4gJU
+RX4LiI7MX3Dqv1tyed9yN9xsC8ORIGG1h+F6F8yua+UErqGlfSxlvTYPI8W8O5Wr
+nv44CmAQOwZdVto5pkQ61YX7csrgaJHLcBtZdxVHEt7ua/887pLuHq4Fq9u+0usG
+3OwPNJ/zJinTww8X1ZazVmYXcDGJK/d04URmUYuo8AGc2XFY3lCjMr4t45saQydK
+Du1telBhT7x2R8w6vi425AkD3QE8t531oc4=
+-----END ENCRYPTED PRIVATE KEY-----
diff --git a/src/test/resources/keys/dsa-pub.der b/src/test/resources/keys/dsa-pub.der
new file mode 100644
index 0000000..c14ea0a
--- /dev/null
+++ b/src/test/resources/keys/dsa-pub.der
Binary files differ
diff --git a/src/test/resources/keys/dsa-pub.pem b/src/test/resources/keys/dsa-pub.pem
new file mode 100644
index 0000000..b8e8c2f
--- /dev/null
+++ b/src/test/resources/keys/dsa-pub.pem
@@ -0,0 +1,12 @@
+-----BEGIN PUBLIC KEY-----
+MIIBtzCCASsGByqGSM44BAEwggEeAoGBAOulifG+AGGBVGWEjunG4661rydB7eFy
+RfHzbwVAVaPU0H3zFcOY35z1l6Pk4ZANVHq7hCbViJBR7XyrkYKaUcaB0nSPLgg3
+vWWOmvGqhuR6tWRGbz4fyHl1urCRk9mrJum4mAJd3OlLugCyuIqozsYUtvJ5mlGe
+vir1zmxinKd7AhUA7fBEySYP53g7FLOlcEyuhIjvQAECgYBJ9baoGzn0zKpeteC4
+jfbGVuKrFksr2eeY0AFJOeTtyFkCnVqrNnF674eN1RAOwA2tzzhWZ96G0AGux8ah
+mGsNRbj/qaUTDNRWr7BPBIvDd+8LpMin4Cb5j4c/A7uOY+5WxhUm3TNifueBRohw
+h1NnexYQqpclcuTRA/ougLX48gOBhQACgYEA6Tw2khtb1g0vcHu6JRgggWPZVTuj
+/HOH3FyjufsfHogWKrlKebZ6hnQ73qAcEgLLYKctPdCX6wnpXN+BsQGYdTkc0FsU
+NZD4VW5L5kaWRiLVfE8x55wXdMZtXKWqg1vL6aXYZw7RFe9U9Ck+/AG90knThDC+
+xrX2FTDm6uC25rk=
+-----END PUBLIC KEY-----
diff --git a/src/test/resources/keys/ec-openssl-prime256v1-named-nopass.der b/src/test/resources/keys/ec-openssl-prime256v1-named-nopass.der
new file mode 100644
index 0000000..71fe542
--- /dev/null
+++ b/src/test/resources/keys/ec-openssl-prime256v1-named-nopass.der
Binary files differ
diff --git a/src/test/resources/keys/ec-openssl-prime256v1-named-nopass.pem b/src/test/resources/keys/ec-openssl-prime256v1-named-nopass.pem
new file mode 100644
index 0000000..35cb960
--- /dev/null
+++ b/src/test/resources/keys/ec-openssl-prime256v1-named-nopass.pem
@@ -0,0 +1,8 @@
+-----BEGIN EC PARAMETERS-----
+BggqhkjOPQMBBw==
+-----END EC PARAMETERS-----
+-----BEGIN EC PRIVATE KEY-----
+MHcCAQEEIKtRX2H01Z5Pu3O53fQJfctXqU/3/n6p5kzKTiQ/XSMxoAoGCCqGSM49
+AwEHoUQDQgAEV0bfrxH9VzCilRisNe/yynt2n1+hc/JktcTD5PK5eHFrAk3nUb7Q
+QClQFWMrrfbB9mmWhKuMqym8uLDsbLtrow==
+-----END EC PRIVATE KEY-----
diff --git a/src/test/resources/keys/ec-openssl-prime256v1-named-pub.pem b/src/test/resources/keys/ec-openssl-prime256v1-named-pub.pem
new file mode 100644
index 0000000..e76a244
--- /dev/null
+++ b/src/test/resources/keys/ec-openssl-prime256v1-named-pub.pem
@@ -0,0 +1,4 @@
+-----BEGIN PUBLIC KEY-----
+MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEV0bfrxH9VzCilRisNe/yynt2n1+h
+c/JktcTD5PK5eHFrAk3nUb7QQClQFWMrrfbB9mmWhKuMqym8uLDsbLtrow==
+-----END PUBLIC KEY-----
diff --git a/src/test/resources/keys/ec-openssl-secp112r1-named-nopass.der b/src/test/resources/keys/ec-openssl-secp112r1-named-nopass.der
new file mode 100644
index 0000000..cac8d2e
--- /dev/null
+++ b/src/test/resources/keys/ec-openssl-secp112r1-named-nopass.der
Binary files differ
diff --git a/src/test/resources/keys/ec-openssl-secp112r1-named-nopass.pem b/src/test/resources/keys/ec-openssl-secp112r1-named-nopass.pem
new file mode 100644
index 0000000..639129f
--- /dev/null
+++ b/src/test/resources/keys/ec-openssl-secp112r1-named-nopass.pem
@@ -0,0 +1,7 @@
+-----BEGIN EC PARAMETERS-----
+BgUrgQQABg==
+-----END EC PARAMETERS-----
+-----BEGIN EC PRIVATE KEY-----
+MD4CAQEEDk5WBCiBT8eE8yf/y6dRoAcGBSuBBAAGoSADHgAEa8bhnjsvOSDXsxED
+mAjM7YymrVrLJTYchIM+Bw==
+-----END EC PRIVATE KEY-----
diff --git a/src/test/resources/keys/ec-openssl-secp112r1-named-pub.pem b/src/test/resources/keys/ec-openssl-secp112r1-named-pub.pem
new file mode 100644
index 0000000..624de16
--- /dev/null
+++ b/src/test/resources/keys/ec-openssl-secp112r1-named-pub.pem
@@ -0,0 +1,4 @@
+-----BEGIN PUBLIC KEY-----
+MDIwEAYHKoZIzj0CAQYFK4EEAAYDHgAEa8bhnjsvOSDXsxEDmAjM7YymrVrLJTYc
+hIM+Bw==
+-----END PUBLIC KEY-----
diff --git a/src/test/resources/keys/ec-openssl-secp224k1-explicit-des.pem b/src/test/resources/keys/ec-openssl-secp224k1-explicit-des.pem
new file mode 100644
index 0000000..549f656
--- /dev/null
+++ b/src/test/resources/keys/ec-openssl-secp224k1-explicit-des.pem
@@ -0,0 +1,11 @@
+-----BEGIN EC PRIVATE KEY-----
+Proc-Type: 4,ENCRYPTED
+DEK-Info: DES-CBC,36232933C6E4A23A
+
+iFYAONnfgUQvmXFtoDsb/hKXjTEhsfW+gT2rzpp0MOS2Ygidbiu9JxwZ2anT1vbV
+SwTfDQ/EPQTRoGft1EY4Cb6CmUEpj02ywo+U7c/smUDRTScOvh7budcuhYoNVzQL
+sifvukhV25nDEWydl8DPiCnVtQYNGIBh8wj1TBsbn1ucHddc0VwaGXZeYoHCbymS
+jAPHE+xHOrFXwAWot2FbcO6tnV7Dqd6Tw8DA1XCux9DQFfA6jxED3l7Wd79NwuKf
+zzpcqV5Xvcxkud5cu3WW3T8aHicKXW/dZHDKkQtet2uBnJ4nNRZErsKtFxorqimH
+cvvZCECsiDUyMdS67Za2PA==
+-----END EC PRIVATE KEY-----
diff --git a/src/test/resources/keys/ec-openssl-secp224k1-explicit-nopass.der b/src/test/resources/keys/ec-openssl-secp224k1-explicit-nopass.der
new file mode 100644
index 0000000..ccaf8fd
--- /dev/null
+++ b/src/test/resources/keys/ec-openssl-secp224k1-explicit-nopass.der
Binary files differ
diff --git a/src/test/resources/keys/ec-openssl-secp224k1-explicit-nopass.pem b/src/test/resources/keys/ec-openssl-secp224k1-explicit-nopass.pem
new file mode 100644
index 0000000..76bdd2a
--- /dev/null
+++ b/src/test/resources/keys/ec-openssl-secp224k1-explicit-nopass.pem
@@ -0,0 +1,8 @@
+-----BEGIN EC PRIVATE KEY-----
+MIH3AgEBBBw3HL0pymjIYdndGvIRdEwFOCYipTJyZZDb8oiFoIGVMIGSAgEBMCgG
+ByqGSM49AQECHQD///////////////////////////////7//+VtMAYEAQAEAQUE
+OQShRVszTfCZ3zD8KKFppGfp5HB1qQ9+ZQ62t6Rcfgif7X+6NEKCyvvW9+MZ98Cw
+vVniykvbVW1hpQIdAQAAAAAAAAAAAAAAAAAB3OjS7GGEyvCpcXafsfcCAQGhPAM6
+AASYYFXr2TWWJSCu9HwUmNH0yvYPmFeEox3Ut4fBntgBo+kINaoSVfTVbj6sAfeo
+kgWQUadcoXNMEw==
+-----END EC PRIVATE KEY-----
diff --git a/src/test/resources/keys/ec-openssl-secp224k1-explicit-pub.pem b/src/test/resources/keys/ec-openssl-secp224k1-explicit-pub.pem
new file mode 100644
index 0000000..3563fd8
--- /dev/null
+++ b/src/test/resources/keys/ec-openssl-secp224k1-explicit-pub.pem
@@ -0,0 +1,7 @@
+-----BEGIN PUBLIC KEY-----
+MIHdMIGeBgcqhkjOPQIBMIGSAgEBMCgGByqGSM49AQECHQD/////////////////
+//////////////7//+VtMAYEAQAEAQUEOQShRVszTfCZ3zD8KKFppGfp5HB1qQ9+
+ZQ62t6Rcfgif7X+6NEKCyvvW9+MZ98CwvVniykvbVW1hpQIdAQAAAAAAAAAAAAAA
+AAAB3OjS7GGEyvCpcXafsfcCAQEDOgAEmGBV69k1liUgrvR8FJjR9Mr2D5hXhKMd
+1LeHwZ7YAaPpCDWqElX01W4+rAH3qJIFkFGnXKFzTBM=
+-----END PUBLIC KEY-----
diff --git a/src/test/resources/keys/ec-openssl-secp256k1-explicit-nopass.pem b/src/test/resources/keys/ec-openssl-secp256k1-explicit-nopass.pem
new file mode 100644
index 0000000..585789b
--- /dev/null
+++ b/src/test/resources/keys/ec-openssl-secp256k1-explicit-nopass.pem
@@ -0,0 +1,5 @@
+-----BEGIN EC PRIVATE KEY-----
+MHQCAQEEIIS3dzDcULU/YxSdkYjQnnnJMTFW3xUQb9rTjVuD6KzHoAcGBSuBBAAK
+oUQDQgAExo65hLAAfoKDrGFzflBulms4hLyntjaUdaoJTnBRt7P3E/d1xtERSUm3
+GTWrelNA9tSw/OPq4jcPz70u4VJtAw==
+-----END EC PRIVATE KEY-----
diff --git a/src/test/resources/keys/ec-openssl-secp256k1-explicit-pub.pem b/src/test/resources/keys/ec-openssl-secp256k1-explicit-pub.pem
new file mode 100644
index 0000000..f08d594
--- /dev/null
+++ b/src/test/resources/keys/ec-openssl-secp256k1-explicit-pub.pem
@@ -0,0 +1,4 @@
+-----BEGIN PUBLIC KEY-----
+MFYwEAYHKoZIzj0CAQYFK4EEAAoDQgAExo65hLAAfoKDrGFzflBulms4hLyntjaU
+daoJTnBRt7P3E/d1xtERSUm3GTWrelNA9tSw/OPq4jcPz70u4VJtAw==
+-----END PUBLIC KEY-----
diff --git a/src/test/resources/keys/ec-openssl-sect409k1-named-nopass.pem b/src/test/resources/keys/ec-openssl-sect409k1-named-nopass.pem
new file mode 100644
index 0000000..b5deb5c
--- /dev/null
+++ b/src/test/resources/keys/ec-openssl-sect409k1-named-nopass.pem
@@ -0,0 +1,9 @@
+-----BEGIN EC PARAMETERS-----
+BgUrgQQAJA==
+-----END EC PARAMETERS-----
+-----BEGIN EC PRIVATE KEY-----
+MIGvAgEBBDNhfrS2YEmpljk4skviH8iSWr7fKoy7Dpe33DqVtjr8KoX05dYrIaSq
+PXpcXzAylaqsCiSgBwYFK4EEACShbANqAAQBen76TxFvShf5SFOx5KtdhrLbkB9Y
+DS4cEUUld3jrQCkJBY7clKBKSsLob/S4CA5j/+mWABIK2M216wcUz9NVk3HLSnd0
+b02d/XAMt9E8V5vcKmVyjo5HQ0LKXnOzZs0h3SIKz9vhWQ==
+-----END EC PRIVATE KEY-----
diff --git a/src/test/resources/keys/ec-openssl-sect409k1-named-pub.pem b/src/test/resources/keys/ec-openssl-sect409k1-named-pub.pem
new file mode 100644
index 0000000..4cea543
--- /dev/null
+++ b/src/test/resources/keys/ec-openssl-sect409k1-named-pub.pem
@@ -0,0 +1,5 @@
+-----BEGIN PUBLIC KEY-----
+MH4wEAYHKoZIzj0CAQYFK4EEACQDagAEAXp++k8Rb0oX+UhTseSrXYay25AfWA0u
+HBFFJXd460ApCQWO3JSgSkrC6G/0uAgOY//plgASCtjNtesHFM/TVZNxy0p3dG9N
+nf1wDLfRPFeb3Cplco6OR0NCyl5zs2bNId0iCs/b4Vk=
+-----END PUBLIC KEY-----
diff --git a/src/test/resources/keys/ec-openssl-sect571r1-explicit-des.pem b/src/test/resources/keys/ec-openssl-sect571r1-explicit-des.pem
new file mode 100644
index 0000000..5cbc267
--- /dev/null
+++ b/src/test/resources/keys/ec-openssl-sect571r1-explicit-des.pem
@@ -0,0 +1,18 @@
+-----BEGIN EC PRIVATE KEY-----
+Proc-Type: 4,ENCRYPTED
+DEK-Info: DES-CBC,A1B708E785CED928
+
+nxXWURBdsbW4WyN25MmBNwWUjVEegjJo/XPwACRsibhAV+diUcbwTCde3qGlvVHs
+Z4BZVtBPP4c7Qcqvo1jAtgA4nFETptSt3iX4JFeZ4CVpmuMDpeJ9QvSHc4xh0rtj
+rBWhmZfmwSIiegzUA1KV3AavK3mYsKoPaW2uqhfD/nLhZewXEwSo8Fna+7XL4w8L
+0qZoj7cm3Xa6CiYit6LOckVwnztPQ2S9lVYo+OQoZu79AETzoPctUL/5XozPkC8m
+m3Kip6Azj3SlcZCBu4HTi3YRnnxi0xtVaB5cgUICOsnY+yIJhuTC1NwqoUn3E622
+2VlBHpIe2/zA3vylMmriM2dPRkgZN75Z//qE4om8ka0Auff8IIeCisLKB3QKWgGY
+hDdoE/Q6r9CxtfNudyZtMGt25idBfKp+toAPyH4GWWc21IKrDBDLmcEbLXGZKKjO
+eU/ohpjhOY7ojunSC/TcSaHKCLwW/9PCkkOb+h3Ze+PbfKLr7nRA6Sr0X8GV8e+h
+1XZwMfkHCaG/u8t3+ZoVHDvogja7IjA7rSfHrLNp/YGSqwsijYwvvaU1flt8cO7K
+9qDpdRULYmBLmiAI5M6W9C768ZQ/qLhzuQ0Bmy+FwSwlt3lOx9XgNkbMCNcMAuv3
+6pI54RVsbXtv3vaW+Rac1O7QQOmH5L2nBYtamZ+7k4AZOmc70QrnUyHU+JxDvWTs
+xhAARVngIt71kDibZZmCGHneZvonXGd1PCAPkXA2C4dudhcaGk5JpkQrGt/FtazS
+C4HXA0/ZVBglYz4EhiLy5WNjUbxOI9KHPkqzDvww/fQ8nzP6s4aklg==
+-----END EC PRIVATE KEY-----
diff --git a/src/test/resources/keys/ec-openssl-sect571r1-explicit-nopass.der b/src/test/resources/keys/ec-openssl-sect571r1-explicit-nopass.der
new file mode 100644
index 0000000..fcaf19c
--- /dev/null
+++ b/src/test/resources/keys/ec-openssl-sect571r1-explicit-nopass.der
Binary files differ
diff --git a/src/test/resources/keys/ec-openssl-sect571r1-explicit-nopass.pem b/src/test/resources/keys/ec-openssl-sect571r1-explicit-nopass.pem
new file mode 100644
index 0000000..7d92f3e
--- /dev/null
+++ b/src/test/resources/keys/ec-openssl-sect571r1-explicit-nopass.pem
@@ -0,0 +1,15 @@
+-----BEGIN EC PRIVATE KEY-----
+MIICXQIBAQRHq9Hu/6ik+nkgja8ol2qgR063MpyVCiX6yaXXWnBvLZOmIslBgatF
+MzaOGfekjsWx6JuHUBZ+9ZKP4r0mCGP/FpZB/uFYtdmgggF1MIIBcQIBATAlBgcq
+hkjOPQECMBoCAgI7BgkqhkjOPQECAwMwCQIBAgIBBQIBCjBkBAEBBEgC9A5+IiHy
+ld4pcRe389YvXGqX/8uM7/HNa6jOSpoYrYT/q72O+lkzK+etZ1ambilK/RhaeP8S
+qlIOTec5usoMf/7/fylVcnoDFQAqoFj3Og4zq0hrD2EEEMU6fxMjEASBkQQDAwAd
+NLhWKWwWwNQNPNd1CpPR0pVfqAql9A/I23sqvb3lOVD0wNKTzdcRo1tn+xSZrmAD
+hhTxOUq/o7TIUNkn4ed2nI7sLRkDe/JzQtpjm23M//63PWnXjGwnpgCcu8oZgPhT
+OSHopoRCPkO6sIpXYpGvj0YbsqizUx0vBIXBmxbi8VFuI908GkgnrxuKwVsCSAP/
+/////////////////////////////////////////////+Zhzhj/VZhzCAWbGGgj
+hR7H3ZyhFh3pPVF01m6Dgum7L+hORwIBAqGBlQOBkgAEAJwZpe20C5w+yTMUKjet
+DfROAC4ydb9Dy25G4PDKewVeoyM4EeDWAU25q5A91ipvCIe0GqeS7Va40HH3VjGg
+Q0yRFh+BKVuTAle0b7VyOg52P8pIGqovQd3ZEk4vmh9uH8yldBYrM3+0hpTXsqcP
+pdoHJFZW93bpu7T+Q9tXTzLQ9wjvQJ8dAo8wqcPN4OOX
+-----END EC PRIVATE KEY-----
diff --git a/src/test/resources/keys/ec-openssl-sect571r1-explicit-pub.pem b/src/test/resources/keys/ec-openssl-sect571r1-explicit-pub.pem
new file mode 100644
index 0000000..261fdc0
--- /dev/null
+++ b/src/test/resources/keys/ec-openssl-sect571r1-explicit-pub.pem
@@ -0,0 +1,14 @@
+-----BEGIN PUBLIC KEY-----
+MIICFzCCAX4GByqGSM49AgEwggFxAgEBMCUGByqGSM49AQIwGgICAjsGCSqGSM49
+AQIDAzAJAgECAgEFAgEKMGQEAQEESAL0Dn4iIfKV3ilxF7fz1i9capf/y4zv8c1r
+qM5KmhithP+rvY76WTMr561nVqZuKUr9GFp4/xKqUg5N5zm6ygx//v9/KVVyegMV
+ACqgWPc6DjOrSGsPYQQQxTp/EyMQBIGRBAMDAB00uFYpbBbA1A0813UKk9HSlV+o
+CqX0D8jbeyq9veU5UPTA0pPN1xGjW2f7FJmuYAOGFPE5Sr+jtMhQ2Sfh53acjuwt
+GQN78nNC2mObbcz//rc9adeMbCemAJy7yhmA+FM5IeimhEI+Q7qwildika+PRhuy
+qLNTHS8EhcGbFuLxUW4j3TwaSCevG4rBWwJIA///////////////////////////
+////////////////////5mHOGP9VmHMIBZsYaCOFHsfdnKEWHek9UXTWboOC6bsv
+6E5HAgECA4GSAAQAnBml7bQLnD7JMxQqN60N9E4ALjJ1v0PLbkbg8Mp7BV6jIzgR
+4NYBTbmrkD3WKm8Ih7Qap5LtVrjQcfdWMaBDTJEWH4EpW5MCV7RvtXI6DnY/ykga
+qi9B3dkSTi+aH24fzKV0Fiszf7SGlNeypw+l2gckVlb3dum7tP5D21dPMtD3CO9A
+nx0CjzCpw83g45c=
+-----END PUBLIC KEY-----
diff --git a/src/test/resources/keys/ec-openssl-sect571r1-named-nopass.der b/src/test/resources/keys/ec-openssl-sect571r1-named-nopass.der
new file mode 100644
index 0000000..fcaf19c
--- /dev/null
+++ b/src/test/resources/keys/ec-openssl-sect571r1-named-nopass.der
Binary files differ
diff --git a/src/test/resources/keys/ec-openssl-sect571r1-named-nopass.pem b/src/test/resources/keys/ec-openssl-sect571r1-named-nopass.pem
new file mode 100644
index 0000000..8bc53d2
--- /dev/null
+++ b/src/test/resources/keys/ec-openssl-sect571r1-named-nopass.pem
@@ -0,0 +1,10 @@
+-----BEGIN EC PARAMETERS-----
+BgUrgQQAJw==
+-----END EC PARAMETERS-----
+-----BEGIN EC PRIVATE KEY-----
+MIHtAgEBBEer0e7/qKT6eSCNryiXaqBHTrcynJUKJfrJpddacG8tk6YiyUGBq0Uz
+No4Z96SOxbHom4dQFn71ko/ivSYIY/8WlkH+4Vi12aAHBgUrgQQAJ6GBlQOBkgAE
+AJwZpe20C5w+yTMUKjetDfROAC4ydb9Dy25G4PDKewVeoyM4EeDWAU25q5A91ipv
+CIe0GqeS7Va40HH3VjGgQ0yRFh+BKVuTAle0b7VyOg52P8pIGqovQd3ZEk4vmh9u
+H8yldBYrM3+0hpTXsqcPpdoHJFZW93bpu7T+Q9tXTzLQ9wjvQJ8dAo8wqcPN4OOX
+-----END EC PRIVATE KEY-----
diff --git a/src/test/resources/keys/ec-pkcs8-prime256v1-named-nopass.der b/src/test/resources/keys/ec-pkcs8-prime256v1-named-nopass.der
new file mode 100644
index 0000000..009a3b4
--- /dev/null
+++ b/src/test/resources/keys/ec-pkcs8-prime256v1-named-nopass.der
Binary files differ
diff --git a/src/test/resources/keys/ec-pkcs8-secp224k1-explicit-nopass.der b/src/test/resources/keys/ec-pkcs8-secp224k1-explicit-nopass.der
new file mode 100644
index 0000000..003e355
--- /dev/null
+++ b/src/test/resources/keys/ec-pkcs8-secp224k1-explicit-nopass.der
Binary files differ
diff --git a/src/test/resources/keys/ec-pkcs8-secp224k1-explicit-nopass.pem b/src/test/resources/keys/ec-pkcs8-secp224k1-explicit-nopass.pem
new file mode 100644
index 0000000..dac54af
--- /dev/null
+++ b/src/test/resources/keys/ec-pkcs8-secp224k1-explicit-nopass.pem
@@ -0,0 +1,8 @@
+-----BEGIN PRIVATE KEY-----
+MIIBBwIBADCBngYHKoZIzj0CATCBkgIBATAoBgcqhkjOPQEBAh0A////////////
+///////////////////+///lbTAGBAEABAEFBDkEoUVbM03wmd8w/CihaaRn6eRw
+dakPfmUOtrekXH4In+1/ujRCgsr71vfjGffAsL1Z4spL21VtYaUCHQEAAAAAAAAA
+AAAAAAAAAdzo0uxhhMrwqXF2n7H3AgEBBGEwXwIBAQQcNxy9KcpoyGHZ3RryEXRM
+BTgmIqUycmWQ2/KIhaE8AzoABJhgVevZNZYlIK70fBSY0fTK9g+YV4SjHdS3h8Ge
+2AGj6Qg1qhJV9NVuPqwB96iSBZBRp1yhc0wT
+-----END PRIVATE KEY-----
diff --git a/src/test/resources/keys/ec-pkcs8-secp224k1-explicit-sha1-rc4-128.der b/src/test/resources/keys/ec-pkcs8-secp224k1-explicit-sha1-rc4-128.der
new file mode 100644
index 0000000..69ece65
--- /dev/null
+++ b/src/test/resources/keys/ec-pkcs8-secp224k1-explicit-sha1-rc4-128.der
Binary files differ
diff --git a/src/test/resources/keys/ec-pkcs8-secp224k1-explicit-v1-sha1-rc2-64.der b/src/test/resources/keys/ec-pkcs8-secp224k1-explicit-v1-sha1-rc2-64.der
new file mode 100644
index 0000000..60ccc1b
--- /dev/null
+++ b/src/test/resources/keys/ec-pkcs8-secp224k1-explicit-v1-sha1-rc2-64.der
Binary files differ
diff --git a/src/test/resources/keys/ec-pkcs8-secp224k1-explicit-v2-des3.pem b/src/test/resources/keys/ec-pkcs8-secp224k1-explicit-v2-des3.pem
new file mode 100644
index 0000000..98ef49f
--- /dev/null
+++ b/src/test/resources/keys/ec-pkcs8-secp224k1-explicit-v2-des3.pem
@@ -0,0 +1,10 @@
+-----BEGIN ENCRYPTED PRIVATE KEY-----
+MIIBVjBABgkqhkiG9w0BBQ0wMzAbBgkqhkiG9w0BBQwwDgQIxeg91cPvDU8CAggA
+MBQGCCqGSIb3DQMHBAiuY7aTDDWyWQSCARA4/DvOJkuMZ1IbSS5/pyqgbGSC+fOu
+6ZuBSYQqUrawNhIDkNt4rvpOlvre6PNh3RAu43wJLVrgYcSMM5ZlJI74na3ru9zd
+c5iZ17/rnk2ooGNRNj0mqx+fAj3Tu4d97mRizRboWAJpsQeTC4Br4uDdx8ttZzIP
+zp1oVMLy9Yj3HsIwwNxGu9jbgBaHiHjIzkJ+lRqza35+9sdpQwcpz2NMUQIXw34S
+JjUhXLaufy91d0HSfmbo8h9a0zSHiRRmHrBUBTUkBYMbLnEkZf5fAlhT21FFafSc
+1v4BzuVZfvGLisjWzOZzW199G+XrYOxNmjUkvLDRyWMJYxL74YK6m6bHnZ3U+wjm
+N2QeMohF0rB8wA==
+-----END ENCRYPTED PRIVATE KEY-----
diff --git a/src/test/resources/keys/ec-pkcs8-sect571r1-explicit-v2-aes128.pem b/src/test/resources/keys/ec-pkcs8-sect571r1-explicit-v2-aes128.pem
new file mode 100644
index 0000000..0d822c0
--- /dev/null
+++ b/src/test/resources/keys/ec-pkcs8-sect571r1-explicit-v2-aes128.pem
@@ -0,0 +1,18 @@
+-----BEGIN ENCRYPTED PRIVATE KEY-----
+MIICzzBJBgkqhkiG9w0BBQ0wPDAbBgkqhkiG9w0BBQwwDgQIDONm+4mH/+cCAggA
+MB0GCWCGSAFlAwQBAgQQdQBCHbXJepgl9MClxApnqgSCAoAph2G4i+iZzPSWxObp
+66uQ5n1tNOJE6pLdbcHinwPHVYoHwp24SAel1hjX8enNuQAJaOAJvoZo9C/2rYwN
+7JL7/hH+aBwojPRasnkTTBD50VYaEQa/DtTSRS/AddJ9T2DC9zwXbCzMkHJuUbtw
+y0ofj/RJZaMIlL0oDtQ0IdssQzxKFdEOII44r9j3lTw3bT24rKC8gbgkqHmnmO86
+R3v0nnhVAj/JZFluy1PxNnsnCTcWj4mB9aHoTBuBZRiE5Qsuoq6OKTlqNQMx0Seg
+dbScAf6fb0cj7Cx/b7xKgR+oLO9QhyhB4oICfmPCnhJVgYlXLi9A2MsTeON0suXi
+7tGIR14HsBZ8qjW9nk/PXnAnZX7PM/GJ1Md80paPOk3ffpTPrEdB6vCxmyYn7Q2m
+O2jcDTDUxb4847TX5EBP2fp3eX6pvbAJ6PC+RTMwXDOCYsfDDjAYypTrJZgatzqq
+M+kprdhvThWr4NVddjnJrGYY0QYL5dFDGXJE5Czfxp+agZdjAyAJ9kZ9TDogJNQV
+UrpY04er8TGieGUwphqn4kBqngoOn2PcNluc/qbLOs0fYoIqqvJzWpqlwQKGevsO
+x5cmot6NWVamarV7IOHanJ59ECu4Na5KxyReLSFgFHx/HCp5PM27+5FjxAZv+ZUP
+C0aWqG020r00k7e1RxA9sBjf7M3UXPYpY0Ip3ec4HEhvtjMjtuBtE35wBXotz8Cf
+6WhTsiabQsiYcRrqZw4RZICU9v4nkJLvAyGDo0K+fI39EhtbzIJBW22QTlv03Zlj
+lK03vgB0ZFyLe1RjKVBjyvJzM3c3BV3cEcuEeDpu7pLQApnCK0u+YunA0pDR0n1J
+40MA
+-----END ENCRYPTED PRIVATE KEY-----
diff --git a/src/test/resources/keys/ec-pkcs8-sect571r1-named-v1-sha1-rc2-64.der b/src/test/resources/keys/ec-pkcs8-sect571r1-named-v1-sha1-rc2-64.der
new file mode 100644
index 0000000..f968392
--- /dev/null
+++ b/src/test/resources/keys/ec-pkcs8-sect571r1-named-v1-sha1-rc2-64.der
Binary files differ
diff --git a/src/test/resources/keys/ec-prime256v1-named-pub.der b/src/test/resources/keys/ec-prime256v1-named-pub.der
new file mode 100644
index 0000000..061fee3
--- /dev/null
+++ b/src/test/resources/keys/ec-prime256v1-named-pub.der
Binary files differ
diff --git a/src/test/resources/keys/ec-prime256v1-named-pub.pem b/src/test/resources/keys/ec-prime256v1-named-pub.pem
new file mode 100644
index 0000000..e76a244
--- /dev/null
+++ b/src/test/resources/keys/ec-prime256v1-named-pub.pem
@@ -0,0 +1,4 @@
+-----BEGIN PUBLIC KEY-----
+MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEV0bfrxH9VzCilRisNe/yynt2n1+h
+c/JktcTD5PK5eHFrAk3nUb7QQClQFWMrrfbB9mmWhKuMqym8uLDsbLtrow==
+-----END PUBLIC KEY-----
diff --git a/src/test/resources/keys/ec-secp224k1-explicit-pub.der b/src/test/resources/keys/ec-secp224k1-explicit-pub.der
new file mode 100644
index 0000000..61ffe8d
--- /dev/null
+++ b/src/test/resources/keys/ec-secp224k1-explicit-pub.der
Binary files differ
diff --git a/src/test/resources/keys/ec-secp224k1-explicit-pub.pem b/src/test/resources/keys/ec-secp224k1-explicit-pub.pem
new file mode 100644
index 0000000..3563fd8
--- /dev/null
+++ b/src/test/resources/keys/ec-secp224k1-explicit-pub.pem
@@ -0,0 +1,7 @@
+-----BEGIN PUBLIC KEY-----
+MIHdMIGeBgcqhkjOPQIBMIGSAgEBMCgGByqGSM49AQECHQD/////////////////
+//////////////7//+VtMAYEAQAEAQUEOQShRVszTfCZ3zD8KKFppGfp5HB1qQ9+
+ZQ62t6Rcfgif7X+6NEKCyvvW9+MZ98CwvVniykvbVW1hpQIdAQAAAAAAAAAAAAAA
+AAAB3OjS7GGEyvCpcXafsfcCAQEDOgAEmGBV69k1liUgrvR8FJjR9Mr2D5hXhKMd
+1LeHwZ7YAaPpCDWqElX01W4+rAH3qJIFkFGnXKFzTBM=
+-----END PUBLIC KEY-----
diff --git a/src/test/resources/keys/rsa-openssl-des-noheader.pem b/src/test/resources/keys/rsa-openssl-des-noheader.pem
new file mode 100644
index 0000000..5cb574f
--- /dev/null
+++ b/src/test/resources/keys/rsa-openssl-des-noheader.pem
@@ -0,0 +1,16 @@
+Proc-Type: 4,ENCRYPTED
+DEK-Info: DES-CBC,C65E6ED4FD6921EF
+
+w9CNDiUxGP8ve2L/4M0vvfIwvwXmEfAYdVt2jFv5waqeZjrwq0eG8Xup1kSAHgaX
+NOX/J3GVH0F8rlMdFDdOIT3+DgDFr5hV1Pcy8e5aI+8tP0VICXGeh8tPttFj5m7k
+RupYtbDF6e71odLwe+9JvoWknUQYl/t7nQx/1GTa7ZKPdTKWq9r8HUJCCzAaG7xQ
+e44C+G1r3C9TVoI/QpPUwWgiDs9scjQRnli83OTBQKHXzUceIKY2EdpukZdIlxUa
+hRRXKCu+ZOxys4FDJzWgd/FEP9V6asIdVn07rDtIZX011hJLNmYaxw6/zls+gwSp
+Q8zRs/lohWnEKELDOJXG6dfWzWsE4/kYZsD5Z/6+w5uOIMCynd+IhG+HLb1Cyu2M
+gIi2o3EuWj7+RMcctNe55xs/Vm9sSlO+5cdXsfDEPrwwF69Gd4ArHRjTojQj+X0h
+vXzKdgjEPsEqgaa/Ln0h7MT6R9dh2feHmDEFRb1eyA4v/79IhbGypdy639SdiRAI
+//FpDrrZm+zlpoVtOpd6vj2YcPSIsMCHQ5osw7hlwt57o3pEyzqlR1O2zHGzngWk
+09PzcVPm94Tza0pXxChJpk3Sn0cxpGgTpzLcjSOIj7ZWHZ/4kXGy5njs1/lHAMcY
+mioZOKuIVNZVC92bbp/uiNQP4NoaGVTGyX0qwFo81+Nb6Z0xdYsNHjp1gW0lD7m/
+hhlMQT83WgApkEUcMhf9jjuN3EYQI8D07r2TbhJZ9Rc5DDlwFq04jkTrEMgDhULK
+JzkncgTsrpWKC0bi4MB6ky7/gcUBTsPr7/zF2czstru8LSqc3Q61lg==
diff --git a/src/test/resources/keys/rsa-openssl-des.pem b/src/test/resources/keys/rsa-openssl-des.pem
new file mode 100644
index 0000000..4551e02
--- /dev/null
+++ b/src/test/resources/keys/rsa-openssl-des.pem
@@ -0,0 +1,18 @@
+-----BEGIN RSA PRIVATE KEY-----
+Proc-Type: 4,ENCRYPTED
+DEK-Info: DES-CBC,C65E6ED4FD6921EF
+
+w9CNDiUxGP8ve2L/4M0vvfIwvwXmEfAYdVt2jFv5waqeZjrwq0eG8Xup1kSAHgaX
+NOX/J3GVH0F8rlMdFDdOIT3+DgDFr5hV1Pcy8e5aI+8tP0VICXGeh8tPttFj5m7k
+RupYtbDF6e71odLwe+9JvoWknUQYl/t7nQx/1GTa7ZKPdTKWq9r8HUJCCzAaG7xQ
+e44C+G1r3C9TVoI/QpPUwWgiDs9scjQRnli83OTBQKHXzUceIKY2EdpukZdIlxUa
+hRRXKCu+ZOxys4FDJzWgd/FEP9V6asIdVn07rDtIZX011hJLNmYaxw6/zls+gwSp
+Q8zRs/lohWnEKELDOJXG6dfWzWsE4/kYZsD5Z/6+w5uOIMCynd+IhG+HLb1Cyu2M
+gIi2o3EuWj7+RMcctNe55xs/Vm9sSlO+5cdXsfDEPrwwF69Gd4ArHRjTojQj+X0h
+vXzKdgjEPsEqgaa/Ln0h7MT6R9dh2feHmDEFRb1eyA4v/79IhbGypdy639SdiRAI
+//FpDrrZm+zlpoVtOpd6vj2YcPSIsMCHQ5osw7hlwt57o3pEyzqlR1O2zHGzngWk
+09PzcVPm94Tza0pXxChJpk3Sn0cxpGgTpzLcjSOIj7ZWHZ/4kXGy5njs1/lHAMcY
+mioZOKuIVNZVC92bbp/uiNQP4NoaGVTGyX0qwFo81+Nb6Z0xdYsNHjp1gW0lD7m/
+hhlMQT83WgApkEUcMhf9jjuN3EYQI8D07r2TbhJZ9Rc5DDlwFq04jkTrEMgDhULK
+JzkncgTsrpWKC0bi4MB6ky7/gcUBTsPr7/zF2czstru8LSqc3Q61lg==
+-----END RSA PRIVATE KEY-----
diff --git a/src/test/resources/keys/rsa-openssl-des3.pem b/src/test/resources/keys/rsa-openssl-des3.pem
new file mode 100644
index 0000000..3337dcd
--- /dev/null
+++ b/src/test/resources/keys/rsa-openssl-des3.pem
@@ -0,0 +1,30 @@
+-----BEGIN RSA PRIVATE KEY-----
+Proc-Type: 4,ENCRYPTED
+DEK-Info: DES-EDE3-CBC,030694C414709D5A
+
+9RGwZOuXp57EMb+PNOH80HX3iprxbFkKH6y1wIddC1VmyohyVY3JMY2sCy0RfU+Z
+/LSahlmq5fwmP9LdFvnxa9jj0A/9fbytUvg6AN3cHHalgFDEKXs/vXAu3katirNa
+t0wVqWKPARJSHHpNy7Zd2XyUN8i+dyWxRU/K/324K9fcRr51oiiEpl2H3DDlBqJE
+VY1a/odRUKm1YGrsN1KvZw91iCv2QfeziqCw/HQ/ehAiEqXXGpxi2zw2tPCuJM1S
+Qi7VAPXmyORInm0YWS0kLkNMUKjJW8uka9Mv7Fz23pQvTipHIul9XTEZWe46tMTv
+ueWdkqWBpmgP9is9NOEdZx+iJigJjgSeNApZjLF9HKkf9/45lcbGQS/FtUXnp1Qf
+MMT8V+CR5wH87kLOAJEkkpQkYKWOw52xlI+WQZrtZe7GsVo6uXRS+Yp1xyGHYYok
+AbTh0z+JSuViDXE80G0zn2hMaAcC/zk4UXcmCbXNDhv4uIcT6pE6n/HCbzbMUVYF
+rOhT/XhA4wZGeGp26J4COqsJHPTBgHbsmKrONwCogm3yCx5BndRlE+tKykBqUFke
+/AliECal85HfoW+VL5ojukB7C+Wy62ioG2qYepfEXKJI+j14GkQrd4bbn7huj14S
+5FdXxjoWe8Ld6ZOJO8QKF8vMpnNp87hWaXXRYrfvKyEBfvUcnU3f8OBVdsDy3LX8
+HPyCGNWBsuRdhxsuFVSgxP1COQ6FdOGYvZx36JXC+nRFxSd1n9BxZf5qqEdTAH2w
+nQiwJVbVtw/8d+vqpaavWn9/v9c+9sTrYUwoajBBOoEWiSOId+JHkdOnuAgE2OvL
+DtlzeB64VK6e4AU+6OSZzufZW1XiCRjYqEB+W0s8hYj5EB+W9hRIaEfA1NMMfsyG
+irCsIZYQD7lP+yGUICcvk2eNiU4ZwcViBq1Rv8JLimFn1X+6je2dcRmtmAby+OWA
+cx3WpnZ04mbI9AnrILa7qnrE7GqLtOpFDwtsQ5kc0//7zadPCg34kf96WEVHNCfW
+EZ0FKno0PtYbxh8bU0YnUpi3IczpbCW1e2BjvIskR80MQaz2ZSEkbsV0+uNBzOia
+8Cjmo1JrdpfeE5NSiEZo5p7QqOEP3t3kL2I0mAZ8qhhN/NUTuAQAP6Lu1bncRyUT
+MXiHhdeOJ6LvWZ0+YehlG5sOpOfVgRKeyGI8HjTVFaff9RdTuYPEZAlDkxgwsHuF
+3isenNHhH3LMRzlcjTzy19q2DUxIOxq1Nm8fkf5448fD9bNlXHmVuLBUe8m2JfAv
+JcP9a0PnCGPuvcSyQqKbtXkydk+4MJrB/55gEb/uClkttrCJXUKI6LvrqAH9+CNI
+Jn9YZ3G/ApcTsA3h/kQNoZcIPUli/bRr6wIBs1Q3VBoUEJK1f27K2LlmqsdWVqwu
+gMBJwIGtUmp32nVAjy+Tkb2Xexe6V7r8Rd1oiy/ewlpU6xTo68UpATeb8PmsCk3d
+DNpqA/ykaWz90UsSxfhadxocRxNKjZQhJC/xkDmG1x7EUGzPR3ESrMsTsP+WGLWl
+vORgbbVMakP6bomJLAREkfVmtQnLtSuR4ZaU8HkBKiX0Rw10n7TROQ==
+-----END RSA PRIVATE KEY-----
diff --git a/src/test/resources/keys/rsa-openssl-nopass.der b/src/test/resources/keys/rsa-openssl-nopass.der
new file mode 100644
index 0000000..f4da603
--- /dev/null
+++ b/src/test/resources/keys/rsa-openssl-nopass.der
Binary files differ
diff --git a/src/test/resources/keys/rsa-openssl-nopass.pem b/src/test/resources/keys/rsa-openssl-nopass.pem
new file mode 100644
index 0000000..c635117
--- /dev/null
+++ b/src/test/resources/keys/rsa-openssl-nopass.pem
@@ -0,0 +1,15 @@
+-----BEGIN RSA PRIVATE KEY-----
+MIICXAIBAAKBgQDkYYrsisNd/+lNvWR3ridkbPg0VJKc9juQAnSrdh3rV99wBtg0
+SQeao5MUPwsmLG3/aQmzNhaFNPeWHcQZDmqZO1QLTB7rFX2xZn3t4uD2+yn764ln
+L10qliZmGCbs6wB4LLrnR50hWYx3MgisdeHe4uOc18FVGvGs8+2k0Im/yQIDAQAB
+AoGAbGEx6XEsp6YzlHXlgLo/7XOdElB0R1K/D2dq8JecOTw5R9OntKBXoHYx7TDB
+3LrG9KdnOAnaBBsPx8iWcAGcafezh9xvKiLANOju3lAgfg+6c8AE941zehfqMk3V
+nxstWYdMT5G5MmgxMEcwDU1iQYIHQKqHQKsDLBywRd858MUCQQD9YNC9D5qxq9WD
+ClJxD2AkAVnG7IKylnfNX9QfPxkkhCyzMAQTHsbGBi14FHbLlzqskcAS3o2qg+9R
+wY7ldd5nAkEA5r6C3uDFFcKRbW8NpRv0/NZPCVY7Fby3timVAmWVsE6KLNIF5kQG
+sD0M/COAmVBrsbERRyuys9XuTDoCHjYyTwJAdwBNiT4W7XNC6DSk26zY/pAT1jWm
+fLHmunJTcgl0iY36YH4gq8o8mSy1ljwmPyBb0kjx6OrVpkwozkEWF/bv6QJBALg1
+n4UPLEgS7MbQwbPufcbb0H1DuifAYWmsCKnBL59xFB50DQGnjS9ljdg8/41mBpP1
+KDYJTMEFKRjxtn8oBUECQEq9Rhvw4hmi0grQMnftbYAtGcRxEbngAZTnNtSC7Dyy
+5453+by1CM1cI6FE39rCFW1FPHM36BW7GFaVqjQMk3M=
+-----END RSA PRIVATE KEY-----
diff --git a/src/test/resources/keys/rsa-pkcs8-nopass-noheader.pem b/src/test/resources/keys/rsa-pkcs8-nopass-noheader.pem
new file mode 100644
index 0000000..48dbfa4
--- /dev/null
+++ b/src/test/resources/keys/rsa-pkcs8-nopass-noheader.pem
@@ -0,0 +1,14 @@
+MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAORhiuyKw13/6U29
+ZHeuJ2Rs+DRUkpz2O5ACdKt2HetX33AG2DRJB5qjkxQ/CyYsbf9pCbM2FoU095Yd
+xBkOapk7VAtMHusVfbFmfe3i4Pb7KfvriWcvXSqWJmYYJuzrAHgsuudHnSFZjHcy
+CKx14d7i45zXwVUa8azz7aTQib/JAgMBAAECgYBsYTHpcSynpjOUdeWAuj/tc50S
+UHRHUr8PZ2rwl5w5PDlH06e0oFegdjHtMMHcusb0p2c4CdoEGw/HyJZwAZxp97OH
+3G8qIsA06O7eUCB+D7pzwAT3jXN6F+oyTdWfGy1Zh0xPkbkyaDEwRzANTWJBggdA
+qodAqwMsHLBF3znwxQJBAP1g0L0PmrGr1YMKUnEPYCQBWcbsgrKWd81f1B8/GSSE
+LLMwBBMexsYGLXgUdsuXOqyRwBLejaqD71HBjuV13mcCQQDmvoLe4MUVwpFtbw2l
+G/T81k8JVjsVvLe2KZUCZZWwToos0gXmRAawPQz8I4CZUGuxsRFHK7Kz1e5MOgIe
+NjJPAkB3AE2JPhbtc0LoNKTbrNj+kBPWNaZ8sea6clNyCXSJjfpgfiCryjyZLLWW
+PCY/IFvSSPHo6tWmTCjOQRYX9u/pAkEAuDWfhQ8sSBLsxtDBs+59xtvQfUO6J8Bh
+aawIqcEvn3EUHnQNAaeNL2WN2Dz/jWYGk/UoNglMwQUpGPG2fygFQQJASr1GG/Di
+GaLSCtAyd+1tgC0ZxHERueABlOc21ILsPLLnjnf5vLUIzVwjoUTf2sIVbUU8czfo
+FbsYVpWqNAyTcw==
diff --git a/src/test/resources/keys/rsa-pkcs8-nopass.der b/src/test/resources/keys/rsa-pkcs8-nopass.der
new file mode 100644
index 0000000..5a7f1d4
--- /dev/null
+++ b/src/test/resources/keys/rsa-pkcs8-nopass.der
Binary files differ
diff --git a/src/test/resources/keys/rsa-pkcs8-nopass.pem b/src/test/resources/keys/rsa-pkcs8-nopass.pem
new file mode 100644
index 0000000..d58631b
--- /dev/null
+++ b/src/test/resources/keys/rsa-pkcs8-nopass.pem
@@ -0,0 +1,16 @@
+-----BEGIN PRIVATE KEY-----
+MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAORhiuyKw13/6U29
+ZHeuJ2Rs+DRUkpz2O5ACdKt2HetX33AG2DRJB5qjkxQ/CyYsbf9pCbM2FoU095Yd
+xBkOapk7VAtMHusVfbFmfe3i4Pb7KfvriWcvXSqWJmYYJuzrAHgsuudHnSFZjHcy
+CKx14d7i45zXwVUa8azz7aTQib/JAgMBAAECgYBsYTHpcSynpjOUdeWAuj/tc50S
+UHRHUr8PZ2rwl5w5PDlH06e0oFegdjHtMMHcusb0p2c4CdoEGw/HyJZwAZxp97OH
+3G8qIsA06O7eUCB+D7pzwAT3jXN6F+oyTdWfGy1Zh0xPkbkyaDEwRzANTWJBggdA
+qodAqwMsHLBF3znwxQJBAP1g0L0PmrGr1YMKUnEPYCQBWcbsgrKWd81f1B8/GSSE
+LLMwBBMexsYGLXgUdsuXOqyRwBLejaqD71HBjuV13mcCQQDmvoLe4MUVwpFtbw2l
+G/T81k8JVjsVvLe2KZUCZZWwToos0gXmRAawPQz8I4CZUGuxsRFHK7Kz1e5MOgIe
+NjJPAkB3AE2JPhbtc0LoNKTbrNj+kBPWNaZ8sea6clNyCXSJjfpgfiCryjyZLLWW
+PCY/IFvSSPHo6tWmTCjOQRYX9u/pAkEAuDWfhQ8sSBLsxtDBs+59xtvQfUO6J8Bh
+aawIqcEvn3EUHnQNAaeNL2WN2Dz/jWYGk/UoNglMwQUpGPG2fygFQQJASr1GG/Di
+GaLSCtAyd+1tgC0ZxHERueABlOc21ILsPLLnjnf5vLUIzVwjoUTf2sIVbUU8czfo
+FbsYVpWqNAyTcw==
+-----END PRIVATE KEY-----
diff --git a/src/test/resources/keys/rsa-pkcs8-v1-md5-des.der b/src/test/resources/keys/rsa-pkcs8-v1-md5-des.der
new file mode 100644
index 0000000..4a34039
--- /dev/null
+++ b/src/test/resources/keys/rsa-pkcs8-v1-md5-des.der
Binary files differ
diff --git a/src/test/resources/keys/rsa-pkcs8-v1-md5-des.pem b/src/test/resources/keys/rsa-pkcs8-v1-md5-des.pem
new file mode 100644
index 0000000..936e555
--- /dev/null
+++ b/src/test/resources/keys/rsa-pkcs8-v1-md5-des.pem
@@ -0,0 +1,17 @@
+-----BEGIN ENCRYPTED PRIVATE KEY-----
+MIICoTAbBgkqhkiG9w0BBQMwDgQIGhFw3dgb3b8CAggABIICgO7i7wykRw78u/Nx
+swPSeJqV4Gvw38t4jGQr5arL8+z5T85URGRI102+pGBf7NtJP6U10rf24+KZL3jb
+9ZhhKDr90dTZPl5XhCFygUNTsusenoW53Tf1Nh2ZbNPB72keckG3or79qgFD5ddp
+ZUq9HhOXksyqZm+SV8qgewXFct+4+PQMK2/CN7qF05Uw9LraTZburh2TnlA5tdwT
+RbPPnj+vYBLJ6liJtGnTxVq6oS9CpGK00NgvFn4OvsW8+nGrTKCvOACvdIrxHiOk
+XCS5Inii58p/bo/v90eajxza+1fWlhDEi0I2+UZrxn1GpcZUKUGsjSMze8bLtUa4
+AMzYKi1nds1bg4FlgLUS58B2x4qwlLcliGYg9bR2iuiHKp21Yi9jILo+Rqc38/a+
+kqI1Gks0RA2ifOrATEogZr2ivK3hf2vgzgIM0UUBQJaddFStIxndOV4c3RDJX0lM
+5SbjvS6HHa6SF2lfLcrV6d4G3VzMIIhiwquREmy1t1CiYM3YhiEVBnB7EgLf2Hm6
+5ul/FmDqg1cdie8nToyTvDlOSC2Qeql3Zo7RW88dupedkrAOOIW739yoFS7Oo1Rd
+e3wh7P1GRxfvrJGZWugG1X4u44zis4iA0sxoAoCXo0qbYinb7iCr8W7JLvN9HB/G
+Mr+/hIuNKGVfTZqbP0Lcq978kCT/BZuH706K1enVL392lsTN3fUcdhHR+0o/Ym61
+SNsdc0wC5jLJ0G8hDJ/o/iUAIvj0iN/cJQ+kJiKaQiDMLgkDtDlgi4x8WaqB+ZWH
+oZtrQ2VcWBy4Octr9w/0qkbis3NXUJHZ3lCe5srU/4ofOCZzPx2jdJ5GvHqr8MOe
+mye2GBQ=
+-----END ENCRYPTED PRIVATE KEY-----
diff --git a/src/test/resources/keys/rsa-pkcs8-v1-md5-rc2-64.der b/src/test/resources/keys/rsa-pkcs8-v1-md5-rc2-64.der
new file mode 100644
index 0000000..20dddb1
--- /dev/null
+++ b/src/test/resources/keys/rsa-pkcs8-v1-md5-rc2-64.der
Binary files differ
diff --git a/src/test/resources/keys/rsa-pkcs8-v2-aes256-noheader.pem b/src/test/resources/keys/rsa-pkcs8-v2-aes256-noheader.pem
new file mode 100644
index 0000000..712fad2
--- /dev/null
+++ b/src/test/resources/keys/rsa-pkcs8-v2-aes256-noheader.pem
@@ -0,0 +1,16 @@
+MIICzzBJBgkqhkiG9w0BBQ0wPDAbBgkqhkiG9w0BBQwwDgQICbSpN5NE6iYCAggA
+MB0GCWCGSAFlAwQBKgQQsTIY+PyJiePgRY0nL+SD1wSCAoCmvaOpL9cSSYtpLD/S
+3Xc21VU/lu6O0FkT5fs5gxFkbnrp5ykPpdhAvlPGVAtoGDN/WnDBrYET+JCDxB12
+8PsrAW5vd9sXs4YjQfdIrUDuqYllEbNo6Rp/8hvSroxT+x7RDtwAIB2eJMOjEZqN
+onAIHhCMf6zz+K/zXONxnCw0APgr6gBMlNn7JvElfOsCTOWjk7x3BPid7qoR4pBt
+EAtjsGpo6xhhc+8yQt4t1M6o8476Th/pqm1WB03LMyjPkXJXnLoP0GHXhVvLYg5y
++EH9S3FLHz4SiIYM2O1uG7ji/4ByoPOE/xvuNgtNLVtNrRdgMrGQukntBbbiUZL6
+qjr4Qa6EHrivXov8w5YAVrqSjfA7S7cW+qgKbpmnB+IDvClkpu24gvsqKdCThgLN
+HI+z9VnoBVbj+We+iKbQEytNXGiH8kls2aY5U0okii8ZWnTu1OXwyzl+vVOnQ5qU
+MYPmzEVPpmmArZTc1gF7udC4jwJcPnzzqFAzoc6ttQ2lX3JsZ9Ms30/WmQqEFEXj
+DJABY2r5Qkli+7ViGbXC9AB6+/yW1RahNe9kenSDUcZuGGlWlXc2Nv8Mx73FeYVc
+o24JfTiIzHlTGlOF3DAB/sOghDgXDkOd/C0+ooslih4hnfdE/0P0DGCgnuYWkk0/
+7Xz/ztRMBijn6kfm2q6EN4RDH98Tro1i+CCyh2T6LX1gC8jiJKcBcKPTuZ5Kd7U6
+1coUyluOdb9wJ2sCvan/oKbJPaTVp+AdTLLtGKtg8sCeuw73AdSv2mgRryL18dOw
+6oOWwXsiSQ8m+mFJgNW36BWmDIJGxej9SoO7gv+Qvqr2Ip72on82EOtgy3cUwZ9l
+w1oB
diff --git a/src/test/resources/keys/rsa-pkcs8-v2-aes256.der b/src/test/resources/keys/rsa-pkcs8-v2-aes256.der
new file mode 100644
index 0000000..ef50081
--- /dev/null
+++ b/src/test/resources/keys/rsa-pkcs8-v2-aes256.der
Binary files differ
diff --git a/src/test/resources/keys/rsa-pkcs8-v2-aes256.pem b/src/test/resources/keys/rsa-pkcs8-v2-aes256.pem
new file mode 100644
index 0000000..3f7a018
--- /dev/null
+++ b/src/test/resources/keys/rsa-pkcs8-v2-aes256.pem
@@ -0,0 +1,18 @@
+-----BEGIN ENCRYPTED PRIVATE KEY-----
+MIICzzBJBgkqhkiG9w0BBQ0wPDAbBgkqhkiG9w0BBQwwDgQICbSpN5NE6iYCAggA
+MB0GCWCGSAFlAwQBKgQQsTIY+PyJiePgRY0nL+SD1wSCAoCmvaOpL9cSSYtpLD/S
+3Xc21VU/lu6O0FkT5fs5gxFkbnrp5ykPpdhAvlPGVAtoGDN/WnDBrYET+JCDxB12
+8PsrAW5vd9sXs4YjQfdIrUDuqYllEbNo6Rp/8hvSroxT+x7RDtwAIB2eJMOjEZqN
+onAIHhCMf6zz+K/zXONxnCw0APgr6gBMlNn7JvElfOsCTOWjk7x3BPid7qoR4pBt
+EAtjsGpo6xhhc+8yQt4t1M6o8476Th/pqm1WB03LMyjPkXJXnLoP0GHXhVvLYg5y
++EH9S3FLHz4SiIYM2O1uG7ji/4ByoPOE/xvuNgtNLVtNrRdgMrGQukntBbbiUZL6
+qjr4Qa6EHrivXov8w5YAVrqSjfA7S7cW+qgKbpmnB+IDvClkpu24gvsqKdCThgLN
+HI+z9VnoBVbj+We+iKbQEytNXGiH8kls2aY5U0okii8ZWnTu1OXwyzl+vVOnQ5qU
+MYPmzEVPpmmArZTc1gF7udC4jwJcPnzzqFAzoc6ttQ2lX3JsZ9Ms30/WmQqEFEXj
+DJABY2r5Qkli+7ViGbXC9AB6+/yW1RahNe9kenSDUcZuGGlWlXc2Nv8Mx73FeYVc
+o24JfTiIzHlTGlOF3DAB/sOghDgXDkOd/C0+ooslih4hnfdE/0P0DGCgnuYWkk0/
+7Xz/ztRMBijn6kfm2q6EN4RDH98Tro1i+CCyh2T6LX1gC8jiJKcBcKPTuZ5Kd7U6
+1coUyluOdb9wJ2sCvan/oKbJPaTVp+AdTLLtGKtg8sCeuw73AdSv2mgRryL18dOw
+6oOWwXsiSQ8m+mFJgNW36BWmDIJGxej9SoO7gv+Qvqr2Ip72on82EOtgy3cUwZ9l
+w1oB
+-----END ENCRYPTED PRIVATE KEY-----
diff --git a/src/test/resources/keys/rsa-pub.der b/src/test/resources/keys/rsa-pub.der
new file mode 100644
index 0000000..f62dba8
--- /dev/null
+++ b/src/test/resources/keys/rsa-pub.der
Binary files differ
diff --git a/src/test/resources/keys/rsa-pub.pem b/src/test/resources/keys/rsa-pub.pem
new file mode 100644
index 0000000..bd22ce2
--- /dev/null
+++ b/src/test/resources/keys/rsa-pub.pem
@@ -0,0 +1,6 @@
+-----BEGIN PUBLIC KEY-----
+MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDkYYrsisNd/+lNvWR3ridkbPg0
+VJKc9juQAnSrdh3rV99wBtg0SQeao5MUPwsmLG3/aQmzNhaFNPeWHcQZDmqZO1QL
+TB7rFX2xZn3t4uD2+yn764lnL10qliZmGCbs6wB4LLrnR50hWYx3MgisdeHe4uOc
+18FVGvGs8+2k0Im/yQIDAQAB
+-----END PUBLIC KEY-----
diff --git a/src/test/resources/keystores/alpha.p12 b/src/test/resources/keystores/alpha.p12
new file mode 100644
index 0000000..0245c8c
--- /dev/null
+++ b/src/test/resources/keystores/alpha.p12
Binary files differ
diff --git a/src/test/resources/keystores/cipher-bean.jceks b/src/test/resources/keystores/cipher-bean.jceks
new file mode 100644
index 0000000..1723ccb
--- /dev/null
+++ b/src/test/resources/keystores/cipher-bean.jceks
Binary files differ
diff --git a/src/test/resources/keystores/factory-bean.jceks b/src/test/resources/keystores/factory-bean.jceks
new file mode 100644
index 0000000..305bcc3
--- /dev/null
+++ b/src/test/resources/keystores/factory-bean.jceks
Binary files differ
diff --git a/src/test/resources/keystores/keystore.bks b/src/test/resources/keystores/keystore.bks
new file mode 100644
index 0000000..b5bc01a
--- /dev/null
+++ b/src/test/resources/keystores/keystore.bks
Binary files differ
diff --git a/src/test/resources/keystores/keystore.jceks b/src/test/resources/keystores/keystore.jceks
new file mode 100644
index 0000000..aaddfd7
--- /dev/null
+++ b/src/test/resources/keystores/keystore.jceks
Binary files differ
diff --git a/src/test/resources/keystores/keystore.jks b/src/test/resources/keystores/keystore.jks
new file mode 100644
index 0000000..3ca2902
--- /dev/null
+++ b/src/test/resources/keystores/keystore.jks
Binary files differ
diff --git a/src/test/resources/keystores/keystore.p12 b/src/test/resources/keystores/keystore.p12
new file mode 100644
index 0000000..2e4447a
--- /dev/null
+++ b/src/test/resources/keystores/keystore.p12
Binary files differ
diff --git a/src/test/resources/plaintexts/lorem-1190.txt b/src/test/resources/plaintexts/lorem-1190.txt
new file mode 100644
index 0000000..d018f3d
--- /dev/null
+++ b/src/test/resources/plaintexts/lorem-1190.txt
@@ -0,0 +1,12 @@
+Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean nunc leo, adipiscing et ultrices a, fermentum eget 
+quam. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum mattis, risus tempus ultricies luctus, nisi 
+elit consectetur velit, lacinia fringilla urna lectus sit amet nunc. Nunc nec dictum arcu. Duis id aliquet nulla. In ut 
+augue elit. Phasellus semper ut lacus ac sagittis. Nunc elementum, dui nec ornare bibendum, ligula ante vulputate nisi, 
+in imperdiet lectus eros rhoncus augue. Maecenas non interdum metus, vitae tempor mi.
+
+Sed rutrum, lorem quis varius tincidunt, augue elit accumsan nisl, sit amet gravida odio massa ultrices urna. Morbi 
+suscipit mauris nec eros iaculis, vel tempus mauris bibendum. Phasellus tempor enim eget commodo gravida. Sed et risus 
+quis orci eleifend ullamcorper. Ut et nisi et sem convallis sodales. Proin nec nibh non metus condimentum vehicula. 
+Maecenas ut felis tellus. Cras id diam diam. Nam eu ultricies nunc, in fermentum tellus. Donec et libero a augue 
+fermentum congue. Nulla vitae augue arcu. Donec mollis faucibus est, ac consectetur lectus aliquet at. Class aptent 
+taciti sociosqu ad litora torquent per conubia nost
\ No newline at end of file
diff --git a/src/test/resources/plaintexts/lorem-1190.txt.b64 b/src/test/resources/plaintexts/lorem-1190.txt.b64
new file mode 100644
index 0000000..540b45b
--- /dev/null
+++ b/src/test/resources/plaintexts/lorem-1190.txt.b64
@@ -0,0 +1,25 @@
+TG9yZW0gaXBzdW0gZG9sb3Igc2l0IGFtZXQsIGNvbnNlY3RldHVyIGFkaXBpc2Np
+bmcgZWxpdC4gQWVuZWFuIG51bmMgbGVvLCBhZGlwaXNjaW5nIGV0IHVsdHJpY2Vz
+IGEsIGZlcm1lbnR1bSBlZ2V0IApxdWFtLiBMb3JlbSBpcHN1bSBkb2xvciBzaXQg
+YW1ldCwgY29uc2VjdGV0dXIgYWRpcGlzY2luZyBlbGl0LiBWZXN0aWJ1bHVtIG1h
+dHRpcywgcmlzdXMgdGVtcHVzIHVsdHJpY2llcyBsdWN0dXMsIG5pc2kgCmVsaXQg
+Y29uc2VjdGV0dXIgdmVsaXQsIGxhY2luaWEgZnJpbmdpbGxhIHVybmEgbGVjdHVz
+IHNpdCBhbWV0IG51bmMuIE51bmMgbmVjIGRpY3R1bSBhcmN1LiBEdWlzIGlkIGFs
+aXF1ZXQgbnVsbGEuIEluIHV0IAphdWd1ZSBlbGl0LiBQaGFzZWxsdXMgc2VtcGVy
+IHV0IGxhY3VzIGFjIHNhZ2l0dGlzLiBOdW5jIGVsZW1lbnR1bSwgZHVpIG5lYyBv
+cm5hcmUgYmliZW5kdW0sIGxpZ3VsYSBhbnRlIHZ1bHB1dGF0ZSBuaXNpLCAKaW4g
+aW1wZXJkaWV0IGxlY3R1cyBlcm9zIHJob25jdXMgYXVndWUuIE1hZWNlbmFzIG5v
+biBpbnRlcmR1bSBtZXR1cywgdml0YWUgdGVtcG9yIG1pLgoKU2VkIHJ1dHJ1bSwg
+bG9yZW0gcXVpcyB2YXJpdXMgdGluY2lkdW50LCBhdWd1ZSBlbGl0IGFjY3Vtc2Fu
+IG5pc2wsIHNpdCBhbWV0IGdyYXZpZGEgb2RpbyBtYXNzYSB1bHRyaWNlcyB1cm5h
+LiBNb3JiaSAKc3VzY2lwaXQgbWF1cmlzIG5lYyBlcm9zIGlhY3VsaXMsIHZlbCB0
+ZW1wdXMgbWF1cmlzIGJpYmVuZHVtLiBQaGFzZWxsdXMgdGVtcG9yIGVuaW0gZWdl
+dCBjb21tb2RvIGdyYXZpZGEuIFNlZCBldCByaXN1cyAKcXVpcyBvcmNpIGVsZWlm
+ZW5kIHVsbGFtY29ycGVyLiBVdCBldCBuaXNpIGV0IHNlbSBjb252YWxsaXMgc29k
+YWxlcy4gUHJvaW4gbmVjIG5pYmggbm9uIG1ldHVzIGNvbmRpbWVudHVtIHZlaGlj
+dWxhLiAKTWFlY2VuYXMgdXQgZmVsaXMgdGVsbHVzLiBDcmFzIGlkIGRpYW0gZGlh
+bS4gTmFtIGV1IHVsdHJpY2llcyBudW5jLCBpbiBmZXJtZW50dW0gdGVsbHVzLiBE
+b25lYyBldCBsaWJlcm8gYSBhdWd1ZSAKZmVybWVudHVtIGNvbmd1ZS4gTnVsbGEg
+dml0YWUgYXVndWUgYXJjdS4gRG9uZWMgbW9sbGlzIGZhdWNpYnVzIGVzdCwgYWMg
+Y29uc2VjdGV0dXIgbGVjdHVzIGFsaXF1ZXQgYXQuIENsYXNzIGFwdGVudCAKdGFj
+aXRpIHNvY2lvc3F1IGFkIGxpdG9yYSB0b3JxdWVudCBwZXIgY29udWJpYSBub3N0
diff --git a/src/test/resources/plaintexts/lorem-1190.txt.b64.crlf b/src/test/resources/plaintexts/lorem-1190.txt.b64.crlf
new file mode 100644
index 0000000..d4e0736
--- /dev/null
+++ b/src/test/resources/plaintexts/lorem-1190.txt.b64.crlf
@@ -0,0 +1,25 @@
+TG9yZW0gaXBzdW0gZG9sb3Igc2l0IGFtZXQsIGNvbnNlY3RldHVyIGFkaXBpc2Np

+bmcgZWxpdC4gQWVuZWFuIG51bmMgbGVvLCBhZGlwaXNjaW5nIGV0IHVsdHJpY2Vz

+IGEsIGZlcm1lbnR1bSBlZ2V0IApxdWFtLiBMb3JlbSBpcHN1bSBkb2xvciBzaXQg

+YW1ldCwgY29uc2VjdGV0dXIgYWRpcGlzY2luZyBlbGl0LiBWZXN0aWJ1bHVtIG1h

+dHRpcywgcmlzdXMgdGVtcHVzIHVsdHJpY2llcyBsdWN0dXMsIG5pc2kgCmVsaXQg

+Y29uc2VjdGV0dXIgdmVsaXQsIGxhY2luaWEgZnJpbmdpbGxhIHVybmEgbGVjdHVz

+IHNpdCBhbWV0IG51bmMuIE51bmMgbmVjIGRpY3R1bSBhcmN1LiBEdWlzIGlkIGFs

+aXF1ZXQgbnVsbGEuIEluIHV0IAphdWd1ZSBlbGl0LiBQaGFzZWxsdXMgc2VtcGVy

+IHV0IGxhY3VzIGFjIHNhZ2l0dGlzLiBOdW5jIGVsZW1lbnR1bSwgZHVpIG5lYyBv

+cm5hcmUgYmliZW5kdW0sIGxpZ3VsYSBhbnRlIHZ1bHB1dGF0ZSBuaXNpLCAKaW4g

+aW1wZXJkaWV0IGxlY3R1cyBlcm9zIHJob25jdXMgYXVndWUuIE1hZWNlbmFzIG5v

+biBpbnRlcmR1bSBtZXR1cywgdml0YWUgdGVtcG9yIG1pLgoKU2VkIHJ1dHJ1bSwg

+bG9yZW0gcXVpcyB2YXJpdXMgdGluY2lkdW50LCBhdWd1ZSBlbGl0IGFjY3Vtc2Fu

+IG5pc2wsIHNpdCBhbWV0IGdyYXZpZGEgb2RpbyBtYXNzYSB1bHRyaWNlcyB1cm5h

+LiBNb3JiaSAKc3VzY2lwaXQgbWF1cmlzIG5lYyBlcm9zIGlhY3VsaXMsIHZlbCB0

+ZW1wdXMgbWF1cmlzIGJpYmVuZHVtLiBQaGFzZWxsdXMgdGVtcG9yIGVuaW0gZWdl

+dCBjb21tb2RvIGdyYXZpZGEuIFNlZCBldCByaXN1cyAKcXVpcyBvcmNpIGVsZWlm

+ZW5kIHVsbGFtY29ycGVyLiBVdCBldCBuaXNpIGV0IHNlbSBjb252YWxsaXMgc29k

+YWxlcy4gUHJvaW4gbmVjIG5pYmggbm9uIG1ldHVzIGNvbmRpbWVudHVtIHZlaGlj

+dWxhLiAKTWFlY2VuYXMgdXQgZmVsaXMgdGVsbHVzLiBDcmFzIGlkIGRpYW0gZGlh

+bS4gTmFtIGV1IHVsdHJpY2llcyBudW5jLCBpbiBmZXJtZW50dW0gdGVsbHVzLiBE

+b25lYyBldCBsaWJlcm8gYSBhdWd1ZSAKZmVybWVudHVtIGNvbmd1ZS4gTnVsbGEg

+dml0YWUgYXVndWUgYXJjdS4gRG9uZWMgbW9sbGlzIGZhdWNpYnVzIGVzdCwgYWMg

+Y29uc2VjdGV0dXIgbGVjdHVzIGFsaXF1ZXQgYXQuIENsYXNzIGFwdGVudCAKdGFj

+aXRpIHNvY2lvc3F1IGFkIGxpdG9yYSB0b3JxdWVudCBwZXIgY29udWJpYSBub3N0

diff --git a/src/test/resources/plaintexts/lorem-1200.txt b/src/test/resources/plaintexts/lorem-1200.txt
new file mode 100644
index 0000000..0eb4195
--- /dev/null
+++ b/src/test/resources/plaintexts/lorem-1200.txt
@@ -0,0 +1,12 @@
+Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean nunc leo, adipiscing et ultrices a, fermentum eget 
+quam. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum mattis, risus tempus ultricies luctus, nisi 
+elit consectetur velit, lacinia fringilla urna lectus sit amet nunc. Nunc nec dictum arcu. Duis id aliquet nulla. In ut 
+augue elit. Phasellus semper ut lacus ac sagittis. Nunc elementum, dui nec ornare bibendum, ligula ante vulputate nisi, 
+in imperdiet lectus eros rhoncus augue. Maecenas non interdum metus, vitae tempor mi.
+
+Sed rutrum, lorem quis varius tincidunt, augue elit accumsan nisl, sit amet gravida odio massa ultrices urna. Morbi 
+suscipit mauris nec eros iaculis, vel tempus mauris bibendum. Phasellus tempor enim eget commodo gravida. Sed et risus 
+quis orci eleifend ullamcorper. Ut et nisi et sem convallis sodales. Proin nec nibh non metus condimentum vehicula. 
+Maecenas ut felis tellus. Cras id diam diam. Nam eu ultricies nunc, in fermentum tellus. Donec et libero a augue 
+fermentum congue. Nulla vitae augue arcu. Donec mollis faucibus est, ac consectetur lectus aliquet at. Class aptent 
+taciti sociosqu ad litora torquent per conubia nostra posuere.
\ No newline at end of file
diff --git a/src/test/resources/plaintexts/lorem-1200.txt.b64 b/src/test/resources/plaintexts/lorem-1200.txt.b64
new file mode 100644
index 0000000..94079e4
--- /dev/null
+++ b/src/test/resources/plaintexts/lorem-1200.txt.b64
@@ -0,0 +1,26 @@
+TG9yZW0gaXBzdW0gZG9sb3Igc2l0IGFtZXQsIGNvbnNlY3RldHVyIGFkaXBpc2Np
+bmcgZWxpdC4gQWVuZWFuIG51bmMgbGVvLCBhZGlwaXNjaW5nIGV0IHVsdHJpY2Vz
+IGEsIGZlcm1lbnR1bSBlZ2V0IApxdWFtLiBMb3JlbSBpcHN1bSBkb2xvciBzaXQg
+YW1ldCwgY29uc2VjdGV0dXIgYWRpcGlzY2luZyBlbGl0LiBWZXN0aWJ1bHVtIG1h
+dHRpcywgcmlzdXMgdGVtcHVzIHVsdHJpY2llcyBsdWN0dXMsIG5pc2kgCmVsaXQg
+Y29uc2VjdGV0dXIgdmVsaXQsIGxhY2luaWEgZnJpbmdpbGxhIHVybmEgbGVjdHVz
+IHNpdCBhbWV0IG51bmMuIE51bmMgbmVjIGRpY3R1bSBhcmN1LiBEdWlzIGlkIGFs
+aXF1ZXQgbnVsbGEuIEluIHV0IAphdWd1ZSBlbGl0LiBQaGFzZWxsdXMgc2VtcGVy
+IHV0IGxhY3VzIGFjIHNhZ2l0dGlzLiBOdW5jIGVsZW1lbnR1bSwgZHVpIG5lYyBv
+cm5hcmUgYmliZW5kdW0sIGxpZ3VsYSBhbnRlIHZ1bHB1dGF0ZSBuaXNpLCAKaW4g
+aW1wZXJkaWV0IGxlY3R1cyBlcm9zIHJob25jdXMgYXVndWUuIE1hZWNlbmFzIG5v
+biBpbnRlcmR1bSBtZXR1cywgdml0YWUgdGVtcG9yIG1pLgoKU2VkIHJ1dHJ1bSwg
+bG9yZW0gcXVpcyB2YXJpdXMgdGluY2lkdW50LCBhdWd1ZSBlbGl0IGFjY3Vtc2Fu
+IG5pc2wsIHNpdCBhbWV0IGdyYXZpZGEgb2RpbyBtYXNzYSB1bHRyaWNlcyB1cm5h
+LiBNb3JiaSAKc3VzY2lwaXQgbWF1cmlzIG5lYyBlcm9zIGlhY3VsaXMsIHZlbCB0
+ZW1wdXMgbWF1cmlzIGJpYmVuZHVtLiBQaGFzZWxsdXMgdGVtcG9yIGVuaW0gZWdl
+dCBjb21tb2RvIGdyYXZpZGEuIFNlZCBldCByaXN1cyAKcXVpcyBvcmNpIGVsZWlm
+ZW5kIHVsbGFtY29ycGVyLiBVdCBldCBuaXNpIGV0IHNlbSBjb252YWxsaXMgc29k
+YWxlcy4gUHJvaW4gbmVjIG5pYmggbm9uIG1ldHVzIGNvbmRpbWVudHVtIHZlaGlj
+dWxhLiAKTWFlY2VuYXMgdXQgZmVsaXMgdGVsbHVzLiBDcmFzIGlkIGRpYW0gZGlh
+bS4gTmFtIGV1IHVsdHJpY2llcyBudW5jLCBpbiBmZXJtZW50dW0gdGVsbHVzLiBE
+b25lYyBldCBsaWJlcm8gYSBhdWd1ZSAKZmVybWVudHVtIGNvbmd1ZS4gTnVsbGEg
+dml0YWUgYXVndWUgYXJjdS4gRG9uZWMgbW9sbGlzIGZhdWNpYnVzIGVzdCwgYWMg
+Y29uc2VjdGV0dXIgbGVjdHVzIGFsaXF1ZXQgYXQuIENsYXNzIGFwdGVudCAKdGFj
+aXRpIHNvY2lvc3F1IGFkIGxpdG9yYSB0b3JxdWVudCBwZXIgY29udWJpYSBub3N0
+cmEgcG9zdWVyZS4=
diff --git a/src/test/resources/plaintexts/lorem-1200.txt.b64.crlf b/src/test/resources/plaintexts/lorem-1200.txt.b64.crlf
new file mode 100644
index 0000000..f9f5bc9
--- /dev/null
+++ b/src/test/resources/plaintexts/lorem-1200.txt.b64.crlf
@@ -0,0 +1,26 @@
+TG9yZW0gaXBzdW0gZG9sb3Igc2l0IGFtZXQsIGNvbnNlY3RldHVyIGFkaXBpc2Np

+bmcgZWxpdC4gQWVuZWFuIG51bmMgbGVvLCBhZGlwaXNjaW5nIGV0IHVsdHJpY2Vz

+IGEsIGZlcm1lbnR1bSBlZ2V0IApxdWFtLiBMb3JlbSBpcHN1bSBkb2xvciBzaXQg

+YW1ldCwgY29uc2VjdGV0dXIgYWRpcGlzY2luZyBlbGl0LiBWZXN0aWJ1bHVtIG1h

+dHRpcywgcmlzdXMgdGVtcHVzIHVsdHJpY2llcyBsdWN0dXMsIG5pc2kgCmVsaXQg

+Y29uc2VjdGV0dXIgdmVsaXQsIGxhY2luaWEgZnJpbmdpbGxhIHVybmEgbGVjdHVz

+IHNpdCBhbWV0IG51bmMuIE51bmMgbmVjIGRpY3R1bSBhcmN1LiBEdWlzIGlkIGFs

+aXF1ZXQgbnVsbGEuIEluIHV0IAphdWd1ZSBlbGl0LiBQaGFzZWxsdXMgc2VtcGVy

+IHV0IGxhY3VzIGFjIHNhZ2l0dGlzLiBOdW5jIGVsZW1lbnR1bSwgZHVpIG5lYyBv

+cm5hcmUgYmliZW5kdW0sIGxpZ3VsYSBhbnRlIHZ1bHB1dGF0ZSBuaXNpLCAKaW4g

+aW1wZXJkaWV0IGxlY3R1cyBlcm9zIHJob25jdXMgYXVndWUuIE1hZWNlbmFzIG5v

+biBpbnRlcmR1bSBtZXR1cywgdml0YWUgdGVtcG9yIG1pLgoKU2VkIHJ1dHJ1bSwg

+bG9yZW0gcXVpcyB2YXJpdXMgdGluY2lkdW50LCBhdWd1ZSBlbGl0IGFjY3Vtc2Fu

+IG5pc2wsIHNpdCBhbWV0IGdyYXZpZGEgb2RpbyBtYXNzYSB1bHRyaWNlcyB1cm5h

+LiBNb3JiaSAKc3VzY2lwaXQgbWF1cmlzIG5lYyBlcm9zIGlhY3VsaXMsIHZlbCB0

+ZW1wdXMgbWF1cmlzIGJpYmVuZHVtLiBQaGFzZWxsdXMgdGVtcG9yIGVuaW0gZWdl

+dCBjb21tb2RvIGdyYXZpZGEuIFNlZCBldCByaXN1cyAKcXVpcyBvcmNpIGVsZWlm

+ZW5kIHVsbGFtY29ycGVyLiBVdCBldCBuaXNpIGV0IHNlbSBjb252YWxsaXMgc29k

+YWxlcy4gUHJvaW4gbmVjIG5pYmggbm9uIG1ldHVzIGNvbmRpbWVudHVtIHZlaGlj

+dWxhLiAKTWFlY2VuYXMgdXQgZmVsaXMgdGVsbHVzLiBDcmFzIGlkIGRpYW0gZGlh

+bS4gTmFtIGV1IHVsdHJpY2llcyBudW5jLCBpbiBmZXJtZW50dW0gdGVsbHVzLiBE

+b25lYyBldCBsaWJlcm8gYSBhdWd1ZSAKZmVybWVudHVtIGNvbmd1ZS4gTnVsbGEg

+dml0YWUgYXVndWUgYXJjdS4gRG9uZWMgbW9sbGlzIGZhdWNpYnVzIGVzdCwgYWMg

+Y29uc2VjdGV0dXIgbGVjdHVzIGFsaXF1ZXQgYXQuIENsYXNzIGFwdGVudCAKdGFj

+aXRpIHNvY2lvc3F1IGFkIGxpdG9yYSB0b3JxdWVudCBwZXIgY29udWJpYSBub3N0

+cmEgcG9zdWVyZS4=

diff --git a/src/test/resources/plaintexts/lorem-5000.txt b/src/test/resources/plaintexts/lorem-5000.txt
new file mode 100644
index 0000000..dcfac44
--- /dev/null
+++ b/src/test/resources/plaintexts/lorem-5000.txt
@@ -0,0 +1,55 @@
+Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent tempus sagittis erat et facilisis. In quis eleifend 
+dolor. Duis sagittis porttitor risus non aliquam. Etiam malesuada purus et felis feugiat, et condimentum augue 
+sagittis. Donec posuere semper lacus eget sollicitudin. Ut feugiat diam ipsum, ut tempor turpis vehicula sed. Duis 
+euismod euismod rhoncus. Praesent vitae leo egestas neque pellentesque ultricies tincidunt et purus. Suspendisse 
+interdum blandit urna imperdiet euismod. Etiam et metus molestie nisl imperdiet cursus.
+
+Donec ullamcorper purus sed leo rutrum tincidunt. Pellentesque iaculis tristique pellentesque. Pellentesque cursus 
+porta nisl, at iaculis ligula dapibus quis. Morbi mattis, eros porta blandit ullamcorper, arcu dolor fermentum nunc, ac 
+cursus magna arcu id nulla. Quisque urna urna, dapibus et blandit vitae, placerat quis nibh. Sed ac est eget nisl 
+ultrices gravida. Aliquam id suscipit ligula. Suspendisse sollicitudin fermentum turpis. Donec auctor consectetur purus 
+eu bibendum. Pellentesque eget elit vitae augue bibendum lacinia. Quisque convallis tristique turpis id viverra. Mauris 
+aliquet bibendum bibendum. Morbi volutpat id dolor non aliquam.
+
+Aliquam ultrices dapibus diam, vel porttitor magna elementum et. Fusce eu dui quis dui malesuada tempor vel rhoncus 
+orci. Vestibulum id vestibulum augue. Aenean turpis felis, auctor vel neque eget, fermentum semper ante. Proin 
+imperdiet quis tellus vitae pulvinar. Phasellus est metus, molestie eget diam vel, consequat hendrerit erat. Donec 
+laoreet vitae quam eu sagittis. Vivamus quis tincidunt turpis. Integer venenatis turpis velit.
+
+Morbi tempor elit mi, ac lacinia ligula interdum vel. Donec commodo erat vitae porta tincidunt. Quisque ullamcorper, 
+est non lacinia consequat, turpis nunc feugiat metus, quis tincidunt lectus augue et ipsum. Curabitur lobortis 
+facilisis luctus. Sed placerat ligula enim, at dignissim urna condimentum eget. Etiam tempus facilisis nibh, lobortis 
+tincidunt ante ultrices at. Integer sed purus nec dolor aliquet dictum ut id mi. Nulla vel sem ipsum. Aliquam nec 
+tincidunt metus. Interdum et malesuada fames ac ante ipsum primis in faucibus. Sed suscipit ipsum a varius commodo. 
+Vestibulum bibendum at lectus sit amet vehicula. Pellentesque ullamcorper est eu purus iaculis laoreet. Nam nec neque 
+eget massa faucibus elementum eu quis eros.
+
+Ut id laoreet dolor. Donec eget gravida elit, ac rutrum elit. Quisque lacus neque, fringilla in turpis et, gravida 
+volutpat orci. Integer in nisl eget lorem ullamcorper condimentum nec venenatis lorem. Curabitur eget magna eu dui 
+fermentum fringilla sed ut dolor. Nulla vestibulum nunc et pharetra imperdiet. Morbi tristique porttitor odio, nec 
+pharetra orci lacinia vitae. Duis vestibulum diam ullamcorper massa vestibulum, ut auctor lorem imperdiet. Vestibulum 
+et venenatis tellus. Suspendisse convallis, odio ac dictum lacinia, neque quam tincidunt risus, non ornare dolor lacus 
+nec tellus. Mauris rhoncus fermentum neque sit amet sollicitudin. Pellentesque nulla magna, commodo vel tincidunt sed, 
+hendrerit quis justo.
+
+In hac habitasse platea dictumst. Nunc et est augue. Vivamus pretium, odio vel sodales semper, risus ante elementum 
+urna, ac ullamcorper massa neque non ligula. Cras id tellus quis urna adipiscing elementum. Donec adipiscing magna vel 
+suscipit vulputate. Quisque in mi eget orci viverra scelerisque nec et magna. Cras posuere, urna lacinia dictum 
+rhoncus, justo orci suscipit metus, ut gravida orci nulla in turpis. Interdum et malesuada fames ac ante ipsum primis 
+in faucibus. In volutpat feugiat turpis, non faucibus nunc interdum ut. Praesent ornare sagittis urna, id lacinia 
+tortor suscipit et. Pellentesque tempor mi ac nisi varius, vitae ullamcorper urna suscipit.
+
+Aliquam pellentesque vulputate turpis vestibulum eleifend. Donec faucibus eu felis eu blandit. Pellentesque pulvinar, 
+elit non lacinia porttitor, orci nibh iaculis augue, ac scelerisque mi tellus quis tortor. Interdum et malesuada fames 
+ac ante ipsum primis in faucibus. Cras nec suscipit risus, ut ultricies elit. Suspendisse et fringilla urna. Donec 
+luctus et nisi quis bibendum. Quisque sed augue lacinia, pellentesque diam vitae, convallis metus. Nam vehicula metus 
+eget orci sagittis sodales.
+
+Curabitur egestas congue nibh sed luctus. Nam luctus tellus at nulla vestibulum, rutrum fermentum est vulputate. Nulla 
+condimentum dui a dolor lobortis, eu aliquet odio pretium. Phasellus id pretium nulla. Donec eu tincidunt lectus, in 
+ornare orci. Pellentesque mollis est eget turpis aliquet, vel tempor velit fringilla. Curabitur a leo elementum elit 
+pellentesque tincidunt vestibulum sit amet mi. Vestibulum varius pulvinar nisi sit amet dignissim. Vestibulum dui 
+velit, commodo vel augue non, dapibus imperdiet mi.
+
+Etiam at turpis velit. Suspendisse tempor, nisl a congue pellentesque, erat tellus gravida sapien, et sodales nulla 
+nisi at mauris. Nunc consectetur ligula vitae posuere pharetra. Integer id suscipit sem. Praesent cras amet.
\ No newline at end of file
diff --git a/src/test/resources/plaintexts/lorem-5000.txt.b64 b/src/test/resources/plaintexts/lorem-5000.txt.b64
new file mode 100644
index 0000000..6259704
--- /dev/null
+++ b/src/test/resources/plaintexts/lorem-5000.txt.b64
@@ -0,0 +1,106 @@
+TG9yZW0gaXBzdW0gZG9sb3Igc2l0IGFtZXQsIGNvbnNlY3RldHVyIGFkaXBpc2Np
+bmcgZWxpdC4gUHJhZXNlbnQgdGVtcHVzIHNhZ2l0dGlzIGVyYXQgZXQgZmFjaWxp
+c2lzLiBJbiBxdWlzIGVsZWlmZW5kIApkb2xvci4gRHVpcyBzYWdpdHRpcyBwb3J0
+dGl0b3IgcmlzdXMgbm9uIGFsaXF1YW0uIEV0aWFtIG1hbGVzdWFkYSBwdXJ1cyBl
+dCBmZWxpcyBmZXVnaWF0LCBldCBjb25kaW1lbnR1bSBhdWd1ZSAKc2FnaXR0aXMu
+IERvbmVjIHBvc3VlcmUgc2VtcGVyIGxhY3VzIGVnZXQgc29sbGljaXR1ZGluLiBV
+dCBmZXVnaWF0IGRpYW0gaXBzdW0sIHV0IHRlbXBvciB0dXJwaXMgdmVoaWN1bGEg
+c2VkLiBEdWlzIApldWlzbW9kIGV1aXNtb2QgcmhvbmN1cy4gUHJhZXNlbnQgdml0
+YWUgbGVvIGVnZXN0YXMgbmVxdWUgcGVsbGVudGVzcXVlIHVsdHJpY2llcyB0aW5j
+aWR1bnQgZXQgcHVydXMuIFN1c3BlbmRpc3NlIAppbnRlcmR1bSBibGFuZGl0IHVy
+bmEgaW1wZXJkaWV0IGV1aXNtb2QuIEV0aWFtIGV0IG1ldHVzIG1vbGVzdGllIG5p
+c2wgaW1wZXJkaWV0IGN1cnN1cy4KCkRvbmVjIHVsbGFtY29ycGVyIHB1cnVzIHNl
+ZCBsZW8gcnV0cnVtIHRpbmNpZHVudC4gUGVsbGVudGVzcXVlIGlhY3VsaXMgdHJp
+c3RpcXVlIHBlbGxlbnRlc3F1ZS4gUGVsbGVudGVzcXVlIGN1cnN1cyAKcG9ydGEg
+bmlzbCwgYXQgaWFjdWxpcyBsaWd1bGEgZGFwaWJ1cyBxdWlzLiBNb3JiaSBtYXR0
+aXMsIGVyb3MgcG9ydGEgYmxhbmRpdCB1bGxhbWNvcnBlciwgYXJjdSBkb2xvciBm
+ZXJtZW50dW0gbnVuYywgYWMgCmN1cnN1cyBtYWduYSBhcmN1IGlkIG51bGxhLiBR
+dWlzcXVlIHVybmEgdXJuYSwgZGFwaWJ1cyBldCBibGFuZGl0IHZpdGFlLCBwbGFj
+ZXJhdCBxdWlzIG5pYmguIFNlZCBhYyBlc3QgZWdldCBuaXNsIAp1bHRyaWNlcyBn
+cmF2aWRhLiBBbGlxdWFtIGlkIHN1c2NpcGl0IGxpZ3VsYS4gU3VzcGVuZGlzc2Ug
+c29sbGljaXR1ZGluIGZlcm1lbnR1bSB0dXJwaXMuIERvbmVjIGF1Y3RvciBjb25z
+ZWN0ZXR1ciBwdXJ1cyAKZXUgYmliZW5kdW0uIFBlbGxlbnRlc3F1ZSBlZ2V0IGVs
+aXQgdml0YWUgYXVndWUgYmliZW5kdW0gbGFjaW5pYS4gUXVpc3F1ZSBjb252YWxs
+aXMgdHJpc3RpcXVlIHR1cnBpcyBpZCB2aXZlcnJhLiBNYXVyaXMgCmFsaXF1ZXQg
+YmliZW5kdW0gYmliZW5kdW0uIE1vcmJpIHZvbHV0cGF0IGlkIGRvbG9yIG5vbiBh
+bGlxdWFtLgoKQWxpcXVhbSB1bHRyaWNlcyBkYXBpYnVzIGRpYW0sIHZlbCBwb3J0
+dGl0b3IgbWFnbmEgZWxlbWVudHVtIGV0LiBGdXNjZSBldSBkdWkgcXVpcyBkdWkg
+bWFsZXN1YWRhIHRlbXBvciB2ZWwgcmhvbmN1cyAKb3JjaS4gVmVzdGlidWx1bSBp
+ZCB2ZXN0aWJ1bHVtIGF1Z3VlLiBBZW5lYW4gdHVycGlzIGZlbGlzLCBhdWN0b3Ig
+dmVsIG5lcXVlIGVnZXQsIGZlcm1lbnR1bSBzZW1wZXIgYW50ZS4gUHJvaW4gCmlt
+cGVyZGlldCBxdWlzIHRlbGx1cyB2aXRhZSBwdWx2aW5hci4gUGhhc2VsbHVzIGVz
+dCBtZXR1cywgbW9sZXN0aWUgZWdldCBkaWFtIHZlbCwgY29uc2VxdWF0IGhlbmRy
+ZXJpdCBlcmF0LiBEb25lYyAKbGFvcmVldCB2aXRhZSBxdWFtIGV1IHNhZ2l0dGlz
+LiBWaXZhbXVzIHF1aXMgdGluY2lkdW50IHR1cnBpcy4gSW50ZWdlciB2ZW5lbmF0
+aXMgdHVycGlzIHZlbGl0LgoKTW9yYmkgdGVtcG9yIGVsaXQgbWksIGFjIGxhY2lu
+aWEgbGlndWxhIGludGVyZHVtIHZlbC4gRG9uZWMgY29tbW9kbyBlcmF0IHZpdGFl
+IHBvcnRhIHRpbmNpZHVudC4gUXVpc3F1ZSB1bGxhbWNvcnBlciwgCmVzdCBub24g
+bGFjaW5pYSBjb25zZXF1YXQsIHR1cnBpcyBudW5jIGZldWdpYXQgbWV0dXMsIHF1
+aXMgdGluY2lkdW50IGxlY3R1cyBhdWd1ZSBldCBpcHN1bS4gQ3VyYWJpdHVyIGxv
+Ym9ydGlzIApmYWNpbGlzaXMgbHVjdHVzLiBTZWQgcGxhY2VyYXQgbGlndWxhIGVu
+aW0sIGF0IGRpZ25pc3NpbSB1cm5hIGNvbmRpbWVudHVtIGVnZXQuIEV0aWFtIHRl
+bXB1cyBmYWNpbGlzaXMgbmliaCwgbG9ib3J0aXMgCnRpbmNpZHVudCBhbnRlIHVs
+dHJpY2VzIGF0LiBJbnRlZ2VyIHNlZCBwdXJ1cyBuZWMgZG9sb3IgYWxpcXVldCBk
+aWN0dW0gdXQgaWQgbWkuIE51bGxhIHZlbCBzZW0gaXBzdW0uIEFsaXF1YW0gbmVj
+IAp0aW5jaWR1bnQgbWV0dXMuIEludGVyZHVtIGV0IG1hbGVzdWFkYSBmYW1lcyBh
+YyBhbnRlIGlwc3VtIHByaW1pcyBpbiBmYXVjaWJ1cy4gU2VkIHN1c2NpcGl0IGlw
+c3VtIGEgdmFyaXVzIGNvbW1vZG8uIApWZXN0aWJ1bHVtIGJpYmVuZHVtIGF0IGxl
+Y3R1cyBzaXQgYW1ldCB2ZWhpY3VsYS4gUGVsbGVudGVzcXVlIHVsbGFtY29ycGVy
+IGVzdCBldSBwdXJ1cyBpYWN1bGlzIGxhb3JlZXQuIE5hbSBuZWMgbmVxdWUgCmVn
+ZXQgbWFzc2EgZmF1Y2lidXMgZWxlbWVudHVtIGV1IHF1aXMgZXJvcy4KClV0IGlk
+IGxhb3JlZXQgZG9sb3IuIERvbmVjIGVnZXQgZ3JhdmlkYSBlbGl0LCBhYyBydXRy
+dW0gZWxpdC4gUXVpc3F1ZSBsYWN1cyBuZXF1ZSwgZnJpbmdpbGxhIGluIHR1cnBp
+cyBldCwgZ3JhdmlkYSAKdm9sdXRwYXQgb3JjaS4gSW50ZWdlciBpbiBuaXNsIGVn
+ZXQgbG9yZW0gdWxsYW1jb3JwZXIgY29uZGltZW50dW0gbmVjIHZlbmVuYXRpcyBs
+b3JlbS4gQ3VyYWJpdHVyIGVnZXQgbWFnbmEgZXUgZHVpIApmZXJtZW50dW0gZnJp
+bmdpbGxhIHNlZCB1dCBkb2xvci4gTnVsbGEgdmVzdGlidWx1bSBudW5jIGV0IHBo
+YXJldHJhIGltcGVyZGlldC4gTW9yYmkgdHJpc3RpcXVlIHBvcnR0aXRvciBvZGlv
+LCBuZWMgCnBoYXJldHJhIG9yY2kgbGFjaW5pYSB2aXRhZS4gRHVpcyB2ZXN0aWJ1
+bHVtIGRpYW0gdWxsYW1jb3JwZXIgbWFzc2EgdmVzdGlidWx1bSwgdXQgYXVjdG9y
+IGxvcmVtIGltcGVyZGlldC4gVmVzdGlidWx1bSAKZXQgdmVuZW5hdGlzIHRlbGx1
+cy4gU3VzcGVuZGlzc2UgY29udmFsbGlzLCBvZGlvIGFjIGRpY3R1bSBsYWNpbmlh
+LCBuZXF1ZSBxdWFtIHRpbmNpZHVudCByaXN1cywgbm9uIG9ybmFyZSBkb2xvciBs
+YWN1cyAKbmVjIHRlbGx1cy4gTWF1cmlzIHJob25jdXMgZmVybWVudHVtIG5lcXVl
+IHNpdCBhbWV0IHNvbGxpY2l0dWRpbi4gUGVsbGVudGVzcXVlIG51bGxhIG1hZ25h
+LCBjb21tb2RvIHZlbCB0aW5jaWR1bnQgc2VkLCAKaGVuZHJlcml0IHF1aXMganVz
+dG8uCgpJbiBoYWMgaGFiaXRhc3NlIHBsYXRlYSBkaWN0dW1zdC4gTnVuYyBldCBl
+c3QgYXVndWUuIFZpdmFtdXMgcHJldGl1bSwgb2RpbyB2ZWwgc29kYWxlcyBzZW1w
+ZXIsIHJpc3VzIGFudGUgZWxlbWVudHVtIAp1cm5hLCBhYyB1bGxhbWNvcnBlciBt
+YXNzYSBuZXF1ZSBub24gbGlndWxhLiBDcmFzIGlkIHRlbGx1cyBxdWlzIHVybmEg
+YWRpcGlzY2luZyBlbGVtZW50dW0uIERvbmVjIGFkaXBpc2NpbmcgbWFnbmEgdmVs
+IApzdXNjaXBpdCB2dWxwdXRhdGUuIFF1aXNxdWUgaW4gbWkgZWdldCBvcmNpIHZp
+dmVycmEgc2NlbGVyaXNxdWUgbmVjIGV0IG1hZ25hLiBDcmFzIHBvc3VlcmUsIHVy
+bmEgbGFjaW5pYSBkaWN0dW0gCnJob25jdXMsIGp1c3RvIG9yY2kgc3VzY2lwaXQg
+bWV0dXMsIHV0IGdyYXZpZGEgb3JjaSBudWxsYSBpbiB0dXJwaXMuIEludGVyZHVt
+IGV0IG1hbGVzdWFkYSBmYW1lcyBhYyBhbnRlIGlwc3VtIHByaW1pcyAKaW4gZmF1
+Y2lidXMuIEluIHZvbHV0cGF0IGZldWdpYXQgdHVycGlzLCBub24gZmF1Y2lidXMg
+bnVuYyBpbnRlcmR1bSB1dC4gUHJhZXNlbnQgb3JuYXJlIHNhZ2l0dGlzIHVybmEs
+IGlkIGxhY2luaWEgCnRvcnRvciBzdXNjaXBpdCBldC4gUGVsbGVudGVzcXVlIHRl
+bXBvciBtaSBhYyBuaXNpIHZhcml1cywgdml0YWUgdWxsYW1jb3JwZXIgdXJuYSBz
+dXNjaXBpdC4KCkFsaXF1YW0gcGVsbGVudGVzcXVlIHZ1bHB1dGF0ZSB0dXJwaXMg
+dmVzdGlidWx1bSBlbGVpZmVuZC4gRG9uZWMgZmF1Y2lidXMgZXUgZmVsaXMgZXUg
+YmxhbmRpdC4gUGVsbGVudGVzcXVlIHB1bHZpbmFyLCAKZWxpdCBub24gbGFjaW5p
+YSBwb3J0dGl0b3IsIG9yY2kgbmliaCBpYWN1bGlzIGF1Z3VlLCBhYyBzY2VsZXJp
+c3F1ZSBtaSB0ZWxsdXMgcXVpcyB0b3J0b3IuIEludGVyZHVtIGV0IG1hbGVzdWFk
+YSBmYW1lcyAKYWMgYW50ZSBpcHN1bSBwcmltaXMgaW4gZmF1Y2lidXMuIENyYXMg
+bmVjIHN1c2NpcGl0IHJpc3VzLCB1dCB1bHRyaWNpZXMgZWxpdC4gU3VzcGVuZGlz
+c2UgZXQgZnJpbmdpbGxhIHVybmEuIERvbmVjIApsdWN0dXMgZXQgbmlzaSBxdWlz
+IGJpYmVuZHVtLiBRdWlzcXVlIHNlZCBhdWd1ZSBsYWNpbmlhLCBwZWxsZW50ZXNx
+dWUgZGlhbSB2aXRhZSwgY29udmFsbGlzIG1ldHVzLiBOYW0gdmVoaWN1bGEgbWV0
+dXMgCmVnZXQgb3JjaSBzYWdpdHRpcyBzb2RhbGVzLgoKQ3VyYWJpdHVyIGVnZXN0
+YXMgY29uZ3VlIG5pYmggc2VkIGx1Y3R1cy4gTmFtIGx1Y3R1cyB0ZWxsdXMgYXQg
+bnVsbGEgdmVzdGlidWx1bSwgcnV0cnVtIGZlcm1lbnR1bSBlc3QgdnVscHV0YXRl
+LiBOdWxsYSAKY29uZGltZW50dW0gZHVpIGEgZG9sb3IgbG9ib3J0aXMsIGV1IGFs
+aXF1ZXQgb2RpbyBwcmV0aXVtLiBQaGFzZWxsdXMgaWQgcHJldGl1bSBudWxsYS4g
+RG9uZWMgZXUgdGluY2lkdW50IGxlY3R1cywgaW4gCm9ybmFyZSBvcmNpLiBQZWxs
+ZW50ZXNxdWUgbW9sbGlzIGVzdCBlZ2V0IHR1cnBpcyBhbGlxdWV0LCB2ZWwgdGVt
+cG9yIHZlbGl0IGZyaW5naWxsYS4gQ3VyYWJpdHVyIGEgbGVvIGVsZW1lbnR1bSBl
+bGl0IApwZWxsZW50ZXNxdWUgdGluY2lkdW50IHZlc3RpYnVsdW0gc2l0IGFtZXQg
+bWkuIFZlc3RpYnVsdW0gdmFyaXVzIHB1bHZpbmFyIG5pc2kgc2l0IGFtZXQgZGln
+bmlzc2ltLiBWZXN0aWJ1bHVtIGR1aSAKdmVsaXQsIGNvbW1vZG8gdmVsIGF1Z3Vl
+IG5vbiwgZGFwaWJ1cyBpbXBlcmRpZXQgbWkuCgpFdGlhbSBhdCB0dXJwaXMgdmVs
+aXQuIFN1c3BlbmRpc3NlIHRlbXBvciwgbmlzbCBhIGNvbmd1ZSBwZWxsZW50ZXNx
+dWUsIGVyYXQgdGVsbHVzIGdyYXZpZGEgc2FwaWVuLCBldCBzb2RhbGVzIG51bGxh
+IApuaXNpIGF0IG1hdXJpcy4gTnVuYyBjb25zZWN0ZXR1ciBsaWd1bGEgdml0YWUg
+cG9zdWVyZSBwaGFyZXRyYS4gSW50ZWdlciBpZCBzdXNjaXBpdCBzZW0uIFByYWVz
+ZW50IGNyYXMgYW1ldC4=
diff --git a/src/test/resources/plaintexts/lorem-5000.txt.b64.crlf b/src/test/resources/plaintexts/lorem-5000.txt.b64.crlf
new file mode 100644
index 0000000..9d6ed26
--- /dev/null
+++ b/src/test/resources/plaintexts/lorem-5000.txt.b64.crlf
@@ -0,0 +1,106 @@
+TG9yZW0gaXBzdW0gZG9sb3Igc2l0IGFtZXQsIGNvbnNlY3RldHVyIGFkaXBpc2Np

+bmcgZWxpdC4gUHJhZXNlbnQgdGVtcHVzIHNhZ2l0dGlzIGVyYXQgZXQgZmFjaWxp

+c2lzLiBJbiBxdWlzIGVsZWlmZW5kIApkb2xvci4gRHVpcyBzYWdpdHRpcyBwb3J0

+dGl0b3IgcmlzdXMgbm9uIGFsaXF1YW0uIEV0aWFtIG1hbGVzdWFkYSBwdXJ1cyBl

+dCBmZWxpcyBmZXVnaWF0LCBldCBjb25kaW1lbnR1bSBhdWd1ZSAKc2FnaXR0aXMu

+IERvbmVjIHBvc3VlcmUgc2VtcGVyIGxhY3VzIGVnZXQgc29sbGljaXR1ZGluLiBV

+dCBmZXVnaWF0IGRpYW0gaXBzdW0sIHV0IHRlbXBvciB0dXJwaXMgdmVoaWN1bGEg

+c2VkLiBEdWlzIApldWlzbW9kIGV1aXNtb2QgcmhvbmN1cy4gUHJhZXNlbnQgdml0

+YWUgbGVvIGVnZXN0YXMgbmVxdWUgcGVsbGVudGVzcXVlIHVsdHJpY2llcyB0aW5j

+aWR1bnQgZXQgcHVydXMuIFN1c3BlbmRpc3NlIAppbnRlcmR1bSBibGFuZGl0IHVy

+bmEgaW1wZXJkaWV0IGV1aXNtb2QuIEV0aWFtIGV0IG1ldHVzIG1vbGVzdGllIG5p

+c2wgaW1wZXJkaWV0IGN1cnN1cy4KCkRvbmVjIHVsbGFtY29ycGVyIHB1cnVzIHNl

+ZCBsZW8gcnV0cnVtIHRpbmNpZHVudC4gUGVsbGVudGVzcXVlIGlhY3VsaXMgdHJp

+c3RpcXVlIHBlbGxlbnRlc3F1ZS4gUGVsbGVudGVzcXVlIGN1cnN1cyAKcG9ydGEg

+bmlzbCwgYXQgaWFjdWxpcyBsaWd1bGEgZGFwaWJ1cyBxdWlzLiBNb3JiaSBtYXR0

+aXMsIGVyb3MgcG9ydGEgYmxhbmRpdCB1bGxhbWNvcnBlciwgYXJjdSBkb2xvciBm

+ZXJtZW50dW0gbnVuYywgYWMgCmN1cnN1cyBtYWduYSBhcmN1IGlkIG51bGxhLiBR

+dWlzcXVlIHVybmEgdXJuYSwgZGFwaWJ1cyBldCBibGFuZGl0IHZpdGFlLCBwbGFj

+ZXJhdCBxdWlzIG5pYmguIFNlZCBhYyBlc3QgZWdldCBuaXNsIAp1bHRyaWNlcyBn

+cmF2aWRhLiBBbGlxdWFtIGlkIHN1c2NpcGl0IGxpZ3VsYS4gU3VzcGVuZGlzc2Ug

+c29sbGljaXR1ZGluIGZlcm1lbnR1bSB0dXJwaXMuIERvbmVjIGF1Y3RvciBjb25z

+ZWN0ZXR1ciBwdXJ1cyAKZXUgYmliZW5kdW0uIFBlbGxlbnRlc3F1ZSBlZ2V0IGVs

+aXQgdml0YWUgYXVndWUgYmliZW5kdW0gbGFjaW5pYS4gUXVpc3F1ZSBjb252YWxs

+aXMgdHJpc3RpcXVlIHR1cnBpcyBpZCB2aXZlcnJhLiBNYXVyaXMgCmFsaXF1ZXQg

+YmliZW5kdW0gYmliZW5kdW0uIE1vcmJpIHZvbHV0cGF0IGlkIGRvbG9yIG5vbiBh

+bGlxdWFtLgoKQWxpcXVhbSB1bHRyaWNlcyBkYXBpYnVzIGRpYW0sIHZlbCBwb3J0

+dGl0b3IgbWFnbmEgZWxlbWVudHVtIGV0LiBGdXNjZSBldSBkdWkgcXVpcyBkdWkg

+bWFsZXN1YWRhIHRlbXBvciB2ZWwgcmhvbmN1cyAKb3JjaS4gVmVzdGlidWx1bSBp

+ZCB2ZXN0aWJ1bHVtIGF1Z3VlLiBBZW5lYW4gdHVycGlzIGZlbGlzLCBhdWN0b3Ig

+dmVsIG5lcXVlIGVnZXQsIGZlcm1lbnR1bSBzZW1wZXIgYW50ZS4gUHJvaW4gCmlt

+cGVyZGlldCBxdWlzIHRlbGx1cyB2aXRhZSBwdWx2aW5hci4gUGhhc2VsbHVzIGVz

+dCBtZXR1cywgbW9sZXN0aWUgZWdldCBkaWFtIHZlbCwgY29uc2VxdWF0IGhlbmRy

+ZXJpdCBlcmF0LiBEb25lYyAKbGFvcmVldCB2aXRhZSBxdWFtIGV1IHNhZ2l0dGlz

+LiBWaXZhbXVzIHF1aXMgdGluY2lkdW50IHR1cnBpcy4gSW50ZWdlciB2ZW5lbmF0

+aXMgdHVycGlzIHZlbGl0LgoKTW9yYmkgdGVtcG9yIGVsaXQgbWksIGFjIGxhY2lu

+aWEgbGlndWxhIGludGVyZHVtIHZlbC4gRG9uZWMgY29tbW9kbyBlcmF0IHZpdGFl

+IHBvcnRhIHRpbmNpZHVudC4gUXVpc3F1ZSB1bGxhbWNvcnBlciwgCmVzdCBub24g

+bGFjaW5pYSBjb25zZXF1YXQsIHR1cnBpcyBudW5jIGZldWdpYXQgbWV0dXMsIHF1

+aXMgdGluY2lkdW50IGxlY3R1cyBhdWd1ZSBldCBpcHN1bS4gQ3VyYWJpdHVyIGxv

+Ym9ydGlzIApmYWNpbGlzaXMgbHVjdHVzLiBTZWQgcGxhY2VyYXQgbGlndWxhIGVu

+aW0sIGF0IGRpZ25pc3NpbSB1cm5hIGNvbmRpbWVudHVtIGVnZXQuIEV0aWFtIHRl

+bXB1cyBmYWNpbGlzaXMgbmliaCwgbG9ib3J0aXMgCnRpbmNpZHVudCBhbnRlIHVs

+dHJpY2VzIGF0LiBJbnRlZ2VyIHNlZCBwdXJ1cyBuZWMgZG9sb3IgYWxpcXVldCBk

+aWN0dW0gdXQgaWQgbWkuIE51bGxhIHZlbCBzZW0gaXBzdW0uIEFsaXF1YW0gbmVj

+IAp0aW5jaWR1bnQgbWV0dXMuIEludGVyZHVtIGV0IG1hbGVzdWFkYSBmYW1lcyBh

+YyBhbnRlIGlwc3VtIHByaW1pcyBpbiBmYXVjaWJ1cy4gU2VkIHN1c2NpcGl0IGlw

+c3VtIGEgdmFyaXVzIGNvbW1vZG8uIApWZXN0aWJ1bHVtIGJpYmVuZHVtIGF0IGxl

+Y3R1cyBzaXQgYW1ldCB2ZWhpY3VsYS4gUGVsbGVudGVzcXVlIHVsbGFtY29ycGVy

+IGVzdCBldSBwdXJ1cyBpYWN1bGlzIGxhb3JlZXQuIE5hbSBuZWMgbmVxdWUgCmVn

+ZXQgbWFzc2EgZmF1Y2lidXMgZWxlbWVudHVtIGV1IHF1aXMgZXJvcy4KClV0IGlk

+IGxhb3JlZXQgZG9sb3IuIERvbmVjIGVnZXQgZ3JhdmlkYSBlbGl0LCBhYyBydXRy

+dW0gZWxpdC4gUXVpc3F1ZSBsYWN1cyBuZXF1ZSwgZnJpbmdpbGxhIGluIHR1cnBp

+cyBldCwgZ3JhdmlkYSAKdm9sdXRwYXQgb3JjaS4gSW50ZWdlciBpbiBuaXNsIGVn

+ZXQgbG9yZW0gdWxsYW1jb3JwZXIgY29uZGltZW50dW0gbmVjIHZlbmVuYXRpcyBs

+b3JlbS4gQ3VyYWJpdHVyIGVnZXQgbWFnbmEgZXUgZHVpIApmZXJtZW50dW0gZnJp

+bmdpbGxhIHNlZCB1dCBkb2xvci4gTnVsbGEgdmVzdGlidWx1bSBudW5jIGV0IHBo

+YXJldHJhIGltcGVyZGlldC4gTW9yYmkgdHJpc3RpcXVlIHBvcnR0aXRvciBvZGlv

+LCBuZWMgCnBoYXJldHJhIG9yY2kgbGFjaW5pYSB2aXRhZS4gRHVpcyB2ZXN0aWJ1

+bHVtIGRpYW0gdWxsYW1jb3JwZXIgbWFzc2EgdmVzdGlidWx1bSwgdXQgYXVjdG9y

+IGxvcmVtIGltcGVyZGlldC4gVmVzdGlidWx1bSAKZXQgdmVuZW5hdGlzIHRlbGx1

+cy4gU3VzcGVuZGlzc2UgY29udmFsbGlzLCBvZGlvIGFjIGRpY3R1bSBsYWNpbmlh

+LCBuZXF1ZSBxdWFtIHRpbmNpZHVudCByaXN1cywgbm9uIG9ybmFyZSBkb2xvciBs

+YWN1cyAKbmVjIHRlbGx1cy4gTWF1cmlzIHJob25jdXMgZmVybWVudHVtIG5lcXVl

+IHNpdCBhbWV0IHNvbGxpY2l0dWRpbi4gUGVsbGVudGVzcXVlIG51bGxhIG1hZ25h

+LCBjb21tb2RvIHZlbCB0aW5jaWR1bnQgc2VkLCAKaGVuZHJlcml0IHF1aXMganVz

+dG8uCgpJbiBoYWMgaGFiaXRhc3NlIHBsYXRlYSBkaWN0dW1zdC4gTnVuYyBldCBl

+c3QgYXVndWUuIFZpdmFtdXMgcHJldGl1bSwgb2RpbyB2ZWwgc29kYWxlcyBzZW1w

+ZXIsIHJpc3VzIGFudGUgZWxlbWVudHVtIAp1cm5hLCBhYyB1bGxhbWNvcnBlciBt

+YXNzYSBuZXF1ZSBub24gbGlndWxhLiBDcmFzIGlkIHRlbGx1cyBxdWlzIHVybmEg

+YWRpcGlzY2luZyBlbGVtZW50dW0uIERvbmVjIGFkaXBpc2NpbmcgbWFnbmEgdmVs

+IApzdXNjaXBpdCB2dWxwdXRhdGUuIFF1aXNxdWUgaW4gbWkgZWdldCBvcmNpIHZp

+dmVycmEgc2NlbGVyaXNxdWUgbmVjIGV0IG1hZ25hLiBDcmFzIHBvc3VlcmUsIHVy

+bmEgbGFjaW5pYSBkaWN0dW0gCnJob25jdXMsIGp1c3RvIG9yY2kgc3VzY2lwaXQg

+bWV0dXMsIHV0IGdyYXZpZGEgb3JjaSBudWxsYSBpbiB0dXJwaXMuIEludGVyZHVt

+IGV0IG1hbGVzdWFkYSBmYW1lcyBhYyBhbnRlIGlwc3VtIHByaW1pcyAKaW4gZmF1

+Y2lidXMuIEluIHZvbHV0cGF0IGZldWdpYXQgdHVycGlzLCBub24gZmF1Y2lidXMg

+bnVuYyBpbnRlcmR1bSB1dC4gUHJhZXNlbnQgb3JuYXJlIHNhZ2l0dGlzIHVybmEs

+IGlkIGxhY2luaWEgCnRvcnRvciBzdXNjaXBpdCBldC4gUGVsbGVudGVzcXVlIHRl

+bXBvciBtaSBhYyBuaXNpIHZhcml1cywgdml0YWUgdWxsYW1jb3JwZXIgdXJuYSBz

+dXNjaXBpdC4KCkFsaXF1YW0gcGVsbGVudGVzcXVlIHZ1bHB1dGF0ZSB0dXJwaXMg

+dmVzdGlidWx1bSBlbGVpZmVuZC4gRG9uZWMgZmF1Y2lidXMgZXUgZmVsaXMgZXUg

+YmxhbmRpdC4gUGVsbGVudGVzcXVlIHB1bHZpbmFyLCAKZWxpdCBub24gbGFjaW5p

+YSBwb3J0dGl0b3IsIG9yY2kgbmliaCBpYWN1bGlzIGF1Z3VlLCBhYyBzY2VsZXJp

+c3F1ZSBtaSB0ZWxsdXMgcXVpcyB0b3J0b3IuIEludGVyZHVtIGV0IG1hbGVzdWFk

+YSBmYW1lcyAKYWMgYW50ZSBpcHN1bSBwcmltaXMgaW4gZmF1Y2lidXMuIENyYXMg

+bmVjIHN1c2NpcGl0IHJpc3VzLCB1dCB1bHRyaWNpZXMgZWxpdC4gU3VzcGVuZGlz

+c2UgZXQgZnJpbmdpbGxhIHVybmEuIERvbmVjIApsdWN0dXMgZXQgbmlzaSBxdWlz

+IGJpYmVuZHVtLiBRdWlzcXVlIHNlZCBhdWd1ZSBsYWNpbmlhLCBwZWxsZW50ZXNx

+dWUgZGlhbSB2aXRhZSwgY29udmFsbGlzIG1ldHVzLiBOYW0gdmVoaWN1bGEgbWV0

+dXMgCmVnZXQgb3JjaSBzYWdpdHRpcyBzb2RhbGVzLgoKQ3VyYWJpdHVyIGVnZXN0

+YXMgY29uZ3VlIG5pYmggc2VkIGx1Y3R1cy4gTmFtIGx1Y3R1cyB0ZWxsdXMgYXQg

+bnVsbGEgdmVzdGlidWx1bSwgcnV0cnVtIGZlcm1lbnR1bSBlc3QgdnVscHV0YXRl

+LiBOdWxsYSAKY29uZGltZW50dW0gZHVpIGEgZG9sb3IgbG9ib3J0aXMsIGV1IGFs

+aXF1ZXQgb2RpbyBwcmV0aXVtLiBQaGFzZWxsdXMgaWQgcHJldGl1bSBudWxsYS4g

+RG9uZWMgZXUgdGluY2lkdW50IGxlY3R1cywgaW4gCm9ybmFyZSBvcmNpLiBQZWxs

+ZW50ZXNxdWUgbW9sbGlzIGVzdCBlZ2V0IHR1cnBpcyBhbGlxdWV0LCB2ZWwgdGVt

+cG9yIHZlbGl0IGZyaW5naWxsYS4gQ3VyYWJpdHVyIGEgbGVvIGVsZW1lbnR1bSBl

+bGl0IApwZWxsZW50ZXNxdWUgdGluY2lkdW50IHZlc3RpYnVsdW0gc2l0IGFtZXQg

+bWkuIFZlc3RpYnVsdW0gdmFyaXVzIHB1bHZpbmFyIG5pc2kgc2l0IGFtZXQgZGln

+bmlzc2ltLiBWZXN0aWJ1bHVtIGR1aSAKdmVsaXQsIGNvbW1vZG8gdmVsIGF1Z3Vl

+IG5vbiwgZGFwaWJ1cyBpbXBlcmRpZXQgbWkuCgpFdGlhbSBhdCB0dXJwaXMgdmVs

+aXQuIFN1c3BlbmRpc3NlIHRlbXBvciwgbmlzbCBhIGNvbmd1ZSBwZWxsZW50ZXNx

+dWUsIGVyYXQgdGVsbHVzIGdyYXZpZGEgc2FwaWVuLCBldCBzb2RhbGVzIG51bGxh

+IApuaXNpIGF0IG1hdXJpcy4gTnVuYyBjb25zZWN0ZXR1ciBsaWd1bGEgdml0YWUg

+cG9zdWVyZSBwaGFyZXRyYS4gSW50ZWdlciBpZCBzdXNjaXBpdCBzZW0uIFByYWVz

+ZW50IGNyYXMgYW1ldC4=