Repository: paragonie/paseto (language: php)

Issue:
Security vulnerability in documentation
It may be a bit strange to call an error in the documentation a security vulnerability, but I think it is justified here.

The [documentation](https://github.com/paragonie/paseto/tree/master/docs/02-PHP-Library#decoding-tokens) claims that 

`$parser = Parser::getLocal($sharedKey, ProtocolCollection::v2());`

is equivalent to

```php
$parser = (new Parser())
    ->setKey($sharedKey)
    // Adding rules to be checked against the token
    ->addRule(new NotExpired)
    ->addRule(new IssuedBy('issuer defined during creation'))
    ->setPurpose(Purpose::local())
    // Only allow version 2
    ->setAllowedVersions(ProtocolCollection::v2());
```

From my understand, however, this is not the case. In particular, the former call does not add a rule banning expired tokens from successful validation. I'm afraid many users of the library, relying on the documentation, will use the former call without explicitly adding such a rule and therefore opening their applications up to misuse of expired tokens.

Please fix this documentation error (or let me know in case I misunderstood something fundamentally here).


Discussion / hints:
Not every PASETO is going to even *have* an `exp` header. It's an optional claim (as it is in JWT).
That fact is undisputed. I exclusively disputed that the two code snippets mentioned are equivalent, while the line between the two snippets reads `// This is the same as:`.
I don't understand.

First you say...

> . I'm afraid many users of the library, relying on the documentation, will use the former call without explicitly adding such a rule and therefore opening their applications up to misuse of expired tokens.

Then I say "those are always optional headers" and then you say "That fact is undisputed"

If it's **always optional** and everyone **expects it to be optional** how is its absence a vulnerability?
The issue is not that that the first line I quoted does not add a check for expired tokens. The issue is that the documentation claims that line is equivalent to the second block of code I quoted, which does add such a check.

Fix patch:
diff --git a/README.md b/README.md
index f724e1c2..ed71e6e5 100644
--- a/README.md
+++ b/README.md
@@ -154,6 +154,13 @@ may be more relevant.
 
 ### Requirements
 
+#### PHP PASETO Library Version 2
+
+* Requires PHP 7.1 or newer.
+* For v3 tokens, the GMP and OpenSSL extensions are required.
+* For v4 tokens, the Sodium extension is strongly recommended (but this library will use
+  [sodium_compat](https://github.com/paragonie/sodium_compat) if it's not).
+
 #### PHP PASETO Library Version 1
 
 * Requires PHP 7.0 or newer.
diff --git a/composer.json b/composer.json
index 9e10d4cb..3f0aaa6c 100644
--- a/composer.json
+++ b/composer.json
@@ -20,14 +20,18 @@
     }
   },
   "require": {
+    "ext-gmp": "*",
+    "ext-json": "*",
+    "ext-openssl": "*",
     "php": "^7.1|^8",
     "paragonie/constant_time_encoding": "^2",
+    "paragonie/easy-ecc": "dev-harden-phpecc",
     "paragonie/sodium_compat": "^1.7",
     "phpseclib/phpseclib": "^2"
   },
   "require-dev": {
     "phpunit/phpunit": "^6|^7|^8|^9",
-    "vimeo/psalm": "^3|^4"
+    "vimeo/psalm": "^4"
   },
   "scripts": {
     "full-test": [
diff --git a/docs/02-PHP-Library/README.md b/docs/02-PHP-Library/README.md
index 88d2507d..2f61ca69 100644
--- a/docs/02-PHP-Library/README.md
+++ b/docs/02-PHP-Library/README.md
@@ -90,7 +90,10 @@ use ParagonIE\Paseto\ProtocolCollection;
  * @var string $providedToken
  * @var SymmetricKey $sharedKey
  */
-$parser = Parser::getLocal($sharedKey, ProtocolCollection::v2());
+$parser = Parser::getLocal($sharedKey, ProtocolCollection::v2())
+    ->addRule(new ValidAt)
+    ->addRule(new IssuedBy('issuer defined during creation'));
+
 // This is the same as:
 $parser = (new Parser())
     ->setKey($sharedKey)
diff --git a/docs/03-Implementation-Guide/Test-Vectors/v1.json b/docs/03-Implementation-Guide/Test-Vectors/v1.json
new file mode 100644
index 00000000..990ea840
--- /dev/null
+++ b/docs/03-Implementation-Guide/Test-Vectors/v1.json
@@ -0,0 +1,149 @@
+{
+  "name": "PASETO v1 Test Vectors",
+  "tests": [
+    {
+      "name": "1-E-1",
+      "nonce": "0000000000000000000000000000000000000000000000000000000000000000",
+      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+      "token": "v1.local.WzhIh1MpbqVNXNt7-HbWvL-JwAym3Tomad9Pc2nl7wK87vGraUVvn2bs8BBNo7jbukCNrkVID0jCK2vr5bP18G78j1bOTbBcP9HZzqnraEdspcjd_PvrxDEhj9cS2MG5fmxtvuoHRp3M24HvxTtql9z26KTfPWxJN5bAJaAM6gos8fnfjJO8oKiqQMaiBP_Cqncmqw8",
+      "payload": {
+        "data": "this is a signed message",
+        "exp": "2019-01-01T00:00:00+00:00"
+      },
+      "footer": "",
+      "implicit-assertion": ""
+    },
+    {
+      "name": "1-E-2",
+      "nonce": "0000000000000000000000000000000000000000000000000000000000000000",
+      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+      "token": "v1.local.w_NOpjgte4bX-2i1JAiTQzHoGUVOgc2yqKqsnYGmaPaCu_KWUkRGlCRnOvZZxeH4HTykY7AE_jkzSXAYBkQ1QnwvKS16uTXNfnmp8IRknY76I2m3S5qsM8klxWQQKFDuQHl8xXV0MwAoeFh9X6vbwIqrLlof3s4PMjRDwKsxYzkMr1RvfDI8emoPoW83q4Q60_xpHaw",
+      "payload": {
+        "data": "this is a secret message",
+        "exp": "2019-01-01T00:00:00+00:00"
+      },
+      "footer": "",
+      "implicit-assertion": ""
+    },
+    {
+      "name": "1-E-3",
+      "nonce": "26f7553354482a1d91d4784627854b8da6b8042a7966523c2b404e8dbbe7f7f2",
+      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+      "token": "v1.local.4VyfcVcFAOAbB8yEM1j1Ob7Iez5VZJy5kHNsQxmlrAwKUbOtq9cv39T2fC0MDWafX0nQJ4grFZzTdroMvU772RW-X1oTtoFBjsl_3YYHWnwgqzs0aFc3ejjORmKP4KUM339W3syBYyjKIOeWnsFQB6Yef-1ov9rvqt7TmwONUHeJUYk4IK_JEdUeo_uFRqAIgHsiGCg",
+      "payload": {
+        "data": "this is a signed message",
+        "exp": "2019-01-01T00:00:00+00:00"
+      },
+      "footer": "",
+      "implicit-assertion": ""
+    },
+    {
+      "name": "1-E-4",
+      "nonce": "26f7553354482a1d91d4784627854b8da6b8042a7966523c2b404e8dbbe7f7f2",
+      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+      "token": "v1.local.IddlRQmpk6ojcD10z1EYdLexXvYiadtY0MrYQaRnq3dnqKIWcbbpOcgXdMIkm3_3gksirTj81bvWrWkQwcUHilt-tQo7LZK8I6HCK1V78B9YeEqGNeeWXOyWWHoJQIe0d5nTdvejdt2Srz_5Q0QG4oiz1gB_wmv4U5pifedaZbHXUTWXchFEi0etJ4u6tqgxZSklcec",
+      "payload": {
+        "data": "this is a secret message",
+        "exp": "2019-01-01T00:00:00+00:00"
+      },
+      "footer": "",
+      "implicit-assertion": ""
+    },
+    {
+      "name": "1-E-5",
+      "nonce": "26f7553354482a1d91d4784627854b8da6b8042a7966523c2b404e8dbbe7f7f2",
+      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+      "token": "v1.local.4VyfcVcFAOAbB8yEM1j1Ob7Iez5VZJy5kHNsQxmlrAwKUbOtq9cv39T2fC0MDWafX0nQJ4grFZzTdroMvU772RW-X1oTtoFBjsl_3YYHWnwgqzs0aFc3ejjORmKP4KUM339W3szA28OabR192eRqiyspQ6xPM35NMR-04-FhRJZEWiF0W5oWjPVtGPjeVjm2DI4YtJg.eyJraWQiOiJVYmtLOFk2aXY0R1poRnA2VHgzSVdMV0xmTlhTRXZKY2RUM3pkUjY1WVp4byJ9",
+      "payload": {
+        "data": "this is a signed message",
+        "exp": "2019-01-01T00:00:00+00:00"
+      },
+      "footer": "{\"kid\":\"UbkK8Y6iv4GZhFp6Tx3IWLWLfNXSEvJcdT3zdR65YZxo\"}",
+      "implicit-assertion": ""
+    },
+    {
+      "name": "1-E-6",
+      "nonce": "26f7553354482a1d91d4784627854b8da6b8042a7966523c2b404e8dbbe7f7f2",
+      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+      "token": "v1.local.IddlRQmpk6ojcD10z1EYdLexXvYiadtY0MrYQaRnq3dnqKIWcbbpOcgXdMIkm3_3gksirTj81bvWrWkQwcUHilt-tQo7LZK8I6HCK1V78B9YeEqGNeeWXOyWWHoJQIe0d5nTdvcT2vnER6NrJ7xIowvFba6J4qMlFhBnYSxHEq9v9NlzcKsz1zscdjcAiXnEuCHyRSc.eyJraWQiOiJVYmtLOFk2aXY0R1poRnA2VHgzSVdMV0xmTlhTRXZKY2RUM3pkUjY1WVp4byJ9",
+      "payload": {
+        "data": "this is a secret message",
+        "exp": "2019-01-01T00:00:00+00:00"
+      },
+      "footer": "{\"kid\":\"UbkK8Y6iv4GZhFp6Tx3IWLWLfNXSEvJcdT3zdR65YZxo\"}",
+      "implicit-assertion": ""
+    },
+    {
+      "name": "1-E-7",
+      "nonce": "26f7553354482a1d91d4784627854b8da6b8042a7966523c2b404e8dbbe7f7f2",
+      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+      "token": "v1.local.4VyfcVcFAOAbB8yEM1j1Ob7Iez5VZJy5kHNsQxmlrAwKUbOtq9cv39T2fC0MDWafX0nQJ4grFZzTdroMvU772RW-X1oTtoFBjsl_3YYHWnwgqzs0aFc3ejjORmKP4KUM339W3szA28OabR192eRqiyspQ6xPM35NMR-04-FhRJZEWiF0W5oWjPVtGPjeVjm2DI4YtJg.eyJraWQiOiJVYmtLOFk2aXY0R1poRnA2VHgzSVdMV0xmTlhTRXZKY2RUM3pkUjY1WVp4byJ9",
+      "payload": {
+        "data": "this is a signed message",
+        "exp": "2019-01-01T00:00:00+00:00"
+      },
+      "footer": "{\"kid\":\"UbkK8Y6iv4GZhFp6Tx3IWLWLfNXSEvJcdT3zdR65YZxo\"}",
+      "implicit-assertion": "discarded-anyway"
+    },
+    {
+      "name": "1-E-8",
+      "nonce": "26f7553354482a1d91d4784627854b8da6b8042a7966523c2b404e8dbbe7f7f2",
+      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+      "token": "v1.local.IddlRQmpk6ojcD10z1EYdLexXvYiadtY0MrYQaRnq3dnqKIWcbbpOcgXdMIkm3_3gksirTj81bvWrWkQwcUHilt-tQo7LZK8I6HCK1V78B9YeEqGNeeWXOyWWHoJQIe0d5nTdvcT2vnER6NrJ7xIowvFba6J4qMlFhBnYSxHEq9v9NlzcKsz1zscdjcAiXnEuCHyRSc.eyJraWQiOiJVYmtLOFk2aXY0R1poRnA2VHgzSVdMV0xmTlhTRXZKY2RUM3pkUjY1WVp4byJ9",
+      "payload": {
+        "data": "this is a secret message",
+        "exp": "2019-01-01T00:00:00+00:00"
+      },
+      "footer": "{\"kid\":\"UbkK8Y6iv4GZhFp6Tx3IWLWLfNXSEvJcdT3zdR65YZxo\"}",
+      "implicit-assertion": "discarded-anyway"
+    },
+    {
+      "name": "1-E-9",
+      "nonce": "26f7553354482a1d91d4784627854b8da6b8042a7966523c2b404e8dbbe7f7f2",
+      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+      "token": "v1.local.IddlRQmpk6ojcD10z1EYdLexXvYiadtY0MrYQaRnq3dnqKIWcbbpOcgXdMIkm3_3gksirTj81bvWrWkQwcUHilt-tQo7LZK8I6HCK1V78B9YeEqGNeeWXOyWWHoJQIe0d5nTdvdgNpe3vI21jV2YL7WVG5p63_JxxzLckBu9azQ0GlDMdPxNAxoyvmU1wbpSbRB9Iw4.YXJiaXRyYXJ5LXN0cmluZy10aGF0LWlzbid0LWpzb24",
+      "payload": {
+        "data": "this is a secret message",
+        "exp": "2019-01-01T00:00:00+00:00"
+      },
+      "footer": "arbitrary-string-that-isn't-json",
+      "implicit-assertion": "discarded-anyway"
+    },
+    {
+      "name": "1-S-1",
+      "public-key": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyaTgTt53ph3p5GHgwoGW\nwz5hRfWXSQA08NCOwe0FEgALWos9GCjNFCd723nCHxBtN1qd74MSh/uN88JPIbwx\nKheDp4kxo4YMN5trPaF0e9G6Bj1N02HnanxFLW+gmLbgYO/SZYfWF/M8yLBcu5Y1\nOt0ZxDDDXS9wIQTtBE0ne3YbxgZJAZTU5XqyQ1DxdzYyC5lF6yBaR5UQtCYTnXAA\npVRuUI2Sd6L1E2vl9bSBumZ5IpNxkRnAwIMjeTJB/0AIELh0mE5vwdihOCbdV6al\nUyhKC1+1w/FW6HWcp/JG1kKC8DPIidZ78Bbqv9YFzkAbNni5eSBOsXVBKG78Zsc8\nowIDAQAB\n-----END PUBLIC KEY-----",
+      "secret-key": "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEAyaTgTt53ph3p5GHgwoGWwz5hRfWXSQA08NCOwe0FEgALWos9\nGCjNFCd723nCHxBtN1qd74MSh/uN88JPIbwxKheDp4kxo4YMN5trPaF0e9G6Bj1N\n02HnanxFLW+gmLbgYO/SZYfWF/M8yLBcu5Y1Ot0ZxDDDXS9wIQTtBE0ne3YbxgZJ\nAZTU5XqyQ1DxdzYyC5lF6yBaR5UQtCYTnXAApVRuUI2Sd6L1E2vl9bSBumZ5IpNx\nkRnAwIMjeTJB/0AIELh0mE5vwdihOCbdV6alUyhKC1+1w/FW6HWcp/JG1kKC8DPI\nidZ78Bbqv9YFzkAbNni5eSBOsXVBKG78Zsc8owIDAQABAoIBAF22jLDa34yKdns3\nqfd7to+C3D5hRzAcMn6Azvf9qc+VybEI6RnjTHxDZWK5EajSP4/sQ15e8ivUk0Jo\nWdJ53feL+hnQvwsab28gghSghrxM2kGwGA1XgO+SVawqJt8SjvE+Q+//01ZKK0Oy\nA0cDJjX3L9RoPUN/moMeAPFw0hqkFEhm72GSVCEY1eY+cOXmL3icxnsnlUD//SS9\nq33RxF2y5oiW1edqcRqhW/7L1yYMbxHFUcxWh8WUwjn1AAhoCOUzF8ZB+0X/PPh+\n1nYoq6xwqL0ZKDwrQ8SDhW/rNDLeO9gic5rl7EetRQRbFvsZ40AdsX2wU+lWFUkB\n42AjuoECgYEA5z/CXqDFfZ8MXCPAOeui8y5HNDtu30aR+HOXsBDnRI8huXsGND04\nFfmXR7nkghr08fFVDmE4PeKUk810YJb+IAJo8wrOZ0682n6yEMO58omqKin+iIUV\nrPXLSLo5CChrqw2J4vgzolzPw3N5I8FJdLomb9FkrV84H+IviPIylyECgYEA3znw\nAG29QX6ATEfFpGVOcogorHCntd4niaWCq5ne5sFL+EwLeVc1zD9yj1axcDelICDZ\nxCZynU7kDnrQcFkT0bjH/gC8Jk3v7XT9l1UDDqC1b7rm/X5wFIZ/rmNa1rVZhL1o\n/tKx5tvM2syJ1q95v7NdygFIEIW+qbIKbc6Wz0MCgYBsUZdQD+qx/xAhELX364I2\nepTryHMUrs+tGygQVrqdiJX5dcDgM1TUJkdQV6jLsKjPs4Vt6OgZRMrnuLMsk02R\n3M8gGQ25ok4f4nyyEZxGGWnVujn55KzUiYWhGWmhgp18UCkoYa59/Q9ss+gocV9h\nB9j9Q43vD80QUjiF4z0DQQKBgC7XQX1VibkMim93QAnXGDcAS0ij+w02qKVBjcHk\nb9mMBhz8GAxGOIu7ZJafYmxhwMyVGB0I1FQeEczYCJUKnBYN6Clsjg6bnBT/z5bJ\nx/Jx1qCzX3Uh6vLjpjc5sf4L39Tyye1u2NXQmZPwB5x9BdcsFConSq/s4K1LJtUT\n3KFxAoGBANGcQ8nObi3m4wROyKrkCWcWxFFMnpwxv0pW727Hn9wuaOs4UbesCnwm\npcMTfzGUDuzYXCtAq2pJl64HG6wsdkWmjBTJEpm6b9ibOBN3qFV2zQ0HyyKlMWxI\nuVSj9gOo61hF7UH9XB6R4HRdlpBOuIbgAWZ46dkj9/HM9ovdP0Iy\n-----END RSA PRIVATE KEY-----",
+      "token": "v1.public.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAxOS0wMS0wMVQwMDowMDowMCswMDowMCJ9cIZKahKeGM5kiAS_4D70Qbz9FIThZpxetJ6n6E6kXP_119SvQcnfCSfY_gG3D0Q2v7FEtm2Cmj04lE6YdgiZ0RwA41WuOjXq7zSnmmHK9xOSH6_2yVgt207h1_LphJzVztmZzq05xxhZsV3nFPm2cCu8oPceWy-DBKjALuMZt_Xj6hWFFie96SfQ6i85lOsTX8Kc6SQaG-3CgThrJJ6W9DC-YfQ3lZ4TJUoY3QNYdtEgAvp1QuWWK6xmIb8BwvkBPej5t88QUb7NcvZ15VyNw3qemQGn2ITSdpdDgwMtpflZOeYdtuxQr1DSGO2aQyZl7s0WYn1IjdQFx6VjSQ4yfw",
+      "payload": {
+        "data": "this is a signed message",
+        "exp": "2019-01-01T00:00:00+00:00"
+      },
+      "footer": "",
+      "implicit-assertion": ""
+    },
+    {
+      "name": "1-S-2",
+      "public-key": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyaTgTt53ph3p5GHgwoGW\nwz5hRfWXSQA08NCOwe0FEgALWos9GCjNFCd723nCHxBtN1qd74MSh/uN88JPIbwx\nKheDp4kxo4YMN5trPaF0e9G6Bj1N02HnanxFLW+gmLbgYO/SZYfWF/M8yLBcu5Y1\nOt0ZxDDDXS9wIQTtBE0ne3YbxgZJAZTU5XqyQ1DxdzYyC5lF6yBaR5UQtCYTnXAA\npVRuUI2Sd6L1E2vl9bSBumZ5IpNxkRnAwIMjeTJB/0AIELh0mE5vwdihOCbdV6al\nUyhKC1+1w/FW6HWcp/JG1kKC8DPIidZ78Bbqv9YFzkAbNni5eSBOsXVBKG78Zsc8\nowIDAQAB\n-----END PUBLIC KEY-----",
+      "secret-key": "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEAyaTgTt53ph3p5GHgwoGWwz5hRfWXSQA08NCOwe0FEgALWos9\nGCjNFCd723nCHxBtN1qd74MSh/uN88JPIbwxKheDp4kxo4YMN5trPaF0e9G6Bj1N\n02HnanxFLW+gmLbgYO/SZYfWF/M8yLBcu5Y1Ot0ZxDDDXS9wIQTtBE0ne3YbxgZJ\nAZTU5XqyQ1DxdzYyC5lF6yBaR5UQtCYTnXAApVRuUI2Sd6L1E2vl9bSBumZ5IpNx\nkRnAwIMjeTJB/0AIELh0mE5vwdihOCbdV6alUyhKC1+1w/FW6HWcp/JG1kKC8DPI\nidZ78Bbqv9YFzkAbNni5eSBOsXVBKG78Zsc8owIDAQABAoIBAF22jLDa34yKdns3\nqfd7to+C3D5hRzAcMn6Azvf9qc+VybEI6RnjTHxDZWK5EajSP4/sQ15e8ivUk0Jo\nWdJ53feL+hnQvwsab28gghSghrxM2kGwGA1XgO+SVawqJt8SjvE+Q+//01ZKK0Oy\nA0cDJjX3L9RoPUN/moMeAPFw0hqkFEhm72GSVCEY1eY+cOXmL3icxnsnlUD//SS9\nq33RxF2y5oiW1edqcRqhW/7L1yYMbxHFUcxWh8WUwjn1AAhoCOUzF8ZB+0X/PPh+\n1nYoq6xwqL0ZKDwrQ8SDhW/rNDLeO9gic5rl7EetRQRbFvsZ40AdsX2wU+lWFUkB\n42AjuoECgYEA5z/CXqDFfZ8MXCPAOeui8y5HNDtu30aR+HOXsBDnRI8huXsGND04\nFfmXR7nkghr08fFVDmE4PeKUk810YJb+IAJo8wrOZ0682n6yEMO58omqKin+iIUV\nrPXLSLo5CChrqw2J4vgzolzPw3N5I8FJdLomb9FkrV84H+IviPIylyECgYEA3znw\nAG29QX6ATEfFpGVOcogorHCntd4niaWCq5ne5sFL+EwLeVc1zD9yj1axcDelICDZ\nxCZynU7kDnrQcFkT0bjH/gC8Jk3v7XT9l1UDDqC1b7rm/X5wFIZ/rmNa1rVZhL1o\n/tKx5tvM2syJ1q95v7NdygFIEIW+qbIKbc6Wz0MCgYBsUZdQD+qx/xAhELX364I2\nepTryHMUrs+tGygQVrqdiJX5dcDgM1TUJkdQV6jLsKjPs4Vt6OgZRMrnuLMsk02R\n3M8gGQ25ok4f4nyyEZxGGWnVujn55KzUiYWhGWmhgp18UCkoYa59/Q9ss+gocV9h\nB9j9Q43vD80QUjiF4z0DQQKBgC7XQX1VibkMim93QAnXGDcAS0ij+w02qKVBjcHk\nb9mMBhz8GAxGOIu7ZJafYmxhwMyVGB0I1FQeEczYCJUKnBYN6Clsjg6bnBT/z5bJ\nx/Jx1qCzX3Uh6vLjpjc5sf4L39Tyye1u2NXQmZPwB5x9BdcsFConSq/s4K1LJtUT\n3KFxAoGBANGcQ8nObi3m4wROyKrkCWcWxFFMnpwxv0pW727Hn9wuaOs4UbesCnwm\npcMTfzGUDuzYXCtAq2pJl64HG6wsdkWmjBTJEpm6b9ibOBN3qFV2zQ0HyyKlMWxI\nuVSj9gOo61hF7UH9XB6R4HRdlpBOuIbgAWZ46dkj9/HM9ovdP0Iy\n-----END RSA PRIVATE KEY-----",
+      "token": "v1.public.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAxOS0wMS0wMVQwMDowMDowMCswMDowMCJ9sBTIb0J_4misAuYc4-6P5iR1rQighzktpXhJ8gtrrp2MqSSDkbb8q5WZh3FhUYuW_rg2X8aflDlTWKAqJkM3otjYwtmfwfOhRyykxRL2AfmIika_A-_MaLp9F0iw4S1JetQQDV8GUHjosd87TZ20lT2JQLhxKjBNJSwWue8ucGhTgJcpOhXcthqaz7a2yudGyd0layzeWziBhdQpoBR6ryTdtIQX54hP59k3XCIxuYbB9qJMpixiPAEKBcjHT74sA-uukug9VgKO7heWHwJL4Rl9ad21xyNwaxAnwAJ7C0fN5oGv8Rl0dF11b3tRmsmbDoIokIM0Dba29x_T3YzOyg.eyJraWQiOiJkWWtJU3lseFFlZWNFY0hFTGZ6Rjg4VVpyd2JMb2xOaUNkcHpVSEd3OVVxbiJ9",
+      "payload": {
+        "data": "this is a signed message",
+        "exp": "2019-01-01T00:00:00+00:00"
+      },
+      "footer": "{\"kid\":\"dYkISylxQeecEcHELfzF88UZrwbLolNiCdpzUHGw9Uqn\"}",
+      "implicit-assertion": ""
+    },
+    {
+      "name": "1-S-3",
+      "public-key": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyaTgTt53ph3p5GHgwoGW\nwz5hRfWXSQA08NCOwe0FEgALWos9GCjNFCd723nCHxBtN1qd74MSh/uN88JPIbwx\nKheDp4kxo4YMN5trPaF0e9G6Bj1N02HnanxFLW+gmLbgYO/SZYfWF/M8yLBcu5Y1\nOt0ZxDDDXS9wIQTtBE0ne3YbxgZJAZTU5XqyQ1DxdzYyC5lF6yBaR5UQtCYTnXAA\npVRuUI2Sd6L1E2vl9bSBumZ5IpNxkRnAwIMjeTJB/0AIELh0mE5vwdihOCbdV6al\nUyhKC1+1w/FW6HWcp/JG1kKC8DPIidZ78Bbqv9YFzkAbNni5eSBOsXVBKG78Zsc8\nowIDAQAB\n-----END PUBLIC KEY-----",
+      "secret-key": "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEAyaTgTt53ph3p5GHgwoGWwz5hRfWXSQA08NCOwe0FEgALWos9\nGCjNFCd723nCHxBtN1qd74MSh/uN88JPIbwxKheDp4kxo4YMN5trPaF0e9G6Bj1N\n02HnanxFLW+gmLbgYO/SZYfWF/M8yLBcu5Y1Ot0ZxDDDXS9wIQTtBE0ne3YbxgZJ\nAZTU5XqyQ1DxdzYyC5lF6yBaR5UQtCYTnXAApVRuUI2Sd6L1E2vl9bSBumZ5IpNx\nkRnAwIMjeTJB/0AIELh0mE5vwdihOCbdV6alUyhKC1+1w/FW6HWcp/JG1kKC8DPI\nidZ78Bbqv9YFzkAbNni5eSBOsXVBKG78Zsc8owIDAQABAoIBAF22jLDa34yKdns3\nqfd7to+C3D5hRzAcMn6Azvf9qc+VybEI6RnjTHxDZWK5EajSP4/sQ15e8ivUk0Jo\nWdJ53feL+hnQvwsab28gghSghrxM2kGwGA1XgO+SVawqJt8SjvE+Q+//01ZKK0Oy\nA0cDJjX3L9RoPUN/moMeAPFw0hqkFEhm72GSVCEY1eY+cOXmL3icxnsnlUD//SS9\nq33RxF2y5oiW1edqcRqhW/7L1yYMbxHFUcxWh8WUwjn1AAhoCOUzF8ZB+0X/PPh+\n1nYoq6xwqL0ZKDwrQ8SDhW/rNDLeO9gic5rl7EetRQRbFvsZ40AdsX2wU+lWFUkB\n42AjuoECgYEA5z/CXqDFfZ8MXCPAOeui8y5HNDtu30aR+HOXsBDnRI8huXsGND04\nFfmXR7nkghr08fFVDmE4PeKUk810YJb+IAJo8wrOZ0682n6yEMO58omqKin+iIUV\nrPXLSLo5CChrqw2J4vgzolzPw3N5I8FJdLomb9FkrV84H+IviPIylyECgYEA3znw\nAG29QX6ATEfFpGVOcogorHCntd4niaWCq5ne5sFL+EwLeVc1zD9yj1axcDelICDZ\nxCZynU7kDnrQcFkT0bjH/gC8Jk3v7XT9l1UDDqC1b7rm/X5wFIZ/rmNa1rVZhL1o\n/tKx5tvM2syJ1q95v7NdygFIEIW+qbIKbc6Wz0MCgYBsUZdQD+qx/xAhELX364I2\nepTryHMUrs+tGygQVrqdiJX5dcDgM1TUJkdQV6jLsKjPs4Vt6OgZRMrnuLMsk02R\n3M8gGQ25ok4f4nyyEZxGGWnVujn55KzUiYWhGWmhgp18UCkoYa59/Q9ss+gocV9h\nB9j9Q43vD80QUjiF4z0DQQKBgC7XQX1VibkMim93QAnXGDcAS0ij+w02qKVBjcHk\nb9mMBhz8GAxGOIu7ZJafYmxhwMyVGB0I1FQeEczYCJUKnBYN6Clsjg6bnBT/z5bJ\nx/Jx1qCzX3Uh6vLjpjc5sf4L39Tyye1u2NXQmZPwB5x9BdcsFConSq/s4K1LJtUT\n3KFxAoGBANGcQ8nObi3m4wROyKrkCWcWxFFMnpwxv0pW727Hn9wuaOs4UbesCnwm\npcMTfzGUDuzYXCtAq2pJl64HG6wsdkWmjBTJEpm6b9ibOBN3qFV2zQ0HyyKlMWxI\nuVSj9gOo61hF7UH9XB6R4HRdlpBOuIbgAWZ46dkj9/HM9ovdP0Iy\n-----END RSA PRIVATE KEY-----",
+      "token": "v1.public.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAxOS0wMS0wMVQwMDowMDowMCswMDowMCJ9sBTIb0J_4misAuYc4-6P5iR1rQighzktpXhJ8gtrrp2MqSSDkbb8q5WZh3FhUYuW_rg2X8aflDlTWKAqJkM3otjYwtmfwfOhRyykxRL2AfmIika_A-_MaLp9F0iw4S1JetQQDV8GUHjosd87TZ20lT2JQLhxKjBNJSwWue8ucGhTgJcpOhXcthqaz7a2yudGyd0layzeWziBhdQpoBR6ryTdtIQX54hP59k3XCIxuYbB9qJMpixiPAEKBcjHT74sA-uukug9VgKO7heWHwJL4Rl9ad21xyNwaxAnwAJ7C0fN5oGv8Rl0dF11b3tRmsmbDoIokIM0Dba29x_T3YzOyg.eyJraWQiOiJkWWtJU3lseFFlZWNFY0hFTGZ6Rjg4VVpyd2JMb2xOaUNkcHpVSEd3OVVxbiJ9",
+      "payload": {
+        "data": "this is a signed message",
+        "exp": "2019-01-01T00:00:00+00:00"
+      },
+      "footer": "{\"kid\":\"dYkISylxQeecEcHELfzF88UZrwbLolNiCdpzUHGw9Uqn\"}",
+      "implicit-assertion": "discarded-anyway"
+    }
+  ]
+}
\ No newline at end of file
diff --git a/docs/03-Implementation-Guide/Test-Vectors/v2.json b/docs/03-Implementation-Guide/Test-Vectors/v2.json
new file mode 100644
index 00000000..18399f47
--- /dev/null
+++ b/docs/03-Implementation-Guide/Test-Vectors/v2.json
@@ -0,0 +1,155 @@
+{
+  "name": "PASETO v2 Test Vectors",
+  "tests": [
+    {
+      "name": "2-E-1",
+      "nonce": "000000000000000000000000000000000000000000000000",
+      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+      "token": "v2.local.97TTOvgwIxNGvV80XKiGZg_kD3tsXM_-qB4dZGHOeN1cTkgQ4PnW8888l802W8d9AvEGnoNBY3BnqHORy8a5cC8aKpbA0En8XELw2yDk2f1sVODyfnDbi6rEGMY3pSfCbLWMM2oHJxvlEl2XbQ",
+      "payload": {
+        "data": "this is a signed message",
+        "exp": "2019-01-01T00:00:00+00:00"
+      },
+      "footer": "",
+      "implicit-assertion": ""
+    },
+    {
+      "name": "2-E-2",
+      "nonce": "000000000000000000000000000000000000000000000000",
+      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+      "token": "v2.local.CH50H-HM5tzdK4kOmQ8KbIvrzJfjYUGuu5Vy9ARSFHy9owVDMYg3-8rwtJZQjN9ABHb2njzFkvpr5cOYuRyt7CRXnHt42L5yZ7siD-4l-FoNsC7J2OlvLlIwlG06mzQVunrFNb7Z3_CHM0PK5w",
+      "payload": {
+        "data": "this is a secret message",
+        "exp": "2019-01-01T00:00:00+00:00"
+      },
+      "footer": "",
+      "implicit-assertion": ""
+    },
+    {
+      "name": "2-E-3",
+      "nonce": "45742c976d684ff84ebdc0de59809a97cda2f64c84fda19b",
+      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+      "token": "v2.local.5K4SCXNhItIhyNuVIZcwrdtaDKiyF81-eWHScuE0idiVqCo72bbjo07W05mqQkhLZdVbxEa5I_u5sgVk1QLkcWEcOSlLHwNpCkvmGGlbCdNExn6Qclw3qTKIIl5-O5xRBN076fSDPo5xUCPpBA",
+      "payload": {
+        "data": "this is a signed message",
+        "exp": "2019-01-01T00:00:00+00:00"
+      },
+      "footer": "",
+      "implicit-assertion": ""
+    },
+    {
+      "name": "2-E-4",
+      "nonce": "45742c976d684ff84ebdc0de59809a97cda2f64c84fda19b",
+      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+      "token": "v2.local.pvFdDeNtXxknVPsbBCZF6MGedVhPm40SneExdClOxa9HNR8wFv7cu1cB0B4WxDdT6oUc2toyLR6jA6sc-EUM5ll1EkeY47yYk6q8m1RCpqTIzUrIu3B6h232h62DPbIxtjGvNRAwsLK7LcV8oQ",
+      "payload": {
+        "data": "this is a secret message",
+        "exp": "2019-01-01T00:00:00+00:00"
+      },
+      "footer": "",
+      "implicit-assertion": ""
+    },
+    {
+      "name": "2-E-5",
+      "nonce": "45742c976d684ff84ebdc0de59809a97cda2f64c84fda19b",
+      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+      "token": "v2.local.5K4SCXNhItIhyNuVIZcwrdtaDKiyF81-eWHScuE0idiVqCo72bbjo07W05mqQkhLZdVbxEa5I_u5sgVk1QLkcWEcOSlLHwNpCkvmGGlbCdNExn6Qclw3qTKIIl5-zSLIrxZqOLwcFLYbVK1SrQ.eyJraWQiOiJ6VmhNaVBCUDlmUmYyc25FY1Q3Z0ZUaW9lQTlDT2NOeTlEZmdMMVc2MGhhTiJ9",
+      "payload": {
+        "data": "this is a signed message",
+        "exp": "2019-01-01T00:00:00+00:00"
+      },
+      "footer": "{\"kid\":\"zVhMiPBP9fRf2snEcT7gFTioeA9COcNy9DfgL1W60haN\"}",
+      "implicit-assertion": ""
+    },
+    {
+      "name": "2-E-6",
+      "nonce": "45742c976d684ff84ebdc0de59809a97cda2f64c84fda19b",
+      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+      "token": "v2.local.pvFdDeNtXxknVPsbBCZF6MGedVhPm40SneExdClOxa9HNR8wFv7cu1cB0B4WxDdT6oUc2toyLR6jA6sc-EUM5ll1EkeY47yYk6q8m1RCpqTIzUrIu3B6h232h62DnMXKdHn_Smp6L_NfaEnZ-A.eyJraWQiOiJ6VmhNaVBCUDlmUmYyc25FY1Q3Z0ZUaW9lQTlDT2NOeTlEZmdMMVc2MGhhTiJ9",
+      "payload": {
+        "data": "this is a secret message",
+        "exp": "2019-01-01T00:00:00+00:00"
+      },
+      "footer": "{\"kid\":\"zVhMiPBP9fRf2snEcT7gFTioeA9COcNy9DfgL1W60haN\"}",
+      "implicit-assertion": ""
+    },
+    {
+      "name": "2-E-7",
+      "nonce": "45742c976d684ff84ebdc0de59809a97cda2f64c84fda19b",
+      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+      "token": "v2.local.5K4SCXNhItIhyNuVIZcwrdtaDKiyF81-eWHScuE0idiVqCo72bbjo07W05mqQkhLZdVbxEa5I_u5sgVk1QLkcWEcOSlLHwNpCkvmGGlbCdNExn6Qclw3qTKIIl5-zSLIrxZqOLwcFLYbVK1SrQ.eyJraWQiOiJ6VmhNaVBCUDlmUmYyc25FY1Q3Z0ZUaW9lQTlDT2NOeTlEZmdMMVc2MGhhTiJ9",
+      "payload": {
+        "data": "this is a signed message",
+        "exp": "2019-01-01T00:00:00+00:00"
+      },
+      "footer": "{\"kid\":\"zVhMiPBP9fRf2snEcT7gFTioeA9COcNy9DfgL1W60haN\"}",
+      "implicit-assertion": "discarded-anyway"
+    },
+    {
+      "name": "2-E-8",
+      "nonce": "45742c976d684ff84ebdc0de59809a97cda2f64c84fda19b",
+      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+      "token": "v2.local.pvFdDeNtXxknVPsbBCZF6MGedVhPm40SneExdClOxa9HNR8wFv7cu1cB0B4WxDdT6oUc2toyLR6jA6sc-EUM5ll1EkeY47yYk6q8m1RCpqTIzUrIu3B6h232h62DnMXKdHn_Smp6L_NfaEnZ-A.eyJraWQiOiJ6VmhNaVBCUDlmUmYyc25FY1Q3Z0ZUaW9lQTlDT2NOeTlEZmdMMVc2MGhhTiJ9",
+      "payload": {
+        "data": "this is a secret message",
+        "exp": "2019-01-01T00:00:00+00:00"
+      },
+      "footer": "{\"kid\":\"zVhMiPBP9fRf2snEcT7gFTioeA9COcNy9DfgL1W60haN\"}",
+      "implicit-assertion": "discarded-anyway"
+    },
+    {
+      "name": "2-E-9",
+      "nonce": "45742c976d684ff84ebdc0de59809a97cda2f64c84fda19b",
+      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+      "token": "v2.local.pvFdDeNtXxknVPsbBCZF6MGedVhPm40SneExdClOxa9HNR8wFv7cu1cB0B4WxDdT6oUc2toyLR6jA6sc-EUM5ll1EkeY47yYk6q8m1RCpqTIzUrIu3B6h232h62DoOJbyKBGPZG50XDZ6mbPtw.YXJiaXRyYXJ5LXN0cmluZy10aGF0LWlzbid0LWpzb24",
+      "payload": {
+        "data": "this is a secret message",
+        "exp": "2019-01-01T00:00:00+00:00"
+      },
+      "footer": "arbitrary-string-that-isn't-json",
+      "implicit-assertion": "discarded-anyway"
+    },
+    {
+      "name": "2-S-1",
+      "public-key": "1eb9dbbbbc047c03fd70604e0071f0987e16b28b757225c11f00415d0e20b1a2",
+      "secret-key": "b4cbfb43df4ce210727d953e4a713307fa19bb7d9f85041438d9e11b942a37741eb9dbbbbc047c03fd70604e0071f0987e16b28b757225c11f00415d0e20b1a2",
+      "secret-key-pem": "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEILTL+0PfTOIQcn2VPkpxMwf6Gbt9n4UEFDjZ4RuUKjd0\n-----END PRIVATE KEY-----",
+      "public-key-pem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAHrnbu7wEfAP9cGBOAHHwmH4Wsot1ciXBHwBBXQ4gsaI=\n-----END PUBLIC KEY-----",
+      "token": "v2.public.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAxOS0wMS0wMVQwMDowMDowMCswMDowMCJ9HQr8URrGntTu7Dz9J2IF23d1M7-9lH9xiqdGyJNvzp4angPW5Esc7C5huy_M8I8_DjJK2ZXC2SUYuOFM-Q_5Cw",
+      "payload": {
+        "data": "this is a signed message",
+        "exp": "2019-01-01T00:00:00+00:00"
+      },
+      "footer": "",
+      "implicit-assertion": ""
+    },
+    {
+      "name": "2-S-2",
+      "public-key": "1eb9dbbbbc047c03fd70604e0071f0987e16b28b757225c11f00415d0e20b1a2",
+      "secret-key": "b4cbfb43df4ce210727d953e4a713307fa19bb7d9f85041438d9e11b942a37741eb9dbbbbc047c03fd70604e0071f0987e16b28b757225c11f00415d0e20b1a2",
+      "secret-key-pem": "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEILTL+0PfTOIQcn2VPkpxMwf6Gbt9n4UEFDjZ4RuUKjd0\n-----END PRIVATE KEY-----",
+      "public-key-pem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAHrnbu7wEfAP9cGBOAHHwmH4Wsot1ciXBHwBBXQ4gsaI=\n-----END PUBLIC KEY-----",
+      "token": "v2.public.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAxOS0wMS0wMVQwMDowMDowMCswMDowMCJ9flsZsx_gYCR0N_Ec2QxJFFpvQAs7h9HtKwbVK2n1MJ3Rz-hwe8KUqjnd8FAnIJZ601tp7lGkguU63oGbomhoBw.eyJraWQiOiJ6VmhNaVBCUDlmUmYyc25FY1Q3Z0ZUaW9lQTlDT2NOeTlEZmdMMVc2MGhhTiJ9",
+      "payload": {
+        "data": "this is a signed message",
+        "exp": "2019-01-01T00:00:00+00:00"
+      },
+      "footer": "{\"kid\":\"zVhMiPBP9fRf2snEcT7gFTioeA9COcNy9DfgL1W60haN\"}",
+      "implicit-assertion": ""
+    },
+    {
+      "name": "2-S-3",
+      "public-key": "1eb9dbbbbc047c03fd70604e0071f0987e16b28b757225c11f00415d0e20b1a2",
+      "secret-key": "b4cbfb43df4ce210727d953e4a713307fa19bb7d9f85041438d9e11b942a37741eb9dbbbbc047c03fd70604e0071f0987e16b28b757225c11f00415d0e20b1a2",
+      "secret-key-pem": "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEILTL+0PfTOIQcn2VPkpxMwf6Gbt9n4UEFDjZ4RuUKjd0\n-----END PRIVATE KEY-----",
+      "public-key-pem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAHrnbu7wEfAP9cGBOAHHwmH4Wsot1ciXBHwBBXQ4gsaI=\n-----END PUBLIC KEY-----",
+      "token": "v2.public.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAxOS0wMS0wMVQwMDowMDowMCswMDowMCJ9flsZsx_gYCR0N_Ec2QxJFFpvQAs7h9HtKwbVK2n1MJ3Rz-hwe8KUqjnd8FAnIJZ601tp7lGkguU63oGbomhoBw.eyJraWQiOiJ6VmhNaVBCUDlmUmYyc25FY1Q3Z0ZUaW9lQTlDT2NOeTlEZmdMMVc2MGhhTiJ9",
+      "payload": {
+        "data": "this is a signed message",
+        "exp": "2019-01-01T00:00:00+00:00"
+      },
+      "footer": "{\"kid\":\"zVhMiPBP9fRf2snEcT7gFTioeA9COcNy9DfgL1W60haN\"}",
+      "implicit-assertion": "discarded-anyway"
+    }
+  ]
+}
\ No newline at end of file
diff --git a/docs/03-Implementation-Guide/Test-Vectors/v3.json b/docs/03-Implementation-Guide/Test-Vectors/v3.json
new file mode 100644
index 00000000..6c407ff3
--- /dev/null
+++ b/docs/03-Implementation-Guide/Test-Vectors/v3.json
@@ -0,0 +1,155 @@
+{
+  "name": "PASETO v3 Test Vectors",
+  "tests": [
+    {
+      "name": "3-E-1",
+      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+      "nonce": "0000000000000000000000000000000000000000000000000000000000000000",
+      "token": "v3.local.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADbfcIURX_0pVZVU1mAESUzrKZAsRm2EsD6yBoZYn6cpVZNzSJOhSDN-sRaWjfLU-yn9OJH1J_B8GKtOQ9gSQlb8yk9Iza7teRdkiR89ZFyvPPsVjjFiepFUVcMa-LP18zV77f_crJrVXWa5PDNRkCSeHfBBeg",
+      "payload": {
+        "data": "this is a secret message",
+        "exp": "2022-01-01T00:00:00+00:00"
+      },
+      "footer": "",
+      "implicit-assertion": ""
+    },
+    {
+      "name": "3-E-2",
+      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+      "nonce": "0000000000000000000000000000000000000000000000000000000000000000",
+      "token": "v3.local.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADbfcIURX_0pVZVU1mAESUzrKZAqhWxBMDgyBoZYn6cpVZNzSJOhSDN-sRaWjfLU-yn9OJH1J_B8GKtOQ9gSQlb8yk9IzZfaZpReVpHlDSwfuygx1riVXYVs-UjcrG_apl9oz3jCVmmJbRuKn5ZfD8mHz2db0A",
+      "payload": {
+        "data": "this is a hidden message",
+        "exp": "2022-01-01T00:00:00+00:00"
+      },
+      "footer": "",
+      "implicit-assertion": ""
+    },
+    {
+      "name": "3-E-3",
+      "nonce": "26f7553354482a1d91d4784627854b8da6b8042a7966523c2b404e8dbbe7f7f2",
+      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+      "token": "v3.local.JvdVM1RIKh2R1HhGJ4VLjaa4BCp5ZlI8K0BOjbvn9_LwY78vQnDait-Q-sjhF88dG2B0ROIIykcrGHn8wzPbTrqObHhyoKpjy3cwZQzLdiwRsdEK5SDvl02_HjWKJW2oqGMOQJlxnt5xyhQjFJomwnt7WW_7r2VT0G704ifult011-TgLCyQ2X8imQhniG_hAQ4BydM",
+      "payload": {
+        "data": "this is a secret message",
+        "exp": "2022-01-01T00:00:00+00:00"
+      },
+      "footer": "",
+      "implicit-assertion": ""
+    },
+    {
+      "name": "3-E-4",
+      "nonce": "26f7553354482a1d91d4784627854b8da6b8042a7966523c2b404e8dbbe7f7f2",
+      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+      "token": "v3.local.JvdVM1RIKh2R1HhGJ4VLjaa4BCp5ZlI8K0BOjbvn9_LwY78vQnDait-Q-sjhF88dG2B0X-4P3EcxGHn8wzPbTrqObHhyoKpjy3cwZQzLdiwRsdEK5SDvl02_HjWKJW2oqGMOQJlBZa_gOpVj4gv0M9lV6Pwjp8JS_MmaZaTA1LLTULXybOBZ2S4xMbYqYmDRhh3IgEk",
+      "payload": {
+        "data": "this is a hidden message",
+        "exp": "2022-01-01T00:00:00+00:00"
+      },
+      "footer": "",
+      "implicit-assertion": ""
+    },
+    {
+      "name": "3-E-5",
+      "nonce": "26f7553354482a1d91d4784627854b8da6b8042a7966523c2b404e8dbbe7f7f2",
+      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+      "token": "v3.local.JvdVM1RIKh2R1HhGJ4VLjaa4BCp5ZlI8K0BOjbvn9_LwY78vQnDait-Q-sjhF88dG2B0ROIIykcrGHn8wzPbTrqObHhyoKpjy3cwZQzLdiwRsdEK5SDvl02_HjWKJW2oqGMOQJlkYSIbXOgVuIQL65UMdW9WcjOpmqvjqD40NNzed-XPqn1T3w-bJvitYpUJL_rmihc.eyJraWQiOiJVYmtLOFk2aXY0R1poRnA2VHgzSVdMV0xmTlhTRXZKY2RUM3pkUjY1WVp4byJ9",
+      "payload": {
+        "data": "this is a secret message",
+        "exp": "2022-01-01T00:00:00+00:00"
+      },
+      "footer": "{\"kid\":\"UbkK8Y6iv4GZhFp6Tx3IWLWLfNXSEvJcdT3zdR65YZxo\"}",
+      "implicit-assertion": ""
+    },
+    {
+      "name": "3-E-6",
+      "nonce": "26f7553354482a1d91d4784627854b8da6b8042a7966523c2b404e8dbbe7f7f2",
+      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+      "token": "v3.local.JvdVM1RIKh2R1HhGJ4VLjaa4BCp5ZlI8K0BOjbvn9_LwY78vQnDait-Q-sjhF88dG2B0X-4P3EcxGHn8wzPbTrqObHhyoKpjy3cwZQzLdiwRsdEK5SDvl02_HjWKJW2oqGMOQJmSeEMphEWHiwtDKJftg41O1F8Hat-8kQ82ZIAMFqkx9q5VkWlxZke9ZzMBbb3Znfo.eyJraWQiOiJVYmtLOFk2aXY0R1poRnA2VHgzSVdMV0xmTlhTRXZKY2RUM3pkUjY1WVp4byJ9",
+      "payload": {
+        "data": "this is a hidden message",
+        "exp": "2022-01-01T00:00:00+00:00"
+      },
+      "footer": "{\"kid\":\"UbkK8Y6iv4GZhFp6Tx3IWLWLfNXSEvJcdT3zdR65YZxo\"}",
+      "implicit-assertion": ""
+    },
+    {
+      "name": "3-E-7",
+      "nonce": "26f7553354482a1d91d4784627854b8da6b8042a7966523c2b404e8dbbe7f7f2",
+      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+      "token": "v3.local.JvdVM1RIKh2R1HhGJ4VLjaa4BCp5ZlI8K0BOjbvn9_LwY78vQnDait-Q-sjhF88dG2B0ROIIykcrGHn8wzPbTrqObHhyoKpjy3cwZQzLdiwRsdEK5SDvl02_HjWKJW2oqGMOQJkzWACWAIoVa0bz7EWSBoTEnS8MvGBYHHo6t6mJunPrFR9JKXFCc0obwz5N-pxFLOc.eyJraWQiOiJVYmtLOFk2aXY0R1poRnA2VHgzSVdMV0xmTlhTRXZKY2RUM3pkUjY1WVp4byJ9",
+      "payload": {
+        "data": "this is a secret message",
+        "exp": "2022-01-01T00:00:00+00:00"
+      },
+      "footer": "{\"kid\":\"UbkK8Y6iv4GZhFp6Tx3IWLWLfNXSEvJcdT3zdR65YZxo\"}",
+      "implicit-assertion": "{\"test-vector\":\"3-E-7\"}"
+    },
+    {
+      "name": "3-E-8",
+      "nonce": "26f7553354482a1d91d4784627854b8da6b8042a7966523c2b404e8dbbe7f7f2",
+      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+      "token": "v3.local.JvdVM1RIKh2R1HhGJ4VLjaa4BCp5ZlI8K0BOjbvn9_LwY78vQnDait-Q-sjhF88dG2B0X-4P3EcxGHn8wzPbTrqObHhyoKpjy3cwZQzLdiwRsdEK5SDvl02_HjWKJW2oqGMOQJmZHSSKYR6AnPYJV6gpHtx6dLakIG_AOPhu8vKexNyrv5_1qoom6_NaPGecoiz6fR8.eyJraWQiOiJVYmtLOFk2aXY0R1poRnA2VHgzSVdMV0xmTlhTRXZKY2RUM3pkUjY1WVp4byJ9",
+      "payload": {
+        "data": "this is a hidden message",
+        "exp": "2022-01-01T00:00:00+00:00"
+      },
+      "footer": "{\"kid\":\"UbkK8Y6iv4GZhFp6Tx3IWLWLfNXSEvJcdT3zdR65YZxo\"}",
+      "implicit-assertion": "{\"test-vector\":\"3-E-8\"}"
+    },
+    {
+      "name": "3-E-9",
+      "nonce": "26f7553354482a1d91d4784627854b8da6b8042a7966523c2b404e8dbbe7f7f2",
+      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+      "token": "v3.local.JvdVM1RIKh2R1HhGJ4VLjaa4BCp5ZlI8K0BOjbvn9_LwY78vQnDait-Q-sjhF88dG2B0X-4P3EcxGHn8wzPbTrqObHhyoKpjy3cwZQzLdiwRsdEK5SDvl02_HjWKJW2oqGMOQJlk1nli0_wijTH_vCuRwckEDc82QWK8-lG2fT9wQF271sgbVRVPjm0LwMQZkvvamqU.YXJiaXRyYXJ5LXN0cmluZy10aGF0LWlzbid0LWpzb24",
+      "payload": {
+        "data": "this is a hidden message",
+        "exp": "2022-01-01T00:00:00+00:00"
+      },
+      "footer": "arbitrary-string-that-isn't-json",
+      "implicit-assertion": "{\"test-vector\":\"3-E-9\"}"
+    },
+    {
+      "name": "3-S-1",
+      "public-key": "02fbcb7c69ee1c60579be7a334134878d9c5c5bf35d552dab63c0140397ed14cef637d7720925c44699ea30e72874c72fb",
+      "secret-key": "20347609607477aca8fbfbc5e6218455f3199669792ef8b466faa87bdc67798144c848dd03661eed5ac62461340cea96",
+      "secret-key-pem": "-----BEGIN EC PRIVATE KEY-----\nMIGkAgEBBDAgNHYJYHR3rKj7+8XmIYRV8xmWaXku+LRm+qh73Gd5gUTISN0DZh7t\nWsYkYTQM6pagBwYFK4EEACKhZANiAAT7y3xp7hxgV5vnozQTSHjZxcW/NdVS2rY8\nAUA5ftFM72N9dyCSXERpnqMOcodMcvt8kgcrB8KcKee0HU23E79/s4CvEs8hBfnj\nSUd/gcAm08EjSIz06iWjrNy4NakxR3I=\n-----END EC PRIVATE KEY-----",
+      "public-key-pem": "-----BEGIN PUBLIC KEY-----\nMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE+8t8ae4cYFeb56M0E0h42cXFvzXVUtq2\nPAFAOX7RTO9jfXcgklxEaZ6jDnKHTHL7fJIHKwfCnCnntB1NtxO/f7OArxLPIQX5\n40lHf4HAJtPBI0iM9Oolo6zcuDWpMUdy\n-----END PUBLIC KEY-----",
+      "token": "v3.public.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAyMi0wMS0wMVQwMDowMDowMCswMDowMCJ9qqEwwrKHKi5lJ7b9MBKc0G4MGZy0ptUiMv3lAUAaz-JY_zjoqBSIxMxhfAoeNYiSyvfUErj76KOPWm1OeNnBPkTSespeSXDGaDfxeIrl3bRrPEIy7tLwLAIsRzsXkfph",
+      "payload": {
+        "data": "this is a signed message",
+        "exp": "2022-01-01T00:00:00+00:00"
+      },
+      "footer": "",
+      "implicit-assertion": ""
+    },
+    {
+      "name": "3-S-2",
+      "public-key": "02fbcb7c69ee1c60579be7a334134878d9c5c5bf35d552dab63c0140397ed14cef637d7720925c44699ea30e72874c72fb",
+      "secret-key": "20347609607477aca8fbfbc5e6218455f3199669792ef8b466faa87bdc67798144c848dd03661eed5ac62461340cea96",
+      "secret-key-pem": "-----BEGIN EC PRIVATE KEY-----\nMIGkAgEBBDAgNHYJYHR3rKj7+8XmIYRV8xmWaXku+LRm+qh73Gd5gUTISN0DZh7t\nWsYkYTQM6pagBwYFK4EEACKhZANiAAT7y3xp7hxgV5vnozQTSHjZxcW/NdVS2rY8\nAUA5ftFM72N9dyCSXERpnqMOcodMcvt8kgcrB8KcKee0HU23E79/s4CvEs8hBfnj\nSUd/gcAm08EjSIz06iWjrNy4NakxR3I=\n-----END EC PRIVATE KEY-----",
+      "public-key-pem": "-----BEGIN PUBLIC KEY-----\nMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE+8t8ae4cYFeb56M0E0h42cXFvzXVUtq2\nPAFAOX7RTO9jfXcgklxEaZ6jDnKHTHL7fJIHKwfCnCnntB1NtxO/f7OArxLPIQX5\n40lHf4HAJtPBI0iM9Oolo6zcuDWpMUdy\n-----END PUBLIC KEY-----",
+      "token": "v3.public.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAyMi0wMS0wMVQwMDowMDowMCswMDowMCJ9ZWrbGZ6L0MDK72skosUaS0Dz7wJ_2bMcM6tOxFuCasO9GhwHrvvchqgXQNLQQyWzGC2wkr-VKII71AvkLpC8tJOrzJV1cap9NRwoFzbcXjzMZyxQ0wkshxZxx8ImmNWP.eyJraWQiOiJkWWtJU3lseFFlZWNFY0hFTGZ6Rjg4VVpyd2JMb2xOaUNkcHpVSEd3OVVxbiJ9",
+      "payload": {
+        "data": "this is a signed message",
+        "exp": "2022-01-01T00:00:00+00:00"
+      },
+      "footer": "{\"kid\":\"dYkISylxQeecEcHELfzF88UZrwbLolNiCdpzUHGw9Uqn\"}",
+      "implicit-assertion": ""
+    },
+    {
+      "name": "3-S-3",
+      "public-key": "02fbcb7c69ee1c60579be7a334134878d9c5c5bf35d552dab63c0140397ed14cef637d7720925c44699ea30e72874c72fb",
+      "secret-key": "20347609607477aca8fbfbc5e6218455f3199669792ef8b466faa87bdc67798144c848dd03661eed5ac62461340cea96",
+      "secret-key-pem": "-----BEGIN EC PRIVATE KEY-----\nMIGkAgEBBDAgNHYJYHR3rKj7+8XmIYRV8xmWaXku+LRm+qh73Gd5gUTISN0DZh7t\nWsYkYTQM6pagBwYFK4EEACKhZANiAAT7y3xp7hxgV5vnozQTSHjZxcW/NdVS2rY8\nAUA5ftFM72N9dyCSXERpnqMOcodMcvt8kgcrB8KcKee0HU23E79/s4CvEs8hBfnj\nSUd/gcAm08EjSIz06iWjrNy4NakxR3I=\n-----END EC PRIVATE KEY-----",
+      "public-key-pem": "-----BEGIN PUBLIC KEY-----\nMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE+8t8ae4cYFeb56M0E0h42cXFvzXVUtq2\nPAFAOX7RTO9jfXcgklxEaZ6jDnKHTHL7fJIHKwfCnCnntB1NtxO/f7OArxLPIQX5\n40lHf4HAJtPBI0iM9Oolo6zcuDWpMUdy\n-----END PUBLIC KEY-----",
+      "token": "v3.public.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAyMi0wMS0wMVQwMDowMDowMCswMDowMCJ94SjWIbjmS7715GjLSnHnpJrC9Z-cnwK45dmvnVvCRQDCCKAXaKEopTajX0DKYx1Xqr6gcTdfqscLCAbiB4eOW9jlt-oNqdG8TjsYEi6aloBfTzF1DXff_45tFlnBukEX.eyJraWQiOiJkWWtJU3lseFFlZWNFY0hFTGZ6Rjg4VVpyd2JMb2xOaUNkcHpVSEd3OVVxbiJ9",
+      "payload": {
+        "data": "this is a signed message",
+        "exp": "2022-01-01T00:00:00+00:00"
+      },
+      "footer": "{\"kid\":\"dYkISylxQeecEcHELfzF88UZrwbLolNiCdpzUHGw9Uqn\"}",
+      "implicit-assertion": "{\"test-vector\":\"3-S-3\"}"
+    }
+  ]
+}
\ No newline at end of file
diff --git a/docs/03-Implementation-Guide/Test-Vectors/v4.json b/docs/03-Implementation-Guide/Test-Vectors/v4.json
new file mode 100644
index 00000000..45fcc08c
--- /dev/null
+++ b/docs/03-Implementation-Guide/Test-Vectors/v4.json
@@ -0,0 +1,155 @@
+{
+  "name": "PASETO v4 Test Vectors",
+  "tests": [
+    {
+      "name": "4-E-1",
+      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+      "nonce": "0000000000000000000000000000000000000000000000000000000000000000",
+      "token": "v4.local.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAr68PS4AXe7If_ZgesdkUMvSwscFlAl1pk5HC0e8kApeaqMfGo_7OpBnwJOAbY9V7WU6abu74MmcUE8YWAiaArVI8XJ5hOb_4v9RmDkneN0S92dx0OW4pgy7omxgf3S8c3LlQg",
+      "payload": {
+        "data": "this is a secret message",
+        "exp": "2022-01-01T00:00:00+00:00"
+      },
+      "footer": "",
+      "implicit-assertion": ""
+    },
+    {
+      "name": "4-E-2",
+      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+      "nonce": "0000000000000000000000000000000000000000000000000000000000000000",
+      "token": "v4.local.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAr68PS4AXe7If_ZgesdkUMvS2csCgglvpk5HC0e8kApeaqMfGo_7OpBnwJOAbY9V7WU6abu74MmcUE8YWAiaArVI8XIemu9chy3WVKvRBfg6t8wwYHK0ArLxxfZP73W_vfwt5A",
+      "payload": {
+        "data": "this is a hidden message",
+        "exp": "2022-01-01T00:00:00+00:00"
+      },
+      "footer": "",
+      "implicit-assertion": ""
+    },
+    {
+      "name": "4-E-3",
+      "nonce": "df654812bac492663825520ba2f6e67cf5ca5bdc13d4e7507a98cc4c2fcc3ad8",
+      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+      "token": "v4.local.32VIErrEkmY4JVILovbmfPXKW9wT1OdQepjMTC_MOtjA4kiqw7_tcaOM5GNEcnTxl60WkwMsYXw6FSNb_UdJPXjpzm0KW9ojM5f4O2mRvE2IcweP-PRdoHjd5-RHCiExR1IK6t6-tyebyWG6Ov7kKvBdkrrAJ837lKP3iDag2hzUPHuMKA",
+      "payload": {
+        "data": "this is a secret message",
+        "exp": "2022-01-01T00:00:00+00:00"
+      },
+      "footer": "",
+      "implicit-assertion": ""
+    },
+    {
+      "name": "4-E-4",
+      "nonce": "df654812bac492663825520ba2f6e67cf5ca5bdc13d4e7507a98cc4c2fcc3ad8",
+      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+      "token": "v4.local.32VIErrEkmY4JVILovbmfPXKW9wT1OdQepjMTC_MOtjA4kiqw7_tcaOM5GNEcnTxl60WiA8rd3wgFSNb_UdJPXjpzm0KW9ojM5f4O2mRvE2IcweP-PRdoHjd5-RHCiExR1IK6t4gt6TiLm55vIH8c_lGxxZpE3AWlH4WTR0v45nsWoU3gQ",
+      "payload": {
+        "data": "this is a hidden message",
+        "exp": "2022-01-01T00:00:00+00:00"
+      },
+      "footer": "",
+      "implicit-assertion": ""
+    },
+    {
+      "name": "4-E-5",
+      "nonce": "df654812bac492663825520ba2f6e67cf5ca5bdc13d4e7507a98cc4c2fcc3ad8",
+      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+      "token": "v4.local.32VIErrEkmY4JVILovbmfPXKW9wT1OdQepjMTC_MOtjA4kiqw7_tcaOM5GNEcnTxl60WkwMsYXw6FSNb_UdJPXjpzm0KW9ojM5f4O2mRvE2IcweP-PRdoHjd5-RHCiExR1IK6t4x-RMNXtQNbz7FvFZ_G-lFpk5RG3EOrwDL6CgDqcerSQ.eyJraWQiOiJ6VmhNaVBCUDlmUmYyc25FY1Q3Z0ZUaW9lQTlDT2NOeTlEZmdMMVc2MGhhTiJ9",
+      "payload": {
+        "data": "this is a secret message",
+        "exp": "2022-01-01T00:00:00+00:00"
+      },
+      "footer": "{\"kid\":\"zVhMiPBP9fRf2snEcT7gFTioeA9COcNy9DfgL1W60haN\"}",
+      "implicit-assertion": ""
+    },
+    {
+      "name": "4-E-6",
+      "nonce": "df654812bac492663825520ba2f6e67cf5ca5bdc13d4e7507a98cc4c2fcc3ad8",
+      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+      "token": "v4.local.32VIErrEkmY4JVILovbmfPXKW9wT1OdQepjMTC_MOtjA4kiqw7_tcaOM5GNEcnTxl60WiA8rd3wgFSNb_UdJPXjpzm0KW9ojM5f4O2mRvE2IcweP-PRdoHjd5-RHCiExR1IK6t6pWSA5HX2wjb3P-xLQg5K5feUCX4P2fpVK3ZLWFbMSxQ.eyJraWQiOiJ6VmhNaVBCUDlmUmYyc25FY1Q3Z0ZUaW9lQTlDT2NOeTlEZmdMMVc2MGhhTiJ9",
+      "payload": {
+        "data": "this is a hidden message",
+        "exp": "2022-01-01T00:00:00+00:00"
+      },
+      "footer": "{\"kid\":\"zVhMiPBP9fRf2snEcT7gFTioeA9COcNy9DfgL1W60haN\"}",
+      "implicit-assertion": ""
+    },
+    {
+      "name": "4-E-7",
+      "nonce": "df654812bac492663825520ba2f6e67cf5ca5bdc13d4e7507a98cc4c2fcc3ad8",
+      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+      "token": "v4.local.32VIErrEkmY4JVILovbmfPXKW9wT1OdQepjMTC_MOtjA4kiqw7_tcaOM5GNEcnTxl60WkwMsYXw6FSNb_UdJPXjpzm0KW9ojM5f4O2mRvE2IcweP-PRdoHjd5-RHCiExR1IK6t40KCCWLA7GYL9KFHzKlwY9_RnIfRrMQpueydLEAZGGcA.eyJraWQiOiJ6VmhNaVBCUDlmUmYyc25FY1Q3Z0ZUaW9lQTlDT2NOeTlEZmdMMVc2MGhhTiJ9",
+      "payload": {
+        "data": "this is a secret message",
+        "exp": "2022-01-01T00:00:00+00:00"
+      },
+      "footer": "{\"kid\":\"zVhMiPBP9fRf2snEcT7gFTioeA9COcNy9DfgL1W60haN\"}",
+      "implicit-assertion": "{\"test-vector\":\"4-E-7\"}"
+    },
+    {
+      "name": "4-E-8",
+      "nonce": "df654812bac492663825520ba2f6e67cf5ca5bdc13d4e7507a98cc4c2fcc3ad8",
+      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+      "token": "v4.local.32VIErrEkmY4JVILovbmfPXKW9wT1OdQepjMTC_MOtjA4kiqw7_tcaOM5GNEcnTxl60WiA8rd3wgFSNb_UdJPXjpzm0KW9ojM5f4O2mRvE2IcweP-PRdoHjd5-RHCiExR1IK6t5uvqQbMGlLLNYBc7A6_x7oqnpUK5WLvj24eE4DVPDZjw.eyJraWQiOiJ6VmhNaVBCUDlmUmYyc25FY1Q3Z0ZUaW9lQTlDT2NOeTlEZmdMMVc2MGhhTiJ9",
+      "payload": {
+        "data": "this is a hidden message",
+        "exp": "2022-01-01T00:00:00+00:00"
+      },
+      "footer": "{\"kid\":\"zVhMiPBP9fRf2snEcT7gFTioeA9COcNy9DfgL1W60haN\"}",
+      "implicit-assertion": "{\"test-vector\":\"4-E-8\"}"
+    },
+    {
+      "name": "4-E-9",
+      "nonce": "df654812bac492663825520ba2f6e67cf5ca5bdc13d4e7507a98cc4c2fcc3ad8",
+      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+      "token": "v4.local.32VIErrEkmY4JVILovbmfPXKW9wT1OdQepjMTC_MOtjA4kiqw7_tcaOM5GNEcnTxl60WiA8rd3wgFSNb_UdJPXjpzm0KW9ojM5f4O2mRvE2IcweP-PRdoHjd5-RHCiExR1IK6t6tybdlmnMwcDMw0YxA_gFSE_IUWl78aMtOepFYSWYfQA.YXJiaXRyYXJ5LXN0cmluZy10aGF0LWlzbid0LWpzb24",
+      "payload": {
+        "data": "this is a hidden message",
+        "exp": "2022-01-01T00:00:00+00:00"
+      },
+      "footer": "arbitrary-string-that-isn't-json",
+      "implicit-assertion": "{\"test-vector\":\"4-E-9\"}"
+    },
+    {
+      "name": "4-S-1",
+      "public-key": "1eb9dbbbbc047c03fd70604e0071f0987e16b28b757225c11f00415d0e20b1a2",
+      "secret-key": "b4cbfb43df4ce210727d953e4a713307fa19bb7d9f85041438d9e11b942a37741eb9dbbbbc047c03fd70604e0071f0987e16b28b757225c11f00415d0e20b1a2",
+      "secret-key-pem": "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEILTL+0PfTOIQcn2VPkpxMwf6Gbt9n4UEFDjZ4RuUKjd0\n-----END PRIVATE KEY-----",
+      "public-key-pem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAHrnbu7wEfAP9cGBOAHHwmH4Wsot1ciXBHwBBXQ4gsaI=\n-----END PUBLIC KEY-----",
+      "token": "v4.public.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAyMi0wMS0wMVQwMDowMDowMCswMDowMCJ9bg_XBBzds8lTZShVlwwKSgeKpLT3yukTw6JUz3W4h_ExsQV-P0V54zemZDcAxFaSeef1QlXEFtkqxT1ciiQEDA",
+      "payload": {
+        "data": "this is a signed message",
+        "exp": "2022-01-01T00:00:00+00:00"
+      },
+      "footer": "",
+      "implicit-assertion": ""
+    },
+    {
+      "name": "4-S-2",
+      "public-key": "1eb9dbbbbc047c03fd70604e0071f0987e16b28b757225c11f00415d0e20b1a2",
+      "secret-key": "b4cbfb43df4ce210727d953e4a713307fa19bb7d9f85041438d9e11b942a37741eb9dbbbbc047c03fd70604e0071f0987e16b28b757225c11f00415d0e20b1a2",
+      "secret-key-pem": "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEILTL+0PfTOIQcn2VPkpxMwf6Gbt9n4UEFDjZ4RuUKjd0\n-----END PRIVATE KEY-----",
+      "public-key-pem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAHrnbu7wEfAP9cGBOAHHwmH4Wsot1ciXBHwBBXQ4gsaI=\n-----END PUBLIC KEY-----",
+      "token": "v4.public.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAyMi0wMS0wMVQwMDowMDowMCswMDowMCJ9v3Jt8mx_TdM2ceTGoqwrh4yDFn0XsHvvV_D0DtwQxVrJEBMl0F2caAdgnpKlt4p7xBnx1HcO-SPo8FPp214HDw.eyJraWQiOiJ6VmhNaVBCUDlmUmYyc25FY1Q3Z0ZUaW9lQTlDT2NOeTlEZmdMMVc2MGhhTiJ9",
+      "payload": {
+        "data": "this is a signed message",
+        "exp": "2022-01-01T00:00:00+00:00"
+      },
+      "footer": "{\"kid\":\"zVhMiPBP9fRf2snEcT7gFTioeA9COcNy9DfgL1W60haN\"}",
+      "implicit-assertion": ""
+    },
+    {
+      "name": "4-S-3",
+      "public-key": "1eb9dbbbbc047c03fd70604e0071f0987e16b28b757225c11f00415d0e20b1a2",
+      "secret-key": "b4cbfb43df4ce210727d953e4a713307fa19bb7d9f85041438d9e11b942a37741eb9dbbbbc047c03fd70604e0071f0987e16b28b757225c11f00415d0e20b1a2",
+      "secret-key-pem": "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEILTL+0PfTOIQcn2VPkpxMwf6Gbt9n4UEFDjZ4RuUKjd0\n-----END PRIVATE KEY-----",
+      "public-key-pem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAHrnbu7wEfAP9cGBOAHHwmH4Wsot1ciXBHwBBXQ4gsaI=\n-----END PUBLIC KEY-----",
+      "token": "v4.public.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAyMi0wMS0wMVQwMDowMDowMCswMDowMCJ9NPWciuD3d0o5eXJXG5pJy-DiVEoyPYWs1YSTwWHNJq6DZD3je5gf-0M4JR9ipdUSJbIovzmBECeaWmaqcaP0DQ.eyJraWQiOiJ6VmhNaVBCUDlmUmYyc25FY1Q3Z0ZUaW9lQTlDT2NOeTlEZmdMMVc2MGhhTiJ9",
+      "payload": {
+        "data": "this is a signed message",
+        "exp": "2022-01-01T00:00:00+00:00"
+      },
+      "footer": "{\"kid\":\"zVhMiPBP9fRf2snEcT7gFTioeA9COcNy9DfgL1W60haN\"}",
+      "implicit-assertion": "{\"test-vector\":\"4-S-3\"}"
+    }
+  ]
+}
\ No newline at end of file
diff --git a/psalm.xml b/psalm.xml
index 44bfe097..b9db2e9d 100644
--- a/psalm.xml
+++ b/psalm.xml
@@ -6,6 +6,9 @@
 >
     <projectFiles>
         <directory name="src" />
+        <ignoreFiles>
+            <directory name="vendor" />
+        </ignoreFiles>
     </projectFiles>
     <issueHandlers>
         <LessSpecificReturnStatement errorLevel="info" />
diff --git a/src/Builder.php b/src/Builder.php
index 39e2ad72..d53bdf3d 100644
--- a/src/Builder.php
+++ b/src/Builder.php
@@ -26,8 +26,8 @@ class Builder
     /** @var string $cached */
     protected $cached = '';
 
-    /** @var array<string, string> */
-    protected $claims = [];
+    /** @var string $implicitAssertions */
+    protected $implicitAssertions = '';
 
     /** @var \Closure|null $unitTestEncrypter -- Do not use this. It's for unit testing! */
     protected $unitTestEncrypter;
@@ -84,6 +84,8 @@ public function get(string $claim)
     }
 
     /**
+     * Get the footer contents as an array.
+     *
      * @return array
      * @throws PasetoException
      */
@@ -92,6 +94,19 @@ public function getFooterArray(): array
         return $this->token->getFooterArray();
     }
 
+    /**
+     * Get the implicit assertions configured for this Builder.
+     *
+     * @return array
+     */
+    public function getImplicitAssertions(): array
+    {
+        if (empty($this->implicitAssertions)) {
+            return [];
+        }
+        return (array) json_decode($this->implicitAssertions, true);
+    }
+
     /**
      * Get a Builder instance configured for local usage.
      * (i.e. shared-key authenticated encryption)
@@ -193,6 +208,28 @@ public function setExpiration(\DateTimeInterface $time = null): self
         return $this->set('exp', $time->format(\DateTime::ATOM));
     }
 
+    /**
+     * Set the implicit assertions for the constructed PASETO token
+     * (only affects v3/v4).
+     *
+     * @param array $assertions
+     * @return self
+     * @throws PasetoException
+     */
+    public function setImplicitAssertions(array $assertions): self
+    {
+        if (empty($assertions)) {
+            $implicit = '';
+        } else {
+            $implicit = json_encode($assertions);
+        }
+        if (!is_string($implicit)) {
+            throw new PasetoException('Could not serialize as string');
+        }
+        $this->implicitAssertions = $implicit;
+        return $this;
+    }
+
     /**
      * Set the 'iat' claim for the token we're building. (Mutable.)
      *
@@ -425,6 +462,16 @@ public function toString(): string
         $claims = \json_encode($this->token->getClaims(), JSON_FORCE_OBJECT);
         $protocol = $this->version;
         ProtocolCollection::throwIfUnsupported($protocol);
+
+        $implicit = '';
+        if (!empty($this->implicitAssertions)) {
+            if (!$protocol::supportsImplicitAssertions()) {
+                throw new PasetoException(
+                    'This version does not support implicit assertions'
+                );
+            }
+            $implicit = $this->implicitAssertions;
+        }
         switch ($this->purpose) {
             case Purpose::local():
                 if ($this->key instanceof SymmetricKey) {
@@ -443,7 +490,8 @@ public function toString(): string
                     $this->cached = (string) $protocol::encrypt(
                         $claims,
                         $this->key,
-                        $this->token->getFooter()
+                        $this->token->getFooter(),
+                        $implicit
                     );
                     return $this->cached;
                 }
@@ -454,7 +502,8 @@ public function toString(): string
                         $this->cached = (string) $protocol::sign(
                             $claims,
                             $this->key,
-                            $this->token->getFooter()
+                            $this->token->getFooter(),
+                            $implicit
                         );
                         return $this->cached;
                     } catch (\Throwable $ex) {
@@ -538,6 +587,20 @@ public function withFooterArray(array $footer = []): self
         return (clone $this)->setFooterArray($footer);
     }
 
+    /**
+     * Return a new Builder instance with changed implicit assertions
+     * for the constructed PASETO token (only affects v3/v4).
+     *
+     * @param array $implicit
+     * @return self
+     *
+     * @throws PasetoException
+     */
+    public function withImplicitAssertions(array $implicit): self
+    {
+        return (clone $this)->setImplicitAssertions($implicit);
+    }
+
     /**
      * Return a new Builder instance with a changed 'iat' claim.
      *
diff --git a/src/Keys/AsymmetricPublicKey.php b/src/Keys/AsymmetricPublicKey.php
index bb5bc133..fbb92efb 100644
--- a/src/Keys/AsymmetricPublicKey.php
+++ b/src/Keys/AsymmetricPublicKey.php
@@ -4,7 +4,8 @@
 
 use ParagonIE\ConstantTime\{
     Base64UrlSafe,
-    Binary
+    Binary,
+    Hex
 };
 use ParagonIE\Paseto\{
     ReceivingKey,
@@ -12,7 +13,9 @@
 };
 use ParagonIE\Paseto\Protocol\{
     Version1,
-    Version2
+    Version2,
+    Version3,
+    Version4
 };
 
 /**
@@ -29,8 +32,9 @@ class AsymmetricPublicKey implements ReceivingKey
 
     /**
      * AsymmetricPublicKey constructor.
+     *
      * @param string $keyMaterial
-     * @param ProtocolInterface $protocol
+     * @param ProtocolInterface|null $protocol
      * @throws \Exception
      */
     public function __construct(
@@ -39,9 +43,16 @@ public function __construct(
     ) {
         $protocol = $protocol ?? new Version2;
 
-        if (\hash_equals($protocol::header(), Version2::HEADER)) {
+        if (
+            \hash_equals($protocol::header(), Version2::HEADER)
+                ||
+            \hash_equals($protocol::header(), Version4::HEADER)
+        ) {
             $len = Binary::safeStrlen($keyMaterial);
-            if ($len !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) {
+            if ($len === SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES << 1) {
+                // Try hex-decoding
+                $keyMaterial = Hex::decode($keyMaterial);
+            } else if ($len !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) {
                 throw new \Exception(
                     'Public keys must be 32 bytes long; ' . $len . ' given.'
                 );
@@ -52,6 +63,8 @@ public function __construct(
     }
 
     /**
+     * Initialize a v1 public key.
+     *
      * @param string $keyMaterial
      *
      * @return self
@@ -63,6 +76,8 @@ public static function v1(string $keyMaterial): self
     }
 
     /**
+     * Initialize a v2 public key.
+     *
      * @param string $keyMaterial
      *
      * @return self
@@ -74,6 +89,34 @@ public static function v2(string $keyMaterial): self
     }
 
     /**
+     * Initialize a v3 public key.
+     *
+     * @param string $keyMaterial
+     *
+     * @return self
+     * @throws \Exception
+     */
+    public static function v3(string $keyMaterial): self
+    {
+        return new self($keyMaterial, new Version3());
+    }
+
+    /**
+     * Initialize a v4 public key.
+     *
+     * @param string $keyMaterial
+     *
+     * @return self
+     * @throws \Exception
+     */
+    public static function v4(string $keyMaterial): self
+    {
+        return new self($keyMaterial, new Version4());
+    }
+
+    /**
+     * Returns the base64url-encoded public key.
+     *
      * @return string
      * @throws \TypeError
      */
@@ -83,6 +126,8 @@ public function encode(): string
     }
 
     /**
+     * Initialize a public key from a base64url-encoded string.
+     *
      * @param string $encoded
      * @param ProtocolInterface|null $version
      *
@@ -93,10 +138,12 @@ public function encode(): string
     public static function fromEncodedString(string $encoded, ProtocolInterface $version = null): self
     {
         $decoded = Base64UrlSafe::decode($encoded);
-        return new self($decoded, $version);
+        return new static($decoded, $version);
     }
 
     /**
+     * Get the version of PASETO that this key is intended for.
+     *
      * @return ProtocolInterface
      */
     public function getProtocol(): ProtocolInterface
@@ -105,6 +152,8 @@ public function getProtocol(): ProtocolInterface
     }
 
     /**
+     * Get the raw key contents.
+     *
      * @return string
      */
     public function raw()
diff --git a/src/Keys/AsymmetricSecretKey.php b/src/Keys/AsymmetricSecretKey.php
index 5e5803d1..0b8de168 100644
--- a/src/Keys/AsymmetricSecretKey.php
+++ b/src/Keys/AsymmetricSecretKey.php
@@ -8,11 +8,18 @@
 };
 use ParagonIE\Paseto\{
     SendingKey,
-    ProtocolInterface
+    ProtocolInterface,
+    Util
+};
+use ParagonIE\EasyECC\ECDSA\{
+    PublicKey,
+    SecretKey
 };
 use ParagonIE\Paseto\Protocol\{
     Version1,
-    Version2
+    Version2,
+    Version3,
+    Version4
 };
 
 /**
@@ -31,7 +38,7 @@ class AsymmetricSecretKey implements SendingKey
      * AsymmetricSecretKey constructor.
      *
      * @param string $keyData
-     * @param ProtocolInterface $protocol
+     * @param ProtocolInterface|null $protocol
      * @throws \Exception
      * @throws \TypeError
      */
@@ -41,7 +48,11 @@ public function __construct(
     ) {
         $protocol = $protocol ?? new Version2;
 
-        if (\hash_equals($protocol::header(), Version2::HEADER)) {
+        if (
+            \hash_equals($protocol::header(), Version2::HEADER)
+                ||
+            \hash_equals($protocol::header(), Version4::HEADER)
+        ) {
             $len = Binary::safeStrlen($keyData);
             if ($len === SODIUM_CRYPTO_SIGN_KEYPAIRBYTES) {
                 $keyData = Binary::safeSubstr($keyData, 0, 64);
@@ -60,6 +71,8 @@ public function __construct(
     }
 
     /**
+     * Initialize a v1 secret key.
+     *
      * @param string $keyMaterial
      *
      * @return self
@@ -72,6 +85,8 @@ public static function v1(string $keyMaterial): self
     }
 
     /**
+     * Initialize a v2 secret key.
+     *
      * @param string $keyMaterial
      *
      * @return self
@@ -84,7 +99,37 @@ public static function v2(string $keyMaterial): self
     }
 
     /**
-     * @param ProtocolInterface $protocol
+     * Initialize a v3 secret key.
+     *
+     * @param string $keyMaterial
+     *
+     * @return self
+     * @throws \Exception
+     * @throws \TypeError
+     */
+    public static function v3(string $keyMaterial): self
+    {
+        return new self($keyMaterial, new Version3());
+    }
+
+    /**
+     * Initialize a v4 secret key.
+     *
+     * @param string $keyMaterial
+     *
+     * @return self
+     * @throws \Exception
+     * @throws \TypeError
+     */
+    public static function v4(string $keyMaterial): self
+    {
+        return new self($keyMaterial, new Version4());
+    }
+
+    /**
+     * Generate a secret key.
+     *
+     * @param ProtocolInterface|null $protocol
      * @return self
      * @throws \Exception
      * @throws \TypeError
@@ -97,7 +142,12 @@ public static function generate(ProtocolInterface $protocol = null): self
             $rsa = Version1::getRsa();
             /** @var array<string, string> $keypair */
             $keypair = $rsa->createKey(2048);
-            return new self($keypair['privatekey'], $protocol);
+            return new self(Util::dos2unix($keypair['privatekey']), $protocol);
+        } elseif (\hash_equals($protocol::header(), Version3::HEADER)) {
+            return new self(
+                Util::dos2unix(SecretKey::generate(Version3::CURVE)->exportPem()),
+                $protocol
+            );
         }
         return new self(
             \sodium_crypto_sign_secretkey(
@@ -108,6 +158,8 @@ public static function generate(ProtocolInterface $protocol = null): self
     }
 
     /**
+     * Return a base64url-encoded representation of this secret key.
+     *
      * @return string
      * @throws \TypeError
      */
@@ -117,6 +169,8 @@ public function encode(): string
     }
 
     /**
+     * Initialize a secret key from a base64url-encoded string.
+     *
      * @param string $encoded
      * @param ProtocolInterface|null $version
      * @return self
@@ -126,10 +180,12 @@ public function encode(): string
     public static function fromEncodedString(string $encoded, ProtocolInterface $version = null): self
     {
         $decoded = Base64UrlSafe::decode($encoded);
-        return new self($decoded, $version);
+        return new static($decoded, $version);
     }
 
     /**
+     * Get the version of PASETO that this key is intended for.
+     *
      * @return ProtocolInterface
      */
     public function getProtocol(): ProtocolInterface
@@ -138,6 +194,8 @@ public function getProtocol(): ProtocolInterface
     }
 
     /**
+     * Get the public key that corresponds to this secret key.
+     *
      * @return AsymmetricPublicKey
      * @throws \Exception
      * @throws \TypeError
@@ -150,6 +208,13 @@ public function getPublicKey(): AsymmetricPublicKey
                     Version1::RsaGetPublicKey($this->key),
                     $this->protocol
                 );
+            case Version3::HEADER:
+                /** @var PublicKey $pk */
+                $pk = SecretKey::importPem($this->key)->getPublicKey();
+                return new AsymmetricPublicKey(
+                    $pk->toString(), // Compressed point
+                    $this->protocol
+                );
             default:
                 return new AsymmetricPublicKey(
                     \sodium_crypto_sign_publickey_from_secretkey($this->key),
@@ -159,6 +224,8 @@ public function getPublicKey(): AsymmetricPublicKey
     }
 
     /**
+     * Get the raw key contents.
+     *
      * @return string
      */
     public function raw()
diff --git a/src/Keys/SymmetricKey.php b/src/Keys/SymmetricKey.php
index 1d39c7d5..f7166a45 100644
--- a/src/Keys/SymmetricKey.php
+++ b/src/Keys/SymmetricKey.php
@@ -2,13 +2,19 @@
 declare(strict_types=1);
 namespace ParagonIE\Paseto\Keys;
 
-use ParagonIE\ConstantTime\Base64UrlSafe;
+use ParagonIE\ConstantTime\{
+    Base64UrlSafe,
+    Binary
+};
 use ParagonIE\Paseto\{
+    Exception\PasetoException,
     ReceivingKey,
     SendingKey,
     ProtocolInterface,
     Protocol\Version1,
     Protocol\Version2,
+    Protocol\Version3,
+    Protocol\Version4,
     Util
 };
 
@@ -56,6 +62,8 @@ public static function generate(ProtocolInterface $protocol = null): self
     }
 
     /**
+     * Initialize a v1 symmetric key.
+     *
      * @param string $keyMaterial
      *
      * @return self
@@ -68,6 +76,8 @@ public static function v1(string $keyMaterial): self
     }
 
     /**
+     * Initialize a v2 symmetric key.
+     *
      * @param string $keyMaterial
      *
      * @return self
@@ -80,6 +90,36 @@ public static function v2(string $keyMaterial): self
     }
 
     /**
+     * Initialize a v3 symmetric key.
+     *
+     * @param string $keyMaterial
+     *
+     * @return self
+     * @throws \Exception
+     * @throws \TypeError
+     */
+    public static function v3(string $keyMaterial): self
+    {
+        return new self($keyMaterial, new Version3());
+    }
+
+    /**
+     * Initialize a v4 symmetric key.
+     *
+     * @param string $keyMaterial
+     *
+     * @return self
+     * @throws \Exception
+     * @throws \TypeError
+     */
+    public static function v4(string $keyMaterial): self
+    {
+        return new self($keyMaterial, new Version4());
+    }
+
+    /**
+     * Return a base64url-encoded representation of this symmetric key.
+     *
      * @return string
      * @throws \TypeError
      */
@@ -89,6 +129,8 @@ public function encode(): string
     }
 
     /**
+     * Initialize a symmetric key from a base64url-encoded string.
+     *
      * @param string $encoded
      * @param ProtocolInterface|null $version
      * @return self
@@ -97,10 +139,12 @@ public function encode(): string
     public static function fromEncodedString(string $encoded, ProtocolInterface $version = null): self
     {
         $decoded = Base64UrlSafe::decode($encoded);
-        return new self($decoded, $version);
+        return new static($decoded, $version);
     }
 
     /**
+     * Get the version of PASETO that this key is intended for.
+     *
      * @return ProtocolInterface
      */
     public function getProtocol(): ProtocolInterface
@@ -109,6 +153,8 @@ public function getProtocol(): ProtocolInterface
     }
 
     /**
+     * Get the raw key contents.
+     *
      * @return string
      */
     public function raw(): string
@@ -116,14 +162,75 @@ public function raw(): string
         return $this->key;
     }
 
+    /**
+     * Split this key into two 256-bit keys and a nonce, using HKDF-SHA384
+     * (with the given salt)
+     *
+     * Used in version 3
+     *
+     * @param string|null $salt
+     * @return array<int, string>
+     *
+     * @throws PasetoException
+     * @throws \TypeError
+     */
+    public function splitV3(string $salt = null): array
+    {
+        $tmp = Util::HKDF(
+            'sha384',
+            $this->key,
+            48,
+            self::INFO_ENCRYPTION . ($salt ?? '')
+        );
+        $encKey = Binary::safeSubstr($tmp, 0, 32);
+        $nonce = Binary::safeSubstr($tmp, 32, 16);
+        $authKey = Util::HKDF(
+            'sha384',
+            $this->key,
+            48,
+            self::INFO_AUTHENTICATION . ($salt ?? '')
+        );
+        return [$encKey, $authKey, $nonce];
+    }
+
+
+    /**
+     * Split this key into two 256-bit keys and a nonce, using BLAKE2b-MAC
+     * (with the given salt)
+     *
+     * Used in version 4
+     *
+     * @param string|null $salt
+     * @return array<int, string>
+     *
+     * @throws \SodiumException
+     */
+    public function splitV4(string $salt = null): array
+    {
+        $tmp = \sodium_crypto_generichash(
+            self::INFO_ENCRYPTION . ($salt ?? ''),
+            $this->key,
+            56
+        );
+        $encKey = Binary::safeSubstr($tmp, 0, 32);
+        $nonce = Binary::safeSubstr($tmp, 32, 24);
+        $authKey = \sodium_crypto_generichash(
+            self::INFO_AUTHENTICATION . ($salt ?? ''),
+            $this->key
+        );
+        return [$encKey, $authKey, $nonce];
+    }
+
     /**
      * Split this key into two 256-bit keys, using HKDF-SHA384
      * (with the given salt)
      *
+     * Used in versions 1 and 2
+     *
      * @param string|null $salt
      * @return array<int, string>
      *
-     * @throws \ParagonIE\Paseto\Exception\PasetoException
+     * @throws PasetoException
      * @throws \TypeError
      */
     public function split(string $salt = null): array
diff --git a/src/Keys/Version3/AsymmetricPublicKey.php b/src/Keys/Version3/AsymmetricPublicKey.php
new file mode 100644
index 00000000..610d2318
--- /dev/null
+++ b/src/Keys/Version3/AsymmetricPublicKey.php
@@ -0,0 +1,28 @@
+<?php
+declare(strict_types=1);
+namespace ParagonIE\Paseto\Keys\Version3;
+
+use ParagonIE\Paseto\Keys\AsymmetricPublicKey as BasePublicKey;
+use ParagonIE\Paseto\Protocol\Version3;
+use ParagonIE\Paseto\ProtocolInterface;
+
+/**
+ * Class AsymmetricPublicKey
+ * @package ParagonIE\Paseto\Keys\Version3
+ */
+class AsymmetricPublicKey extends BasePublicKey
+{
+    /**
+     * AsymmetricPublicKey constructor.
+     *
+     * @param string $keyData
+     * @param ProtocolInterface|null $protocol
+     *
+     * @throws \Exception
+     * @throws \TypeError
+     */
+    public function __construct(string $keyData, ProtocolInterface $protocol = null)
+    {
+        parent::__construct($keyData, new Version3());
+    }
+}
diff --git a/src/Keys/Version3/AsymmetricSecretKey.php b/src/Keys/Version3/AsymmetricSecretKey.php
new file mode 100644
index 00000000..909e1aad
--- /dev/null
+++ b/src/Keys/Version3/AsymmetricSecretKey.php
@@ -0,0 +1,28 @@
+<?php
+declare(strict_types=1);
+namespace ParagonIE\Paseto\Keys\Version3;
+
+use ParagonIE\Paseto\Keys\AsymmetricSecretKey as BaseSecretKey;
+use ParagonIE\Paseto\Protocol\Version3;
+use ParagonIE\Paseto\ProtocolInterface;
+
+/**
+ * Class AsymmetricSecretKey
+ * @package ParagonIE\Paseto\Keys\Version3
+ */
+class AsymmetricSecretKey extends BaseSecretKey
+{
+    /**
+     * AsymmetricSecretKey constructor.
+     *
+     * @param string $keyData
+     * @param ProtocolInterface|null $protocol
+     *
+     * @throws \Exception
+     * @throws \TypeError
+     */
+    public function __construct(string $keyData, ProtocolInterface $protocol = null)
+    {
+        parent::__construct($keyData, new Version3());
+    }
+}
diff --git a/src/Keys/Version3/SymmetricKey.php b/src/Keys/Version3/SymmetricKey.php
new file mode 100644
index 00000000..c393b57b
--- /dev/null
+++ b/src/Keys/Version3/SymmetricKey.php
@@ -0,0 +1,27 @@
+<?php
+declare(strict_types=1);
+namespace ParagonIE\Paseto\Keys\Version3;
+
+use ParagonIE\Paseto\Keys\SymmetricKey as BaseSymmetricKey;
+use ParagonIE\Paseto\Protocol\Version3;
+use ParagonIE\Paseto\ProtocolInterface;
+
+/**
+ * Class SymmetricKey
+ * @package ParagonIE\Paseto\Keys\Version3
+ */
+class SymmetricKey extends BaseSymmetricKey
+{
+    /**
+     * SymmetricKey constructor.
+     *
+     * @param string $keyMaterial
+     * @param ProtocolInterface|null $protocol
+     */
+    public function __construct(
+        string $keyMaterial,
+        ProtocolInterface $protocol = null
+    ) {
+        return parent::__construct($keyMaterial, new Version3());
+    }
+}
diff --git a/src/Keys/Version4/AsymmetricPublicKey.php b/src/Keys/Version4/AsymmetricPublicKey.php
new file mode 100644
index 00000000..f67dc312
--- /dev/null
+++ b/src/Keys/Version4/AsymmetricPublicKey.php
@@ -0,0 +1,28 @@
+<?php
+declare(strict_types=1);
+namespace ParagonIE\Paseto\Keys\Version4;
+
+use ParagonIE\Paseto\Keys\AsymmetricPublicKey as BasePublicKey;
+use ParagonIE\Paseto\Protocol\Version4;
+use ParagonIE\Paseto\ProtocolInterface;
+
+/**
+ * Class AsymmetricPublicKey
+ * @package ParagonIE\Paseto\Keys\Version4
+ */
+class AsymmetricPublicKey extends BasePublicKey
+{
+    /**
+     * AsymmetricPublicKey constructor.
+     *
+     * @param string $keyData
+     * @param ProtocolInterface|null $protocol
+     *
+     * @throws \Exception
+     * @throws \TypeError
+     */
+    public function __construct(string $keyData, ProtocolInterface $protocol = null)
+    {
+        parent::__construct($keyData, new Version4());
+    }
+}
diff --git a/src/Keys/Version4/AsymmetricSecretKey.php b/src/Keys/Version4/AsymmetricSecretKey.php
new file mode 100644
index 00000000..0285f1e6
--- /dev/null
+++ b/src/Keys/Version4/AsymmetricSecretKey.php
@@ -0,0 +1,28 @@
+<?php
+declare(strict_types=1);
+namespace ParagonIE\Paseto\Keys\Version4;
+
+use ParagonIE\Paseto\Keys\AsymmetricSecretKey as BaseSecretKey;
+use ParagonIE\Paseto\Protocol\Version4;
+use ParagonIE\Paseto\ProtocolInterface;
+
+/**
+ * Class AsymmetricSecretKey
+ * @package ParagonIE\Paseto\Keys\Version4
+ */
+class AsymmetricSecretKey extends BaseSecretKey
+{
+    /**
+     * AsymmetricSecretKey constructor.
+     *
+     * @param string $keyData
+     * @param ProtocolInterface|null $protocol
+     *
+     * @throws \Exception
+     * @throws \TypeError
+     */
+    public function __construct(string $keyData, ProtocolInterface $protocol = null)
+    {
+        parent::__construct($keyData, new Version4());
+    }
+}
diff --git a/src/Keys/Version4/SymmetricKey.php b/src/Keys/Version4/SymmetricKey.php
new file mode 100644
index 00000000..5444dba5
--- /dev/null
+++ b/src/Keys/Version4/SymmetricKey.php
@@ -0,0 +1,27 @@
+<?php
+declare(strict_types=1);
+namespace ParagonIE\Paseto\Keys\Version4;
+
+use ParagonIE\Paseto\Keys\SymmetricKey as BaseSymmetricKey;
+use ParagonIE\Paseto\Protocol\Version4;
+use ParagonIE\Paseto\ProtocolInterface;
+
+/**
+ * Class SymmetricKey
+ * @package ParagonIE\Paseto\Keys\Version4
+ */
+class SymmetricKey extends BaseSymmetricKey
+{
+    /**
+     * SymmetricKey constructor.
+     *
+     * @param string $keyMaterial
+     * @param ProtocolInterface|null $protocol
+     */
+    public function __construct(
+        string $keyMaterial,
+        ProtocolInterface $protocol = null
+    ) {
+        return parent::__construct($keyMaterial, new Version4());
+    }
+}
diff --git a/src/Parser.php b/src/Parser.php
index 7d387c15..cbf6d164 100644
--- a/src/Parser.php
+++ b/src/Parser.php
@@ -15,6 +15,7 @@
     AsymmetricPublicKey,
     SymmetricKey
 };
+use ParagonIE\ConstantTime\Binary;
 use ParagonIE\Paseto\Parsing\PasetoMessage;
 use ParagonIE\Paseto\Traits\RegisteredClaims;
 
@@ -30,9 +31,21 @@ class Parser
     /** @var ProtocolCollection */
     protected $allowedVersions;
 
+    /** @var string $implicitAssertions */
+    protected $implicitAssertions = '';
+
     /** @var ReceivingKey $key */
     protected $key;
 
+    /** @var ?int $maxClaimCount */
+    protected $maxClaimCount = null;
+
+    /** @var ?int $maxClaimDepth */
+    protected $maxClaimDepth = null;
+
+    /** @var ?int $maxJsonLength */
+    protected $maxJsonLength = null;
+
     /** @var Purpose|null $purpose */
     protected $purpose;
 
@@ -70,6 +83,17 @@ public function __construct(
         }
     }
 
+    /**
+     * @return array
+     */
+    public function getImplicitAssertions(): array
+    {
+        if (empty($this->implicitAssertions)) {
+            return [];
+        }
+        return (array) json_decode($this->implicitAssertions, true);
+    }
+
     /**
      * @param string $tainted
      *
@@ -157,7 +181,6 @@ public function parse(string $tainted, bool $skipValidation = false): JsonToken
         $parsed = PasetoMessage::fromString($tainted);
 
         // First, check against the user's specified list of allowed versions.
-        /** @var ProtocolInterface $protocol */
         $protocol = $parsed->header()->protocol();
         if (!$this->allowedVersions->has($protocol)) {
             throw new InvalidVersionException('Disallowed or unsupported version');
@@ -178,14 +201,24 @@ public function parse(string $tainted, bool $skipValidation = false): JsonToken
             throw new InvalidKeyException('Invalid key type');
         }
 
+        $implicit = '';
+        if (!empty($this->implicitAssertions)) {
+            if (!$protocol::supportsImplicitAssertions()) {
+                throw new PasetoException(
+                    'This version does not support implicit assertions'
+                );
+            }
+            $implicit = $this->implicitAssertions;
+        }
+
+        /** @var string|null $decoded */
         // Let's verify/decode according to the appropriate method:
         switch ($purpose) {
             case Purpose::local():
                 /** @var SymmetricKey $key */
                 $key = $this->key;
                 try {
-                    /** @var string $decoded */
-                    $decoded = $protocol::decrypt($tainted, $key, $footer);
+                    $decoded = $protocol::decrypt($tainted, $key, $footer, $implicit);
                 } catch (\Throwable $ex) {
                     throw new PasetoException('An error occurred', 0, $ex);
                 }
@@ -194,8 +227,7 @@ public function parse(string $tainted, bool $skipValidation = false): JsonToken
                 /** @var AsymmetricPublicKey $key */
                 $key = $this->key;
                 try {
-                    /** @var string $decoded */
-                    $decoded = $protocol::verify($tainted, $key, $footer);
+                    $decoded = $protocol::verify($tainted, $key, $footer, $implicit);
                 } catch (\Throwable $ex) {
                     throw new PasetoException('An error occurred', 0, $ex);
                 }
@@ -206,6 +238,10 @@ public function parse(string $tainted, bool $skipValidation = false): JsonToken
         if (!isset($decoded)) {
             throw new PasetoException('Unsupported purpose or version.');
         }
+
+        // Throw if the claims were invalid:
+        $this->throwIfClaimsJsonInvalid($decoded);
+
         /** @var array<string, string>|bool $claims */
         $claims = \json_decode((string) $decoded, true);
         if (!\is_array($claims)) {
@@ -235,6 +271,64 @@ public function setAllowedVersions(ProtocolCollection $whitelist): self
         return $this;
     }
 
+    /**
+     * Set the implicit assertions for the constructed PASETO token
+     * (only affects v3/v4).
+     *
+     * @param array $assertions
+     * @return self
+     * @throws PasetoException
+     */
+    public function setImplicitAssertions(array $assertions): self
+    {
+        if (empty($assertions)) {
+            $implicit = '';
+        } else {
+            $implicit = json_encode($assertions);
+        }
+        if (!is_string($implicit)) {
+            throw new PasetoException('Could not serialize as string');
+        }
+        $this->implicitAssertions = $implicit;
+        return $this;
+    }
+
+    /**
+     * Limit the length of the decoded JSON payload containing the claims.
+     *
+     * @param int|null $length
+     * @return self
+     */
+    public function setMaxJsonLength(?int $length = null): self
+    {
+        $this->maxJsonLength = $length;
+        return $this;
+    }
+
+    /**
+     * Limit the maximum number of claims in the decoded JSON payload.
+     *
+     * @param int|null $maximum
+     * @return self
+     */
+    public function setMaxClaimCount(?int $maximum = null): self
+    {
+        $this->maxClaimCount = $maximum;
+        return $this;
+    }
+
+    /**
+     * Limit the maximum depth of the decoded JSON payload containign the claims.
+     *
+     * @param int|null $maximum
+     * @return self
+     */
+    public function setMaxClaimDepth(?int $maximum = null): self
+    {
+        $this->maxClaimDepth = $maximum;
+        return $this;
+    }
+
     /**
      * Specify the key for the token we are going to parse.
      *
@@ -272,7 +366,6 @@ public function setKey(ReceivingKey $key, bool $checkPurpose = false): self
     public function setPurpose(Purpose $purpose, bool $checkKeyType = false): self
     {
         if ($checkKeyType) {
-            /** @var Purpose */
             $expectedPurpose = Purpose::fromReceivingKey($this->key);
             if (!$purpose->equals($expectedPurpose)) {
                 throw new InvalidPurposeException(
@@ -286,6 +379,37 @@ public function setPurpose(Purpose $purpose, bool $checkKeyType = false): self
         return $this;
     }
 
+    /**
+     * @throws EncodingException
+     */
+    public function throwIfClaimsJsonInvalid(string $jsonString): void
+    {
+        if (!is_null($this->maxJsonLength)) {
+            $length = Binary::safeStrlen($jsonString);
+            if ($length > $this->maxJsonLength) {
+                throw new EncodingException(
+                    "Claims length is too long ({$length} > {$this->maxJsonLength}"
+                );
+            }
+        }
+        if (!is_null($this->maxClaimCount)) {
+            $count = Util::countJsonKeys($jsonString);
+            if ($count > $this->maxClaimCount) {
+                throw new EncodingException(
+                    "Too many claims in this token ({$count} > {$this->maxClaimCount}"
+                );
+            }
+        }
+        if (!is_null($this->maxClaimDepth)) {
+            $depth = Util::calculateJsonDepth($jsonString);
+            if ($depth > $this->maxClaimDepth) {
+                throw new EncodingException(
+                    "Too many layers of claims ({$depth} > {$this->maxClaimDepth}"
+                );
+            }
+        }
+    }
+
     /**
      * Does this token pass all of the rules defined?
      *
@@ -300,7 +424,6 @@ public function validate(JsonToken $token, bool $throwOnFailure = false): bool
             // No rules defined, so we default to "true".
             return true;
         }
-        /** @var ValidationRuleInterface $rule */
         foreach ($this->rules as $rule) {
             if (!$rule->isValid($token)) {
                 if ($throwOnFailure) {
diff --git a/src/Protocol/Version1.php b/src/Protocol/Version1.php
index e4ba60dd..3d569fa3 100644
--- a/src/Protocol/Version1.php
+++ b/src/Protocol/Version1.php
@@ -105,12 +105,24 @@ public static function header(): string
         return self::HEADER;
     }
 
+    /**
+     * Does this protocol support implicit assertions?
+     * No.
+     *
+     * @return bool
+     */
+    public static function supportsImplicitAssertions(): bool
+    {
+        return false;
+    }
+
     /**
      * Encrypt a message using a shared key.
      *
      * @param string $data
      * @param SymmetricKey $key
      * @param string $footer
+     * @param string $implicit
      * @return string
      * @throws PasetoException
      * @throws \TypeError
@@ -118,7 +130,8 @@ public static function header(): string
     public static function encrypt(
         string $data,
         SymmetricKey $key,
-        string $footer = ''
+        string $footer = '',
+        string $implicit = ''
     ): string {
         return self::__encrypt($data, $key, $footer);
     }
@@ -138,6 +151,7 @@ protected static function __encrypt(
         string $data,
         SymmetricKey $key,
         string $footer = '',
+        string $implicit = '',
         string $nonceForUnitTesting = ''
     ): string {
         if (!($key->getProtocol() instanceof Version1)) {
@@ -165,7 +179,8 @@ protected static function __encrypt(
     public static function decrypt(
         string $data,
         SymmetricKey $key,
-        string $footer = null
+        string $footer = null,
+        string $implicit = ''
     ): string {
         if (!($key->getProtocol() instanceof Version1)) {
             throw new InvalidVersionException('The given key is not intended for this version of PASETO.');
@@ -197,7 +212,8 @@ public static function decrypt(
     public static function sign(
         string $data,
         AsymmetricSecretKey $key,
-        string $footer = ''
+        string $footer = '',
+        string $implicit = ''
     ): string {
         if (!($key->getProtocol() instanceof Version1)) {
             throw new InvalidVersionException('The given key is not intended for this version of PASETO.');
@@ -229,7 +245,8 @@ public static function sign(
     public static function verify(
         string $signMsg,
         AsymmetricPublicKey $key,
-        string $footer = null
+        string $footer = null,
+        string $implicit = ''
     ): string {
         if (!($key->getProtocol() instanceof Version1)) {
             throw new InvalidVersionException('The given key is not intended for this version of PASETO.');
@@ -445,8 +462,8 @@ public static function RsaGetPublicKey(string $keyData): string
         /** @var array<string, string> $pubkey */
         $pubkey = \openssl_pkey_get_details($res);
         return \rtrim(
-            \str_replace("\n", "\r\n", $pubkey['key']),
-            "\r\n"
+            \str_replace("\r\n", "\n", $pubkey['key']),
+            "\n"
         );
     }
 }
diff --git a/src/Protocol/Version2.php b/src/Protocol/Version2.php
index a725666f..8e7462af 100644
--- a/src/Protocol/Version2.php
+++ b/src/Protocol/Version2.php
@@ -55,6 +55,17 @@ public static function header(): string
         return self::HEADER;
     }
 
+    /**
+     * Does this protocol support implicit assertions?
+     * No.
+     *
+     * @return bool
+     */
+    public static function supportsImplicitAssertions(): bool
+    {
+        return false;
+    }
+
     /**
      * @return int
      */
@@ -97,7 +108,8 @@ public static function generateSymmetricKey(): SymmetricKey
     public static function encrypt(
         string $data,
         SymmetricKey $key,
-        string $footer = ''
+        string $footer = '',
+        string $implicit = ''
     ): string {
         return self::__encrypt($data, $key, $footer);
     }
@@ -118,6 +130,7 @@ protected static function __encrypt(
         string $data,
         SymmetricKey $key,
         string $footer = '',
+        string $implicit = '',
         string $nonceForUnitTesting = ''
     ): string {
         if (!($key->getProtocol() instanceof Version2)) {
@@ -147,7 +160,8 @@ protected static function __encrypt(
     public static function decrypt(
         string $data,
         SymmetricKey $key,
-        string $footer = null
+        string $footer = null,
+        string $implicit = ''
     ): string {
         if (!($key->getProtocol() instanceof Version2)) {
             throw new InvalidVersionException('The given key is not intended for this version of PASETO.');
@@ -185,7 +199,8 @@ public static function decrypt(
     public static function sign(
         string $data,
         AsymmetricSecretKey $key,
-        string $footer = ''
+        string $footer = '',
+        string $implicit = ''
     ): string {
         if (!($key->getProtocol() instanceof Version2)) {
             throw new InvalidVersionException('The given key is not intended for this version of PASETO.');
@@ -216,7 +231,8 @@ public static function sign(
     public static function verify(
         string $signMsg,
         AsymmetricPublicKey $key,
-        string $footer = null
+        string $footer = null,
+        string $implicit = ''
     ): string {
         if (!($key->getProtocol() instanceof Version2)) {
             throw new InvalidVersionException('The given key is not intended for this version of PASETO.');
@@ -227,7 +243,6 @@ public static function verify(
             $signMsg = Util::validateAndRemoveFooter($signMsg, $footer);
         }
         $signMsg = Util::removeFooter($signMsg);
-        /** @var string $footer */
         $expectHeader = self::HEADER . '.public.';
         $givenHeader = Binary::safeSubstr($signMsg, 0, 10);
         if (!\hash_equals($expectHeader, $givenHeader)) {
diff --git a/src/Protocol/Version3.php b/src/Protocol/Version3.php
new file mode 100644
index 00000000..2d36f328
--- /dev/null
+++ b/src/Protocol/Version3.php
@@ -0,0 +1,444 @@
+<?php
+declare(strict_types=1);
+namespace ParagonIE\Paseto\Protocol;
+
+use FG\ASN1\Exception\ParserException as ASN1ParserException;
+use ParagonIE\ConstantTime\{
+    Base64UrlSafe,
+    Binary,
+    Hex
+};
+use ParagonIE\Paseto\Keys\{
+    AsymmetricPublicKey,
+    AsymmetricSecretKey,
+    SymmetricKey
+};
+use ParagonIE\Paseto\Keys\Version3\{
+    AsymmetricSecretKey as V3AsymmetricSecretKey,
+    SymmetricKey as V3SymmetricKey
+};
+use ParagonIE\Paseto\Exception\{
+    InvalidVersionException,
+    PasetoException,
+    SecurityException
+};
+use ParagonIE\EasyECC\EasyECC;
+use ParagonIE\EasyECC\ECDSA\{
+    PublicKey,
+    SecretKey
+};
+use ParagonIE\Paseto\{
+    ProtocolInterface,
+    Util
+};
+use ParagonIE\Paseto\Parsing\{
+    Header,
+    PasetoMessage
+};
+
+/**
+ * Class Version1
+ * @package ParagonIE\Paseto\Protocol
+ */
+class Version3 implements ProtocolInterface
+{
+    const HEADER = 'v3';
+    const CIPHER_MODE = 'aes-256-ctr';
+    const HASH_ALGO = 'sha384';
+    const CURVE = 'P384';
+
+    const SYMMETRIC_KEY_BYTES = 32;
+
+    const NONCE_SIZE = 32;
+    const MAC_SIZE = 48;
+    const SIGN_SIZE = 96; // 384-bit ECDSA = 96-byte signature
+
+    /**
+     * Must be constructable with no arguments so an instance may be passed
+     * around in a type safe way.
+     */
+    public function __construct()
+    {
+    }
+
+    /**
+     * @return int
+     */
+    public static function getSymmetricKeyByteLength(): int
+    {
+        return (int) static::SYMMETRIC_KEY_BYTES;
+    }
+
+    /**
+     * @return AsymmetricSecretKey
+     * @throws \Exception
+     * @throws \TypeError
+     */
+    public static function generateAsymmetricSecretKey(): AsymmetricSecretKey
+    {
+        return V3AsymmetricSecretKey::generate(new static);
+    }
+
+    /**
+     * @return SymmetricKey
+     * @throws \Exception
+     * @throws \TypeError
+     */
+    public static function generateSymmetricKey(): SymmetricKey
+    {
+        return V3SymmetricKey::generate(new static);
+    }
+
+    /**
+     * A unique header string with which the protocol can be identified.
+     *
+     * @return string
+     */
+    public static function header(): string
+    {
+        return self::HEADER;
+    }
+
+    /**
+     * Does this protocol support implicit assertions?
+     * Yes.
+     *
+     * @return bool
+     */
+    public static function supportsImplicitAssertions(): bool
+    {
+        return true;
+    }
+
+    /**
+     * Encrypt a message using a shared key.
+     *
+     * @param string $data
+     * @param SymmetricKey $key
+     * @param string $footer
+     * @param string $implicit
+     * @return string
+     * @throws PasetoException
+     */
+    public static function encrypt(
+        string $data,
+        SymmetricKey $key,
+        string $footer = '',
+        string $implicit = ''
+    ): string {
+        return self::__encrypt($data, $key, $footer, $implicit);
+    }
+
+    /**
+     * Encrypt a message using a shared key.
+     *
+     * @param string $data
+     * @param SymmetricKey $key
+     * @param string $footer
+     * @param string $implicit
+     * @param string $nonceForUnitTesting
+     * @return string
+     * @throws PasetoException
+     * @throws \TypeError
+     */
+    protected static function __encrypt(
+        string $data,
+        SymmetricKey $key,
+        string $footer = '',
+        string $implicit = '',
+        string $nonceForUnitTesting = ''
+    ): string {
+        if (!($key->getProtocol() instanceof Version3)) {
+            throw new InvalidVersionException(
+                'The given key is not intended for this version of PASETO.'
+            );
+        }
+        return self::aeadEncrypt(
+            $data,
+            self::HEADER . '.local.', // PASETO v3 - Encrypt - Step 1
+            $key,
+            $footer,
+            $implicit,
+            $nonceForUnitTesting
+        );
+    }
+
+    /**
+     * Decrypt a message using a shared key.
+     *
+     * @param string $data
+     * @param SymmetricKey $key
+     * @param string|null $footer
+     * @param string $implicit
+     * @return string
+     * @throws PasetoException
+     * @throws \TypeError
+     */
+    public static function decrypt(
+        string $data,
+        SymmetricKey $key,
+        string $footer = null,
+        string $implicit = ''
+    ): string {
+        if (!($key->getProtocol() instanceof Version3)) {
+            throw new InvalidVersionException('The given key is not intended for this version of PASETO.');
+        }
+        // PASETO v3 - Decrypt - Step 1:
+        if (\is_null($footer)) {
+            $footer = Util::extractFooter($data);
+            $data = Util::removeFooter($data);
+        } else {
+            $data = Util::validateAndRemoveFooter($data, $footer);
+        }
+        return self::aeadDecrypt(
+            $data,
+            self::HEADER . '.local.',
+            $key,
+            (string) $footer,
+            $implicit
+        );
+    }
+
+    /**
+     * Sign a message. Public-key digital signatures.
+     *
+     * @param string $data
+     * @param AsymmetricSecretKey $key
+     * @param string $footer
+     * @param string $implicit
+     * @return string
+     *
+     * @throws PasetoException
+     * @throws \TypeError
+     * @throws InvalidVersionException
+     * @throws SecurityException
+     * @throws \SodiumException
+     */
+    public static function sign(
+        string $data,
+        AsymmetricSecretKey $key,
+        string $footer = '',
+        string $implicit = ''
+    ): string {
+        if (!($key->getProtocol() instanceof Version3)) {
+            throw new InvalidVersionException('The given key is not intended for this version of PASETO.');
+        }
+        // PASETO v3 - Sign - Step 1:
+        $header = self::HEADER . '.public.';
+        $easyEcc = new EasyECC(self::CURVE);
+        // PASETO v3 - Sign - Step 2 & 3:
+        $pk = Hex::decode($key->getPublicKey()->raw());
+        $signature = Hex::decode($easyEcc->sign(
+            Util::preAuthEncode($pk, $header, $data, $footer, $implicit),
+            SecretKey::importPem($key->raw()),
+            true
+        ));
+        // PASETO v3 - Sign - Step 4:
+        return (new PasetoMessage(
+            Header::fromString($header),
+            $data . $signature,
+            $footer
+        ))->toString();
+    }
+
+    /**
+     * Verify a signed message. Public-key digital signatures.
+     *
+     * @param string $signMsg
+     * @param AsymmetricPublicKey $key
+     * @param string|null $footer
+     * @param string $implicit
+     * @return string
+     *
+     * @throws InvalidVersionException
+     * @throws PasetoException
+     * @throws ASN1ParserException
+     * @throws \SodiumException
+     */
+    public static function verify(
+        string $signMsg,
+        AsymmetricPublicKey $key,
+        string $footer = null,
+        string $implicit = ''
+    ): string {
+        // PASETO v3 - Verify - Step 1:
+        if (!($key->getProtocol() instanceof Version3)) {
+            throw new InvalidVersionException('The given key is not intended for this version of PASETO.');
+        }
+        if (\is_null($footer)) {
+            $footer = Util::extractFooter($signMsg);
+        } else {
+            $signMsg = Util::validateAndRemoveFooter($signMsg, $footer);
+        }
+        $signMsg = Util::removeFooter($signMsg);
+
+        // PASETO v3 - Verify - Step 2:
+        $expectHeader = self::HEADER . '.public.';
+        $givenHeader = Binary::safeSubstr($signMsg, 0, 10);
+        if (!\hash_equals($expectHeader, $givenHeader)) {
+            throw new PasetoException('Invalid message header.');
+        }
+
+        // PASETO v3 - Verify - Step 3:
+        $decoded = Base64UrlSafe::decode(Binary::safeSubstr($signMsg, 10));
+        $len = Binary::safeStrlen($decoded);
+        $message = Binary::safeSubstr($decoded, 0, $len - self::SIGN_SIZE);
+        $signature = Binary::safeSubstr($decoded, $len - self::SIGN_SIZE);
+
+        $easyEcc = new EasyECC(self::CURVE);
+
+        // PASETO v3 - Verify - Step 4 & 5:
+        $pk = Hex::decode($key->raw());
+        $valid = $easyEcc->verify(
+            Util::preAuthEncode($pk, $givenHeader, $message, $footer, $implicit),
+            PublicKey::fromString($key->raw(), 'P384'),
+            Hex::encode($signature),
+            true
+        );
+
+        // PASETO v3 - Verify - Step 6:
+        if (!$valid) {
+            throw new PasetoException('Invalid signature for this message');
+        }
+        return $message;
+    }
+
+    /**
+     * Authenticated Encryption with Associated Data -- Encryption
+     *
+     * Algorithm: AES-256-CTR + HMAC-SHA384 (Encrypt then MAC)
+     *
+     * @param string $plaintext
+     * @param string $header
+     * @param SymmetricKey $key
+     * @param string $footer
+     * @param string $implicit
+     * @param string $nonceForUnitTesting
+     * @return string
+     * @throws PasetoException
+     * @throws SecurityException
+     */
+    public static function aeadEncrypt(
+        string $plaintext,
+        string $header,
+        SymmetricKey $key,
+        string $footer = '',
+        string $implicit = '',
+        string $nonceForUnitTesting = ''
+    ): string {
+        // PASETO v3 - Encrypt - Step 2:
+        if ($nonceForUnitTesting) {
+            $nonce = $nonceForUnitTesting;
+        } else {
+            $nonce = \random_bytes(self::NONCE_SIZE);
+        }
+        // PASETO v3 - Encrypt - Step 3:
+        list($encKey, $authKey, $nonce2) = $key->splitV3($nonce);
+
+        /** @var string|bool $ciphertext */
+        // PASETO v3 - Encrypt - Step 4:
+        $ciphertext = \openssl_encrypt(
+            $plaintext,
+            self::CIPHER_MODE,
+            $encKey,
+            OPENSSL_RAW_DATA,
+            $nonce2
+        );
+        \sodium_memzero($encKey);
+        if (!\is_string($ciphertext)) {
+            throw new PasetoException('Encryption failed.');
+        }
+        // PASETO v3 - Encrypt - Step 5 & 6:
+        $mac = \hash_hmac(
+            self::HASH_ALGO,
+            Util::preAuthEncode($header, $nonce, $ciphertext, $footer, $implicit),
+            $authKey,
+            true
+        );
+        \sodium_memzero($authKey);
+
+        // PASETO v3 - Encrypt - Step 7:
+        return (new PasetoMessage(
+            Header::fromString($header),
+            $nonce . $ciphertext . $mac,
+            $footer
+        ))->toString();
+    }
+
+    /**
+     * Authenticated Encryption with Associated Data -- Decryption
+     *
+     * @param string $message
+     * @param string $header
+     * @param SymmetricKey $key
+     * @param string $footer
+     * @param string $implicit
+     * @return string
+     * @throws PasetoException
+     * @throws \TypeError
+     */
+    public static function aeadDecrypt(
+        string $message,
+        string $header,
+        SymmetricKey $key,
+        string $footer = '',
+        string $implicit = ''
+    ): string {
+        $expectedLen = Binary::safeStrlen($header);
+        $givenHeader = Binary::safeSubstr($message, 0, $expectedLen);
+
+        // PASETO v3 - Decrypt - Step 2:
+        if (!\hash_equals($header, $givenHeader)) {
+            throw new PasetoException('Invalid message header.');
+        }
+
+        // PASETO v3 - Decrypt - Step 3:
+        try {
+            $decoded = Base64UrlSafe::decode(Binary::safeSubstr($message, $expectedLen));
+        } catch (\Throwable $ex) {
+            throw new PasetoException('Invalid encoding detected', 0, $ex);
+        }
+        $len = Binary::safeStrlen($decoded);
+        $nonce = Binary::safeSubstr($decoded, 0, self::NONCE_SIZE);
+        $ciphertext = Binary::safeSubstr(
+            $decoded,
+            self::NONCE_SIZE,
+            $len - (self::NONCE_SIZE + self::MAC_SIZE)
+        );
+        $mac = Binary::safeSubstr($decoded, $len - self::MAC_SIZE);
+
+        // PASETO v3 - Decrypt - Step 4:
+        list($encKey, $authKey, $nonce2) = $key->splitV3($nonce);
+
+        // PASETO v3 - Decrypt - Step 5 & 6:
+        $calc = \hash_hmac(
+            self::HASH_ALGO,
+            Util::preAuthEncode($header, $nonce, $ciphertext, $footer, $implicit),
+            $authKey,
+            true
+        );
+        \sodium_memzero($authKey);
+
+        // PASETO v3 - Decrypt - Step 7:
+        if (!\hash_equals($calc, $mac)) {
+            throw new SecurityException('Invalid MAC for given ciphertext.');
+        }
+
+        // PASETO v3 - Decrypt - Step 8:
+        /** @var string|bool $plaintext */
+        $plaintext = \openssl_decrypt(
+            $ciphertext,
+            self::CIPHER_MODE,
+            $encKey,
+            OPENSSL_RAW_DATA,
+            $nonce2
+        );
+        \sodium_memzero($encKey);
+
+        if (!\is_string($plaintext)) {
+            throw new PasetoException('Encryption failed.');
+        }
+
+        return $plaintext;
+    }
+}
diff --git a/src/Protocol/Version4.php b/src/Protocol/Version4.php
new file mode 100644
index 00000000..616edf11
--- /dev/null
+++ b/src/Protocol/Version4.php
@@ -0,0 +1,415 @@
+<?php
+declare(strict_types=1);
+namespace ParagonIE\Paseto\Protocol;
+
+use ParagonIE\ConstantTime\{
+    Base64UrlSafe,
+    Binary
+};
+use ParagonIE\Paseto\Keys\{
+    AsymmetricPublicKey,
+    AsymmetricSecretKey,
+    SymmetricKey
+};
+use ParagonIE\Paseto\Keys\Version4\{
+    AsymmetricSecretKey as V4AsymmetricSecretKey,
+    SymmetricKey as V4SymmetricKey
+};
+use ParagonIE\Paseto\Exception\{
+    InvalidVersionException,
+    PasetoException,
+    SecurityException
+};
+use ParagonIE\Paseto\{
+    ProtocolInterface,
+    Util
+};
+use ParagonIE\Paseto\Parsing\{
+    Header,
+    PasetoMessage
+};
+
+/**
+ * Class Version1
+ * @package ParagonIE\Paseto\Protocol
+ */
+class Version4 implements ProtocolInterface
+{
+    /** @const string HEADER */
+    const HEADER = 'v4';
+    const SYMMETRIC_KEY_BYTES = 32;
+    const NONCE_SIZE = 32;
+    const MAC_SIZE = 32;
+
+    /**
+     * Must be constructable with no arguments so an instance may be passed
+     * around in a type safe way.
+     */
+    public function __construct()
+    {
+    }
+
+    /**
+     * @return int
+     */
+    public static function getSymmetricKeyByteLength(): int
+    {
+        return (int) static::SYMMETRIC_KEY_BYTES;
+    }
+
+    /**
+     * @return AsymmetricSecretKey
+     * @throws \Exception
+     * @throws \TypeError
+     */
+    public static function generateAsymmetricSecretKey(): AsymmetricSecretKey
+    {
+        return V4AsymmetricSecretKey::generate(new static);
+    }
+
+    /**
+     * @return SymmetricKey
+     * @throws \Exception
+     * @throws \TypeError
+     */
+    public static function generateSymmetricKey(): SymmetricKey
+    {
+        return V4SymmetricKey::generate(new static);
+    }
+
+    /**
+     * A unique header string with which the protocol can be identified.
+     *
+     * @return string
+     */
+    public static function header(): string
+    {
+        return self::HEADER;
+    }
+
+    /**
+     * Does this protocol support implicit assertions?
+     * Yes.
+     *
+     * @return bool
+     */
+    public static function supportsImplicitAssertions(): bool
+    {
+        return true;
+    }
+
+    /**
+     * Encrypt a message using a shared key.
+     *
+     * @param string $data
+     * @param SymmetricKey $key
+     * @param string $footer
+     * @param string $implicit
+     * @return string
+     * @throws PasetoException
+     */
+    public static function encrypt(
+        string $data,
+        SymmetricKey $key,
+        string $footer = '',
+        string $implicit = ''
+    ): string {
+        return self::__encrypt($data, $key, $footer, $implicit);
+    }
+
+    /**
+     * Encrypt a message using a shared key.
+     *
+     * @param string $data
+     * @param SymmetricKey $key
+     * @param string $footer
+     * @param string $implicit
+     * @param string $nonceForUnitTesting
+     * @return string
+     * @throws PasetoException
+     * @throws \TypeError
+     */
+    protected static function __encrypt(
+        string $data,
+        SymmetricKey $key,
+        string $footer = '',
+        string $implicit = '',
+        string $nonceForUnitTesting = ''
+    ): string {
+        if (!($key->getProtocol() instanceof Version4)) {
+            throw new InvalidVersionException(
+                'The given key is not intended for this version of PASETO.'
+            );
+        }
+        return self::aeadEncrypt(
+            $data,
+            self::HEADER . '.local.', // PASETO v4 - Encrypt - Step 1
+            $key,
+            $footer,
+            $implicit,
+            $nonceForUnitTesting
+        );
+    }
+
+    /**
+     * Decrypt a message using a shared key.
+     *
+     * @param string $data
+     * @param SymmetricKey $key
+     * @param string|null $footer
+     * @param string $implicit
+     * @return string
+     * @throws PasetoException
+     * @throws \TypeError
+     */
+    public static function decrypt(
+        string $data,
+        SymmetricKey $key,
+        string $footer = null,
+        string $implicit = ''
+    ): string {
+        if (!($key->getProtocol() instanceof Version4)) {
+            throw new InvalidVersionException('The given key is not intended for this version of PASETO.');
+        }
+        // PASETO v4 - Decrypt - Step 1:
+        if (\is_null($footer)) {
+            $footer = Util::extractFooter($data);
+            $data = Util::removeFooter($data);
+        } else {
+            $data = Util::validateAndRemoveFooter($data, $footer);
+        }
+        return self::aeadDecrypt(
+            $data,
+            self::HEADER . '.local.', // PASETO v4 - Decrypt - Step 2
+            $key,
+            (string) $footer,
+            $implicit
+        );
+    }
+
+    /**
+     * Sign a message. Public-key digital signatures.
+     *
+     * @param string $data
+     * @param AsymmetricSecretKey $key
+     * @param string $footer
+     * @param string $implicit
+     * @return string
+     *
+     * @throws PasetoException
+     * @throws \TypeError
+     * @throws InvalidVersionException
+     * @throws SecurityException
+     * @throws \SodiumException
+     */
+    public static function sign(
+        string $data,
+        AsymmetricSecretKey $key,
+        string $footer = '',
+        string $implicit = ''
+    ): string {
+        if (!($key->getProtocol() instanceof Version4)) {
+            throw new InvalidVersionException('The given key is not intended for this version of PASETO.');
+        }
+        // PASETO v4 - Sign - Step 1:
+        $header = self::HEADER . '.public.';
+
+        // PASETO v3 - Sign - Step 2 & 3:
+        $signature = \sodium_crypto_sign_detached(
+            Util::preAuthEncode($header, $data, $footer, $implicit),
+            $key->raw()
+        );
+
+        // PASETO v4 - Sign - Step 4:
+        return (new PasetoMessage(
+            Header::fromString($header),
+            $data . $signature,
+            $footer
+        ))->toString();
+    }
+
+    /**
+     * Verify a signed message. Public-key digital signatures.
+     *
+     * @param string $signMsg
+     * @param AsymmetricPublicKey $key
+     * @param string|null $footer
+     * @return string
+     * @throws PasetoException
+     * @throws \TypeError
+     */
+    public static function verify(
+        string $signMsg,
+        AsymmetricPublicKey $key,
+        string $footer = null,
+        string $implicit = ''
+    ): string {
+        if (!($key->getProtocol() instanceof Version4)) {
+            throw new InvalidVersionException('The given key is not intended for this version of PASETO.');
+        }
+        if (\is_null($footer)) {
+            $footer = Util::extractFooter($signMsg);
+        } else {
+            $signMsg = Util::validateAndRemoveFooter($signMsg, $footer);
+        }
+        $signMsg = Util::removeFooter($signMsg);
+        $expectHeader = self::HEADER . '.public.';
+        $givenHeader = Binary::safeSubstr($signMsg, 0, 10);
+        if (!\hash_equals($expectHeader, $givenHeader)) {
+            throw new PasetoException('Invalid message header.');
+        }
+        $decoded = Base64UrlSafe::decode(Binary::safeSubstr($signMsg, 10));
+        $len = Binary::safeStrlen($decoded);
+
+        // Separate the decoded bundle into the message and signature.
+        $message = Binary::safeSubstr(
+            $decoded,
+            0,
+            $len - SODIUM_CRYPTO_SIGN_BYTES
+        );
+        $signature = Binary::safeSubstr(
+            $decoded,
+            $len - SODIUM_CRYPTO_SIGN_BYTES
+        );
+
+        $valid = \sodium_crypto_sign_verify_detached(
+            $signature,
+            Util::preAuthEncode($givenHeader, $message, $footer, $implicit),
+            $key->raw()
+        );
+        if (!$valid) {
+            throw new PasetoException('Invalid signature for this message');
+        }
+        return $message;
+    }
+
+
+    /**
+     * Authenticated Encryption with Associated Data -- Encryption
+     *
+     * Algorithm: XChaCha20-Poly1305 + BLAKE2b-MAC (Encrypt-then-MAC)
+     *
+     * @param string $plaintext
+     * @param string $header
+     * @param SymmetricKey $key
+     * @param string $footer
+     * @param string $implicit
+     * @param string $nonceForUnitTesting
+     * @return string
+     * @throws PasetoException
+     * @throws SecurityException
+     */
+    public static function aeadEncrypt(
+        string $plaintext,
+        string $header,
+        SymmetricKey $key,
+        string $footer = '',
+        string $implicit = '',
+        string $nonceForUnitTesting = ''
+    ): string {
+        // PASETO v4 - Encrypt - Step 2:
+        if ($nonceForUnitTesting) {
+            $nonce = $nonceForUnitTesting;
+        } else {
+            $nonce = \random_bytes(self::NONCE_SIZE);
+        }
+        // PASETO v4 - Encrypt - Step 3:
+        list($encKey, $authKey, $nonce2) = $key->splitV4($nonce);
+
+        /** @var string|bool $ciphertext */
+        // PASETO v4 - Encrypt - Step 4:
+        $ciphertext = \sodium_crypto_stream_xchacha20_xor(
+            $plaintext,
+            $nonce2,
+            $encKey
+        );
+        if (!\is_string($ciphertext)) {
+            throw new PasetoException('Encryption failed.');
+        }
+        // PASETO v4 - Encrypt - Step 5 & 6:
+        $mac = \sodium_crypto_generichash(
+            Util::preAuthEncode($header, $nonce, $ciphertext, $footer, $implicit),
+            $authKey
+        );
+        \sodium_memzero($encKey);
+        \sodium_memzero($authKey);
+
+        // PASETO v4 - Encrypt - Step 7:
+        return (new PasetoMessage(
+            Header::fromString($header),
+            $nonce . $ciphertext . $mac,
+            $footer
+        ))->toString();
+    }
+
+    /**
+     * Authenticated Encryption with Associated Data -- Decryption
+     *
+     * @param string $message
+     * @param string $header
+     * @param SymmetricKey $key
+     * @param string $footer
+     * @param string $implicit
+     * @return string
+     *
+     * @throws PasetoException
+     * @throws \TypeError
+     * @throws \SodiumException
+     */
+    public static function aeadDecrypt(
+        string $message,
+        string $header,
+        SymmetricKey $key,
+        string $footer = '',
+        string $implicit = ''
+    ): string {
+        $expectedLen = Binary::safeStrlen($header);
+        $givenHeader = Binary::safeSubstr($message, 0, $expectedLen);
+
+        // PASETO v4 - Decrypt - Step 2:
+        if (!\hash_equals($header, $givenHeader)) {
+            throw new PasetoException('Invalid message header.');
+        }
+
+        // PASETO v4 - Decrypt - Step 3:
+        try {
+            $decoded = Base64UrlSafe::decode(Binary::safeSubstr($message, $expectedLen));
+        } catch (\Throwable $ex) {
+            throw new PasetoException('Invalid encoding detected', 0, $ex);
+        }
+        $len = Binary::safeStrlen($decoded);
+        $nonce = Binary::safeSubstr($decoded, 0, self::NONCE_SIZE);
+        $ciphertext = Binary::safeSubstr(
+            $decoded,
+            self::NONCE_SIZE,
+            $len - (self::NONCE_SIZE + self::MAC_SIZE)
+        );
+        $mac = Binary::safeSubstr($decoded, $len - self::MAC_SIZE);
+
+        // PASETO v4 - Decrypt - Step 4:
+        list($encKey, $authKey, $nonce2) = $key->splitV4($nonce);
+
+        // PASETO v4 - Decrypt - Step 5 & 6:
+        $calc = \sodium_crypto_generichash(
+            Util::preAuthEncode($header, $nonce, $ciphertext, $footer, $implicit),
+            $authKey
+        );
+        // PASETO v4 - Decrypt - Step 7:
+        if (!\hash_equals($calc, $mac)) {
+            throw new SecurityException('Invalid MAC for given ciphertext.');
+        }
+
+        // PASETO v4 - Decrypt - Step 8:
+        /** @var string|bool $plaintext */
+        $plaintext = \sodium_crypto_stream_xchacha20_xor(
+            $ciphertext,
+            $nonce2,
+            $encKey
+        );
+        if (!\is_string($plaintext)) {
+            throw new PasetoException('Encryption failed.');
+        }
+
+        return $plaintext;
+    }
+}
diff --git a/src/ProtocolCollection.php b/src/ProtocolCollection.php
index 889b47ef..34a81d59 100644
--- a/src/ProtocolCollection.php
+++ b/src/ProtocolCollection.php
@@ -5,7 +5,9 @@
 use ParagonIE\Paseto\Exception\SecurityException;
 use ParagonIE\Paseto\Protocol\{
     Version1,
-    Version2
+    Version2,
+    Version3,
+    Version4
 };
 use ParagonIE\Paseto\Exception\InvalidVersionException;
 
@@ -16,12 +18,15 @@
 final class ProtocolCollection
 {
     /**
-     * Our built-in whitelist of protocol types is defined here.
+     * Our built-in allow-list of protocol types is defined here.
+     *
      * @const array<int, class-string<ProtocolInterface>>
      */
-    const WHITELIST = [
+    const ALLOWED = [
         Version1::class,
         Version2::class,
+        Version3::class,
+        Version4::class,
     ];
 
     /** @var array<array-key, ProtocolInterface> */
@@ -41,7 +46,6 @@ public function __construct(ProtocolInterface ...$protocols)
             throw new \LogicException('At least one version is necessary');
         }
 
-        /** @var ProtocolInterface $protocol */
         foreach ($protocols as $protocol) {
             self::throwIfUnsupported($protocol);
         }
@@ -68,7 +72,7 @@ public function has(ProtocolInterface $protocol): bool
      */
     public static function isValid(ProtocolInterface $protocol): bool
     {
-        return \in_array(\get_class($protocol), self::WHITELIST, true);
+        return \in_array(\get_class($protocol), self::ALLOWED, true);
     }
 
     /**
@@ -96,7 +100,7 @@ public static function throwIfUnsupported(ProtocolInterface $protocol)
      */
     public static function protocolFromHeaderPart(string $headerPart): ProtocolInterface {
         if (empty(self::$headerLookup)) {
-            foreach (self::WHITELIST as $protocolClass) {
+            foreach (self::ALLOWED as $protocolClass) {
                 self::$headerLookup[$protocolClass::header()] = new $protocolClass;
             }
         }
@@ -122,7 +126,7 @@ function (string $p): ProtocolInterface {
                 $protocol = new $p;
                 return $protocol;
             },
-            self::WHITELIST
+            self::ALLOWED
         ));
     }
 
@@ -148,4 +152,26 @@ public static function v2(): self
     {
         return new self(new Version2);
     }
+
+    /**
+     * Get a collection containing protocol version 3.
+     *
+     * @return self
+     * @throws InvalidVersionException
+     */
+    public static function v3(): self
+    {
+        return new self(new Version3);
+    }
+
+    /**
+     * Get a collection containing protocol version 4.
+     *
+     * @return self
+     * @throws InvalidVersionException
+     */
+    public static function v4(): self
+    {
+        return new self(new Version4);
+    }
 }
diff --git a/src/ProtocolInterface.php b/src/ProtocolInterface.php
index 6a24173d..71ca2be8 100644
--- a/src/ProtocolInterface.php
+++ b/src/ProtocolInterface.php
@@ -27,6 +27,13 @@ public function __construct();
      */
     public static function header(): string;
 
+    /**
+     * Does this protocol support implicit assertions?
+     *
+     * @return bool
+     */
+    public static function supportsImplicitAssertions(): bool;
+
     /**
      * @return AsymmetricSecretKey
      */
@@ -48,12 +55,14 @@ public static function getSymmetricKeyByteLength(): int;
      * @param string $data
      * @param SymmetricKey $key
      * @param string $footer
+     * @param string $implicit
      * @return string
      */
     public static function encrypt(
         string $data,
         SymmetricKey $key,
-        string $footer = ''
+        string $footer = '',
+        string $implicit = ''
     ): string;
 
     /**
@@ -62,12 +71,14 @@ public static function encrypt(
      * @param string $data
      * @param SymmetricKey $key
      * @param string|null $footer
+     * @param string $implicit
      * @return string
      */
     public static function decrypt(
         string $data,
         SymmetricKey $key,
-        string $footer = null
+        string $footer = null,
+        string $implicit = ''
     ): string;
 
     /**
@@ -76,12 +87,14 @@ public static function decrypt(
      * @param string $data
      * @param AsymmetricSecretKey $key
      * @param string $footer
+     * @param string $implicit
      * @return string
      */
     public static function sign(
         string $data,
         AsymmetricSecretKey $key,
-        string $footer = ''
+        string $footer = '',
+        string $implicit = ''
     ): string;
 
     /**
@@ -90,11 +103,13 @@ public static function sign(
      * @param string $signMsg
      * @param AsymmetricPublicKey $key
      * @param string|null $footer
+     * @param string $implicit
      * @return string
      */
     public static function verify(
         string $signMsg,
         AsymmetricPublicKey $key,
-        string $footer = null
+        string $footer = null,
+        string $implicit = ''
     ): string;
 }
diff --git a/src/Rules/FooterJSON.php b/src/Rules/FooterJSON.php
new file mode 100644
index 00000000..f934ea18
--- /dev/null
+++ b/src/Rules/FooterJSON.php
@@ -0,0 +1,135 @@
+<?php
+declare(strict_types=1);
+namespace ParagonIE\Paseto\Rules;
+
+use ParagonIE\ConstantTime\Binary;
+use ParagonIE\Paseto\Exception\EncodingException;
+use ParagonIE\Paseto\JsonToken;
+use ParagonIE\Paseto\Util;
+use ParagonIE\Paseto\ValidationRuleInterface;
+
+/**
+ * Class FooterJSON
+ * @package ParagonIE\Paseto\Rules
+ */
+class FooterJSON implements ValidationRuleInterface
+{
+    /** @var int $maxDepth */
+    protected $maxDepth;
+
+    /** @var int $maxKeys */
+    protected $maxKeys;
+
+    /** @var int $maxLength */
+    protected $maxLength;
+
+    /** @var string $rejectReason */
+    protected $rejectReason = '';
+
+    /**
+     * FooterJSON constructor.
+     *
+     * @param int $maxDepth
+     * @param int $maxLength
+     * @param int $maxKeys
+     */
+    public function __construct(
+        int $maxDepth = 2,
+        int $maxLength = 8192,
+        int $maxKeys = 512
+    ) {
+        $this->maxDepth = $maxDepth;
+        $this->maxKeys = $maxKeys;
+        $this->maxLength = $maxLength;
+    }
+
+    /**
+     * Get the message of the last failure. Optional.
+     *
+     * @return string
+     */
+    public function getFailureMessage(): string
+    {
+        if ($this->rejectReason) {
+            return 'The JSON-encoded footer is invalid: ' . $this->rejectReason;
+        }
+        return 'The JSON-encoded footer is invalid';
+    }
+
+    /**
+     * Validate this token according to this rule.
+     *
+     * @param JsonToken $token
+     * @return bool
+     *
+     * @throws EncodingException
+     */
+    public function isValid(JsonToken $token): bool
+    {
+        $json = $token->getFooter();
+        if (empty($json)) {
+            $this->rejectReason = "Footer is empty, when JSON was expected.";
+            return false;
+        }
+        $length = Binary::safeStrlen($json);
+        if ($length > $this->maxLength) {
+            $this->rejectReason = "Footer is too long ({$length}, when the maximum allowed is {$this->maxLength})";
+            return false;
+        }
+
+        $count = Util::countJsonKeys($json);
+        if ($count > $this->maxKeys) {
+            $this->rejectReason = "Footer has too many keys ({$count}, when the maximum allowed is {$this->maxKeys})";
+            return false;
+        }
+
+        $depth = Util::calculateJsonDepth($json);
+        if ($depth > $this->maxDepth) {
+            $this->rejectReason = "Maximum stack depth exceeded";
+            return false;
+        }
+
+        /** @var array|bool|null $decoded */
+        $decoded = json_decode($json, true, $this->maxDepth);
+        if (!$decoded) {
+            $this->rejectReason = json_last_error_msg();
+        }
+        return is_array($decoded);
+    }
+
+    /**
+     * Set the maximum permitted depth for the JSON payload in the footer.
+     *
+     * @param int $maxDepth
+     * @return self
+     */
+    public function setMaxDepth(int $maxDepth): self
+    {
+        $this->maxDepth = $maxDepth;
+        return $this;
+    }
+
+    /**
+     * Set the maximum number of keys in the JSON payload in the footer.
+     *
+     * @param int $maxKeys
+     * @return self
+     */
+    public function setMaxKeys(int $maxKeys): self
+    {
+        $this->maxKeys = $maxKeys;
+        return $this;
+    }
+
+    /**
+     * Set the maximum length of the JSON payload in the footer.
+     *
+     * @param int $maxLength
+     * @return self
+     */
+    public function setMaxLength(int $maxLength): self
+    {
+        $this->maxLength = $maxLength;
+        return $this;
+    }
+}
\ No newline at end of file
diff --git a/src/Util.php b/src/Util.php
index 91aec332..bd229745 100644
--- a/src/Util.php
+++ b/src/Util.php
@@ -6,6 +6,7 @@
     Base64UrlSafe,
     Binary
 };
+use ParagonIE\Paseto\Exception\EncodingException;
 use ParagonIE\Paseto\Exception\PasetoException;
 
 /**
@@ -14,6 +15,64 @@
  */
 abstract class Util
 {
+    /**
+     * Calculate the depth of a JSON string without passing it to json_decode()
+     *
+     * @param string $json
+     * @return int
+     *
+     * @throws EncodingException
+     */
+    public static function calculateJsonDepth(string $json): int
+    {
+        // Remove quotes quotes first:
+        $stripped = str_replace('\"', '', $json);
+
+        // Remove whitespace:
+        $stripped = preg_replace('/\s+/', '', $stripped);
+
+        // Strip everything out of quotes:
+        $stripped = preg_replace('#"[^"]+"([:,}\]])#', '$1', $stripped);
+
+        // Remove everything that isn't a map or list definition
+        $stripped = preg_replace('#[^\[\]{}]#', '', $stripped);
+
+        $previous = '';
+        $depth = 1;
+        while (!empty($stripped) && $stripped !== $previous) {
+            $previous = $stripped;
+            // Remove pairs of tokens
+            $stripped = str_replace(['[]', '{}'], [], $stripped);
+            ++$depth;
+        }
+        if (!empty($stripped)) {
+            throw new EncodingException('Invalid JSON string provided');
+        }
+        return $depth;
+    }
+
+    /**
+     * Count the number of instances of `":` without a preceding backslash.
+     *
+     * @param string $json
+     * @return int
+     */
+    public static function countJsonKeys(string $json): int
+    {
+        return preg_match_all('#[^\\\]":#', $json);
+    }
+
+    /**
+     * Normalize line-endings to UNIX-style (LF rather than CRLF).
+     *
+     * @param string $in
+     * @return string
+     */
+    public static function dos2unix(string $in): string
+    {
+        return str_replace("\r\n", "\n", $in);
+    }
+
     /**
      * Computes the HKDF key derivation function specified in
      * http://tools.ietf.org/html/rfc5869.
@@ -97,11 +156,19 @@ public static function HKDF(
         }
 
         // ORM = first L octets of T
-        /** @var string $orm */
         $orm = Binary::safeSubstr($t, 0, $length);
         return (string) $orm;
     }
 
+    /**
+     * @param int $long
+     * @return string
+     */
+    public static function longToBytes(int $long): string
+    {
+        return \pack('P', $long);
+    }
+
     /**
      * Format the Additional Associated Data.
      *
@@ -117,19 +184,18 @@ public static function HKDF(
      */
     public static function preAuthEncode(string ...$pieces): string
     {
-        $accumulator = \ParagonIE_Sodium_Core_Util::store64_le(\count($pieces) & PHP_INT_MAX);
+        $accumulator = self::longToBytes(\count($pieces) & PHP_INT_MAX);
         foreach ($pieces as $piece) {
             $len = Binary::safeStrlen($piece);
-            $accumulator .= \ParagonIE_Sodium_Core_Util::store64_le($len & PHP_INT_MAX);
+            $accumulator .= self::longToBytes($len & PHP_INT_MAX);
             $accumulator .= $piece;
         }
         return $accumulator;
     }
 
     /**
-     * If a footer was included with the message, first verify that
-     * it's equivalent to the one we expect, then remove it from the
-     * token payload.
+     * If a footer was included with the message, extract it.
+     * Otherwise, return an empty string.
      *
      * @param string $payload
      * @return string
@@ -146,9 +212,7 @@ public static function extractFooter(string $payload): string
     }
 
     /**
-     * If a footer was included with the message, first verify that
-     * it's equivalent to the one we expect, then remove it from the
-     * token payload.
+     * If a footer was included with the message, remove it.
      *
      * @param string $payload
      * @return string


Test patch:
diff --git a/tests/KnownAnswerTest.php b/tests/KnownAnswerTest.php
new file mode 100644
index 00000000..763005cc
--- /dev/null
+++ b/tests/KnownAnswerTest.php
@@ -0,0 +1,152 @@
+<?php
+declare(strict_types=1);
+namespace ParagonIE\Paseto\Tests;
+
+use ParagonIE\ConstantTime\Hex;
+use ParagonIE\Paseto\Keys\AsymmetricPublicKey;
+use ParagonIE\Paseto\Keys\SymmetricKey;
+use ParagonIE\Paseto\Protocol\{
+    Version1,
+    Version2,
+    Version3,
+    Version4
+};
+use ParagonIE\Paseto\ProtocolInterface;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * Class KnownAnswerTest
+ * @package ParagonIE\Paseto\Tests
+ */
+class KnownAnswerTest extends TestCase
+{
+    private $cacheKey;
+    /** @var array<string, AsymmetricPublicKey|SymmetricKey> */
+    private $keys = [];
+    private $dir;
+
+    public function setUp(): void
+    {
+        $this->cacheKey = sodium_crypto_shorthash_keygen();
+        $this->dir = dirname(__DIR__) . '/docs/03-Implementation-Guide/Test-Vectors';
+    }
+
+    public function testV1()
+    {
+        $contents = json_decode(file_get_contents($this->dir . '/v1.json'), true);
+        if (!is_array($contents)) {
+            $this->markTestSkipped('Could not load test vector file');
+        }
+        $this->genericTests(new Version1(), $contents['tests']);
+    }
+
+    public function testV2()
+    {
+        $contents = json_decode(file_get_contents($this->dir . '/v2.json'), true);
+        if (!is_array($contents)) {
+            $this->markTestSkipped('Could not load test vector file');
+        }
+        $this->genericTests(new Version2(), $contents['tests']);
+    }
+
+    public function testV3()
+    {
+        $contents = json_decode(file_get_contents($this->dir . '/v3.json'), true);
+        if (!is_array($contents)) {
+            $this->markTestSkipped('Could not load test vector file');
+        }
+        $this->genericTests(new Version3(), $contents['tests']);
+    }
+
+    public function testV4()
+    {
+        $contents = json_decode(file_get_contents($this->dir . '/v4.json'), true);
+        if (!is_array($contents)) {
+            $this->markTestSkipped('Could not load test vector file');
+        }
+
+        $this->genericTests(new Version4(), $contents['tests']);
+    }
+
+
+    /**
+     * @param ProtocolInterface $protocol
+     * @param array $tests
+     * @throws \SodiumException
+     *
+     * @psalm-suppress MixedArrayAccess
+     */
+    protected function genericTests(ProtocolInterface $protocol, array $tests)
+    {
+        $fixedEncrypt = NonceFixer::buildUnitTestEncrypt($protocol)->bindTo(null, $protocol);
+        foreach ($tests as $test) {
+            // First, assert that the payload decodes correctly.
+            if (isset($test['public-key'])) {
+                $decoded = $protocol::verify(
+                    $test['token'],
+                    $this->cacheKey($protocol, $test['public-key'], true),
+                    $test['footer'] ?? '',
+                    $test['implicit-assertion'] ?? ''
+                );
+            } elseif (isset($test['key'])) {
+                $decoded = $protocol::decrypt(
+                    $test['token'],
+                    $this->cacheKey($protocol, $test['key']),
+                    $test['footer'] ?? '',
+                    $test['implicit-assertion'] ?? ''
+                );
+            } else {
+                $this->fail('Key not provided');
+            }
+            $this->assertSame(json_encode($test['payload']), $decoded, $test['name']);
+
+            // Next, assert that we get the same token (if local):
+            if (isset($test['key'])) {
+                $encoded = $fixedEncrypt(
+                    json_encode($test['payload']),
+                    $this->cacheKey($protocol, $test['key']),
+                    $test['footer'] ?? '',
+                    $test['implicit-assertion'] ?? '',
+                    Hex::decode($test['nonce'])
+                );
+                $this->assertSame($test['token'], $encoded, $test['name']);
+            }
+        }
+    }
+
+    /**
+     * Cache keys to save on memory.
+     *
+     * @param ProtocolInterface $protocol
+     * @param string $hex
+     * @param bool $public
+     * @return AsymmetricPublicKey|SymmetricKey
+     * @throws \SodiumException
+     */
+    protected function cacheKey(ProtocolInterface $protocol, string $hex, bool $public = false)
+    {
+        $lookup = Hex::encode(sodium_crypto_shorthash(
+            ($public ? 'PUBLIC' : 'SECRET') . $protocol->header() . $hex,
+            $this->cacheKey
+        ));
+        if (empty($this->keys[$lookup])) {
+            $this->keys[$lookup] = $this->loadKey($protocol, $hex, $public);
+        }
+        return $this->keys[$lookup];
+    }
+
+    /**
+     * @param ProtocolInterface $protocol
+     * @param string $key
+     * @param bool $public
+     * @return AsymmetricPublicKey|SymmetricKey
+     * @throws \Exception
+     */
+    protected function loadKey(ProtocolInterface $protocol, string $key, bool $public = false)
+    {
+        if ($public) {
+            return new AsymmetricPublicKey($key, $protocol);
+        }
+        return new SymmetricKey(Hex::decode($key), $protocol);
+    }
+}
diff --git a/tests/NonceFixer.php b/tests/NonceFixer.php
index 212a0963..e387017e 100644
--- a/tests/NonceFixer.php
+++ b/tests/NonceFixer.php
@@ -11,9 +11,10 @@ public static function buildUnitTestEncrypt(ProtocolInterface $protocol): \Closu
             string $data,
             SymmetricKey $key,
             string $footer = '',
+            string $implicit = '',
             string $nonceForUnitTesting = ''
         ) use ($protocol): string {
-            return $protocol::__encrypt($data, $key, $footer, $nonceForUnitTesting);
+            return $protocol::__encrypt($data, $key, $footer, $implicit, $nonceForUnitTesting);
         };
     }
 
@@ -38,12 +39,14 @@ public static function setProtocol(ProtocolInterface $protocol)
                     public static function encrypt(
                         string $data,
                         SymmetricKey $key,
-                        string $footer = ''
+                        string $footer = '',
+                        string $implicit = ''
                     ): string {
                         return NonceFixer::buildUnitTestEncrypt(self::$protocol)->bindTo(null, self::$protocol)(
                             $data,
                             $key,
                             $footer,
+                            $implicit,
                             self::$nonce
                         );
                     }
diff --git a/tests/ParserTest.php b/tests/ParserTest.php
index 2c77cf52..677ee0ef 100644
--- a/tests/ParserTest.php
+++ b/tests/ParserTest.php
@@ -4,10 +4,12 @@
 
 use ParagonIE\ConstantTime\Hex;
 use ParagonIE\Paseto\Builder;
+use ParagonIE\Paseto\Exception\EncodingException;
 use ParagonIE\Paseto\Exception\InvalidKeyException;
 use ParagonIE\Paseto\Exception\InvalidPurposeException;
 use ParagonIE\Paseto\Exception\InvalidVersionException;
 use ParagonIE\Paseto\Exception\PasetoException;
+use ParagonIE\Paseto\Exception\RuleViolation;
 use ParagonIE\Paseto\JsonToken;
 use ParagonIE\Paseto\Keys\{
     AsymmetricPublicKey,
@@ -19,9 +21,12 @@
     SymmetricKey as V2SymmetricKey
 };
 use ParagonIE\Paseto\Parser;
+use ParagonIE\Paseto\Protocol\Version4;
+use ParagonIE\Paseto\ProtocolInterface;
 use ParagonIE\Paseto\Purpose;
 use ParagonIE\Paseto\Protocol\Version2;
 use ParagonIE\Paseto\ProtocolCollection;
+use ParagonIE\Paseto\Rules\FooterJSON;
 use ParagonIE\Paseto\Rules\NotExpired;
 use PHPUnit\Framework\TestCase;
 
@@ -165,6 +170,113 @@ public function testAuthToken()
         );
     }
 
+    /**
+     * @param SymmetricKey $key
+     * @param ProtocolInterface|null $v
+     * @return string
+     * @throws InvalidKeyException
+     * @throws InvalidPurposeException
+     * @throws PasetoException
+     */
+    protected function getDummyToken(SymmetricKey $key, ?ProtocolInterface $v = null): string
+    {
+        if (!$v) {
+            $v = $key->getProtocol();
+        }
+        $builder = (new Builder())
+            ->setVersion($v)
+            ->setPurpose(Purpose::local())
+            ->setKey($key)
+            ->setClaims([
+                'a' => 'b',
+                'c' => 'd',
+                'e' => [
+                    'f' => 1,
+                    'g' => [
+                        'x' => 234
+                    ],
+                    'h' => 'ijk'
+                ],
+                'l' => json_encode(['pq' => 'rs'])
+            ]);
+        return $builder->toString();
+    }
+
+    /**
+     * @throws InvalidKeyException
+     * @throws InvalidPurposeException
+     * @throws InvalidVersionException
+     * @throws PasetoException
+     */
+    public function testLimitedJsonClaimCount(): void
+    {
+        // Setup
+        $v2key = new V2SymmetricKey('YELLOW SUBMARINE, BLACK WIZARDRY');
+        $dummy = $this->getDummyToken($v2key);
+
+        $parser = (new Parser())
+            ->setAllowedVersions(ProtocolCollection::v2())
+            ->setPurpose(Purpose::local())
+            ->setKey($v2key)
+            ->setMaxClaimCount(16);
+
+        // OK
+        $this->assertInstanceOf(JsonToken::class, $parser->parse($dummy));
+
+        $this->expectException(EncodingException::class);
+        $parser->setMaxClaimCount(2)->parse($dummy);
+    }
+
+    /**
+     * @throws InvalidKeyException
+     * @throws InvalidPurposeException
+     * @throws InvalidVersionException
+     * @throws PasetoException
+     */
+    public function testLimitedJsonClaimDepth(): void
+    {
+        // Setup
+        $v2key = new V2SymmetricKey('YELLOW SUBMARINE, BLACK WIZARDRY');
+        $dummy = $this->getDummyToken($v2key);
+
+        $parser = (new Parser())
+            ->setAllowedVersions(ProtocolCollection::v2())
+            ->setPurpose(Purpose::local())
+            ->setKey($v2key)
+            ->setMaxClaimDepth(16);
+
+        // OK
+        $this->assertInstanceOf(JsonToken::class, $parser->parse($dummy));
+
+        $this->expectException(EncodingException::class);
+        $parser->setMaxClaimDepth(3)->parse($dummy);
+    }
+
+    /**
+     * @throws InvalidKeyException
+     * @throws InvalidPurposeException
+     * @throws InvalidVersionException
+     * @throws PasetoException
+     */
+    public function testLimitedJsonLength(): void
+    {
+        // Setup
+        $v2key = new V2SymmetricKey('YELLOW SUBMARINE, BLACK WIZARDRY');
+        $dummy = $this->getDummyToken($v2key);
+
+        $parser = (new Parser())
+            ->setAllowedVersions(ProtocolCollection::v2())
+            ->setPurpose(Purpose::local())
+            ->setKey($v2key)
+            ->setMaxJsonLength(8192);
+
+        // OK
+        $this->assertInstanceOf(JsonToken::class, $parser->parse($dummy));
+
+        $this->expectException(EncodingException::class);
+        $parser->setMaxJsonLength(16)->parse($dummy);
+    }
+
     /**
      * @throws PasetoException
      * @throws \ParagonIE\Paseto\Exception\InvalidPurposeException
@@ -195,6 +307,81 @@ public function testExtractFooter()
         }
     }
 
+    /**
+     * @throws InvalidKeyException
+     * @throws InvalidPurposeException
+     * @throws InvalidVersionException
+     * @throws PasetoException
+     */
+    public function testFooterJSON()
+    {
+        $expires = (new \DateTime('NOW'))
+            ->add(new \DateInterval('P01D'));
+        $key = SymmetricKey::generate(new Version4());
+        $builder = (new Builder())
+            ->setKey($key)
+            ->setPurpose(Purpose::local())
+            ->setVersion(new Version4())
+            ->setExpiration($expires)
+            ->setFooterArray(['a' => 1]);
+
+        $parser = (new Parser(ProtocolCollection::v4(), Purpose::local(), $key))
+            ->addRule(new FooterJSON(2, 4096, 4));
+
+        // This should be OK:
+        $in = $builder->toString();
+        $this->assertInstanceOf(JsonToken::class, $parser->parse($in));
+
+        $builder->setFooterArray(['a' => 1, 'b' => 2, 'c' => 3, 'd' => 4, 'e' => 5]);
+        $in = $builder->toString();
+        try {
+            $parser->parse($in);
+            $this->fail('Failed to catch rule violation: too many keys');
+        } catch (RuleViolation $ex) {
+            $this->assertStringContainsString('Footer has too many keys', $ex->getMessage());
+        }
+
+        $builder->setFooterArray(['a' => ['b' => 2]]);
+        $in = $builder->toString();
+        try {
+            $parser->parse($in);
+            $this->fail('Failed to catch rule violation: too many keys');
+        } catch (RuleViolation $ex) {
+            $this->assertStringContainsString('Maximum stack depth exceeded', $ex->getMessage());
+        }
+
+        $parser = (new Parser(ProtocolCollection::v4(), Purpose::local(), $key))
+            ->addRule(new FooterJSON(2, 8192, 128));
+
+        $junk = [];
+        for ($i = 0; $i < 257; ++$i) {
+            $junk['a' . $i] = 0;
+        }
+        $builder->setFooterArray($junk);
+        $in = $builder->toString();
+        try {
+            $parser->parse($in);
+            $this->fail('Failed to catch rule violation: too many keys');
+        } catch (RuleViolation $ex) {
+            $this->assertStringContainsString('Footer has too many keys', $ex->getMessage());
+        }
+        $parser = (new Parser(ProtocolCollection::v4(), Purpose::local(), $key))
+            ->addRule(new FooterJSON(2, 1024, 128));
+
+        $junk = [];
+        for ($i = 0; $i < 257; ++$i) {
+            $junk['a' . $i] = 0;
+        }
+        $builder->setFooterArray($junk);
+        $in = $builder->toString();
+        try {
+            $parser->parse($in);
+            $this->fail('Failed to catch rule violation: too long');
+        } catch (RuleViolation $ex) {
+            $this->assertStringContainsString('Footer is too long', $ex->getMessage());
+        }
+    }
+
     /**
      * @throws PasetoException
      * @throws \Exception
diff --git a/tests/UtilTest.php b/tests/UtilTest.php
index e5491019..6af0d32a 100644
--- a/tests/UtilTest.php
+++ b/tests/UtilTest.php
@@ -6,6 +6,7 @@
     Base64UrlSafe,
     Hex
 };
+use ParagonIE\Paseto\Exception\EncodingException;
 use ParagonIE\Paseto\Util;
 use PHPUnit\Framework\TestCase;
 
@@ -15,6 +16,41 @@
  */
 class UtilTest extends TestCase
 {
+    /**
+     * @throws EncodingException
+     */
+    public function testDepth()
+    {
+        $this->assertSame(1, Util::calculateJsonDepth('"abc"'));
+        $this->assertSame(2, Util::calculateJsonDepth('{"abc":"def"}'));
+        $this->assertSame(3, Util::calculateJsonDepth('{"abc":{"abc":"def"}}'));
+        $this->assertSame(4, Util::calculateJsonDepth('{"abc":{"abc":{"abc":"def"}}}'));
+        $this->assertSame(2, Util::calculateJsonDepth('{"abc":"{\"test\":\"foo\"}"}'));
+        $this->assertSame(3, Util::calculateJsonDepth('{"abc":"{\"test\":\"foo\"}","def":[{"abc":"def"}]}'));
+        $this->assertSame(3, Util::calculateJsonDepth('{"abc":"{\"test\":\"foo\"}" ,"def":[{"abc":"def"}]}'));
+        $depth = random_int(1024, 8192);
+        $this->assertSame(
+            $depth + 1,
+            Util::calculateJsonDepth(
+                '{"a":' .
+                str_repeat('[', $depth) .
+                '1, 2, 3' .
+                str_repeat(']', $depth) .
+                '}'
+            )
+        );
+    }
+
+    /**
+     * @throws EncodingException
+     */
+    public function testInvalid()
+    {
+        $this->expectException(EncodingException::class);
+        Util::calculateJsonDepth('{"a":[}');
+    }
+    
+    
     /**
      * @covers Util::HKDF()
      * @ref https://tools.ietf.org/html/rfc5869
diff --git a/tests/Version1Test.php b/tests/Version1Test.php
index 4b0a1ad3..79bd5e67 100644
--- a/tests/Version1Test.php
+++ b/tests/Version1Test.php
@@ -29,7 +29,7 @@ public function testKeyGen()
         $this->assertInstanceOf('ParagonIE\Paseto\Keys\AsymmetricSecretKey', $secret);
 
         $this->assertSame(Version1::getSymmetricKeyByteLength(), Binary::safeStrlen($symmetric->raw()));
-        $this->assertGreaterThanOrEqual(1700, Binary::safeStrlen($secret->raw())); // PEM encoded
+        $this->assertGreaterThanOrEqual(1670, Binary::safeStrlen($secret->raw())); // PEM encoded
     }
 
     /**
diff --git a/tests/Version1VectorTest.php b/tests/Version1VectorTest.php
deleted file mode 100644
index e9c38927..00000000
--- a/tests/Version1VectorTest.php
+++ /dev/null
@@ -1,206 +0,0 @@
-<?php
-declare(strict_types=1);
-namespace ParagonIE\Paseto\Tests;
-
-use ParagonIE\ConstantTime\Hex;
-use ParagonIE\Paseto\Keys\AsymmetricPublicKey;
-use ParagonIE\Paseto\Keys\Version1\SymmetricKey;
-use ParagonIE\Paseto\Protocol\Version1;
-use PHPUnit\Framework\TestCase;
-
-/**
- * Class Version1VectorTest
- *
- * Contains test vectors for building compatible implementations in other languages.
- *
- * @package ParagonIE\Paseto\Tests
- */
-class Version1VectorTest extends TestCase
-{
-    /**
-     * @throws \ParagonIE\Paseto\Exception\PasetoException
-     * @throws \ParagonIE\Paseto\Exception\SecurityException
-     */
-    public function testOfficialVectors()
-    {
-        $symmetricKey = new SymmetricKey(
-            Hex::decode(
-                '707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f'
-            )
-        );
-        $nonce = str_repeat("\0", 32);
-        $nonce2 = Hex::decode('26f7553354482a1d91d4784627854b8da6b8042a7966523c2b404e8dbbe7f7f2');
-
-        $version1Encrypt = NonceFixer::buildUnitTestEncrypt(new Version1)->bindTo(null, new Version1);
-
-        $message = \json_encode(['data' => 'this is a signed message', 'exp' => '2019-01-01T00:00:00+00:00']);
-        $this->assertSame(
-            'v1.local.WzhIh1MpbqVNXNt7-HbWvL-JwAym3Tomad9Pc2nl7wK87vGraUVvn2bs8BBNo7jbukCNrkVID0jCK2vr5bP18G78j1bOTbBcP9HZzqnraEdspcjd_PvrxDEhj9cS2MG5fmxtvuoHRp3M24HvxTtql9z26KTfPWxJN5bAJaAM6gos8fnfjJO8oKiqQMaiBP_Cqncmqw8',
-            $version1Encrypt($message, $symmetricKey, '', $nonce),
-            'Test Vector 1.1'
-        );
-        $message = \json_encode(['data' => 'this is a secret message', 'exp' => '2019-01-01T00:00:00+00:00']);
-        $this->assertSame(
-            'v1.local.w_NOpjgte4bX-2i1JAiTQzHoGUVOgc2yqKqsnYGmaPaCu_KWUkRGlCRnOvZZxeH4HTykY7AE_jkzSXAYBkQ1QnwvKS16uTXNfnmp8IRknY76I2m3S5qsM8klxWQQKFDuQHl8xXV0MwAoeFh9X6vbwIqrLlof3s4PMjRDwKsxYzkMr1RvfDI8emoPoW83q4Q60_xpHaw',
-            $version1Encrypt($message, $symmetricKey, '', $nonce),
-            'Test Vector 1.2'
-        );
-
-        $message = \json_encode(['data' => 'this is a signed message', 'exp' => '2019-01-01T00:00:00+00:00']);
-        $this->assertSame(
-            'v1.local.4VyfcVcFAOAbB8yEM1j1Ob7Iez5VZJy5kHNsQxmlrAwKUbOtq9cv39T2fC0MDWafX0nQJ4grFZzTdroMvU772RW-X1oTtoFBjsl_3YYHWnwgqzs0aFc3ejjORmKP4KUM339W3syBYyjKIOeWnsFQB6Yef-1ov9rvqt7TmwONUHeJUYk4IK_JEdUeo_uFRqAIgHsiGCg',
-            $version1Encrypt($message, $symmetricKey, '', $nonce2),
-            'Test Vector 1.3'
-        );
-
-        $message = \json_encode(['data' => 'this is a secret message', 'exp' => '2019-01-01T00:00:00+00:00']);
-        $this->assertSame(
-            'v1.local.IddlRQmpk6ojcD10z1EYdLexXvYiadtY0MrYQaRnq3dnqKIWcbbpOcgXdMIkm3_3gksirTj81bvWrWkQwcUHilt-tQo7LZK8I6HCK1V78B9YeEqGNeeWXOyWWHoJQIe0d5nTdvejdt2Srz_5Q0QG4oiz1gB_wmv4U5pifedaZbHXUTWXchFEi0etJ4u6tqgxZSklcec',
-            $version1Encrypt($message, $symmetricKey, '', $nonce2),
-            'Test Vector 1.4'
-        );
-
-        $message = \json_encode(['data' => 'this is a signed message', 'exp' => '2019-01-01T00:00:00+00:00']);
-        $footer = \json_encode(['kid' => 'UbkK8Y6iv4GZhFp6Tx3IWLWLfNXSEvJcdT3zdR65YZxo']);
-        $this->assertSame(
-            'v1.local.4VyfcVcFAOAbB8yEM1j1Ob7Iez5VZJy5kHNsQxmlrAwKUbOtq9cv39T2fC0MDWafX0nQJ4grFZzTdroMvU772RW-X1oTtoFBjsl_3YYHWnwgqzs0aFc3ejjORmKP4KUM339W3szA28OabR192eRqiyspQ6xPM35NMR-04-FhRJZEWiF0W5oWjPVtGPjeVjm2DI4YtJg.eyJraWQiOiJVYmtLOFk2aXY0R1poRnA2VHgzSVdMV0xmTlhTRXZKY2RUM3pkUjY1WVp4byJ9',
-            $version1Encrypt($message, $symmetricKey, $footer, $nonce2),
-            'Test Vector 1.5'
-        );
-        $message = \json_encode(['data' => 'this is a secret message', 'exp' => '2019-01-01T00:00:00+00:00']);
-        $footer = \json_encode(['kid' => 'UbkK8Y6iv4GZhFp6Tx3IWLWLfNXSEvJcdT3zdR65YZxo']);
-        $this->assertSame(
-            'v1.local.IddlRQmpk6ojcD10z1EYdLexXvYiadtY0MrYQaRnq3dnqKIWcbbpOcgXdMIkm3_3gksirTj81bvWrWkQwcUHilt-tQo7LZK8I6HCK1V78B9YeEqGNeeWXOyWWHoJQIe0d5nTdvcT2vnER6NrJ7xIowvFba6J4qMlFhBnYSxHEq9v9NlzcKsz1zscdjcAiXnEuCHyRSc.eyJraWQiOiJVYmtLOFk2aXY0R1poRnA2VHgzSVdMV0xmTlhTRXZKY2RUM3pkUjY1WVp4byJ9',
-            $version1Encrypt($message, $symmetricKey, $footer, $nonce2),
-            'Test Vector 1.6'
-        );
-        $publicKeyText = '-----BEGIN PUBLIC KEY-----
-MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyaTgTt53ph3p5GHgwoGW
-wz5hRfWXSQA08NCOwe0FEgALWos9GCjNFCd723nCHxBtN1qd74MSh/uN88JPIbwx
-KheDp4kxo4YMN5trPaF0e9G6Bj1N02HnanxFLW+gmLbgYO/SZYfWF/M8yLBcu5Y1
-Ot0ZxDDDXS9wIQTtBE0ne3YbxgZJAZTU5XqyQ1DxdzYyC5lF6yBaR5UQtCYTnXAA
-pVRuUI2Sd6L1E2vl9bSBumZ5IpNxkRnAwIMjeTJB/0AIELh0mE5vwdihOCbdV6al
-UyhKC1+1w/FW6HWcp/JG1kKC8DPIidZ78Bbqv9YFzkAbNni5eSBOsXVBKG78Zsc8
-owIDAQAB
------END PUBLIC KEY-----';
-        $publicKey = new AsymmetricPublicKey($publicKeyText, new Version1);
-
-        $message = \json_encode(['data' => 'this is a signed message', 'exp' => '2019-01-01T00:00:00+00:00']);
-        $footer = '';
-        $this->assertSame(
-            $message,
-            Version1::verify(
-                'v1.public.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAxOS0wMS0wMVQwMDowMDowMCswMDowMCJ9cIZKahKeGM5kiAS_4D70Qbz9FIThZpxetJ6n6E6kXP_119SvQcnfCSfY_gG3D0Q2v7FEtm2Cmj04lE6YdgiZ0RwA41WuOjXq7zSnmmHK9xOSH6_2yVgt207h1_LphJzVztmZzq05xxhZsV3nFPm2cCu8oPceWy-DBKjALuMZt_Xj6hWFFie96SfQ6i85lOsTX8Kc6SQaG-3CgThrJJ6W9DC-YfQ3lZ4TJUoY3QNYdtEgAvp1QuWWK6xmIb8BwvkBPej5t88QUb7NcvZ15VyNw3qemQGn2ITSdpdDgwMtpflZOeYdtuxQr1DSGO2aQyZl7s0WYn1IjdQFx6VjSQ4yfw',
-                $publicKey,
-                $footer
-            ),
-            'Version 1-S-1'
-        );
-        $footer = '{"kid":"dYkISylxQeecEcHELfzF88UZrwbLolNiCdpzUHGw9Uqn"}';
-        $this->assertSame(
-            $message,
-            Version1::verify(
-                'v1.public.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAxOS0wMS0wMVQwMDowMDowMCswMDowMCJ9sBTIb0J_4misAuYc4-6P5iR1rQighzktpXhJ8gtrrp2MqSSDkbb8q5WZh3FhUYuW_rg2X8aflDlTWKAqJkM3otjYwtmfwfOhRyykxRL2AfmIika_A-_MaLp9F0iw4S1JetQQDV8GUHjosd87TZ20lT2JQLhxKjBNJSwWue8ucGhTgJcpOhXcthqaz7a2yudGyd0layzeWziBhdQpoBR6ryTdtIQX54hP59k3XCIxuYbB9qJMpixiPAEKBcjHT74sA-uukug9VgKO7heWHwJL4Rl9ad21xyNwaxAnwAJ7C0fN5oGv8Rl0dF11b3tRmsmbDoIokIM0Dba29x_T3YzOyg.eyJraWQiOiJkWWtJU3lseFFlZWNFY0hFTGZ6Rjg4VVpyd2JMb2xOaUNkcHpVSEd3OVVxbiJ9',
-                $publicKey,
-                $footer
-            ),
-            'Version 1-S-2'
-        );
-    }
-
-    /**
-     * @throws \TypeError
-     * @throws \ParagonIE\Paseto\Exception\SecurityException
-     */
-    public function testEncrypt()
-    {
-        $nullKey = new SymmetricKey(\str_repeat("\0", 32));
-        $fullKey = new SymmetricKey(\str_repeat("\xff", 32));
-        $symmetricKey = new SymmetricKey(
-            Hex::decode(
-                '707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f'
-            )
-        );
-        $nonce = str_repeat("\0", 32);
-        //$nonce2 = hash('sha256', 'Paragon Initiative Enterprises, LLC', true);
-        $nonce2 = Hex::decode('26f7553354482a1d91d4784627854b8da6b8042a7966523c2b404e8dbbe7f7f2');
-
-        $version1Encrypt = NonceFixer::buildUnitTestEncrypt(new Version1)->bindTo(null, new Version1);
-
-        // Empty message, empty footer, empty nonce
-        $this->assertSame(
-            'v1.local.bB8u6Tj60uJL2RKYR0OCyiGMdds9g-EUs9Q2d3bRTTXyNMehtdOLJS_vq4YzYdaZ6vwItmpjx-Lt3AtVanBmiMyzFyqJMHCaWVMpEMUyxUg',
-            $version1Encrypt('', $nullKey, '', $nonce),
-            'Test Vector 1E-1-1'
-        );
-        $this->assertSame(
-            'v1.local.bB8u6Tj60uJL2RKYR0OCyiGMdds9g-EUs9Q2d3bRTTWgetvu2STfe7gxkDpAOk_IXGmBeea4tGW6HsoH12oKElAWap57-PQMopNurtEoEdk',
-            $version1Encrypt('', $fullKey, '', $nonce),
-            'Test Vector 1E-1-2'
-        );
-        $this->assertSame(
-            'v1.local.bB8u6Tj60uJL2RKYR0OCyiGMdds9g-EUs9Q2d3bRTTV8OmiMvoZgzer20TE8kb3R0QN9Ay-ICSkDD1-UDznTCdBiHX1fbb53wdB5ng9nCDY',
-            $version1Encrypt('', $symmetricKey, '', $nonce),
-            'Test Vector 1E-1-3'
-        );
-
-        // Empty message, non-empty footer, empty nonce
-        $this->assertSame(
-            'v1.local.bB8u6Tj60uJL2RKYR0OCyiGMdds9g-EUs9Q2d3bRTTVhyXOB4vmrFm9GvbJdMZGArV5_10Kxwlv4qSb-MjRGgFzPg00-T2TCFdmc9BMvJAA.Q3VvbiBBbHBpbnVz',
-            $version1Encrypt('', $nullKey, 'Cuon Alpinus', $nonce),
-            'Test Vector 1E-2-1'
-        );
-        $this->assertSame(
-            'v1.local.bB8u6Tj60uJL2RKYR0OCyiGMdds9g-EUs9Q2d3bRTTVna3s7WqUwfQaVM8ddnvjPkrWkYRquX58-_RgRQTnHn7hwGJwKT3H23ZDlioSiJeo.Q3VvbiBBbHBpbnVz',
-            $version1Encrypt('', $fullKey, 'Cuon Alpinus', $nonce),
-            'Test Vector 1E-2-2'
-        );
-        $this->assertSame(
-            'v1.local.bB8u6Tj60uJL2RKYR0OCyiGMdds9g-EUs9Q2d3bRTTW9MRfGNyfC8vRpl8xsgnsWt-zHinI9bxLIVF0c6INWOv0_KYIYEaZjrtumY8cyo7M.Q3VvbiBBbHBpbnVz',
-            $version1Encrypt('', $symmetricKey, 'Cuon Alpinus', $nonce),
-            'Test Vector 1E-2-3'
-        );
-
-        // Non-empty message, empty footer, empty nonce
-        $this->assertSame(
-            'v1.local.N9n3wL3RJUckyWdg4kABZeMwaAfzNT3B64lhyx7QA45LtwQCqG8LYmNfBHIX-4Uxfm8KzaYAUUHqkxxv17MFxsEvk-Ex67g9P-z7EBFW09xxSt21Xm1ELB6pxErl4RE1gGtgvAm9tl3rW2-oy6qHlYx2',
-            $version1Encrypt('Love is stronger than hate or fear', $nullKey, '', $nonce),
-            'Test Vector 1E-3-1'
-        );
-        $this->assertSame(
-            'v1.local.N9n3wL3RJUckyWdg4kABZeMwaAfzNT3B64lhyx7QA47lQ79wMmeM7sC4c0-BnsXzIteEQQBQpu_FyMznRnzYg4gN-6Kt50rXUxgPPfwDpOr3lUb5U16RzIGrMNemKy0gRhfKvAh1b8N57NKk93pZLpEz',
-            $version1Encrypt('Love is stronger than hate or fear', $fullKey, '', $nonce),
-            'Test Vector 1E-3-2'
-        );
-        $this->assertSame(
-            'v1.local.N9n3wL3RJUckyWdg4kABZeMwaAfzNT3B64lhyx7QA47hvAicYf1zfZrxPrLeBFdbEKO3JRQdn3gjqVEkR1aXXttscmmZ6t48tfuuudETldFD_xbqID74_TIDO1JxDy7OFgYI_PehxzcapQ8t040Fgj9k',
-            $version1Encrypt('Love is stronger than hate or fear', $symmetricKey, '', $nonce),
-            'Test Vector 1E-3-3'
-        );
-
-        // Non-empty message, non-empty footer, non-empty nonce
-        $this->assertSame(
-            'v1.local.rElw-WywOuwAqKC9Yao3YokSp7vx0YiUB9hLTnsVOYbivwqsESBnr82_ZoMFFGzolJ6kpkOihkulB4K_JhfMHoFw4E9yCR6ltWX3e9MTNSud8mpBzZiwNXNbgXBLxF_Igb5Ixo_feIonmCucOXDlLVUT.Q3VvbiBBbHBpbnVz',
-            $version1Encrypt('Love is stronger than hate or fear', $nullKey, 'Cuon Alpinus', $nonce2),
-            'Test Vector 1E-4-1'
-        );
-        $this->assertSame(
-            'v1.local.rElw-WywOuwAqKC9Yao3YokSp7vx0YiUB9hLTnsVOYZ8rQTA12SNb9cY8jVtVyikY2jj_tEBzY5O7GJsxb5MdQ6cMSnDz2uJGV20vhzVDgvkjdEcN9D44VaHid26qy1_1YlHjU6pmyTmJt8WT21LqzDl.Q3VvbiBBbHBpbnVz',
-            $version1Encrypt('Love is stronger than hate or fear', $fullKey, 'Cuon Alpinus', $nonce2),
-            'Test Vector 1E-4-2'
-        );
-        $this->assertSame(
-            'v1.local.rElw-WywOuwAqKC9Yao3YokSp7vx0YiUB9hLTnsVOYYTojmVaYumJSQt8aggtCaFKWyaodw5k-CUWhYKATopiabAl4OAmTxHCfm2E4NSPvrmMcmi8n-JcZ93HpcxC6rx_ps22vutv7iP7wf8QcSD1Mwx.Q3VvbiBBbHBpbnVz',
-            $version1Encrypt('Love is stronger than hate or fear', $symmetricKey, 'Cuon Alpinus', $nonce2),
-            'Test Vector 1E-4-3'
-        );
-
-        $message = \json_encode(['data' => 'this is a signed message', 'exp' => '2019-01-01T00:00:00+00:00']);
-        $footer = \json_encode(['kid' => 'UbkK8Y6iv4GZhFp6Tx3IWLWLfNXSEvJcdT3zdR65YZxo']);
-        $this->assertSame(
-            'v1.local.4VyfcVcFAOAbB8yEM1j1Ob7Iez5VZJy5kHNsQxmlrAwKUbOtq9cv39T2fC0MDWafX0nQJ4grFZzTdroMvU772RW-X1oTtoFBjsl_3YYHWnwgqzs0aFc3ejjORmKP4KUM339W3szA28OabR192eRqiyspQ6xPM35NMR-04-FhRJZEWiF0W5oWjPVtGPjeVjm2DI4YtJg.eyJraWQiOiJVYmtLOFk2aXY0R1poRnA2VHgzSVdMV0xmTlhTRXZKY2RUM3pkUjY1WVp4byJ9',
-            $version1Encrypt($message, $symmetricKey, $footer, $nonce2),
-            'Test Vector 1E-5-1'
-        );
-    }
-}
diff --git a/tests/Version2VectorTest.php b/tests/Version2VectorTest.php
deleted file mode 100644
index 3cda7d6a..00000000
--- a/tests/Version2VectorTest.php
+++ /dev/null
@@ -1,314 +0,0 @@
-<?php
-declare(strict_types=1);
-namespace ParagonIE\Paseto\Tests;
-
-use ParagonIE\ConstantTime\Hex;
-use ParagonIE\Paseto\Exception\PasetoException;
-use ParagonIE\Paseto\Keys\Version2\AsymmetricPublicKey;
-use ParagonIE\Paseto\Keys\Version2\AsymmetricSecretKey;
-use ParagonIE\Paseto\Keys\Version2\SymmetricKey;
-use ParagonIE\Paseto\Protocol\Version2;
-use PHPUnit\Framework\TestCase;
-
-/**
- * Class Version2VectorTest
- *
- * Contains test vectors for building compatible implementations in other languages.
- *
- * @package ParagonIE\Paseto\Tests
- */
-class Version2VectorTest extends TestCase
-{
-    /** @var SymmetricKey */
-    protected $fullKey;
-    /** @var SymmetricKey */
-
-    protected $nullKey;
-
-    /** @var AsymmetricSecretKey */
-    protected $privateKey;
-
-    /** @var AsymmetricSecretKey */
-    protected $publicKey;
-
-    /** @var SymmetricKey */
-    protected $symmetricKey;
-
-    private $beforeCalled = false;
-
-    /**
-     * This just sets up two asymmetric keys, generated once
-     * upon a time, to facilitate the standard test vectors.
-     *
-     * DO NOT USE THESE KEYS EVER FOR ANY PURPOSE OTHER THAN
-     * VERIFYING THE PROVIDED TEST VECTORS FOR VERSION 2.
-     *
-     * @throws \Error
-     * @throws \Exception
-     * @throws \TypeError
-     * @before
-     */
-    public function before()
-    {
-        if ($this->beforeCalled) {
-            return;
-        }
-        $this->beforeCalled = true;
-        $this->symmetricKey = new SymmetricKey(
-            Hex::decode(
-                '707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f'
-            )
-        );
-        $this->nullKey = new SymmetricKey(\str_repeat("\0", 32));
-        $this->fullKey = new SymmetricKey(\str_repeat("\xff", 32));
-
-        $this->privateKey = new AsymmetricSecretKey(
-            Hex::decode(
-                'b4cbfb43df4ce210727d953e4a713307fa19bb7d9f85041438d9e11b942a3774' .
-                '1eb9dbbbbc047c03fd70604e0071f0987e16b28b757225c11f00415d0e20b1a2'
-            ),
-            new Version2
-        );
-
-        $this->publicKey = new AsymmetricPublicKey(
-            Hex::decode(
-                '1eb9dbbbbc047c03fd70604e0071f0987e16b28b757225c11f00415d0e20b1a2'
-            ),
-            new Version2
-        );
-    }
-
-    /**
-     * @throws PasetoException
-     */
-    public function testOfficialVectors()
-    {
-        $nonce = str_repeat("\0", 24);
-        // $nonce2 = sodium_crypto_generichash('Paragon Initiative Enterprises, LLC', '', 24);
-        $nonce2 = Hex::decode('45742c976d684ff84ebdc0de59809a97cda2f64c84fda19b');
-
-        $version2Encrypt = NonceFixer::buildUnitTestEncrypt(new Version2)->bindTo(null, new Version2);
-
-        $footer = '';
-
-        $message = \json_encode(['data' => 'this is a signed message', 'exp' => '2019-01-01T00:00:00+00:00']);
-        $this->assertSame(
-            'v2.local.97TTOvgwIxNGvV80XKiGZg_kD3tsXM_-qB4dZGHOeN1cTkgQ4PnW8888l802W8d9AvEGnoNBY3BnqHORy8a5cC8aKpbA0En8XELw2yDk2f1sVODyfnDbi6rEGMY3pSfCbLWMM2oHJxvlEl2XbQ',
-            $version2Encrypt($message, $this->symmetricKey, $footer, $nonce),
-            'Test Vector 2-E-1'
-        );
-
-        $message = \json_encode(['data' => 'this is a secret message', 'exp' => '2019-01-01T00:00:00+00:00']);
-        $this->assertSame(
-            'v2.local.CH50H-HM5tzdK4kOmQ8KbIvrzJfjYUGuu5Vy9ARSFHy9owVDMYg3-8rwtJZQjN9ABHb2njzFkvpr5cOYuRyt7CRXnHt42L5yZ7siD-4l-FoNsC7J2OlvLlIwlG06mzQVunrFNb7Z3_CHM0PK5w',
-            $version2Encrypt($message, $this->symmetricKey, $footer, $nonce),
-            'Test Vector 2-E-2'
-        );
-        $message = \json_encode(['data' => 'this is a signed message', 'exp' => '2019-01-01T00:00:00+00:00']);
-        $this->assertSame(
-            'v2.local.5K4SCXNhItIhyNuVIZcwrdtaDKiyF81-eWHScuE0idiVqCo72bbjo07W05mqQkhLZdVbxEa5I_u5sgVk1QLkcWEcOSlLHwNpCkvmGGlbCdNExn6Qclw3qTKIIl5-O5xRBN076fSDPo5xUCPpBA',
-            $version2Encrypt($message, $this->symmetricKey, $footer, $nonce2),
-            'Test Vector 2-E-3'
-        );
-
-        $message = \json_encode(['data' => 'this is a secret message', 'exp' => '2019-01-01T00:00:00+00:00']);
-        $this->assertSame(
-            'v2.local.pvFdDeNtXxknVPsbBCZF6MGedVhPm40SneExdClOxa9HNR8wFv7cu1cB0B4WxDdT6oUc2toyLR6jA6sc-EUM5ll1EkeY47yYk6q8m1RCpqTIzUrIu3B6h232h62DPbIxtjGvNRAwsLK7LcV8oQ',
-            $version2Encrypt($message, $this->symmetricKey, $footer, $nonce2),
-            'Test Vector 2-E-4'
-        );
-
-        $footer = \json_encode(['kid' => 'zVhMiPBP9fRf2snEcT7gFTioeA9COcNy9DfgL1W60haN']);
-        $message = \json_encode(['data' => 'this is a signed message', 'exp' => '2019-01-01T00:00:00+00:00']);
-        $this->assertSame(
-            'v2.local.5K4SCXNhItIhyNuVIZcwrdtaDKiyF81-eWHScuE0idiVqCo72bbjo07W05mqQkhLZdVbxEa5I_u5sgVk1QLkcWEcOSlLHwNpCkvmGGlbCdNExn6Qclw3qTKIIl5-zSLIrxZqOLwcFLYbVK1SrQ.eyJraWQiOiJ6VmhNaVBCUDlmUmYyc25FY1Q3Z0ZUaW9lQTlDT2NOeTlEZmdMMVc2MGhhTiJ9',
-            $version2Encrypt($message, $this->symmetricKey, $footer, $nonce2),
-            'Test Vector 2-E-5'
-        );
-
-        $message = \json_encode(['data' => 'this is a secret message', 'exp' => '2019-01-01T00:00:00+00:00']);
-        $this->assertSame(
-            'v2.local.pvFdDeNtXxknVPsbBCZF6MGedVhPm40SneExdClOxa9HNR8wFv7cu1cB0B4WxDdT6oUc2toyLR6jA6sc-EUM5ll1EkeY47yYk6q8m1RCpqTIzUrIu3B6h232h62DnMXKdHn_Smp6L_NfaEnZ-A.eyJraWQiOiJ6VmhNaVBCUDlmUmYyc25FY1Q3Z0ZUaW9lQTlDT2NOeTlEZmdMMVc2MGhhTiJ9',
-            $version2Encrypt($message, $this->symmetricKey, $footer, $nonce2),
-            'Test Vector 2-E-6'
-        );
-
-        $footer = '';
-        $message = \json_encode(['data' => 'this is a signed message', 'exp' => '2019-01-01T00:00:00+00:00']);
-        $this->assertSame(
-            'v2.public.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAxOS0wMS0wMVQwMDowMDowMCswMDowMCJ9HQr8URrGntTu7Dz9J2IF23d1M7-9lH9xiqdGyJNvzp4angPW5Esc7C5huy_M8I8_DjJK2ZXC2SUYuOFM-Q_5Cw',
-            Version2::sign($message, $this->privateKey, $footer),
-            'Test Vector 2-S-1'
-        );
-
-        $footer = \json_encode(['kid' => 'zVhMiPBP9fRf2snEcT7gFTioeA9COcNy9DfgL1W60haN']);
-        $this->assertSame(
-            'v2.public.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAxOS0wMS0wMVQwMDowMDowMCswMDowMCJ9flsZsx_gYCR0N_Ec2QxJFFpvQAs7h9HtKwbVK2n1MJ3Rz-hwe8KUqjnd8FAnIJZ601tp7lGkguU63oGbomhoBw.eyJraWQiOiJ6VmhNaVBCUDlmUmYyc25FY1Q3Z0ZUaW9lQTlDT2NOeTlEZmdMMVc2MGhhTiJ9',
-            Version2::sign($message, $this->privateKey, $footer),
-            'Test Vector 2-S-2'
-        );
-
-    }
-
-
-    /**
-     * @throws \TypeError
-     */
-    public function testEncrypt()
-    {
-        $nonce = str_repeat("\0", 24);
-        // $nonce2 = sodium_crypto_generichash('Paragon Initiative Enterprises, LLC', '', 24);
-        $nonce2 = Hex::decode('45742c976d684ff84ebdc0de59809a97cda2f64c84fda19b');
-
-        $version2Encrypt = NonceFixer::buildUnitTestEncrypt(new Version2)->bindTo(null, new Version2);
-
-        // Empty message, empty footer, empty nonce
-        $this->assertSame(
-            'v2.local.driRNhM20GQPvlWfJCepzh6HdijAq-yNUtKpdy5KXjKfpSKrOlqQvQ',
-            $version2Encrypt('', $this->nullKey, '', $nonce),
-            'Test Vector 2E-1-1'
-        );
-        $this->assertSame(
-            'v2.local.driRNhM20GQPvlWfJCepzh6HdijAq-yNSOvpveyCsjPYfe9mtiJDVg',
-            $version2Encrypt('', $this->fullKey, '', $nonce),
-            'Test Vector 2E-1-2'
-        );
-        $this->assertSame(
-            'v2.local.driRNhM20GQPvlWfJCepzh6HdijAq-yNkIWACdHuLiJiW16f2GuGYA',
-            $version2Encrypt('', $this->symmetricKey, '', $nonce),
-            'Test Vector 2E-1-3'
-        );
-
-        // Empty message, non-empty footer, empty nonce
-        $this->assertSame(
-            'v2.local.driRNhM20GQPvlWfJCepzh6HdijAq-yNfzz6yGkE4ZxojJAJwKLfvg.Q3VvbiBBbHBpbnVz',
-            $version2Encrypt('', $this->nullKey, 'Cuon Alpinus', $nonce),
-            'Test Vector 2E-2-1'
-        );
-        $this->assertSame(
-            'v2.local.driRNhM20GQPvlWfJCepzh6HdijAq-yNJbTJxAGtEg4ZMXY9g2LSoQ.Q3VvbiBBbHBpbnVz',
-            $version2Encrypt('', $this->fullKey, 'Cuon Alpinus', $nonce),
-            'Test Vector 2E-2-2'
-        );
-        $this->assertSame(
-            'v2.local.driRNhM20GQPvlWfJCepzh6HdijAq-yNreCcZAS0iGVlzdHjTf2ilg.Q3VvbiBBbHBpbnVz',
-            $version2Encrypt('', $this->symmetricKey, 'Cuon Alpinus', $nonce),
-            'Test Vector 2E-2-3'
-        );
-
-        // Non-empty message, empty footer, empty nonce
-        $this->assertSame(
-            'v2.local.BEsKs5AolRYDb_O-bO-lwHWUextpShFSvu6cB-KuR4wR9uDMjd45cPiOF0zxb7rrtOB5tRcS7dWsFwY4ONEuL5sWeunqHC9jxU0',
-            $version2Encrypt('Love is stronger than hate or fear', $this->nullKey, '', $nonce),
-            'Test Vector 2E-3-1'
-        );
-        $this->assertSame(
-            'v2.local.BEsKs5AolRYDb_O-bO-lwHWUextpShFSjvSia2-chHyMi4LtHA8yFr1V7iZmKBWqzg5geEyNAAaD6xSEfxoET1xXqahe1jqmmPw',
-            $version2Encrypt('Love is stronger than hate or fear', $this->fullKey, '', $nonce),
-            'Test Vector 2E-3-2'
-        );
-        $this->assertSame(
-            'v2.local.BEsKs5AolRYDb_O-bO-lwHWUextpShFSXlvv8MsrNZs3vTSnGQG4qRM9ezDl880jFwknSA6JARj2qKhDHnlSHx1GSCizfcF019U',
-            $version2Encrypt('Love is stronger than hate or fear', $this->symmetricKey, '', $nonce),
-            'Test Vector 2E-3-3'
-        );
-
-        // Non-empty message, non-empty footer, non-empty nonce
-        $this->assertSame(
-            'v2.local.FGVEQLywggpvH0AzKtLXz0QRmGYuC6yvbcqXgWxM3vJGrJ9kWqquP61Xl7bz4ZEqN5XwH7xyzV0QqPIo0k52q5sWxUQ4LMBFFso.Q3VvbiBBbHBpbnVz',
-            $version2Encrypt('Love is stronger than hate or fear', $this->nullKey, 'Cuon Alpinus', $nonce2),
-            'Test Vector 2E-4-1'
-        );
-        $this->assertSame(
-            'v2.local.FGVEQLywggpvH0AzKtLXz0QRmGYuC6yvZMW3MgUMFplQXsxcNlg2RX8LzFxAqj4qa2FwgrUdH4vYAXtCFrlGiLnk-cHHOWSUSaw.Q3VvbiBBbHBpbnVz',
-            $version2Encrypt('Love is stronger than hate or fear', $this->fullKey, 'Cuon Alpinus', $nonce2),
-            'Test Vector 2E-4-2'
-        );
-        $this->assertSame(
-            'v2.local.FGVEQLywggpvH0AzKtLXz0QRmGYuC6yvl05z9GIX0cnol6UK94cfV77AXnShlUcNgpDR12FrQiurS8jxBRmvoIKmeMWC5wY9Y6w.Q3VvbiBBbHBpbnVz',
-            $version2Encrypt('Love is stronger than hate or fear', $this->symmetricKey, 'Cuon Alpinus', $nonce2),
-            'Test Vector 2E-4-3'
-        );
-
-        $message = \json_encode(['data' => 'this is a signed message', 'exp' => '2019-01-01T00:00:00+00:00']);
-        $footer = 'Paragon Initiative Enterprises';
-        $this->assertSame(
-            'v2.local.5K4SCXNhItIhyNuVIZcwrdtaDKiyF81-eWHScuE0idiVqCo72bbjo07W05mqQkhLZdVbxEa5I_u5sgVk1QLkcWEcOSlLHwNpCkvmGGlbCdNExn6Qclw3qTKIIl5-zKeei_8CY0oUMtEai3HYcQ.UGFyYWdvbiBJbml0aWF0aXZlIEVudGVycHJpc2Vz',
-            $version2Encrypt($message, $this->symmetricKey, $footer, $nonce2),
-            'Test Vector 2E-5'
-        );
-
-        $message = \json_encode(['data' => 'this is a signed message', 'exp' => '2019-01-01T00:00:00+00:00']);
-        $footer = \json_encode(['kid' => 'zVhMiPBP9fRf2snEcT7gFTioeA9COcNy9DfgL1W60haN']);
-        $this->assertSame(
-            'v2.local.5K4SCXNhItIhyNuVIZcwrdtaDKiyF81-eWHScuE0idiVqCo72bbjo07W05mqQkhLZdVbxEa5I_u5sgVk1QLkcWEcOSlLHwNpCkvmGGlbCdNExn6Qclw3qTKIIl5-zSLIrxZqOLwcFLYbVK1SrQ.eyJraWQiOiJ6VmhNaVBCUDlmUmYyc25FY1Q3Z0ZUaW9lQTlDT2NOeTlEZmdMMVc2MGhhTiJ9',
-            $version2Encrypt($message, $this->symmetricKey, $footer, $nonce2),
-            'Test Vector 2E-6'
-        );
-    }
-
-    /**
-     * @covers Version2::sign()
-     *
-     * @throws \TypeError
-     * @throws PasetoException
-     */
-    public function testSignVectors()
-    {
-        // Empty string, 32-character NUL byte key.
-        $this->assertSame(
-            'v2.public.xnHHprS7sEyjP5vWpOvHjAP2f0HER7SWfPuehZ8QIctJRPTrlZLtRCk9_iNdugsrqJoGaO4k9cDBq3TOXu24AA',
-            Version2::sign('', $this->privateKey),
-            'Test Vector S-1'
-        );
-
-        // Empty string, 32-character NUL byte key, non-empty footer.
-        $this->assertSame(
-            'v2.public.Qf-w0RdU2SDGW_awMwbfC0Alf_nd3ibUdY3HigzU7tn_4MPMYIKAJk_J_yKYltxrGlxEdrWIqyfjW81njtRyDw.Q3VvbiBBbHBpbnVz',
-            Version2::sign('', $this->privateKey, 'Cuon Alpinus'),
-            'Test Vector S-2'
-        );
-
-        // Non-empty string, 32-character 0xFF byte key.
-        $this->assertSame(
-            'v2.public.RnJhbmsgRGVuaXMgcm9ja3NBeHgns4TLYAoyD1OPHww0qfxHdTdzkKcyaE4_fBF2WuY1JNRW_yI8qRhZmNTaO19zRhki6YWRaKKlCZNCNrQM',
-            Version2::sign('Frank Denis rocks', $this->privateKey),
-            'Test Vector S-3'
-        );
-
-        // Non-empty string, 32-character 0xFF byte key. (One character difference)
-        $this->assertSame(
-            'v2.public.RnJhbmsgRGVuaXMgcm9ja3qIOKf8zCok6-B5cmV3NmGJCD6y3J8fmbFY9KHau6-e9qUICrGlWX8zLo-EqzBFIT36WovQvbQZq4j6DcVfKCML',
-            Version2::sign('Frank Denis rockz', $this->privateKey),
-            'Test Vector S-4'
-        );
-
-        // Non-empty string, 32-character 0xFF byte key, non-empty footer.
-        $this->assertSame(
-            'v2.public.RnJhbmsgRGVuaXMgcm9ja3O7MPuu90WKNyvBUUhAGFmi4PiPOr2bN2ytUSU-QWlj8eNefki2MubssfN1b8figynnY0WusRPwIQ-o0HSZOS0F.Q3VvbiBBbHBpbnVz',
-            Version2::sign('Frank Denis rocks', $this->privateKey, 'Cuon Alpinus'),
-            'Test Vector S-5'
-        );
-
-        $message = \json_encode(['data' => 'this is a signed message', 'exp' => '2019-01-01T00:00:00+00:00']);
-        $footer = 'Paragon Initiative Enterprises';
-        $this->assertSame(
-            'v2.public.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAxOS0wMS0wMVQwMDowMDowMCswMDowMCJ9HQr8URrGntTu7Dz9J2IF23d1M7-9lH9xiqdGyJNvzp4angPW5Esc7C5huy_M8I8_DjJK2ZXC2SUYuOFM-Q_5Cw',
-            Version2::sign($message, $this->privateKey),
-            'Test Vector S-6'
-        );
-        $this->assertSame(
-            'v2.public.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAxOS0wMS0wMVQwMDowMDowMCswMDowMCJ9fgvV_frkjyH7h0CWrGfonEctefgzQaCkICOAxDdbixbPvH_SMm0T6343YfgEAlOi8--euLS5gLlykHhREL38BA.UGFyYWdvbiBJbml0aWF0aXZlIEVudGVycHJpc2Vz',
-            Version2::sign($message, $this->privateKey, $footer),
-            'Test Vector S-7'
-        );
-
-        $message = \json_encode(['data' => 'this is a signed message', 'exp' => '2019-01-01T00:00:00+00:00']);
-        $footer = \json_encode(['kid' => 'zVhMiPBP9fRf2snEcT7gFTioeA9COcNy9DfgL1W60haN']);
-        $this->assertSame(
-            'v2.public.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAxOS0wMS0wMVQwMDowMDowMCswMDowMCJ9flsZsx_gYCR0N_Ec2QxJFFpvQAs7h9HtKwbVK2n1MJ3Rz-hwe8KUqjnd8FAnIJZ601tp7lGkguU63oGbomhoBw.eyJraWQiOiJ6VmhNaVBCUDlmUmYyc25FY1Q3Z0ZUaW9lQTlDT2NOeTlEZmdMMVc2MGhhTiJ9',
-            Version2::sign($message, $this->privateKey, $footer),
-            'Test Vector S-8'
-        );
-    }
-}
diff --git a/tests/Version3Test.php b/tests/Version3Test.php
new file mode 100644
index 00000000..db9023c8
--- /dev/null
+++ b/tests/Version3Test.php
@@ -0,0 +1,154 @@
+<?php
+namespace ParagonIE\Paseto\Tests;
+
+use ParagonIE\ConstantTime\Binary;
+use ParagonIE\Paseto\Exception\InvalidVersionException;
+use ParagonIE\Paseto\Exception\PasetoException;
+use ParagonIE\Paseto\Keys\AsymmetricSecretKey;
+use ParagonIE\Paseto\Keys\Version3\SymmetricKey;
+use ParagonIE\Paseto\Protocol\{
+    Version2,
+    Version3
+};
+use PHPUnit\Framework\TestCase;
+
+class Version3Test extends TestCase
+{
+    use TestTrait;
+
+    /**
+     * @throws \Exception
+     * @throws \TypeError
+     */
+    public function testKeyGen()
+    {
+        $symmetric = Version3::generateSymmetricKey();
+        $secret = Version3::generateAsymmetricSecretKey();
+
+        $this->assertInstanceOf('ParagonIE\Paseto\Keys\SymmetricKey', $symmetric);
+        $this->assertInstanceOf('ParagonIE\Paseto\Keys\AsymmetricSecretKey', $secret);
+        $this->assertSame(Version3::getSymmetricKeyByteLength(), Binary::safeStrlen($symmetric->raw()));
+        $this->assertGreaterThanOrEqual(48, Binary::safeStrlen($secret->raw())); // PEM encoded
+    }
+
+    /**
+     * @covers Version3::decrypt()
+     * @covers Version3::encrypt()
+     *
+     * @throws \Error
+     * @throws \Exception
+     * @throws \SodiumException
+     * @throws \TypeError
+     */
+    public function testEncrypt()
+    {
+        $key = new SymmetricKey(random_bytes(32));
+        $year = (int) (\date('Y')) + 1;
+        $messages = [
+            'test',
+            \json_encode(['data' => 'this is a signed message', 'exp' => $year . '-01-01T00:00:00'])
+        ];
+
+
+        foreach ($messages as $message) {
+            $encrypted = Version3::encrypt($message, $key);
+            $this->assertIsStringType( $encrypted);
+            $this->assertSame('v3.local.', Binary::safeSubstr($encrypted, 0, 9));
+
+            $decode = Version3::decrypt($encrypted, $key);
+            $this->assertIsStringType( $decode);
+            $this->assertSame($message, $decode);
+
+            // Now with a footer
+            try {
+                Version3::decrypt($message, $key);
+                $this->fail('Not a token');
+            } catch (PasetoException $ex) {
+            }
+            try {
+                Version3::decrypt($encrypted, $key, 'footer');
+                $this->fail('Footer did not cause expected MAC failure.');
+            } catch (PasetoException $ex) {
+            }
+            $encrypted = Version3::encrypt($message, $key, 'footer');
+            $this->assertIsStringType( $encrypted);
+            $this->assertSame('v3.local.', Binary::safeSubstr($encrypted, 0, 9));
+
+            $decode = Version3::decrypt($encrypted, $key, 'footer');
+            $this->assertIsStringType( $decode);
+            $this->assertSame($message, $decode);
+            try {
+                Version3::decrypt($encrypted, $key, '');
+                $this->fail('Missing footer');
+            } catch (PasetoException $ex) {
+            }
+        }
+
+        try {
+            Version2::encrypt('test', $key);
+            $this->fail('Invalid version accepted');
+        } catch (InvalidVersionException $ex) {
+        }
+        $encrypted = Version3::encrypt('test', $key);
+        try {
+            Version2::decrypt($encrypted, $key);
+            $this->fail('Invalid version accepted');
+        } catch (InvalidVersionException $ex) {
+        }
+    }
+
+    /**
+     * @covers Version3::sign()
+     * @covers Version3::verify()
+     *
+     * @throws InvalidVersionException
+     * @throws \Exception
+     * @throws \TypeError
+     */
+    public function testSign()
+    {
+        $privateKey = AsymmetricSecretKey::generate(new Version3());
+        $publicKey = $privateKey->getPublicKey();
+
+        $year = (int) (\date('Y')) + 1;
+        $messages = [
+            'test',
+            \json_encode(['data' => 'this is a signed message', 'exp' => $year . '-01-01T00:00:00'])
+        ];
+
+        foreach ($messages as $message) {
+            $signed = Version3::sign($message, $privateKey);
+            $this->assertIsStringType($signed);
+            $this->assertSame('v3.public.', Binary::safeSubstr($signed, 0, 10));
+
+            $decode = Version3::verify($signed, $publicKey);
+            $this->assertIsStringType($decode);
+            $this->assertSame($message, $decode);
+
+            // Now with a footer
+            $signedWithFooter = Version3::sign($message, $privateKey, 'footer');
+            $this->assertIsStringType($signedWithFooter);
+            $this->assertSame('v3.public.', Binary::safeSubstr($signedWithFooter, 0, 10));
+            try {
+                Version3::verify($signedWithFooter, $publicKey, '');
+                $this->fail('Missing footer');
+            } catch (PasetoException $ex) {
+            }
+            $decode = Version3::verify($signedWithFooter, $publicKey, 'footer');
+            $this->assertIsStringType($decode);
+            $this->assertSame($message, $decode);
+        }
+
+        try {
+            Version2::sign('test', $privateKey);
+            $this->fail('Invalid version accepted');
+        } catch (InvalidVersionException $ex) {
+        }
+        $signed = Version3::sign('test', $privateKey);
+        try {
+            Version2::verify($signed, $publicKey);
+            $this->fail('Invalid version accepted');
+        } catch (InvalidVersionException $ex) {
+        }
+    }
+}
diff --git a/tests/Version4Test.php b/tests/Version4Test.php
new file mode 100644
index 00000000..b536f91a
--- /dev/null
+++ b/tests/Version4Test.php
@@ -0,0 +1,153 @@
+<?php
+namespace ParagonIE\Paseto\Tests;
+
+use ParagonIE\ConstantTime\Binary;
+use ParagonIE\Paseto\Exception\InvalidVersionException;
+use ParagonIE\Paseto\Exception\PasetoException;
+use ParagonIE\Paseto\Keys\Version4\SymmetricKey;
+use ParagonIE\Paseto\Protocol\{
+    Version3,
+    Version4
+};
+use PHPUnit\Framework\TestCase;
+
+class Version4Test extends TestCase
+{
+    use TestTrait;
+
+    /**
+     * @throws \Exception
+     * @throws \TypeError
+     */
+    public function testKeyGen()
+    {
+        $symmetric = Version4::generateSymmetricKey();
+        $secret = Version4::generateAsymmetricSecretKey();
+
+        $this->assertInstanceOf('ParagonIE\Paseto\Keys\SymmetricKey', $symmetric);
+        $this->assertInstanceOf('ParagonIE\Paseto\Keys\AsymmetricSecretKey', $secret);
+        $this->assertSame(Version4::getSymmetricKeyByteLength(), Binary::safeStrlen($symmetric->raw()));
+        $this->assertGreaterThanOrEqual(48, Binary::safeStrlen($secret->raw())); // PEM encoded
+    }
+
+    /**
+     * @covers Version4::decrypt()
+     * @covers Version4::encrypt()
+     *
+     * @throws \Error
+     * @throws \Exception
+     * @throws \SodiumException
+     * @throws \TypeError
+     */
+    public function testEncrypt()
+    {
+        $key = new SymmetricKey(random_bytes(32));
+        $year = (int) (\date('Y')) + 1;
+        $messages = [
+            'test',
+            \json_encode(['data' => 'this is a signed message', 'exp' => $year . '-01-01T00:00:00'])
+        ];
+
+
+        foreach ($messages as $message) {
+            $encrypted = Version4::encrypt($message, $key);
+            $this->assertIsStringType( $encrypted);
+            $this->assertSame('v4.local.', Binary::safeSubstr($encrypted, 0, 9));
+
+            $decode = Version4::decrypt($encrypted, $key);
+            $this->assertIsStringType( $decode);
+            $this->assertSame($message, $decode);
+
+            // Now with a footer
+            try {
+                Version4::decrypt($message, $key);
+                $this->fail('Not a token');
+            } catch (PasetoException $ex) {
+            }
+            try {
+                Version4::decrypt($encrypted, $key, 'footer');
+                $this->fail('Footer did not cause expected MAC failure.');
+            } catch (PasetoException $ex) {
+            }
+            $encrypted = Version4::encrypt($message, $key, 'footer');
+            $this->assertIsStringType( $encrypted);
+            $this->assertSame('v4.local.', Binary::safeSubstr($encrypted, 0, 9));
+
+            $decode = Version4::decrypt($encrypted, $key, 'footer');
+            $this->assertIsStringType( $decode);
+            $this->assertSame($message, $decode);
+            try {
+                Version4::decrypt($encrypted, $key, '');
+                $this->fail('Missing footer');
+            } catch (PasetoException $ex) {
+            }
+        }
+
+        try {
+            Version3::encrypt('test', $key);
+            $this->fail('Invalid version accepted');
+        } catch (InvalidVersionException $ex) {
+        }
+        $encrypted = Version4::encrypt('test', $key);
+        try {
+            Version3::decrypt($encrypted, $key);
+            $this->fail('Invalid version accepted');
+        } catch (InvalidVersionException $ex) {
+        }
+    }
+
+    /**
+     * @covers Version4::sign()
+     * @covers Version4::verify()
+     *
+     * @throws InvalidVersionException
+     * @throws \Exception
+     * @throws \TypeError
+     */
+    public function testSign()
+    {
+        $privateKey = Version4::generateAsymmetricSecretKey();
+        $publicKey = $privateKey->getPublicKey();
+
+        $year = (int) (\date('Y')) + 1;
+        $messages = [
+            'test',
+            \json_encode(['data' => 'this is a signed message', 'exp' => $year . '-01-01T00:00:00'])
+        ];
+
+        foreach ($messages as $message) {
+            $signed = Version4::sign($message, $privateKey);
+            $this->assertIsStringType( $signed);
+            $this->assertSame('v4.public.', Binary::safeSubstr($signed, 0, 10));
+
+            $decode = Version4::verify($signed, $publicKey);
+            $this->assertIsStringType( $decode);
+            $this->assertSame($message, $decode);
+
+            // Now with a footer
+            $signed = Version4::sign($message, $privateKey, 'footer');
+            $this->assertIsStringType( $signed);
+            $this->assertSame('v4.public.', Binary::safeSubstr($signed, 0, 10));
+            try {
+                Version4::verify($signed, $publicKey, '');
+                $this->fail('Missing footer');
+            } catch (PasetoException $ex) {
+            }
+            $decode = Version4::verify($signed, $publicKey, 'footer');
+            $this->assertIsStringType( $decode);
+            $this->assertSame($message, $decode);
+        }
+
+        try {
+            Version3::sign('test', $privateKey);
+            $this->fail('Invalid version accepted');
+        } catch (InvalidVersionException $ex) {
+        }
+        $signed = Version4::sign('test', $privateKey);
+        try {
+            Version3::verify($signed, $publicKey);
+            $this->fail('Invalid version accepted');
+        } catch (InvalidVersionException $ex) {
+        }
+    }
+}