├── .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 | [![Build Status](https://travis-ci.org/paragonie/past.svg?branch=master)](https://travis-ci.org/paragonie/past) 4 | [![Latest Stable Version](https://poser.pugx.org/paragonie/past/v/stable)](https://packagist.org/packages/paragonie/past) 5 | [![Latest Unstable Version](https://poser.pugx.org/paragonie/past/v/unstable)](https://packagist.org/packages/paragonie/past) 6 | [![License](https://poser.pugx.org/paragonie/past/license)](https://packagist.org/packages/paragonie/past) 7 | [![Downloads](https://img.shields.io/packagist/dt/paragonie/past.svg)](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 | --------------------------------------------------------------------------------