├── .gitignore
├── .travis.yml
├── phpunit.xml.dist
├── src
├── Exception
│ ├── PastException.php
│ ├── RuleViolation.php
│ ├── NotFoundException.php
│ ├── SecurityException.php
│ ├── EncodingException.php
│ ├── InvalidKeyException.php
│ ├── InvalidPurposeException.php
│ └── InvalidVersionException.php
├── KeyInterface.php
├── ValidationRuleInterface.php
├── Traits
│ └── RegisteredClaims.php
├── Rules
│ ├── IdentifiedBy.php
│ ├── NotExpired.php
│ ├── IssuedBy.php
│ ├── Subject.php
│ ├── ForAudience.php
│ └── ValidAt.php
├── Keys
│ ├── SymmetricAuthenticationKey.php
│ ├── AsymmetricPublicKey.php
│ ├── SymmetricEncryptionKey.php
│ └── AsymmetricSecretKey.php
├── ProtocolInterface.php
├── Util.php
├── Protocol
│ ├── Version2.php
│ └── Version1.php
├── Parser.php
└── JsonToken.php
├── psalm.xml
├── LICENSE
├── composer.json
├── docs
├── README.md
├── 01-Protocol-Versions
│ ├── Common.md
│ └── README.md
└── 02-PHP-Library
│ └── README.md
├── tests
├── Version1VectorTest.php
├── ParserTest.php
├── JsonTokenTest.php
├── UtilTest.php
├── Version2VectorTest.php
├── Version1Test.php
└── Version2Test.php
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | /.idea
2 | /composer.lock
3 | /vendor
4 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: php
2 |
3 | php:
4 | - 7.0
5 | - 7.1
6 | - 7.2
7 |
8 | before_install:
9 | - composer update
10 |
11 | script:
12 | - composer full-test
13 |
--------------------------------------------------------------------------------
/phpunit.xml.dist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | tests
5 |
6 |
7 |
--------------------------------------------------------------------------------
/src/Exception/PastException.php:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/src/Exception/InvalidPurposeException.php:
--------------------------------------------------------------------------------
1 |
13 | *
14 | * Adopted from JWT for usability
15 | */
16 | public $registeredClaims = [
17 | 'iss' => 'Issuer',
18 | 'sub' => 'Subject',
19 | 'aud' => 'Audience',
20 | 'exp' => 'Expiration',
21 | 'nbf' => 'Not Before',
22 | 'iat' => 'Issued At',
23 | 'jti' => 'Token Identifier'
24 | ];
25 | }
26 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | /*
2 | * ISC License
3 | *
4 | * Copyright (c) 2018
5 | * Paragon Initiative Enterprises
6 | *
7 | * Copyright (c) 2013-2018
8 | * Frank Denis
9 | *
10 | * Permission to use, copy, modify, and/or distribute this software for any
11 | * purpose with or without fee is hereby granted, provided that the above
12 | * copyright notice and this permission notice appear in all copies.
13 | *
14 | * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
15 | * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
16 | * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
17 | * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
18 | * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
19 | * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
20 | * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
21 | */
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "paragonie/past",
3 | "description": "Platform-Agnostic Security Tokens",
4 | "license": "ISC",
5 | "type": "library",
6 | "authors": [
7 | {
8 | "name": "Paragon Initiative Enterprises",
9 | "email": "security@paragonie.com"
10 | }
11 | ],
12 | "autoload": {
13 | "psr-4": {
14 | "ParagonIE\\PAST\\": "src/"
15 | }
16 | },
17 | "autoload-dev": {
18 | "psr-4": {
19 | "ParagonIE\\PAST\\Tests\\": "tests/"
20 | }
21 | },
22 | "require": {
23 | "php": "^7",
24 | "paragonie/constant_time_encoding": "^2",
25 | "paragonie/sodium_compat": "^1.5",
26 | "phpseclib/phpseclib": "^2"
27 | },
28 | "require-dev": {
29 | "phpunit/phpunit": "^6|^7",
30 | "vimeo/psalm": "^0.3"
31 | },
32 | "scripts": {
33 | "full-test": [
34 | "@static-analysis",
35 | "@test"
36 | ],
37 | "static-analysis": "psalm",
38 | "test": "phpunit"
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/Rules/IdentifiedBy.php:
--------------------------------------------------------------------------------
1 | id = $id;
30 | }
31 |
32 | /**
33 | * @return string
34 | */
35 | public function getFailureMessage(): string
36 | {
37 | return $this->failure;
38 | }
39 |
40 | /**
41 | * @param JsonToken $token
42 | * @return bool
43 | */
44 | public function isValid(JsonToken $token): bool
45 | {
46 | try {
47 | $jti = $token->getJti();
48 | if (!\hash_equals($this->id, $jti)) {
49 | $this->failure = 'This token was expected to be identified by ' .
50 | $this->id . ', but it was identified by ' .
51 | $jti .' instead.';
52 | return false;
53 | }
54 | } catch (PastException $ex) {
55 | $this->failure = $ex->getMessage();
56 | return false;
57 | }
58 | return true;
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/Rules/NotExpired.php:
--------------------------------------------------------------------------------
1 | now = $now;
33 | }
34 | /**
35 | * @return string
36 | */
37 | public function getFailureMessage(): string
38 | {
39 | return $this->failure;
40 | }
41 |
42 | /**
43 | * @param JsonToken $token
44 | * @return bool
45 | */
46 | public function isValid(JsonToken $token): bool
47 | {
48 | try {
49 | $expires = $token->getExpiration();
50 | if ($expires < $this->now) {
51 | $this->failure = 'This token has expired.';
52 | return false;
53 | }
54 | } catch (PastException $ex) {
55 | $this->failure = $ex->getMessage();
56 | return false;
57 | }
58 | return true;
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/Rules/IssuedBy.php:
--------------------------------------------------------------------------------
1 | issuer = $issuer;
30 | }
31 |
32 | /**
33 | * @return string
34 | */
35 | public function getFailureMessage(): string
36 | {
37 | return $this->failure;
38 | }
39 |
40 | /**
41 | * @param JsonToken $token
42 | * @return bool
43 | */
44 | public function isValid(JsonToken $token): bool
45 | {
46 | try {
47 | $issuedBy = $token->getIssuer();
48 | if (!\hash_equals($this->issuer, $issuedBy)) {
49 | $this->failure = 'This token was not issued by ' .
50 | $this->issuer . ' (expected); it was issued by ' .
51 | $issuedBy . ' instead.';
52 | return false;
53 | }
54 | } catch (PastException $ex) {
55 | $this->failure = $ex->getMessage();
56 | return false;
57 | }
58 | return true;
59 | }
60 | }
--------------------------------------------------------------------------------
/src/Rules/Subject.php:
--------------------------------------------------------------------------------
1 | subject = $subject;
30 | }
31 |
32 | /**
33 | * @return string
34 | */
35 | public function getFailureMessage(): string
36 | {
37 | return $this->failure;
38 | }
39 |
40 | /**
41 | * @param JsonToken $token
42 | * @return bool
43 | */
44 | public function isValid(JsonToken $token): bool
45 | {
46 | try {
47 | $subject = $token->getSubject();
48 | if (!\hash_equals($this->subject, $subject)) {
49 | $this->failure = 'This token was not related to ' .
50 | $this->subject . ' (expected); its subject is ' .
51 | $subject . ' instead.';
52 | return false;
53 | }
54 | } catch (PastException $ex) {
55 | $this->failure = $ex->getMessage();
56 | return false;
57 | }
58 | return true;
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/Rules/ForAudience.php:
--------------------------------------------------------------------------------
1 | audience = $audience;
30 | }
31 |
32 | /**
33 | * @return string
34 | */
35 | public function getFailureMessage(): string
36 | {
37 | return $this->failure;
38 | }
39 |
40 | /**
41 | * @param JsonToken $token
42 | * @return bool
43 | */
44 | public function isValid(JsonToken $token): bool
45 | {
46 | try {
47 | $audience = $token->getAudience();
48 | if (!\hash_equals($this->audience, $audience)) {
49 | $this->failure = 'This token is not intended for ' .
50 | $this->audience . ' (expected); instead, it is intended for ' .
51 | $audience . ' instead.';
52 | return false;
53 | }
54 | } catch (PastException $ex) {
55 | $this->failure = $ex->getMessage();
56 | return false;
57 | }
58 | return true;
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/Keys/SymmetricAuthenticationKey.php:
--------------------------------------------------------------------------------
1 | key = $keyMaterial;
29 | $this->protocol = $protocol;
30 | }
31 |
32 | /**
33 | * @return string
34 | */
35 | public function encode(): string
36 | {
37 | return Base64UrlSafe::encode($this->key);
38 | }
39 |
40 | /**
41 | * @param string $encoded
42 | * @return self
43 | */
44 | public static function fromEncodedString(string $encoded): self
45 | {
46 | $decoded = Base64UrlSafe::decode($encoded);
47 | return new self($decoded);
48 | }
49 |
50 | /**
51 | * @return string
52 | */
53 | public function getProtocol(): string
54 | {
55 | return $this->protocol;
56 | }
57 |
58 | /**
59 | * @return string
60 | */
61 | public function raw()
62 | {
63 | return $this->key;
64 | }
65 |
66 | /**
67 | * @return array
68 | */
69 | public function __debugInfo()
70 | {
71 | return [];
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/src/Rules/ValidAt.php:
--------------------------------------------------------------------------------
1 | now = $now;
33 | }
34 |
35 | /**
36 | * @return string
37 | */
38 | public function getFailureMessage(): string
39 | {
40 | return $this->failure;
41 | }
42 |
43 | /**
44 | * @param JsonToken $token
45 | * @return bool
46 | */
47 | public function isValid(JsonToken $token): bool
48 | {
49 | try {
50 | $issuedAt = $token->getIssuedAt();
51 | if ($issuedAt > $this->now) {
52 | $this->failure = 'This token was issued in the future.';
53 | return false;
54 | }
55 | $notBefore = $token->getNotBefore();
56 | if ($notBefore > $this->now) {
57 | $this->failure = 'This token cannot be used yet.';
58 | return false;
59 | }
60 | $expires = $token->getExpiration();
61 | if ($expires < $this->now) {
62 | $this->failure = 'This token has expired.';
63 | return false;
64 | }
65 | } catch (PastException $ex) {
66 | $this->failure = $ex->getMessage();
67 | return false;
68 | }
69 | return true;
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/src/Keys/AsymmetricPublicKey.php:
--------------------------------------------------------------------------------
1 | key = $keyMaterial;
37 | $this->protocol = $protocol;
38 | }
39 |
40 | /**
41 | * @return string
42 | */
43 | public function encode(): string
44 | {
45 | return Base64UrlSafe::encode($this->key);
46 | }
47 |
48 | /**
49 | * @param string $encoded
50 | * @return self
51 | */
52 | public static function fromEncodedString(string $encoded): self
53 | {
54 | $decoded = Base64UrlSafe::decode($encoded);
55 | return new self($decoded);
56 | }
57 |
58 | /**
59 | * @return string
60 | */
61 | public function getProtocol(): string
62 | {
63 | return $this->protocol;
64 | }
65 |
66 | /**
67 | * @return string
68 | */
69 | public function raw()
70 | {
71 | return $this->key;
72 | }
73 |
74 | /**
75 | * @return array
76 | */
77 | public function __debugInfo()
78 | {
79 | return [];
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/docs/README.md:
--------------------------------------------------------------------------------
1 | # Implementation Details
2 |
3 | ## PAST Message Format:
4 |
5 | ### Without the Optional Footer
6 |
7 | ```
8 | version.purpose.payload
9 | version.purpose.one-time-key.ciphertext (sealing only)
10 | ```
11 |
12 | The `version` is a string that represents the current version of the protocol. Currently,
13 | two versions are specified, which each possess their own ciphersuites. Accepted values:
14 | `v1`, `v2`.
15 |
16 | The `purpose` is a short string describing the purpose of the token. Accepted values:
17 | `enc`, `auth`, `sign`, `seal`.
18 |
19 | If the `purpose` is set to `seal`, then the payload is preceded by a `one-time-key` value,
20 | which was generated by the sender and is needed to successfully decrypt the token. This
21 | is not present for other `purpose`s than `seal`, and is either an ephemeral (one-time)
22 | Diffie-Hellman public key, or a random key encrypted with the recipient's long-term public
23 | key.
24 |
25 | The `payload` is a base64url-encoded string that contains the data that is secured. It may be
26 | encrypted. It may use public-key cryptography. It MUST be authenticated or signed. Encrypting
27 | a message using PAST implicitly authenticates it.
28 |
29 | ### With an Optional Footer
30 |
31 | ```
32 | version.purpose.payload.optional
33 | version.purpose.one-time-key.ciphertext.optional (sealing only)
34 | ```
35 |
36 | Any `optional` data can be appended to the end. This information is public (unencrypted), even
37 | if the payload is encrypted. However, it is always authenticated. It's always base64url-encoded.
38 |
39 | * For encrypted tokens, it's included in the associated data alongside the nonce.
40 | * For authenticated/signed tokens, it's appended to the message during the actual
41 | authentication/signing step.
42 |
43 | ## Versions and their Respective Purposes
44 |
45 | See [Protocol Versions](01-Protocol-Versions) for specifics.
46 |
47 | ## How should we pronounce PAST?
48 |
49 | Like the English word "pasta" without the final "a". It rhymes with "frost"
50 | and the first syllable in "roster".
51 |
52 | Pronouncing it like the English word "past" is acceptable, but
53 | politely discouraged.
54 |
55 | Implementations in other languages are encouraged, but not required,
56 | to make pasta puns in their naming convention.
57 |
--------------------------------------------------------------------------------
/src/Keys/SymmetricEncryptionKey.php:
--------------------------------------------------------------------------------
1 | key = $keyMaterial;
36 | $this->protocol = $protocol;
37 | }
38 |
39 | /**
40 | * @return string
41 | */
42 | public function encode(): string
43 | {
44 | return Base64UrlSafe::encode($this->key);
45 | }
46 |
47 | /**
48 | * @param string $encoded
49 | * @return self
50 | */
51 | public static function fromEncodedString(string $encoded): self
52 | {
53 | $decoded = Base64UrlSafe::decode($encoded);
54 | return new self($decoded);
55 | }
56 |
57 | /**
58 | * @return string
59 | */
60 | public function getProtocol(): string
61 | {
62 | return $this->protocol;
63 | }
64 |
65 | /**
66 | * @return string
67 | */
68 | public function raw(): string
69 | {
70 | return $this->key;
71 | }
72 |
73 | /**
74 | * @param string|null $salt
75 | * @return array
76 | *
77 | * @throws \Error
78 | * @throws \TypeError
79 | */
80 | public function split(string $salt = null): array
81 | {
82 | $encKey = Util::HKDF('sha384', $this->key, 32, self::INFO_ENCRYPTION, $salt);
83 | $authKey = Util::HKDF('sha384', $this->key, 32, self::INFO_AUTHENTICATION, $salt);
84 | return [$encKey, $authKey];
85 | }
86 |
87 | /**
88 | * @return array
89 | */
90 | public function __debugInfo()
91 | {
92 | return [];
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/tests/Version1VectorTest.php:
--------------------------------------------------------------------------------
1 | assertSame(
30 | 'v1.auth.6n6C7vHPzV5xHULfMOjiLg7F46iPeymVwawZN5kF3B-OyhzPsjqAOLYhtCc52-Wt',
31 | Version1::auth('', $nullAuthKey),
32 | 'Test Vector A-1'
33 | );
34 |
35 | // Empty string, 32-character NUL byte key, non-empty footer.
36 | $this->assertSame(
37 | 'v1.auth.JEEQ-GXQAK2qNYilKVXynuLhlXUw8xdeHNhsBH8OMA6mS_sYMzavZ_kUrdMgmNKr.Q3VvbiBBbHBpbnVz',
38 | Version1::auth('', $nullAuthKey, 'Cuon Alpinus'),
39 | 'Test Vector A-2'
40 | );
41 |
42 | // Non-empty string, 32-character 0xFF byte key.
43 | $this->assertSame(
44 | 'v1.auth.RnJhbmsgRGVuaXMgcm9ja3OvktwlGNM0U3P2mAbLVKRcHWC33xXQwVN-IlE8M3idKitswqz33kA5q2ThfOT4uqU=',
45 | Version1::auth('Frank Denis rocks', $fullAuthKey),
46 | 'Test Vector A-3'
47 | );
48 |
49 | // Non-empty string, 32-character 0xFF byte key. (One character difference)
50 | $this->assertSame(
51 | 'v1.auth.RnJhbmsgRGVuaXMgcm9ja3qoKuUpOSkEDafLOA9FDz8zYCX18f6ILDXjbgOwxsfD_HxRo6Jnz5xFN236X_1IdrQ=',
52 | Version1::auth('Frank Denis rockz', $fullAuthKey),
53 | 'Test Vector A-4'
54 | );
55 |
56 | // Non-empty string, 32-character 0xFF byte key, non-empty footer.
57 | $this->assertSame(
58 | 'v1.auth.RnJhbmsgRGVuaXMgcm9ja3N_vl77CqDA-VdqmjEs6ugayZRK7Fl20OviMWGefxRDbeMtNsuhosEfDU0CeJPodSM=.Q3VvbiBBbHBpbnVz',
59 | Version1::auth('Frank Denis rocks', $fullAuthKey, 'Cuon Alpinus'),
60 | 'Test Vector A-5'
61 | );
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/tests/ParserTest.php:
--------------------------------------------------------------------------------
1 | setPurpose('auth')
31 | ->setKey($key);
32 | $token = $parser->parse($serialized);
33 | $this->assertSame(
34 | '2039-01-01T00:00:00+00:00',
35 | $token->getExpiration()->format(\DateTime::ATOM),
36 | 'Mismatched expiration date/time'
37 | );
38 | $this->assertSame(
39 | 'this is a signed message',
40 | $token->get('data'),
41 | 'Custom claim not found'
42 | );
43 | $this->assertSame($serialized, (string) $token);
44 |
45 | $this->assertTrue($parser->validate($token));
46 | $parser->addRule(new NotExpired(new \DateTime('2007-01-01T00:00:00')));
47 | $this->assertTrue($parser->validate($token));
48 |
49 | $cloned = clone $parser;
50 | $cloned->addRule(new NotExpired(new \DateTime('2050-01-01T23:59:59')));
51 | $this->assertFalse($cloned->validate($token));
52 |
53 | try {
54 | $cloned->parse($serialized);
55 | $this->fail('Validation logic is being ignored.');
56 | } catch (PastException $ex) {
57 | }
58 | $parser->parse($serialized);
59 |
60 | // Switch to asymmetric-key crypto:
61 | $token->setPurpose('sign')
62 | ->setKey(new AsymmetricSecretKey('YELLOW SUBMARINE, BLACK WIZARDRY'), true);
63 | $this->assertSame(
64 | 'v2.sign.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAzOS0wMS0wMVQwMDowMDowMCJ9weCkPrvT6Ay-8pPpyplaGT4NwVI1zXfoDi7Mg6xkhbsGSR4yOfzoAOJAG9MRbJDm3bPcAVttJbZPnox_EwtwAg==',
65 | (string) $token,
66 | 'Switching to signing caused a different signature'
67 | );
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/docs/01-Protocol-Versions/Common.md:
--------------------------------------------------------------------------------
1 | # Common Implementation Details
2 |
3 | ## Authentication Padding
4 |
5 | Multi-part messages (e.g. header, content, footer) are encoded
6 | in a specific manner before being passed to the respective
7 | cryptographic function.
8 |
9 | For encrypted modes (`enc` and `seal`), this encoding is applied
10 | to the additional associated data (AAD). For unencrypted modes,
11 | this encoding is applied to the components of the token, with
12 | respect to the protocol version being followed.
13 |
14 | The reference implementation resides in `Util::preAuthEncode()`.
15 | We will refer to it as **PAE** in this document (short for
16 | Pre-Authentication Encoding).
17 |
18 | ### PAE Definition
19 |
20 | **PAE()** accepts an array of strings (usually denoted as
21 | `array` in docblocks to signify integer keys, but in
22 | other languages, `string[]` is preferred; in the PHP community
23 | they're synonymous).
24 |
25 | **LE64()** encodes a 64-bit unsigned integer into a little-endian
26 | binary string.
27 |
28 | The first 8 bytes of the output will be the number of pieces. Typically
29 | this is a small number (3 to 5). This is calculated by `LE64()` of the
30 | size of the array.
31 |
32 | Next, for each piece provided, the length of the piece is encoded via
33 | `LE64()` and prefixed to each piece before concatenation.
34 |
35 | An implementation may look like this:
36 |
37 | ```javascript
38 | function PAE(pieces) {
39 | if (!Array.isArray(pieces)) {
40 | throw TypeError('Expected an array.');
41 | }
42 | var count = pieces.length;
43 | var output = LE64(count);
44 | for (var i = 0; i < count; i++) {
45 | output += LE64(pieces[i].length);
46 | output += pieces[i];
47 | }
48 | return output;
49 | }
50 | ```
51 |
52 | As a consequence:
53 |
54 | * `PAE([])` will always return `"\x00\x00\x00\x00\x00\x00\x00\x00"`
55 | * `PAE([''])` will always return
56 | `"\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"`
57 | * `PAE(['test'])` will always return
58 | `"\x01\x00\x00\x00\x00\x00\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00test"`
59 |
60 | As a result, you cannot create a collision with only a partially controlled
61 | plaintext. Either the number of pieces will differ, or the length of one
62 | of the fields (which is prefixed to the input you can provide) will differ,
63 | or both.
64 |
65 | Due to the length being expressed as an unsigned 64-bit integer, it remains
66 | infeasible to generate/transmit enough data to create an integer overflow.
67 |
68 | This is not used to encode data prior to decryption, and no decoding function
69 | is provided or specified. This merely exists to prevent canonicalization
70 | attacks.
71 |
--------------------------------------------------------------------------------
/src/ProtocolInterface.php:
--------------------------------------------------------------------------------
1 | key = $keyData;
45 | $this->protocol = $protocol;
46 | }
47 |
48 | /**
49 | * @param string $protocol
50 | * @return self
51 | */
52 | public static function generate(string $protocol = Version2::HEADER): self
53 | {
54 | if (\hash_equals($protocol, Version1::HEADER)) {
55 | $rsa = Version1::getRsa(false);
56 | /** @var array $keypair */
57 | $keypair = $rsa->createKey(2048);
58 | return new self($keypair['privatekey']);
59 | }
60 | return new self(
61 | \sodium_crypto_sign_secretkey(
62 | \sodium_crypto_sign_keypair()
63 | )
64 | );
65 | }
66 |
67 | /**
68 | * @return string
69 | */
70 | public function encode(): string
71 | {
72 | return Base64UrlSafe::encode($this->key);
73 | }
74 |
75 | /**
76 | * @param string $encoded
77 | * @return self
78 | */
79 | public static function fromEncodedString(string $encoded): self
80 | {
81 | $decoded = Base64UrlSafe::decode($encoded);
82 | return new self($decoded);
83 | }
84 |
85 | /**
86 | * @return string
87 | */
88 | public function getProtocol(): string
89 | {
90 | return $this->protocol;
91 | }
92 |
93 | /**
94 | * @return AsymmetricPublicKey
95 | */
96 | public function getPublicKey(): AsymmetricPublicKey
97 | {
98 | switch ($this->protocol) {
99 | case Version1::HEADER:
100 | return new AsymmetricPublicKey(
101 | Version1::RsaGetPublicKey($this->key),
102 | Version1::HEADER
103 | );
104 | default:
105 | return new AsymmetricPublicKey(
106 | \sodium_crypto_sign_publickey_from_secretkey($this->key)
107 | );
108 | }
109 | }
110 |
111 | /**
112 | * @return string
113 | */
114 | public function raw()
115 | {
116 | return $this->key;
117 | }
118 |
119 | /**
120 | * @return array
121 | */
122 | public function __debugInfo()
123 | {
124 | return [];
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/tests/JsonTokenTest.php:
--------------------------------------------------------------------------------
1 | setPurpose('auth')
28 | ->setKey($key)
29 | ->set('data', 'this is a signed message')
30 | ->setExpiration(new \DateTime('2039-01-01T00:00:00+00:00'));
31 |
32 | $this->assertSame(
33 | 'v2.auth.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAzOS0wMS0wMVQwMDowMDowMCswMDowMCJ93f3nsnVwKRNLECMSi0_vhOUzXLj62UvCfPBGx4Nva9M=',
34 | (string) $builder,
35 | 'Auth, no footer'
36 | );
37 | $footer = (string) \json_encode(['key-id' => 'gandalf0']);
38 | $this->assertSame(
39 | 'v2.auth.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAzOS0wMS0wMVQwMDowMDowMCswMDowMCJ9V5lr8_gYa6yH3ZAKMcqnv_Deuow7TPCMtGBPLC6ZVbU=.eyJrZXktaWQiOiJnYW5kYWxmMCJ9',
40 | (string) $builder->setFooter($footer),
41 | 'Auth, footer'
42 | );
43 | $this->assertSame(
44 | 'v2.auth.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAzOS0wMS0wMVQwMDowMDowMCswMDowMCJ93f3nsnVwKRNLECMSi0_vhOUzXLj62UvCfPBGx4Nva9M=',
45 | (string) $builder->setFooter(''),
46 | 'Auth, removed footer'
47 | );
48 |
49 | // Now let's switch gears to asymmetric crypto:
50 | $builder->setPurpose('sign')
51 | ->setKey(new AsymmetricSecretKey('YELLOW SUBMARINE, BLACK WIZARDRY'), true);
52 | $this->assertSame(
53 | 'v2.sign.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAzOS0wMS0wMVQwMDowMDowMCswMDowMCJ9HUL-xbk0NkbdgAkFVt75Cm2N01fb30V79xSMrCnkAha2iS3cqc-cJnTEyRiD5hazSXqwU3gV4QsZw2AEgFy2Dw==',
54 | (string) $builder,
55 | 'Sign, no footer'
56 | );
57 | $this->assertSame(
58 | 'v2.sign.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAzOS0wMS0wMVQwMDowMDowMCswMDowMCJ9VJyhiHv4L-EalZB4FVqBPmfx5MlgZg305gJT1dUULR8ll_tFYIX8OmFt_ZZmn1bYrkJ9Mla24cz4_trbwAyGDA==.eyJrZXktaWQiOiJnYW5kYWxmMCJ9',
59 | (string) $builder->setFooter($footer),
60 | 'Sign, footer'
61 | );
62 | $this->assertSame(
63 | 'v2.sign.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAzOS0wMS0wMVQwMDowMDowMCswMDowMCJ9HUL-xbk0NkbdgAkFVt75Cm2N01fb30V79xSMrCnkAha2iS3cqc-cJnTEyRiD5hazSXqwU3gV4QsZw2AEgFy2Dw==',
64 | (string) $builder->setFooter(''),
65 | 'Sign, removed footer'
66 | );
67 | }
68 |
69 | /**
70 | * @throws PastException
71 | */
72 | public function testAuthTokenCustomFooter()
73 | {
74 | $key = new SymmetricAuthenticationKey('YELLOW SUBMARINE, BLACK WIZARDRY');
75 | $footerArray = ['key-id' => 'gandalf0'];
76 | $builder = (new JsonToken())
77 | ->setPurpose('auth')
78 | ->setKey($key)
79 | ->set('data', 'this is a signed message')
80 | ->setExpiration(new \DateTime('2039-01-01T00:00:00+00:00'))
81 | ->setFooterArray($footerArray);
82 | $this->assertSame(
83 | 'v2.auth.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAzOS0wMS0wMVQwMDowMDowMCswMDowMCJ9V5lr8_gYa6yH3ZAKMcqnv_Deuow7TPCMtGBPLC6ZVbU=.eyJrZXktaWQiOiJnYW5kYWxmMCJ9',
84 | (string) $builder,
85 | 'Auth, footer'
86 | );
87 | $this->assertEquals(
88 | $footerArray,
89 | $builder->getFooterArray()
90 | );
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # PAST: Platform-Agnostic Security Tokens
2 |
3 | [](https://travis-ci.org/paragonie/past)
4 | [](https://packagist.org/packages/paragonie/past)
5 | [](https://packagist.org/packages/paragonie/past)
6 | [](https://packagist.org/packages/paragonie/past)
7 | [](https://packagist.org/packages/paragonie/past)
8 |
9 | PAST is everything you love about JOSE (JWT, JWE, JWS) without any of the
10 | [many design deficits that plague the JOSE standards](https://paragonie.com/blog/2017/03/jwt-json-web-tokens-is-bad-standard-that-everyone-should-avoid).
11 |
12 | What follows is a reference implementation. **Requires PHP 7 or newer.**
13 |
14 | # What is PAST?
15 |
16 | PAST (Platform-Agnostic Security Tokens) is a specification and reference implementation
17 | for secure stateless tokens.
18 |
19 | Unlike JSON Web Tokens (JWT), which gives developers more than enough rope with which to
20 | hang themselves, PAST only allows secure operations. JWT gives you "algorithm agility",
21 | PAST gives you "versioned protocols". It's incredibly unlikely that you'll be able to
22 | use PAST in [an insecure way](https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries).
23 |
24 | ## Key Differences between PAST and JWT
25 |
26 | ### PAST
27 |
28 | ```
29 | v2.auth.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAzOS0wMS0wMVQwMDowMDowMCJ9VpWy4KU60YnKUzTkixFi9foXhXKTHbcDBtpg7oWllm8=
30 | ```
31 |
32 | This decodes to:
33 |
34 | * Version: `v2`
35 | * Purpose: `auth` (shared-key authentication)
36 | * Payload:
37 | ```json
38 | {
39 | "data": "this is a signed message",
40 | "exp": "2039-01-01T00:00:00"
41 | }
42 | ```
43 | * Authentication tag:
44 | ```
45 | VpWy4KU60YnKUzTkixFi9foXhXKTHbcDBtpg7oWllm8=
46 | ```
47 |
48 | To learn what each version means, please see [this page in the documentation](https://github.com/paragonie/past/tree/master/docs/01-Protocol-Versions).
49 |
50 | ### JWT
51 |
52 | An example JWT ([taken from JWT.io](https://jwt.io)) might look like this:
53 |
54 | ```
55 | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
56 | ```
57 |
58 | This decodes to:
59 |
60 | **Header**:
61 | ```json
62 | {
63 | "alg": "HS256",
64 | "typ": "JWT"
65 | }
66 | ```
67 |
68 | **Body**:
69 | ```json
70 | {
71 | "sub": "1234567890",
72 | "name": "John Doe",
73 | "admin": true
74 | }
75 | ```
76 |
77 | **Signature**:
78 | ```
79 | TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
80 | ```
81 |
82 | ## Motivation
83 |
84 | As you can see, with JWT, you get to specify an `alg` header. There are a lot of options to
85 | choose from (including `none`).
86 |
87 | There have been ways to exploit JWT libraries by replacing RS256 with HS256 and using
88 | the known public key as the HMAC-SHA256 key, thereby allowing arbitrary token forgery.
89 |
90 | With PAST, your options are `version` and a `purpose`. There are four possible
91 | values for `purpose`:
92 |
93 | * `auth` -- shared-key authentication
94 | * `enc` -- shared-key encryption
95 | * `seal` -- public-key encryption
96 | * `sign` -- public-key authentication (a.k.a. digital signatures)
97 |
98 | All encryption modes use [authenticated modes](https://tonyarcieri.com/all-the-crypto-code-youve-ever-written-is-probably-broken).
99 |
100 | Regardless of the purpose selected, the header (and an optional footer, which is always
101 | cleartext but base64url-encoded) is included in the signature or authentication tag.
102 |
103 | ## How to Use this Library
104 |
105 | See [the documentation](https://github.com/paragonie/past/tree/master/docs).
106 |
107 | The section dedicated to [this PHP implementation](https://github.com/paragonie/past/tree/master/docs/02-PHP-Library)
108 | may be more relevant.
109 |
--------------------------------------------------------------------------------
/docs/02-PHP-Library/README.md:
--------------------------------------------------------------------------------
1 | # How to use the PHP library
2 |
3 | The first thing you should know about PAST is that it tries to accomplish
4 | type-safety by wrapping cryptographic keys inside of objects. For example:
5 |
6 | ```php
7 | getPublicKey();
16 |
17 | $sharedEncKey = new SymmetricEncryptionKey(random_bytes(32));
18 | $sharedAuthKey = new SymmetricAuthenticationKey(random_bytes(32));
19 | ```
20 |
21 | You can access the key's internal strings by invoking `$key->raw()`.
22 |
23 | No version of the protocol will let you misuse a key by accident.
24 | This will generate a `TypeError`:
25 |
26 | ```php
27 | setKey($sharedAuthKey)
54 | ->setVersion(Version2::HEADER)
55 | ->setPurpose('auth')
56 | // Set it to expire in one day
57 | ->setExpiration(
58 | (new DateTime())->add(new DateInterval('P01D'))
59 | )
60 | // Store arbitrary data
61 | ->setClaims([
62 | 'example' => 'Hello world',
63 | 'security' => 'Now as easy as PIE'
64 | ]);
65 | echo $token; // Converts automatically to a string
66 | ```
67 |
68 | ### Decoding tokens
69 |
70 | First, you need to define your `Parser` rules.
71 |
72 | * Which versions of the protocol do you wish to allow? If you're only
73 | using v2 in your app, you should specify this.
74 |
75 | ```php
76 | setKey($sharedAuthKey)
87 | ->setPurpose('auth')
88 | // Only allow version 2
89 | ->setAllowedVarsions(['v2']);
90 |
91 | try {
92 | $token = $parser->parse($providedToken);
93 | } catch (PastException $ex) {
94 | /* Handle invalid token cases here. */
95 | }
96 | var_dump($token instanceof \ParagonIE\PAST\JsonToken);
97 | // bool(true)
98 | ```
99 |
100 | ## Using the Protocol Directly
101 |
102 | Unlike JWT, we don't force you to use JSON. You can store arbitrary binary
103 | data in a PAST, by invoking the Protocol classes directly. This is an advanced
104 | usage, of course.
105 |
106 | ```php
107 | assertSame(
32 | '3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865',
33 | Hex::encode(
34 | Util::HKDF('sha256', $ikm, 42, $info, $salt)
35 | ),
36 | 'Test Case #1 from the RFC'
37 | );
38 |
39 | // Test case 2:
40 | $ikm = Hex::decode(
41 | '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f2021222324252627' .
42 | '28292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f'
43 | );
44 | $salt = Hex::decode(
45 | '606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f8081828384858687' .
46 | '88898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeaf'
47 | );
48 | $info = Hex::decode(
49 | 'b0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7' .
50 | 'd8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff'
51 | );
52 |
53 | $this->assertSame(
54 | 'b11e398dc80327a1c8e7f78c596a49344f012eda2d4efad8a050cc4c19afa97c59045a99cac7827271' .
55 | 'cb41c65e590e09da3275600c2f09b8367793a9aca3db71cc30c58179ec3e87c14c01d5c1f3434f1d87',
56 | Hex::encode(
57 | Util::HKDF('sha256', $ikm, 82, $info, $salt)
58 | ),
59 | 'Test Case #2 from the RFC'
60 | );
61 |
62 | $ikm = Hex::decode('0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b');
63 | $this->assertSame(
64 | '8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d201395faa4b61a96c8',
65 | Hex::encode(
66 | Util::HKDF('sha256', $ikm, 42, '', '')
67 | ),
68 | 'Test Case #3 from the RFC'
69 | );
70 | }
71 |
72 | /**
73 | * @covers Util::preAuthEncode()
74 | */
75 | public function testPreAuthEncode()
76 | {
77 | $this->assertSame(
78 | '0000000000000000',
79 | Hex::encode(Util::preAuthEncode([])),
80 | 'Empty array'
81 | );
82 | $this->assertSame(
83 | '01000000000000000000000000000000',
84 | Hex::encode(Util::preAuthEncode([''])),
85 | 'Array of empty string'
86 | );
87 | $this->assertSame(
88 | '020000000000000000000000000000000000000000000000',
89 | Hex::encode(Util::preAuthEncode(['', ''])),
90 | 'Array of empty strings'
91 | );
92 | $this->assertSame(
93 | '0100000000000000070000000000000050617261676f6e',
94 | Hex::encode(Util::preAuthEncode(['Paragon'])),
95 | 'Array of non-empty string'
96 | );
97 | $this->assertSame(
98 | '0200000000000000070000000000000050617261676f6e0a00000000000000496e6974696174697665',
99 | Hex::encode(Util::preAuthEncode(['Paragon', 'Initiative'])),
100 | 'Array of two non-empty strings'
101 | );
102 | $this->assertSame(
103 | '0100000000000000190000000000000050617261676f6e0a00000000000000496e6974696174697665',
104 | Hex::encode(Util::preAuthEncode([
105 | 'Paragon' . chr(10) . str_repeat("\0", 7) . 'Initiative'
106 | ])),
107 | 'Ensure that faked padding results in different prefixes'
108 | );
109 | }
110 |
111 | /**
112 | * @covers Util::validateAndRemoveFooter()
113 | */
114 | public function testValidateAndRemoveFooter()
115 | {
116 | $token = Base64UrlSafe::encode(random_bytes(30));
117 | $footer = random_bytes(10);
118 | $combined = $token . '.' . Base64UrlSafe::encode($footer);
119 |
120 | $this->assertSame(
121 | $token,
122 | Util::validateAndRemoveFooter($combined, $footer)
123 | );
124 |
125 | try {
126 | Util::validateAndRemoveFooter($combined, 'wrong');
127 | $this->fail('Invalid footer was accepted');
128 | } catch (\Exception $ex) {
129 | }
130 | }
131 | }
132 |
--------------------------------------------------------------------------------
/src/Util.php:
--------------------------------------------------------------------------------
1 | 255 * $digest_length) {
53 | throw new \Error(
54 | 'Bad output length requested of HKDF.'
55 | );
56 | }
57 |
58 | // "if [salt] not provided, is set to a string of HashLen zeroes."
59 | if (\is_null($salt)) {
60 | $salt = \str_repeat("\x00", $digest_length);
61 | }
62 |
63 | // HKDF-Extract:
64 | // PRK = HMAC-Hash(salt, IKM)
65 | // The salt is the HMAC key.
66 | $prk = \hash_hmac($hash, $ikm, $salt, true);
67 |
68 | // HKDF-Expand:
69 |
70 | // This check is useless, but it serves as a reminder to the spec.
71 | if (Binary::safeStrlen($prk) < $digest_length) {
72 | throw new \Error('An unexpected condition occurred in the HKDF internals');
73 | }
74 |
75 | // T(0) = ''
76 | $t = '';
77 | $last_block = '';
78 | for ($block_index = 1; Binary::safeStrlen($t) < $length; ++$block_index) {
79 | // T(i) = HMAC-Hash(PRK, T(i-1) | info | 0x??)
80 | $last_block = \hash_hmac(
81 | $hash,
82 | $last_block . $info . \chr($block_index),
83 | $prk,
84 | true
85 | );
86 | // T = T(1) | T(2) | T(3) | ... | T(N)
87 | $t .= $last_block;
88 | }
89 |
90 | // ORM = first L octets of T
91 | /** @var string $orm */
92 | $orm = Binary::safeSubstr($t, 0, $length);
93 | if (!\is_string($orm)) {
94 | throw new \TypeError('Could not get a substring at the end of HKDF processing.');
95 | }
96 | return (string) $orm;
97 | }
98 |
99 | /**
100 | * If a footer was included with the message, first verify that
101 | * it's equivalent to the one we expect, then remove it from the
102 | * token payload.
103 | *
104 | * @param string $payload
105 | * @param string $footer
106 | * @return string
107 | * @throws \Exception
108 | * @throws \TypeError
109 | */
110 | public static function validateAndRemoveFooter(string $payload, string $footer = ''): string
111 | {
112 | if (empty($footer)) {
113 | return $payload;
114 | }
115 | $footer = Base64UrlSafe::encode($footer);
116 | $payload_len = Binary::safeStrlen($payload);
117 | $footer_len = Binary::safeStrlen($footer) + 1;
118 |
119 | $trailing = Binary::safeSubstr($payload, $payload_len - $footer_len, $footer_len);
120 | if (!\hash_equals('.' . $footer, $trailing)) {
121 | throw new \Exception('Invalid message footer');
122 | }
123 | return Binary::safeSubstr($payload, 0, $payload_len - $footer_len);
124 | }
125 |
126 | /**
127 | * Format the Additional Associated Data.
128 | *
129 | * Prefix with the length (64-bit unsigned little-endian integer)
130 | * followed by each message. This provides a more explicit domain
131 | * separation between each piece of the message.
132 | *
133 | * @param array $pieces
134 | * @return string
135 | */
136 | public static function preAuthEncode(array $pieces): string
137 | {
138 | $accumulator = \pack('P', \count($pieces));
139 | foreach ($pieces as $piece) {
140 | $len = Binary::safeStrlen($piece);
141 | $accumulator .= \pack('P', $len);
142 | $accumulator .= $piece;
143 | }
144 | return $accumulator;
145 | }
146 | }
147 |
--------------------------------------------------------------------------------
/tests/Version2VectorTest.php:
--------------------------------------------------------------------------------
1 | privateKey = new AsymmetricSecretKey(
37 | Hex::decode(
38 | 'b4cbfb43df4ce210727d953e4a713307fa19bb7d9f85041438d9e11b942a3774' .
39 | '1eb9dbbbbc047c03fd70604e0071f0987e16b28b757225c11f00415d0e20b1a2'
40 | ),
41 | Version2::HEADER
42 | );
43 |
44 | $this->publicKey = new AsymmetricPublicKey(
45 | Hex::decode(
46 | '1eb9dbbbbc047c03fd70604e0071f0987e16b28b757225c11f00415d0e20b1a2'
47 | ),
48 | Version2::HEADER
49 | );
50 | }
51 |
52 | /**
53 | * @covers Version2::auth()
54 | */
55 | public function testAuthVectors()
56 | {
57 | $nullAuthKey = new SymmetricAuthenticationKey(\str_repeat("\0", 32));
58 | $fullAuthKey = new SymmetricAuthenticationKey(\str_repeat("\xff", 32));
59 |
60 | // Empty string, 32-character NUL byte key.
61 | $this->assertSame(
62 | 'v2.auth.xnXx4GERjFlWU-nLJO-UJlQ7XKU74mOvBV-u5UymqKg=',
63 | Version2::auth('', $nullAuthKey),
64 | 'Test Vector A-1'
65 | );
66 |
67 | // Empty string, 32-character NUL byte key, non-empty footer.
68 | $this->assertSame(
69 | 'v2.auth.s9S77tR3hP7KgflCquKbYPPQlsOJrquQgGrU4za-jog=.Q3VvbiBBbHBpbnVz',
70 | Version2::auth('', $nullAuthKey, 'Cuon Alpinus'),
71 | 'Test Vector A-2'
72 | );
73 |
74 | // Non-empty string, 32-character 0xFF byte key.
75 | $this->assertSame(
76 | 'v2.auth.RnJhbmsgRGVuaXMgcm9ja3OlPWwML5vX9jz8eWMxZY0J6pvcheSEXJl4cWaGzyGQ6w==',
77 | Version2::auth('Frank Denis rocks', $fullAuthKey),
78 | 'Test Vector A-3'
79 | );
80 |
81 | // Non-empty string, 32-character 0xFF byte key. (One character difference)
82 | $this->assertSame(
83 | 'v2.auth.RnJhbmsgRGVuaXMgcm9ja3qtXffI1R5G4KJuLWjKmF6L84REbNNOtcsqr-3z7zfxyw==',
84 | Version2::auth('Frank Denis rockz', $fullAuthKey),
85 | 'Test Vector A-4'
86 | );
87 |
88 | // Non-empty string, 32-character 0xFF byte key, non-empty footer.
89 | $this->assertSame(
90 | 'v2.auth.RnJhbmsgRGVuaXMgcm9ja3N0ncPqYRX7SWTwgwS_MK65vnFPVHq_ciVqpO8MvlZiaA==.Q3VvbiBBbHBpbnVz',
91 | Version2::auth('Frank Denis rocks', $fullAuthKey, 'Cuon Alpinus'),
92 | 'Test Vector A-5'
93 | );
94 | }
95 |
96 | /**
97 | * @covers Version2::sign()
98 | */
99 | public function testSignVectors()
100 | {
101 | // Empty string, 32-character NUL byte key.
102 | $this->assertSame(
103 | 'v2.sign.uSe9owhGweXNMjH2NrUQNuUqLa8WB7i49txhXYESYOyuPyvUwczk12uSIgH1ju9esybqXIY13tRUv3KIMXGdCg==',
104 | Version2::sign('', $this->privateKey),
105 | 'Test Vector S-1'
106 | );
107 |
108 | // Empty string, 32-character NUL byte key, non-empty footer.
109 | $this->assertSame(
110 | 'v2.sign.rvZMDKWEur7JGgrJ4p6d5S4ymHunVg80ymzl8Gi9eCM3ZDlqBht-1koKxdyW834xm4JdXcqu9v6gUetNyBGmDA==.Q3VvbiBBbHBpbnVz',
111 | Version2::sign('', $this->privateKey, 'Cuon Alpinus'),
112 | 'Test Vector S-2'
113 | );
114 |
115 | // Non-empty string, 32-character 0xFF byte key.
116 | $this->assertSame(
117 | 'v2.sign.RnJhbmsgRGVuaXMgcm9ja3OCetrstPDcM-eMqEbbPiRplvLiLMB-RzJfgFeNrm_aQVX3AIrdGdREPL4RwlQ-HckuiAbcad22Pc_sMUTe5dwF',
118 | Version2::sign('Frank Denis rocks', $this->privateKey),
119 | 'Test Vector S-3'
120 | );
121 |
122 | // Non-empty string, 32-character 0xFF byte key. (One character difference)
123 | $this->assertSame(
124 | 'v2.sign.RnJhbmsgRGVuaXMgcm9ja3olt14-8N5T7RKW6XeXvKzEUaeS2GMoevR8mH8xblc076eESVZx0sHGSJUsAJ9TAIEYa0DKxToOj6B_lCKPclsP',
125 | Version2::sign('Frank Denis rockz', $this->privateKey),
126 | 'Test Vector S-4'
127 | );
128 |
129 | // Non-empty string, 32-character 0xFF byte key, non-empty footer.
130 | $this->assertSame(
131 | 'v2.sign.RnJhbmsgRGVuaXMgcm9ja3OyFOsrobYVbyj3IWticlQ8ueEB0tGQA820l6pUzhzy6s0By0WABq4jcdwiNX_xFUx3DMqKHrMEUXSbH9Lgp0EK.Q3VvbiBBbHBpbnVz',
132 | Version2::sign('Frank Denis rocks', $this->privateKey, 'Cuon Alpinus'),
133 | 'Test Vector S-5'
134 | );
135 | }
136 | }
137 |
--------------------------------------------------------------------------------
/tests/Version1Test.php:
--------------------------------------------------------------------------------
1 | 'this is a signed message', 'expires' => $year . '-01-01T00:00:00'])
25 | ];
26 |
27 | foreach ($messages as $message) {
28 | $auth = Version1::auth($message, $key);
29 | $this->assertTrue(\is_string($auth));
30 | $this->assertSame('v1.auth.', Binary::safeSubstr($auth, 0, 8));
31 |
32 | $decode = Version1::authVerify($auth, $key);
33 | $this->assertTrue(\is_string($decode));
34 | $this->assertSame($message, $decode);
35 |
36 | // Now with a footer
37 | $auth = Version1::auth($message, $key, 'footer');
38 | $this->assertTrue(\is_string($auth));
39 | $this->assertSame('v1.auth.', Binary::safeSubstr($auth, 0, 8));
40 | try {
41 | Version1::authVerify($auth, $key);
42 | $this->fail('Missing footer');
43 | } catch (\Exception $ex) {
44 | }
45 | $decode = Version1::authVerify($auth, $key, 'footer');
46 | $this->assertTrue(\is_string($decode));
47 | $this->assertSame($message, $decode);
48 | }
49 | }
50 |
51 | /**
52 | * @covers Version1::decrypt()
53 | * @covers Version1::encrypt()
54 | */
55 | public function testEncrypt()
56 | {
57 | $key = new SymmetricEncryptionKey(random_bytes(32));
58 | $year = (int) (\date('Y')) + 1;
59 | $messages = [
60 | 'test',
61 | \json_encode(['data' => 'this is a signed message', 'expires' => $year . '-01-01T00:00:00'])
62 | ];
63 |
64 | foreach ($messages as $message) {
65 | $encrypted = Version1::encrypt($message, $key);
66 | $this->assertTrue(\is_string($encrypted));
67 | $this->assertSame('v1.enc.', Binary::safeSubstr($encrypted, 0, 7));
68 |
69 | $decode = Version1::decrypt($encrypted, $key);
70 | $this->assertTrue(\is_string($decode));
71 | $this->assertSame($message, $decode);
72 |
73 | // Now with a footer
74 | try {
75 | Version1::decrypt($message, $key);
76 | $this->fail('Missing footer');
77 | } catch (\Exception $ex) {
78 | }
79 | $encrypted = Version1::encrypt($message, $key, 'footer');
80 | $this->assertTrue(\is_string($encrypted));
81 | $this->assertSame('v1.enc.', Binary::safeSubstr($encrypted, 0, 7));
82 |
83 | $decode = Version1::decrypt($encrypted, $key, 'footer');
84 | $this->assertTrue(\is_string($decode));
85 | $this->assertSame($message, $decode);
86 | }
87 | }
88 |
89 | /**
90 | * @covers Version1::seal()
91 | * @covers Version1::unseal()
92 | */
93 | public function testSeal()
94 | {
95 | $rsa = Version1::getRsa(false);
96 | $keypair = $rsa->createKey(2048);
97 | $privateKey = new AsymmetricSecretKey($keypair['privatekey'], 'v1');
98 | $publicKey = new AsymmetricPublicKey($keypair['publickey'], 'v1');
99 |
100 | $year = (int) (\date('Y')) + 1;
101 | $messages = [
102 | 'test',
103 | \json_encode(['data' => 'this is a signed message', 'expires' => $year . '-01-01T00:00:00'])
104 | ];
105 |
106 | foreach ($messages as $message) {
107 | $sealed = Version1::seal($message, $publicKey);
108 | $this->assertTrue(\is_string($sealed));
109 | $this->assertSame('v1.seal.', Binary::safeSubstr($sealed, 0, 8));
110 |
111 | $decode = Version1::unseal($sealed, $privateKey);
112 | $this->assertTrue(\is_string($decode));
113 | $this->assertSame($message, $decode);
114 |
115 | // Now with a footer
116 | $sealed = Version1::seal($message, $publicKey, 'footer');
117 | $this->assertTrue(\is_string($sealed));
118 | $this->assertSame('v1.seal.', Binary::safeSubstr($sealed, 0, 8));
119 |
120 | try {
121 | Version1::unseal($sealed, $privateKey);
122 | $this->fail('Missing footer');
123 | } catch (\Exception $ex) {
124 | }
125 | $decode = Version1::unseal($sealed, $privateKey, 'footer');
126 | $this->assertTrue(\is_string($decode));
127 | $this->assertSame($message, $decode);
128 | }
129 | }
130 |
131 | /**
132 | * @covers Version1::sign()
133 | * @covers Version1::signVerify()
134 | */
135 | public function testSign()
136 | {
137 | $rsa = Version1::getRsa(false);
138 | $keypair = $rsa->createKey(2048);
139 | $privateKey = new AsymmetricSecretKey($keypair['privatekey'], 'v1');
140 | $publicKey = new AsymmetricPublicKey($keypair['publickey'], 'v1');
141 |
142 | $year = (int) (\date('Y')) + 1;
143 | $messages = [
144 | 'test',
145 | \json_encode(['data' => 'this is a signed message', 'expires' => $year . '-01-01T00:00:00'])
146 | ];
147 |
148 | foreach ($messages as $message) {
149 | $signed = Version1::sign($message, $privateKey);
150 | $this->assertTrue(\is_string($signed));
151 | $this->assertSame('v1.sign.', Binary::safeSubstr($signed, 0, 8));
152 |
153 | $decode = Version1::signVerify($signed, $publicKey);
154 | $this->assertTrue(\is_string($decode));
155 | $this->assertSame($message, $decode);
156 |
157 | // Now with a footer
158 | $signed = Version1::sign($message, $privateKey, 'footer');
159 | $this->assertTrue(\is_string($signed));
160 | $this->assertSame('v1.sign.', Binary::safeSubstr($signed, 0, 8));
161 | try {
162 | Version1::signVerify($signed, $publicKey);
163 | $this->fail('Missing footer');
164 | } catch (\Exception $ex) {
165 | }
166 | $decode = Version1::signVerify($signed, $publicKey, 'footer');
167 | $this->assertTrue(\is_string($decode));
168 | $this->assertSame($message, $decode);
169 | }
170 | }
171 | public function testAlterations()
172 | {
173 | $key = new SymmetricAuthenticationKey('YELLOW SUBMARINE, BLACK WIZARDRY');
174 | $messsage = \json_encode(['data' => 'this is a signed message', 'exp' => '2039-01-01T00:00:00']);
175 | $footer = \json_encode(['key-id' => 'gandalf0']);
176 |
177 | $this->assertSame(
178 | 'v1.auth.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAzOS0wMS0wMVQwMDowMDowMCJ9oneoWrZWNIceku3gc3mxky87q171X2AaPG1yXkluTTuEf0O2vJSSxnzXZKLm5tHq',
179 | Version1::auth($messsage, $key)
180 | );
181 |
182 | $this->assertSame(
183 | 'v1.auth.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAzOS0wMS0wMVQwMDowMDowMCJ9wCeZ4vYcmi6EdjT3W0UYpniF8S37SDRyYVDD8JQbk6tvxQyH2sip8TnMwU3sN8SK.eyJrZXktaWQiOiJnYW5kYWxmMCJ9',
184 | Version1::auth($messsage, $key, $footer)
185 | );
186 | try {
187 | Version1::authVerify(
188 | 'v2.auth.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAzOS0wMS0wMVQwMDowMDowMCJ9hHAIS4KV4dbi2kvBjiUEapFTCN6SZYdZpv-u40HYsIvH32u0mu1_DN224We-oQBu.eyJrZXktaWQiOiJnYW5kYWxmMCJ9',
189 | $key,
190 | $footer
191 | );
192 | $this->fail('Incorrect version number was accepted');
193 | } catch (\Exception $ex) {
194 | }
195 |
196 | try {
197 | Version1::authVerify(
198 | 'v1.auth.fyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAzOS0wMS0wMVQwMDowMDowMCJ9hHAIS4KV4dbi2kvBjiUEapFTCN6SZYdZpv-u40HYsIvH32u0mu1_DN224We-oQBu.eyJrZXktaWQiOiJnYW5kYWxmMCJ9',
199 | $key,
200 | $footer
201 | );
202 | $this->fail('Invalid MAC was accepted');
203 | } catch (\Exception $ex) {
204 | }
205 |
206 | try {
207 | Version1::authVerify(
208 | 'v1.auth.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAzOS0wMS0wMVQwMDowMDowMCJ9hHAIS4KV4dbi2kvBjiUEapFTCN6SZYdZpv-u40HYsIvH32u0mu1_EN224We-oQBu.eyJrZXktaWQiOiJnYW5kYWxmMCJ9',
209 | $key,
210 | $footer
211 | );
212 | $this->fail('Invalid MAC was accepted');
213 | } catch (\Exception $ex) {
214 | }
215 |
216 | try {
217 | Version1::authVerify(
218 | 'v1.auth.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAzOS0wMS0wMVQwMDowMDowMCJ9hHAIS4KV4dbi2kvBjiUEapFTCN6SZYdZpv-u40HYsIvH32u0mu1_DN224We-oQBu.fyJrZXktaWQiOiJnYW5kYWxmMCJ9',
219 | $key,
220 | $footer
221 | );
222 | $this->fail('Invalid MAC was accepted');
223 | } catch (\Exception $ex) {
224 | }
225 | }
226 | }
227 |
--------------------------------------------------------------------------------
/tests/Version2Test.php:
--------------------------------------------------------------------------------
1 | 'this is a signed message', 'expires' => $year . '-01-01T00:00:00'])
26 | ];
27 |
28 | foreach ($messages as $message) {
29 | $auth = Version2::auth($message, $key);
30 | $this->assertTrue(\is_string($auth));
31 | $this->assertSame('v2.auth.', Binary::safeSubstr($auth, 0, 8));
32 |
33 | $decode = Version2::authVerify($auth, $key);
34 | $this->assertTrue(\is_string($decode));
35 | $this->assertSame($message, $decode);
36 |
37 | // Now with a footer
38 | $auth = Version2::auth($message, $key, 'footer');
39 | $this->assertTrue(\is_string($auth));
40 | $this->assertSame('v2.auth.', Binary::safeSubstr($auth, 0, 8));
41 | try {
42 | Version2::authVerify($auth, $key);
43 | $this->fail('Missing footer');
44 | } catch (\Exception $ex) {
45 | }
46 | $decode = Version2::authVerify($auth, $key, 'footer');
47 | $this->assertTrue(\is_string($decode));
48 | $this->assertSame($message, $decode);
49 | }
50 | }
51 |
52 | public function testAlterations()
53 | {
54 | $key = new SymmetricAuthenticationKey('YELLOW SUBMARINE, BLACK WIZARDRY');
55 | $messsage = \json_encode(['data' => 'this is a signed message', 'exp' => '2039-01-01T00:00:00']);
56 | $footer = \json_encode(['key-id' => 'gandalf0']);
57 |
58 | $this->assertSame(
59 | 'v2.auth.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAzOS0wMS0wMVQwMDowMDowMCJ9VpWy4KU60YnKUzTkixFi9foXhXKTHbcDBtpg7oWllm8=',
60 | Version2::auth($messsage, $key)
61 | );
62 | $this->assertSame(
63 | 'v2.auth.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAzOS0wMS0wMVQwMDowMDowMCJ9W9kUi7Z0QzuNSaIKQ-xlPQc3SsRXpWl4CkfwOBwfxAg=.eyJrZXktaWQiOiJnYW5kYWxmMCJ9',
64 | Version2::auth($messsage, $key, $footer)
65 | );
66 | try {
67 | Version2::authVerify(
68 | 'v1.auth.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAzOS0wMS0wMVQwMDowMDowMCJ9W9kUi7Z0QzuNSaIKQ-xlPQc3SsRXpWl4CkfwOBwfxAg=.eyJrZXktaWQiOiJnYW5kYWxmMCJ9',
69 | $key,
70 | $footer
71 | );
72 | $this->fail('Incorrect version number was accepted');
73 | } catch (\Exception $ex) {
74 | }
75 |
76 | try {
77 | Version2::authVerify(
78 | 'v2.auth.fyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAzOS0wMS0wMVQwMDowMDowMCJ9W9kUi7Z0QzuNSaIKQ-xlPQc3SsRXpWl4CkfwOBwfxAg=.eyJrZXktaWQiOiJnYW5kYWxmMCJ9',
79 | $key,
80 | $footer
81 | );
82 | $this->fail('Invalid MAC was accepted');
83 | } catch (\Exception $ex) {
84 | }
85 |
86 | try {
87 | Version2::authVerify(
88 | 'v2.auth.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAzOS0wMS0wMVQwMDowMDowMCJ9W9kUi7Z0QzuNSaIKQ-xlPQc3SsRXpWl4CkfwOBwgxAg=.eyJrZXktaWQiOiJnYW5kYWxmMCJ9',
89 | $key,
90 | $footer
91 | );
92 | $this->fail('Invalid MAC was accepted');
93 | } catch (\Exception $ex) {
94 | }
95 |
96 | try {
97 | Version2::authVerify(
98 | 'v2.auth.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAzOS0wMS0wMVQwMDowMDowMCJ9W9kUi7Z0QzuNSaIKQ-xlPQc3SsRXpWl4CkfwOBwfxAg=.fyJrZXktaWQiOiJnYW5kYWxmMCJ9',
99 | $key,
100 | $footer
101 | );
102 | $this->fail('Invalid MAC was accepted');
103 | } catch (\Exception $ex) {
104 | }
105 | }
106 |
107 | /**
108 | * @covers Version2::decrypt()
109 | * @covers Version2::encrypt()
110 | */
111 | public function testEncrypt()
112 | {
113 | $key = new SymmetricEncryptionKey(random_bytes(32));
114 | $year = (int) (\date('Y')) + 1;
115 | $messages = [
116 | 'test',
117 | \json_encode(['data' => 'this is a signed message', 'expires' => $year . '-01-01T00:00:00'])
118 | ];
119 |
120 | foreach ($messages as $message) {
121 | $encrypted = Version2::encrypt($message, $key);
122 | $this->assertTrue(\is_string($encrypted));
123 | $this->assertSame('v2.enc.', Binary::safeSubstr($encrypted, 0, 7));
124 |
125 | $decode = Version2::decrypt($encrypted, $key);
126 | $this->assertTrue(\is_string($decode));
127 | $this->assertSame($message, $decode);
128 |
129 | // Now with a footer
130 | try {
131 | Version2::decrypt($message, $key);
132 | $this->fail('Missing footer');
133 | } catch (\Exception $ex) {
134 | }
135 | $encrypted = Version2::encrypt($message, $key, 'footer');
136 | $this->assertTrue(\is_string($encrypted));
137 | $this->assertSame('v2.enc.', Binary::safeSubstr($encrypted, 0, 7));
138 |
139 | $decode = Version2::decrypt($encrypted, $key, 'footer');
140 | $this->assertTrue(\is_string($decode));
141 | $this->assertSame($message, $decode);
142 | }
143 | }
144 |
145 | /**
146 | * @covers Version2::seal()
147 | * @covers Version2::unseal()
148 | */
149 | public function testSeal()
150 | {
151 | $keypair = sodium_crypto_sign_keypair();
152 | $privateKey = new AsymmetricSecretKey(sodium_crypto_sign_secretkey($keypair));
153 | $publicKey = new AsymmetricPublicKey(sodium_crypto_sign_publickey($keypair));
154 |
155 | $year = (int) (\date('Y')) + 1;
156 | $messages = [
157 | 'test',
158 | \json_encode(['data' => 'this is a signed message', 'expires' => $year . '-01-01T00:00:00'])
159 | ];
160 |
161 | foreach ($messages as $message) {
162 | $sealed = Version2::seal($message, $publicKey);
163 | $this->assertTrue(\is_string($sealed));
164 | $this->assertSame('v2.seal.', Binary::safeSubstr($sealed, 0, 8));
165 |
166 | $decode = Version2::unseal($sealed, $privateKey);
167 | $this->assertTrue(\is_string($decode));
168 | $this->assertSame($message, $decode);
169 |
170 | // Now with a footer
171 | $sealed = Version2::seal($message, $publicKey, 'footer');
172 | $this->assertTrue(\is_string($sealed));
173 | $this->assertSame('v2.seal.', Binary::safeSubstr($sealed, 0, 8));
174 |
175 | try {
176 | Version2::unseal($sealed, $privateKey);
177 | $this->fail('Missing footer');
178 | } catch (\Exception $ex) {
179 | }
180 | $decode = Version2::unseal($sealed, $privateKey, 'footer');
181 | $this->assertTrue(\is_string($decode));
182 | $this->assertSame($message, $decode);
183 | }
184 | }
185 |
186 | /**
187 | * @covers Version2::sign()
188 | * @covers Version2::signVerify()
189 | */
190 | public function testSign()
191 | {
192 | $keypair = sodium_crypto_sign_keypair();
193 | $privateKey = new AsymmetricSecretKey(sodium_crypto_sign_secretkey($keypair));
194 | $publicKey = new AsymmetricPublicKey(sodium_crypto_sign_publickey($keypair));
195 |
196 | $year = (int) (\date('Y')) + 1;
197 | $messages = [
198 | 'test',
199 | \json_encode(['data' => 'this is a signed message', 'expires' => $year . '-01-01T00:00:00'])
200 | ];
201 |
202 | foreach ($messages as $message) {
203 | $signed = Version2::sign($message, $privateKey);
204 | $this->assertTrue(\is_string($signed));
205 | $this->assertSame('v2.sign.', Binary::safeSubstr($signed, 0, 8));
206 |
207 | $decode = Version2::signVerify($signed, $publicKey);
208 | $this->assertTrue(\is_string($decode));
209 | $this->assertSame($message, $decode);
210 |
211 | // Now with a footer
212 | $signed = Version2::sign($message, $privateKey, 'footer');
213 | $this->assertTrue(\is_string($signed));
214 | $this->assertSame('v2.sign.', Binary::safeSubstr($signed, 0, 8));
215 | try {
216 | Version2::signVerify($signed, $publicKey);
217 | $this->fail('Missing footer');
218 | } catch (\Exception $ex) {
219 | }
220 | $decode = Version2::signVerify($signed, $publicKey, 'footer');
221 | $this->assertTrue(\is_string($decode));
222 | $this->assertSame($message, $decode);
223 | }
224 | }
225 |
226 | /**
227 | * @covers AsymmetricSecretKey for version 2
228 | */
229 | public function testWeirdKeypairs()
230 | {
231 | $keypair = sodium_crypto_sign_keypair();
232 | $privateKey = new AsymmetricSecretKey(sodium_crypto_sign_secretkey($keypair));
233 | $publicKey = new AsymmetricPublicKey(sodium_crypto_sign_publickey($keypair));
234 |
235 | $seed = Binary::safeSubstr($keypair, 0, 32);
236 | $privateAlt = new AsymmetricSecretKey($seed);
237 | $publicKeyAlt = $privateAlt->getPublicKey();
238 |
239 | $this->assertSame(
240 | Base64UrlSafe::encode($privateAlt->raw()),
241 | Base64UrlSafe::encode($privateKey->raw())
242 | );
243 | $this->assertSame(
244 | Base64UrlSafe::encode($publicKeyAlt->raw()),
245 | Base64UrlSafe::encode($publicKey->raw())
246 | );
247 | }
248 | }
249 |
--------------------------------------------------------------------------------
/src/Protocol/Version2.php:
--------------------------------------------------------------------------------
1 | raw()
46 | );
47 | if ($footer) {
48 | return $header .
49 | Base64UrlSafe::encode($data . $mac) .
50 | '.' .
51 | Base64UrlSafe::encode($footer);
52 | }
53 | return $header . Base64UrlSafe::encode($data . $mac);
54 | }
55 |
56 | /**
57 | * Verify a message with a shared key.
58 | *
59 | * @param string $authMsg
60 | * @param SymmetricAuthenticationKey $key
61 | * @param string $footer
62 | * @return string
63 | * @throws \Exception
64 | * @throws \TypeError
65 | */
66 | public static function authVerify(
67 | string $authMsg,
68 | SymmetricAuthenticationKey $key,
69 | string $footer = ''
70 | ): string {
71 | $authMsg = Util::validateAndRemoveFooter($authMsg, $footer);
72 | $expectHeader = self::HEADER . '.auth.';
73 | $givenHeader = Binary::safeSubstr($authMsg, 0, 8);
74 | if (!\hash_equals($expectHeader, $givenHeader)) {
75 | throw new \Exception('Invalid message header.');
76 | }
77 |
78 | $body = Binary::safeSubstr($authMsg, 8);
79 | $decoded = Base64UrlSafe::decode($body);
80 | $len = Binary::safeStrlen($decoded);
81 |
82 | $message = Binary::safeSubstr($decoded, 0, $len - 32);
83 | $mac = Binary::safeSubstr($decoded, $len - 32);
84 | $valid = \sodium_crypto_auth_verify(
85 | $mac,
86 | Util::preAuthEncode([$givenHeader, $message, $footer]),
87 | $key->raw()
88 | );
89 | if (!$valid) {
90 | throw new \Exception('Invalid MAC');
91 | }
92 | return $message;
93 | }
94 |
95 | /**
96 | * Encrypt a message using a shared key.
97 | *
98 | * @param string $data
99 | * @param SymmetricEncryptionKey $key
100 | * @param string $footer
101 | * @return string
102 | * @throws \SodiumException
103 | * @throws \TypeError
104 | */
105 | public static function encrypt(
106 | string $data,
107 | SymmetricEncryptionKey $key,
108 | string $footer = ''
109 | ): string
110 | {
111 | return self::aeadEncrypt(
112 | $data,
113 | self::HEADER . '.enc.',
114 | $key,
115 | $footer
116 | );
117 | }
118 |
119 | /**
120 | * Decrypt a message using a shared key.
121 | *
122 | * @param string $data
123 | * @param SymmetricEncryptionKey $key
124 | * @param string $footer
125 | * @return string
126 | * @throws \Exception
127 | * @throws \Error
128 | * @throws \Exception
129 | * @throws \TypeError
130 | */
131 | public static function decrypt(string $data, SymmetricEncryptionKey $key, string $footer = ''): string
132 | {
133 | return self::aeadDecrypt(
134 | Util::validateAndRemoveFooter($data, $footer),
135 | self::HEADER . '.enc.',
136 | $key,
137 | $footer
138 | );
139 | }
140 |
141 | /**
142 | * Encrypt a message using a recipient's public key.
143 | *
144 | * @param string $data
145 | * @param AsymmetricPublicKey $key
146 | * @param string $footer
147 | * @return string
148 | * @throws \SodiumException
149 | * @throws \TypeError
150 | */
151 | public static function seal(string $data, AsymmetricPublicKey $key, string $footer = ''): string
152 | {
153 | $header = self::HEADER . '.seal.';
154 |
155 | $recipPublic = \ParagonIE_Sodium_Compat::crypto_sign_ed25519_pk_to_curve25519($key->raw());
156 |
157 | // Ephemeral keypairs
158 | $ephKeypair = \sodium_crypto_box_keypair();
159 | $ephSecret = \sodium_crypto_box_secretkey($ephKeypair);
160 | $ephPublic = \sodium_crypto_box_publickey($ephKeypair);
161 | try {
162 | \sodium_memzero($ephKeypair);
163 | } catch (\Throwable $ex) {
164 | }
165 |
166 | $symmetricKey = new SymmetricEncryptionKey(
167 | \ParagonIE_Sodium_Compat::crypto_kx(
168 | $ephSecret,
169 | $recipPublic,
170 | $ephPublic,
171 | $recipPublic
172 | )
173 | );
174 |
175 | try {
176 | \sodium_memzero($ephKeypair);
177 | \sodium_memzero($ephSecret);
178 | } catch (\Throwable $ex) {
179 | }
180 |
181 | $header .= Base64UrlSafe::encode($ephPublic) . '.';
182 |
183 | return self::aeadEncrypt($data, $header, $symmetricKey, $footer);
184 | }
185 |
186 | /**
187 | * Decrypt a message using your private key.
188 | *
189 | * @param string $data
190 | * @param AsymmetricSecretKey $key
191 | * @param string $footer
192 | * @return string
193 | * @throws \Error
194 | * @throws \Exception
195 | * @throws \TypeError
196 | */
197 | public static function unseal(string $data, AsymmetricSecretKey $key, string $footer = ''): string
198 | {
199 | $data = Util::validateAndRemoveFooter($data, $footer);
200 | $header = self::HEADER . '.seal.';
201 | $givenHeader = Binary::safeSubstr($data, 0, 8);
202 | if (!\hash_equals($header, $givenHeader)) {
203 | throw new \Exception('Invalid message header.');
204 | }
205 |
206 | $pieces = \explode('.', $data);
207 | if (\count($pieces) !== 4) {
208 | throw new \Exception('Invalid sealed message');
209 | }
210 | $ephPublic = Base64UrlSafe::decode($pieces[2]);
211 |
212 | $mySecret = \sodium_crypto_sign_ed25519_sk_to_curve25519($key->raw());
213 | $myPublic = \sodium_crypto_box_publickey_from_secretkey($mySecret);
214 |
215 | $symmetricKey = new SymmetricEncryptionKey(
216 | \ParagonIE_Sodium_Compat::crypto_kx(
217 | $mySecret,
218 | $ephPublic,
219 | $ephPublic,
220 | $myPublic
221 | )
222 | );
223 |
224 | $header .= Base64UrlSafe::encode($ephPublic) . '.';
225 | return self::aeadDecrypt($data, $header, $symmetricKey, $footer);
226 | }
227 |
228 | /**
229 | * Sign a message. Public-key digital signatures.
230 | *
231 | * @param string $data
232 | * @param AsymmetricSecretKey $key
233 | * @param string $footer
234 | * @return string
235 | */
236 | public static function sign(string $data, AsymmetricSecretKey $key, string $footer = ''): string
237 | {
238 | $header = self::HEADER . '.sign.';
239 | $signature = \sodium_crypto_sign_detached(
240 | Util::preAuthEncode([$header, $data, $footer]),
241 | $key->raw()
242 | );
243 | if ($footer) {
244 | return $header .
245 | Base64UrlSafe::encode($data . $signature) .
246 | '.' .
247 | Base64UrlSafe::encode($footer);
248 | }
249 | return $header . Base64UrlSafe::encode($data . $signature);
250 | }
251 |
252 | /**
253 | * Verify a signed message. Public-key digital signatures.
254 | *
255 | * @param string $signMsg
256 | * @param AsymmetricPublicKey $key
257 | * @param string $footer
258 | * @return string
259 | * @throws \Exception
260 | * @throws \TypeError
261 | */
262 | public static function signVerify(string $signMsg, AsymmetricPublicKey $key, string $footer = ''): string
263 | {
264 | $signMsg = Util::validateAndRemoveFooter($signMsg, $footer);
265 | $expectHeader = self::HEADER . '.sign.';
266 | $givenHeader = Binary::safeSubstr($signMsg, 0, 8);
267 | if (!\hash_equals($expectHeader, $givenHeader)) {
268 | throw new \Exception('Invalid message header.');
269 | }
270 | $decoded = Base64UrlSafe::decode(Binary::safeSubstr($signMsg, 8));
271 | $len = Binary::safeStrlen($decoded);
272 | $message = Binary::safeSubstr($decoded, 0, $len - SODIUM_CRYPTO_SIGN_BYTES);
273 | $signature = Binary::safeSubstr($decoded, $len - SODIUM_CRYPTO_SIGN_BYTES);
274 |
275 | $valid = \sodium_crypto_sign_verify_detached(
276 | $signature,
277 | Util::preAuthEncode([$givenHeader, $message, $footer]),
278 | $key->raw()
279 | );
280 | if (!$valid) {
281 | throw new \Exception('Invalid signature for this message');
282 | }
283 | return $message;
284 | }
285 |
286 | /**
287 | * @param string $plaintext
288 | * @param string $header
289 | * @param SymmetricEncryptionKey $key
290 | * @param string $footer
291 | * @return string
292 | * @throws \SodiumException
293 | * @throws \TypeError
294 | */
295 | public static function aeadEncrypt(
296 | string $plaintext,
297 | string $header,
298 | SymmetricEncryptionKey $key,
299 | string $footer = ''
300 | ): string {
301 | $nonce = \random_bytes(\ParagonIE_Sodium_Compat::CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
302 | $ciphertext = \ParagonIE_Sodium_Compat::crypto_aead_xchacha20poly1305_ietf_encrypt(
303 | $plaintext,
304 | Util::preAuthEncode([$header, $nonce, $footer]),
305 | $nonce,
306 | $key->raw()
307 | );
308 | if ($footer) {
309 | return $header .
310 | Base64UrlSafe::encode($nonce . $ciphertext) .
311 | '.' .
312 | Base64UrlSafe::encode($footer);
313 | }
314 | return $header . Base64UrlSafe::encode($nonce . $ciphertext);
315 | }
316 |
317 | /**
318 | * @param string $message
319 | * @param string $header
320 | * @param SymmetricEncryptionKey $key
321 | * @param string $footer
322 | * @return string
323 | * @throws \Error
324 | * @throws \Exception
325 | * @throws \TypeError
326 | */
327 | public static function aeadDecrypt(
328 | string $message,
329 | string $header,
330 | SymmetricEncryptionKey $key,
331 | string $footer = ''
332 | ): string {
333 | $expectedLen = Binary::safeStrlen($header);
334 | $givenHeader = Binary::safeSubstr($message, 0, $expectedLen);
335 | if (!\hash_equals($header, $givenHeader)) {
336 | throw new \Exception('Invalid message header.');
337 | }
338 | $decoded = Base64UrlSafe::decode(Binary::safeSubstr($message, $expectedLen));
339 | $len = Binary::safeStrlen($decoded);
340 | $nonce = Binary::safeSubstr(
341 | $decoded,
342 | 0,
343 | \ParagonIE_Sodium_Compat::CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES
344 | );
345 | $ciphertext = Binary::safeSubstr(
346 | $decoded,
347 | \ParagonIE_Sodium_Compat::CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES,
348 | $len - \ParagonIE_Sodium_Compat::CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES
349 | );
350 | return \ParagonIE_Sodium_Compat::crypto_aead_xchacha20poly1305_ietf_decrypt(
351 | $ciphertext,
352 | Util::preAuthEncode([$header, $nonce, $footer]),
353 | $nonce,
354 | $key->raw()
355 | );
356 | }
357 | }
358 |
--------------------------------------------------------------------------------
/src/Parser.php:
--------------------------------------------------------------------------------
1 | */
42 | protected $allowedVersions;
43 |
44 | /** @var KeyInterface $key */
45 | protected $key;
46 |
47 | /** @var string $purpose */
48 | protected $purpose;
49 |
50 | /** @var array */
51 | protected $rules = [];
52 |
53 | /**
54 | * Parser constructor.
55 | *
56 | * @param array $allowedVersions
57 | * @param string $purpose
58 | * @param KeyInterface|null $key
59 | * @param array $parserRules
60 | * @throws PastException
61 | */
62 | public function __construct(
63 | array $allowedVersions = self::DEFAULT_VERSION_ALLOW,
64 | string $purpose = '',
65 | KeyInterface $key = null,
66 | array $parserRules = []
67 | ) {
68 | $this->allowedVersions = $allowedVersions;
69 | $this->purpose = $purpose;
70 | if (!\is_null($key)) {
71 | $this->setKey($key, true);
72 | }
73 | if (!empty($parserRules)) {
74 | foreach ($parserRules as $rule) {
75 | if ($rule instanceof ValidationRuleInterface) {
76 | $this->addRule($rule);
77 | }
78 | }
79 | }
80 | }
81 |
82 | /**
83 | * Add a validation rule to be invoked by parse().
84 | *
85 | * @param ValidationRuleInterface $rule
86 | * @return self
87 | */
88 | public function addRule(ValidationRuleInterface $rule): self
89 | {
90 | $this->rules[] = $rule;
91 | return $this;
92 | }
93 |
94 | /**
95 | * Parse a string into a JsonToken object.
96 | *
97 | * @param string $tainted Tainted user-provided string.
98 | * @param bool $skipValidation Don't validate according to the Rules.
99 | * (Does not disable cryptographic security.)
100 | * @return JsonToken
101 | * @throws PastException
102 | */
103 | public function parse(string $tainted, bool $skipValidation = false): JsonToken
104 | {
105 | /** @var array $pieces */
106 | $pieces = \explode('.', $tainted);
107 | if (\count($pieces) < 3) {
108 | throw new SecurityException('Truncated or invalid token');
109 | }
110 |
111 | // First, check against the user's specified list of allowed versions.
112 | $header = $pieces[0];
113 | if (!\in_array($header, $this->allowedVersions, true)) {
114 | throw new InvalidVersionException('Disallowed or unsupported version');
115 | }
116 |
117 | // Our parser's built-in whitelist of headers is defined here.
118 | switch ($header) {
119 | case Version1::HEADER:
120 | $protocol = Version1::class;
121 | break;
122 | case Version2::HEADER:
123 | $protocol = Version2::class;
124 | break;
125 | default:
126 | throw new InvalidVersionException('Disallowed or unsupported version');
127 | }
128 | /** @var ProtocolInterface $protocol */
129 | /** @var string $purpose */
130 | $footer = '';
131 | $purpose = $pieces[1];
132 |
133 | // $this->purpose is not mandatory, but if it's set, verify against it.
134 | if (!empty($this->purpose)) {
135 | if (!\hash_equals($this->purpose, $purpose)) {
136 | throw new InvalidPurposeException('Disallowed or unsupported purpose');
137 | }
138 | }
139 |
140 | // Let's verify/decode according to the appropriate method:
141 | switch ($purpose) {
142 | case 'auth':
143 | if (!($this->key instanceof SymmetricAuthenticationKey)) {
144 | throw new InvalidKeyException('Invalid key type');
145 | }
146 | $footer = (\count($pieces) > 3)
147 | ? Base64UrlSafe::decode($pieces[3])
148 | : '';
149 | try {
150 | /** @var string $decoded */
151 | $decoded = $protocol::authVerify($tainted, $this->key, $footer);
152 | } catch (\Throwable $ex) {
153 | throw new PastException('An error occurred', 0, $ex);
154 | }
155 | break;
156 | case 'enc':
157 | if (!($this->key instanceof SymmetricEncryptionKey)) {
158 | throw new InvalidKeyException('Invalid key type');
159 | }
160 | $footer = (\count($pieces) > 3)
161 | ? Base64UrlSafe::decode($pieces[3])
162 | : '';
163 | try {
164 | /** @var string $decoded */
165 | $decoded = $protocol::decrypt($tainted, $this->key, $footer);
166 | } catch (\Throwable $ex) {
167 | throw new PastException('An error occurred', 0, $ex);
168 | }
169 | break;
170 | case 'seal':
171 | if (!($this->key instanceof AsymmetricSecretKey)) {
172 | throw new InvalidKeyException('Invalid key type');
173 | }
174 | $footer = (\count($pieces) > 4)
175 | ? Base64UrlSafe::decode($pieces[4])
176 | : '';
177 | try {
178 | /** @var string $decoded */
179 | $decoded = $protocol::unseal($tainted, $this->key, $footer);
180 | } catch (\Throwable $ex) {
181 | throw new PastException('An error occurred', 0, $ex);
182 | }
183 | break;
184 | case 'sign':
185 | if (!($this->key instanceof AsymmetricPublicKey)) {
186 | throw new InvalidKeyException('Invalid key type');
187 | }
188 | $footer = (\count($pieces) > 4)
189 | ? Base64UrlSafe::decode($pieces[4])
190 | : '';
191 | try {
192 | /** @var string $decoded */
193 | $decoded = $protocol::signVerify($tainted, $this->key, $footer);
194 | } catch (\Throwable $ex) {
195 | throw new PastException('An error occurred', 0, $ex);
196 | }
197 | break;
198 | }
199 |
200 | // Did we get data?
201 | if (!isset($decoded)) {
202 | throw new PastException('Unsupported purpose or version.');
203 | }
204 | /** @var array $claims */
205 | $claims = \json_decode((string) $decoded, true);
206 | if (!\is_array($claims)) {
207 | throw new EncodingException('Not a JSON token.');
208 | }
209 |
210 | // Let's build the token object.
211 | $token = (new JsonToken())
212 | ->setVersion($header)
213 | ->setPurpose($purpose)
214 | ->setKey($this->key)
215 | ->setFooter($footer)
216 | ->setClaims($claims);
217 | if (!$skipValidation && !empty($this->rules)) {
218 | // Validate all of the rules that were specified:
219 | $this->validate($token, true);
220 | }
221 | return $token;
222 | }
223 |
224 | /**
225 | * Which protocol versions to permit.
226 | *
227 | * @param array $whitelist
228 | * @return self
229 | */
230 | public function setAllowedVersions(array $whitelist): self
231 | {
232 | $this->allowedVersions = $whitelist;
233 | return $this;
234 | }
235 |
236 | /**
237 | * Specify the key for the token we are going to parse.
238 | *
239 | * @param KeyInterface $key
240 | * @param bool $checkPurpose
241 | * @return self
242 | * @throws PastException
243 | */
244 | public function setKey(KeyInterface $key, bool $checkPurpose = false): self
245 | {
246 | if ($checkPurpose) {
247 | switch ($this->purpose) {
248 | case 'auth':
249 | if (!($key instanceof SymmetricAuthenticationKey)) {
250 | throw new InvalidKeyException(
251 | 'Invalid key type. Expected ' . SymmetricAuthenticationKey::class . ', got ' . \get_class($key)
252 | );
253 | }
254 | break;
255 | case 'enc':
256 | if (!($key instanceof SymmetricEncryptionKey)) {
257 | throw new InvalidKeyException(
258 | 'Invalid key type. Expected ' . SymmetricEncryptionKey::class . ', got ' . \get_class($key)
259 | );
260 | }
261 | break;
262 | case 'seal':
263 | if (!($key instanceof AsymmetricSecretKey)) {
264 | throw new InvalidKeyException(
265 | 'Invalid key type. Expected ' . AsymmetricSecretKey::class . ', got ' . \get_class($key)
266 | );
267 | }
268 | break;
269 | case 'sign':
270 | if (!($key instanceof AsymmetricPublicKey)) {
271 | throw new InvalidKeyException(
272 | 'Invalid key type. Expected ' . AsymmetricPublicKey::class . ', got ' . \get_class($key)
273 | );
274 | }
275 | break;
276 | default:
277 | throw new InvalidKeyException('Unknown purpose');
278 | }
279 | }
280 | $this->key = $key;
281 | return $this;
282 | }
283 |
284 | /**
285 | * Specify the allowed 'purpose' for the token we are going to parse.
286 | *
287 | * @param string $purpose
288 | * @param bool $checkKeyType
289 | * @return self
290 | * @throws PastException
291 | */
292 | public function setPurpose(string $purpose, bool $checkKeyType = false): self
293 | {
294 | if ($checkKeyType) {
295 | $keyType = \get_class($this->key);
296 | switch ($keyType) {
297 | case SymmetricAuthenticationKey::class:
298 | if (!\hash_equals('auth', $purpose)) {
299 | throw new InvalidPurposeException(
300 | 'Invalid purpose. Expected auth, got ' . $purpose
301 | );
302 | }
303 | break;
304 | case SymmetricEncryptionKey::class:
305 | if (!\hash_equals('enc', $purpose)) {
306 | throw new InvalidPurposeException(
307 | 'Invalid purpose. Expected enc, got ' . $purpose
308 | );
309 | }
310 | break;
311 | case AsymmetricSecretKey::class:
312 | if (!\hash_equals('seal', $purpose)) {
313 | throw new InvalidPurposeException(
314 | 'Invalid purpose. Expected seal, got ' . $purpose
315 | );
316 | }
317 | break;
318 | case AsymmetricPublicKey::class:
319 | if (!\hash_equals('sign', $purpose)) {
320 | throw new InvalidPurposeException(
321 | 'Invalid purpose. Expected sign, got ' . $purpose
322 | );
323 | }
324 | break;
325 | default:
326 | throw new InvalidPurposeException('Unknown purpose: ' . $purpose);
327 | }
328 | }
329 |
330 | $this->purpose = $purpose;
331 | return $this;
332 | }
333 |
334 | /**
335 | * Does this token pass all of the rules defined?
336 | *
337 | * @param JsonToken $token
338 | * @param bool $throwOnFailure
339 | * @return bool
340 | * @throws RuleViolation
341 | */
342 | public function validate(JsonToken $token, bool $throwOnFailure = false): bool
343 | {
344 | if (empty($this->rules)) {
345 | return true;
346 | }
347 | /** @var ValidationRuleInterface $rule */
348 | foreach ($this->rules as $rule) {
349 | if (!$rule->isValid($token)) {
350 | if ($throwOnFailure) {
351 | throw new RuleViolation($rule->getFailureMessage());
352 | }
353 | return false;
354 | }
355 | }
356 | return true;
357 | }
358 | }
359 |
--------------------------------------------------------------------------------
/src/Protocol/Version1.php:
--------------------------------------------------------------------------------
1 | raw(),
50 | true
51 | );
52 | if ($footer) {
53 | return $header .
54 | Base64UrlSafe::encode($data . $mac) .
55 | '.' .
56 | Base64UrlSafe::encode($footer);
57 | }
58 | return $header . Base64UrlSafe::encode($data . $mac);
59 | }
60 |
61 | /**
62 | * Verify a message with a shared key.
63 | *
64 | * @param string $authMsg
65 | * @param SymmetricAuthenticationKey $key
66 | * @param string $footer
67 | * @return string
68 | * @throws \Exception
69 | * @throws \TypeError
70 | */
71 | public static function authVerify(string $authMsg, SymmetricAuthenticationKey $key, string $footer = ''): string
72 | {
73 | $authMsg = Util::validateAndRemoveFooter($authMsg, $footer);
74 | $expectHeader = self::HEADER . '.auth.';
75 | $givenHeader = Binary::safeSubstr($authMsg, 0, 8);
76 | if (!\hash_equals($expectHeader, $givenHeader)) {
77 | throw new \Exception('Invalid message header.');
78 | }
79 |
80 | $body = Binary::safeSubstr($authMsg, 8);
81 | $decoded = Base64UrlSafe::decode($body);
82 | $len = Binary::safeStrlen($decoded);
83 |
84 | $message = Binary::safeSubstr($decoded, 0, $len - 48);
85 | $mac = Binary::safeSubstr($decoded, $len - 48);
86 | $calc = \hash_hmac(
87 | self::HASH_ALGO,
88 | Util::preAuthEncode([$givenHeader, $message, $footer]),
89 | $key->raw(),
90 | true
91 | );
92 | if (!\hash_equals($calc, $mac)) {
93 | throw new \Exception('Invalid MAC');
94 | }
95 | return $message;
96 | }
97 |
98 | /**
99 | * Encrypt a message using a shared key.
100 | *
101 | * @param string $data
102 | * @param SymmetricEncryptionKey $key
103 | * @param string $footer
104 | * @return string
105 | * @throws \Error
106 | * @throws \TypeError
107 | */
108 | public static function encrypt(string $data, SymmetricEncryptionKey $key, string $footer = ''): string
109 | {
110 | return self::aeadEncrypt(
111 | $data,
112 | self::HEADER . '.enc.',
113 | $key,
114 | $footer
115 | );
116 | }
117 |
118 | /**
119 | * Decrypt a message using a shared key.
120 | *
121 | * @param string $data
122 | * @param SymmetricEncryptionKey $key
123 | * @param string $footer
124 | * @return string
125 | * @throws \Exception
126 | * @throws \Error
127 | * @throws \Exception
128 | * @throws \TypeError
129 | */
130 | public static function decrypt(string $data, SymmetricEncryptionKey $key, string $footer = ''): string
131 | {
132 | return self::aeadDecrypt(
133 | Util::validateAndRemoveFooter($data, $footer),
134 | self::HEADER . '.enc.',
135 | $key,
136 | $footer
137 | );
138 | }
139 |
140 | /**
141 | * Encrypt a message using a recipient's public key.
142 | *
143 | * @param string $data
144 | * @param AsymmetricPublicKey $key
145 | * @param string $footer
146 | * @return string
147 | * @throws \Error
148 | * @throws \TypeError
149 | */
150 | public static function seal(string $data, AsymmetricPublicKey $key, string $footer = ''): string
151 | {
152 | $header = self::HEADER . '.seal.';
153 |
154 | $rsa = self::getRsa(false);
155 | $rsa->loadKey($key->raw());
156 |
157 | // Random encryption key
158 | $randomKey = \random_bytes(32);
159 |
160 | // Use RSA to encrypt the random key
161 | $rsaOut = $rsa->encrypt($randomKey);
162 |
163 | // Use HKDF-SHA384 to derive a new key for this message:
164 | $symmetricKey = new SymmetricEncryptionKey(
165 | Util::HKDF(self::HASH_ALGO, $rsaOut, 32, 'rsa kem+dem', $randomKey)
166 | );
167 |
168 | $header .= Base64UrlSafe::encode($rsaOut) . '.';
169 |
170 | return self::aeadEncrypt($data, $header, $symmetricKey, $footer);
171 | }
172 |
173 | /**
174 | * Decrypt a message using your private key.
175 | *
176 | * @param string $data
177 | * @param AsymmetricSecretKey $key
178 | * @param string $footer
179 | * @return string
180 | * @throws \Error
181 | * @throws \Exception
182 | * @throws \TypeError
183 | */
184 | public static function unseal(string $data, AsymmetricSecretKey $key, string $footer = ''): string
185 | {
186 | $data = Util::validateAndRemoveFooter($data, $footer);
187 | $header = self::HEADER . '.seal.';
188 | $givenHeader = Binary::safeSubstr($data, 0, 8);
189 | if (!\hash_equals($header, $givenHeader)) {
190 | throw new \Exception('Invalid message header.');
191 | }
192 |
193 | $pieces = \explode('.', $data);
194 | if (\count($pieces) !== 4) {
195 | throw new \Exception('Invalid sealed message');
196 | }
197 | $rsaCipher = Base64UrlSafe::decode($pieces[2]);
198 |
199 | $rsa = self::getRsa(false);
200 | $rsa->loadKey($key->raw());
201 | $randomKey = $rsa->decrypt($rsaCipher);
202 |
203 | // Use HKDF-SHA384 to derive a new key for this message:
204 | $symmetricKey = new SymmetricEncryptionKey(
205 | Util::HKDF(self::HASH_ALGO, $rsaCipher, 32, 'rsa kem+dem', $randomKey)
206 | );
207 | $header .= Base64UrlSafe::encode($rsaCipher) . '.';
208 |
209 | return self::aeadDecrypt($data, $header, $symmetricKey, $footer);
210 | }
211 |
212 | /**
213 | * Sign a message. Public-key digital signatures.
214 | *
215 | * @param string $data
216 | * @param AsymmetricSecretKey $key
217 | * @param string $footer
218 | * @return string
219 | */
220 | public static function sign(string $data, AsymmetricSecretKey $key, string $footer = ''): string
221 | {
222 | $header = self::HEADER . '.sign.';
223 | $rsa = self::getRsa(true);
224 | $rsa->loadKey($key->raw());
225 | $signature = $rsa->sign(
226 | Util::preAuthEncode([$header, $data, $footer])
227 | );
228 | if ($footer) {
229 | return $header .
230 | Base64UrlSafe::encode($data . $signature) .
231 | '.' .
232 | Base64UrlSafe::encode($footer);
233 | }
234 | return $header . Base64UrlSafe::encode($data . $signature);
235 | }
236 |
237 | /**
238 | * Verify a signed message. Public-key digital signatures.
239 | *
240 | * @param string $signMsg
241 | * @param AsymmetricPublicKey $key
242 | * @param string $footer
243 | * @return string
244 | * @throws \Exception
245 | * @throws \TypeError
246 | */
247 | public static function signVerify(string $signMsg, AsymmetricPublicKey $key, string $footer = ''): string
248 | {
249 | $signMsg = Util::validateAndRemoveFooter($signMsg, $footer);
250 | $expectHeader = self::HEADER . '.sign.';
251 | $givenHeader = Binary::safeSubstr($signMsg, 0, 8);
252 | if (!\hash_equals($expectHeader, $givenHeader)) {
253 | throw new \Exception('Invalid message header.');
254 | }
255 | $decoded = Base64UrlSafe::decode(Binary::safeSubstr($signMsg, 8));
256 | $len = Binary::safeStrlen($decoded);
257 | $message = Binary::safeSubstr($decoded, 0, $len - self::SIGN_SIZE);
258 | $signature = Binary::safeSubstr($decoded, $len - self::SIGN_SIZE);
259 |
260 | $rsa = self::getRsa(true);
261 | $rsa->loadKey($key->raw());
262 | $valid = $rsa->verify(
263 | Util::preAuthEncode([$givenHeader, $message, $footer]),
264 | $signature
265 | );
266 | if (!$valid) {
267 | throw new \Exception('Invalid signature for this message');
268 | }
269 | return $message;
270 | }
271 |
272 | /**
273 | * @param string $plaintext
274 | * @param string $header
275 | * @param SymmetricEncryptionKey $key
276 | * @param string $footer
277 | * @return string
278 | * @throws \Error
279 | * @throws \TypeError
280 | */
281 | public static function aeadEncrypt(
282 | string $plaintext,
283 | string $header,
284 | SymmetricEncryptionKey $key,
285 | string $footer = ''
286 | ): string {
287 | $nonce = \random_bytes(self::NONCE_SIZE);
288 | list($encKey, $authKey) = $key->split(
289 | Binary::safeSubstr($nonce, 0, 16)
290 | );
291 | /** @var string $ciphertext */
292 | $ciphertext = \openssl_encrypt(
293 | $plaintext,
294 | self::CIPHER_MODE,
295 | $encKey,
296 | OPENSSL_RAW_DATA,
297 | Binary::safeSubstr($nonce, 16, 16)
298 | );
299 | if (!\is_string($ciphertext)) {
300 | throw new \Error('Encryption failed.');
301 | }
302 | $mac = \hash_hmac(
303 | self::HASH_ALGO,
304 | Util::preAuthEncode([$header, $nonce, $ciphertext, $footer]),
305 | $authKey,
306 | true
307 | );
308 | if ($footer) {
309 | return $header .
310 | Base64UrlSafe::encode($nonce . $ciphertext . $mac) .
311 | '.' .
312 | Base64UrlSafe::encode($footer);
313 | }
314 | return $header . Base64UrlSafe::encode($nonce . $ciphertext . $mac);
315 | }
316 |
317 | /**
318 | * @param string $message
319 | * @param string $header
320 | * @param SymmetricEncryptionKey $key
321 | * @param string $footer
322 | * @return string
323 | * @throws \Error
324 | * @throws \Exception
325 | * @throws \TypeError
326 | */
327 | public static function aeadDecrypt(
328 | string $message,
329 | string $header,
330 | SymmetricEncryptionKey $key,
331 | string $footer = ''
332 | ): string {
333 | $expectedLen = Binary::safeStrlen($header);
334 | $givenHeader = Binary::safeSubstr($message, 0, $expectedLen);
335 | if (!\hash_equals($header, $givenHeader)) {
336 | throw new \Exception('Invalid message header.');
337 | }
338 | $decoded = Base64UrlSafe::decode(Binary::safeSubstr($message, $expectedLen));
339 | $len = Binary::safeStrlen($decoded);
340 | $nonce = Binary::safeSubstr($decoded, 0, self::NONCE_SIZE);
341 | $ciphertext = Binary::safeSubstr(
342 | $decoded,
343 | self::NONCE_SIZE,
344 | $len - (self::NONCE_SIZE + self::MAC_SIZE)
345 | );
346 | $mac = Binary::safeSubstr($decoded, $len - self::MAC_SIZE);
347 |
348 | list($encKey, $authKey) = $key->split(
349 | Binary::safeSubstr($nonce, 0, 16)
350 | );
351 |
352 | $calc = \hash_hmac(
353 | self::HASH_ALGO,
354 | Util::preAuthEncode([$header, $nonce, $ciphertext, $footer]),
355 | $authKey,
356 | true
357 | );
358 | if (!\hash_equals($calc, $mac)) {
359 | throw new \Exception('Invalid MAC');
360 | }
361 |
362 | /** @var string $plaintext */
363 | $plaintext = \openssl_decrypt(
364 | $ciphertext,
365 | self::CIPHER_MODE,
366 | $encKey,
367 | OPENSSL_RAW_DATA,
368 | Binary::safeSubstr($nonce, 16, 16)
369 | );
370 | if (!\is_string($plaintext)) {
371 | throw new \Error('Encryption failed.');
372 | }
373 |
374 | return $plaintext;
375 | }
376 |
377 | /** @var RSA */
378 | protected static $rsa;
379 |
380 | /**
381 | * Get the PHPSecLib RSA provider
382 | *
383 | * @param bool $signing
384 | * @return RSA
385 | */
386 | public static function getRsa(bool $signing): RSA
387 | {
388 | $rsa = new RSA();
389 | $rsa->setHash('sha384');
390 | $rsa->setMGFHash('sha384');
391 | if ($signing) {
392 | $rsa->setEncryptionMode(RSA::SIGNATURE_PSS);
393 | } else {
394 | $rsa->setEncryptionMode(RSA::ENCRYPTION_OAEP);
395 | }
396 | return $rsa;
397 | }
398 |
399 | /**
400 | * @param string $keyData
401 | * @return string
402 | */
403 | public static function RsaGetPublicKey(string $keyData): string
404 | {
405 | $res = \openssl_pkey_get_private($keyData);
406 | /** @var array $pubkey */
407 | $pubkey = \openssl_pkey_get_details($res);
408 | return \rtrim(
409 | \str_replace("\n", "\r\n", $pubkey['key']),
410 | "\r\n"
411 | );
412 | }
413 | }
414 |
--------------------------------------------------------------------------------
/src/JsonToken.php:
--------------------------------------------------------------------------------
1 | */
34 | protected $claims = [];
35 |
36 | /** @var string $footer */
37 | protected $footer = '';
38 |
39 | /** @var KeyInterface $key */
40 | protected $key = null;
41 |
42 | /** @var string $purpose */
43 | protected $purpose = '';
44 |
45 | /** @var string $version */
46 | protected $version = Version2::HEADER;
47 |
48 | /**
49 | * Get any arbitrary claim.
50 | *
51 | * @param string $claim
52 | * @return mixed
53 | * @throws PastException
54 | */
55 | public function get(string $claim)
56 | {
57 | if (\array_key_exists($claim, $this->claims)) {
58 | return $this->claims[$claim];
59 | }
60 | throw new NotFoundException('Claim not found: ' . $claim);
61 | }
62 |
63 | /**
64 | * Get the 'exp' claim.
65 | *
66 | * @return string
67 | * @throws PastException
68 | */
69 | public function getAudience(): string
70 | {
71 | return (string) $this->get('aud');
72 | }
73 |
74 | /**
75 | * Get all of the claims stored in this PAST.
76 | *
77 | * @return array
78 | */
79 | public function getClaims(): array
80 | {
81 | return $this->claims;
82 | }
83 |
84 | /**
85 | * Get the 'exp' claim.
86 | *
87 | * @return \DateTime
88 | * @throws PastException
89 | */
90 | public function getExpiration(): \DateTime
91 | {
92 | return new \DateTime((string) $this->get('exp'));
93 | }
94 |
95 | /**
96 | * Get the footer as a string.
97 | *
98 | * @return string
99 | */
100 | public function getFooter(): string
101 | {
102 | return $this->footer;
103 | }
104 |
105 | /**
106 | * Get the footer as an array. Assumes JSON.
107 | *
108 | * @return array
109 | * @throws PastException
110 | */
111 | public function getFooterArray(): array
112 | {
113 | /** @var array $decoded */
114 | $decoded = \json_decode($this->footer, true);
115 | if (!\is_array($decoded)) {
116 | throw new EncodingException('Footer is not a valid JSON document');
117 | }
118 | return $decoded;
119 | }
120 |
121 | /**
122 | * Get the 'iat' claim.
123 | *
124 | * @return \DateTime
125 | * @throws PastException
126 | */
127 | public function getIssuedAt(): \DateTime
128 | {
129 | return new \DateTime((string) $this->get('iat'));
130 | }
131 |
132 | /**
133 | * Get the 'iss' claim.
134 | *
135 | * @return string
136 | * @throws PastException
137 | */
138 | public function getIssuer(): string
139 | {
140 | return (string) $this->get('iss');
141 | }
142 |
143 | /**
144 | * Get the 'jti' claim.
145 | *
146 | * @return string
147 | * @throws PastException
148 | */
149 | public function getJti(): string
150 | {
151 | return (string) $this->get('jti');
152 | }
153 |
154 | /**
155 | * Get the 'nbf' claim.
156 | *
157 | * @return \DateTime
158 | * @throws PastException
159 | */
160 | public function getNotBefore(): \DateTime
161 | {
162 | return new \DateTime((string) $this->get('nbf'));
163 | }
164 |
165 | /**
166 | * Get the 'sub' claim.
167 | *
168 | * @return string
169 | * @throws PastException
170 | */
171 | public function getSubject(): string
172 | {
173 | return (string) $this->get('sub');
174 | }
175 |
176 | /**
177 | * Set a claim to an arbitrary value.
178 | *
179 | * @param string $claim
180 | * @param string $value
181 | * @return self
182 | */
183 | public function set(string $claim, $value): self
184 | {
185 | $this->claims[$claim] = $value;
186 | return $this;
187 | }
188 |
189 | /**
190 | * Set the 'aud' claim.
191 | *
192 | * @param string $aud
193 | * @return self
194 | */
195 | public function setAudience(string $aud): self
196 | {
197 | $this->claims['aud'] = $aud;
198 | return $this;
199 | }
200 |
201 | /**
202 | * Set an array of claims in one go.
203 | *
204 | * @param array $claims
205 | * @return self
206 | */
207 | public function setClaims(array $claims): self
208 | {
209 | $this->claims = $claims;
210 | return $this;
211 | }
212 |
213 | /**
214 | * Set the 'exp' claim.
215 | *
216 | * @param \DateTime|null $time
217 | * @return self
218 | */
219 | public function setExpiration(\DateTime $time = null): self
220 | {
221 | if (!$time) {
222 | $time = new \DateTime('NOW');
223 | }
224 | $this->claims['exp'] = $time->format(\DateTime::ATOM);
225 | return $this;
226 | }
227 |
228 | /**
229 | * Set the footer.
230 | *
231 | * @param string $footer
232 | * @return self
233 | */
234 | public function setFooter(string $footer = ''): self
235 | {
236 | $this->footer = $footer;
237 | return $this;
238 | }
239 |
240 | /**
241 | * Set the footer, given an array of data. Converts to JSON.
242 | *
243 | * @param array $footer
244 | * @return self
245 | * @throws PastException
246 | */
247 | public function setFooterArray(array $footer = []): self
248 | {
249 | $encoded = \json_encode($footer);
250 | if (!\is_string($encoded)) {
251 | throw new EncodingException('Could not encode array into JSON');
252 | }
253 | return $this->setFooter($encoded);
254 | }
255 |
256 | /**
257 | * Set the 'iat' claim.
258 | *
259 | * @param \DateTime|null $time
260 | * @return self
261 | */
262 | public function setIssuedAt(\DateTime $time = null): self
263 | {
264 | if (!$time) {
265 | $time = new \DateTime('NOW');
266 | }
267 | $this->claims['iat'] = $time->format(\DateTime::ATOM);
268 | return $this;
269 | }
270 |
271 | /**
272 | * Set the 'iss' claim.
273 | *
274 | * @param string $iss
275 | * @return self
276 | */
277 | public function setIssuer(string $iss): self
278 | {
279 | $this->claims['iss'] = $iss;
280 | return $this;
281 | }
282 |
283 | /**
284 | * Set the 'jti' claim.
285 | *
286 | * @param string $id
287 | * @return self
288 | */
289 | public function setJti(string $id): self
290 | {
291 | $this->claims['jti'] = $id;
292 | return $this;
293 | }
294 |
295 | /**
296 | * Set the cryptographic key used to authenticate (and possibly encrypt)
297 | * the serialized token.
298 | *
299 | * @param KeyInterface $key
300 | * @param bool $checkPurpose
301 | * @return self
302 | * @throws PastException
303 | */
304 | public function setKey(KeyInterface $key, bool $checkPurpose = false): self
305 | {
306 | if ($checkPurpose) {
307 | switch ($this->purpose) {
308 | case 'auth':
309 | if (!($key instanceof SymmetricAuthenticationKey)) {
310 | throw new InvalidKeyException(
311 | 'Invalid key type. Expected ' . SymmetricAuthenticationKey::class . ', got ' . \get_class($key)
312 | );
313 | }
314 | break;
315 | case 'enc':
316 | if (!($key instanceof SymmetricEncryptionKey)) {
317 | throw new InvalidKeyException(
318 | 'Invalid key type. Expected ' . SymmetricEncryptionKey::class . ', got ' . \get_class($key)
319 | );
320 | }
321 | break;
322 | case 'seal':
323 | if (!($key instanceof AsymmetricPublicKey)) {
324 | throw new InvalidKeyException(
325 | 'Invalid key type. Expected ' . AsymmetricPublicKey::class . ', got ' . \get_class($key)
326 | );
327 | }
328 | if (!\hash_equals($this->version, $key->getProtocol())) {
329 | throw new InvalidKeyException(
330 | 'Invalid key type. This key is for ' . $key->getProtocol() . ', not ' . $this->version
331 | );
332 | }
333 | break;
334 | case 'sign':
335 | if (!($key instanceof AsymmetricSecretKey)) {
336 | throw new InvalidKeyException(
337 | 'Invalid key type. Expected ' . AsymmetricSecretKey::class . ', got ' . \get_class($key)
338 | );
339 | }
340 | if (!\hash_equals($this->version, $key->getProtocol())) {
341 | throw new InvalidKeyException(
342 | 'Invalid key type. This key is for ' . $key->getProtocol() . ', not ' . $this->version
343 | );
344 | }
345 | break;
346 | default:
347 | throw new InvalidKeyException('Unknown purpose');
348 | }
349 | }
350 | $this->key = $key;
351 | return $this;
352 | }
353 |
354 | /**
355 | * Set the 'nbf' claim.
356 | *
357 | * @param \DateTime|null $time
358 | * @return self
359 | */
360 | public function setNotBefore(\DateTime $time = null): self
361 | {
362 | if (!$time) {
363 | $time = new \DateTime('NOW');
364 | }
365 | $this->claims['nbf'] = $time->format(\DateTime::ATOM);
366 | return $this;
367 | }
368 |
369 | /**
370 | * Set the purpose for this token. Allowed values:
371 | * 'auth', 'enc', 'seal', 'sign'.
372 | *
373 | * @param string $purpose
374 | * @param bool $checkKeyType
375 | * @return self
376 | * @throws InvalidPurposeException
377 | */
378 | public function setPurpose(string $purpose, bool $checkKeyType = false): self
379 | {
380 | if ($checkKeyType) {
381 | $keyType = \get_class($this->key);
382 | switch ($keyType) {
383 | case SymmetricAuthenticationKey::class:
384 | if (!\hash_equals('auth', $purpose)) {
385 | throw new InvalidPurposeException(
386 | 'Invalid purpose. Expected auth, got ' . $purpose
387 | );
388 | }
389 | break;
390 | case SymmetricEncryptionKey::class:
391 | if (!\hash_equals('enc', $purpose)) {
392 | throw new InvalidPurposeException(
393 | 'Invalid purpose. Expected enc, got ' . $purpose
394 | );
395 | }
396 | break;
397 | case AsymmetricPublicKey::class:
398 | if (!\hash_equals('seal', $purpose)) {
399 | throw new InvalidPurposeException(
400 | 'Invalid purpose. Expected seal, got ' . $purpose
401 | );
402 | }
403 | break;
404 | case AsymmetricSecretKey::class:
405 | if (!\hash_equals('sign', $purpose)) {
406 | throw new InvalidPurposeException(
407 | 'Invalid purpose. Expected sign, got ' . $purpose
408 | );
409 | }
410 | break;
411 | default:
412 | throw new InvalidPurposeException('Unknown purpose: ' . $purpose);
413 | }
414 | }
415 |
416 | $this->purpose = $purpose;
417 | return $this;
418 | }
419 |
420 | /**
421 | * Set the 'sub' claim.
422 | *
423 | * @param string $sub
424 | * @return self
425 | */
426 | public function setSubject(string $sub): self
427 | {
428 | $this->claims['sub'] = $sub;
429 | return $this;
430 | }
431 |
432 | /**
433 | * Set the version for the protocol.
434 | *
435 | * @param string $version
436 | * @return self
437 | */
438 | public function setVersion(string $version): self
439 | {
440 | $this->version = $version;
441 | return $this;
442 | }
443 |
444 | /**
445 | * Get the token as a string.
446 | *
447 | * @return string
448 | * @throws PastException
449 | * @psalm-suppress MixedInferredReturnType
450 | */
451 | public function toString(): string
452 | {
453 | // Mutual sanity checks
454 | $this->setKey($this->key, true);
455 | $this->setPurpose($this->purpose, true);
456 |
457 | $claims = \json_encode($this->claims);
458 | switch ($this->version) {
459 | case Version1::HEADER:
460 | $protocol = Version1::class;
461 | break;
462 | case Version2::HEADER:
463 | $protocol = Version2::class;
464 | break;
465 | default:
466 | throw new InvalidVersionException('Unsupported version: ' . $this->version);
467 | }
468 | /** @var ProtocolInterface $protocol */
469 | switch ($this->purpose) {
470 | case 'auth':
471 | if ($this->key instanceof SymmetricAuthenticationKey) {
472 | return $protocol::auth($claims, $this->key, $this->footer);
473 | }
474 | break;
475 | case 'enc':
476 | if ($this->key instanceof SymmetricEncryptionKey) {
477 | return $protocol::encrypt($claims, $this->key, $this->footer);
478 | }
479 | break;
480 | case 'seal':
481 | if ($this->key instanceof AsymmetricPublicKey) {
482 | try {
483 | return $protocol::seal($claims, $this->key, $this->footer);
484 | } catch (\Throwable $ex) {
485 | throw new PastException('Sealing failed.', 0, $ex);
486 | }
487 | }
488 | break;
489 | case 'sign':
490 | if ($this->key instanceof AsymmetricSecretKey) {
491 | try {
492 | return $protocol::sign($claims, $this->key, $this->footer);
493 | } catch (\Throwable $ex) {
494 | throw new PastException('Signing failed.', 0, $ex);
495 | }
496 | }
497 | break;
498 | }
499 | throw new PastException('Unsupported key/purpose pairing.');
500 | }
501 |
502 | /**
503 | * @return string
504 | */
505 | public function __toString()
506 | {
507 | try {
508 | return $this->toString();
509 | } catch (\Throwable $ex) {
510 | return '';
511 | }
512 | }
513 | }
514 |
--------------------------------------------------------------------------------