-
Couldn't load subscription status.
- Fork 110
PASETO authenticator and identifier #538
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
| * | ||
| * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) | ||
| * @link https://cakephp.org CakePHP(tm) Project | ||
| * @since 1.0.0 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You'll likely need 3.0.0 here.
| if (!class_exists(\Cake\Utility\Security::class)) { | ||
| throw new RuntimeException('PASETO `secretKey` config must be defined'); | ||
| } | ||
| $this->setConfig('secretKey', \Cake\Utility\Security::getSalt()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will the application salt always be long enough? Are there length requirements in paseto?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For Symmetric keys between 16 and 64 bytes. sodium_crypto_generichash will throw a SodiumException: unsupported key length exception otherwise, unfortunately this is not well documented here: sodium_crypto_generichash. We could add a validation for this in the authenticator.
For Asymmetric keys it is recommended to use sodium_crypto_sign_keypair to generate the key pairs:
$privateKey = new AsymmetricSecretKey(sodium_crypto_sign_keypair());
$publicKey = $privateKey->getPublicKey();
var_dump($privateKey->encode());
var_dump($publicKey->encode());This is where I wish the library provided a cli to easily generate those for developers.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is where I wish the library provided a cli to easily generate those for developers.
We could provide that CLI tool 😄
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For Symmetric keys between 16 and 64 bytes. sodium_crypto_generichash will throw a SodiumException: unsupported key length exception otherwise, unfortunately this is not well documented here
That will work. If users get tripped up by the exceptions from libsodium we can always catch errors and rethrow with a better message.
| if ($this->getConfig('returnPayload')) { | ||
| $array = array_merge( | ||
| $result->getClaims(), | ||
| ['footer' => $result->getFooterArray()] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What is the footer used for by application logic?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A token is split into version, purpose, encrypted data, and unencrypted data with dots as the separator.
v4.local.KQVqiaVJbg_V0W9s-ekQMYyjA5sZCSMUFw6WxLJ_6806uE7ceNad9ABgGwgJ_HDN8zLDC_SF9hzFoESX8lt-v_o1LjsahdoOETFlp884ztE-vz5MZq6CfvaVhH4cjT8Wi6p43H95jegzM3BhbjrJpAiUOWISPxbR5Da90WSkCUVSQfMjCzSrTjEQRdTlbBNpjzEOen6f8Vaw-Wl2eqtmzgVDZ5LDcyR9fatO_p_IhH8cFwLXILoORpjFBgeCDXZqr9jaum6zEvAs3XSfWB86RLUM_QD-elM_2DkaCwqS_1btNYKpahRkLpr6ehM8dSKL.eyJmb290ZXJfZGF0YSI6ImlzIHVuZW5jcnlwdGVkIGJ1dCB0YW1wZXIgcHJvb2YifQ
So you can base64 decode the footer segment: eyJmb290ZXJfZGF0YSI6ImlzIHVuZW5jcnlwdGVkIGJ1dCB0YW1wZXIgcHJvb2YifQ to:
{"footer_data":"is unencrypted but tamper proof"}
I assume to give API clients or any client that doesn't have the secret the ability to read non-sensitive data.
| 'version' => null, | ||
| 'purpose' => null, | ||
| 'secretKey' => null, | ||
| //'subjectKey' => IdentifierInterface::CREDENTIAL_JWT_SUBJECT, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm thinking for this we will just remove subjectKey and always use sub.
| */ | ||
| protected $_defaultConfig = [ | ||
| 'tokenField' => 'id', | ||
| 'dataField' => self::CREDENTIAL_JWT_SUBJECT, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thinking of hardcoding dataField to "sub"
| } | ||
|
|
||
| if (empty($this->getConfig('secretKey'))) { | ||
| if (!class_exists(\Cake\Utility\Security::class)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don't know how to test this, thinking this should be marked as code coverage ignore.
| */ | ||
| public function getPayload(?ServerRequestInterface $request = null): ?object | ||
| { | ||
| if (!$request) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure I understand this condition, it was borrowed from JwtAuthenticator. How is this scenario possible?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The behavior looks like it wants to reuse the stored payload property. By passing null in, you get the last parsed result.
| "cakephp/cakephp-codesniffer": "^4.0", | ||
| "firebase/php-jwt": "^6.2", | ||
| "paragonie/paseto": "^2.4", | ||
| "paragonie/constant_time_encoding": "^2.2", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This was required because of prefer-lowest on 7.2. The method needed from the package was not included until 2.2 (current is 2.5).
|
@cnizzardini After fixing the phpstan error, can you squash the commits? |
7ece209 to
3d73585
Compare
| if ($this->version === null) { | ||
| throw new RuntimeException('PASETO `version` must be one of: v3 or v4'); | ||
| } | ||
|
|
||
| if (!in_array($this->getConfig('purpose'), [self::PUBLIC, self::LOCAL])) { | ||
| throw new RuntimeException('PASETO `purpose` config must one of: local or public'); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are there no reasonably secure defaults we could use? Even 'most secure' is a reasonable defaults. Right now we're forcing a lot of decisions on the developer.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can default to local and v4.
| */ | ||
| public function getPayload(?ServerRequestInterface $request = null): ?object | ||
| { | ||
| if (!$request) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The behavior looks like it wants to reuse the stored payload property. By passing null in, you get the last parsed result.
| protected function decodeToken(string $token): ?JsonToken | ||
| { | ||
| if ($this->getConfig('purpose') === self::PUBLIC) { | ||
| $receivingKey = AsymmetricSecretKey::fromEncodedString( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This seems like a reasonable default purpose to me.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was thinking LOCAL should be the default purpose. Local is similar to the default for JWT which uses an HMAC secret.
| switch ($this->getConfig('version')) { | ||
| case 'v3': | ||
| return new Version3(); | ||
| case 'v4': |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Defaulting to the highest version seems like a good default value as well.
| ->addArgument('purpose', [ | ||
| 'help' => 'The PASETO purpose', | ||
| 'required' => true, | ||
| 'choices' => [PasetoAuthenticator::LOCAL, PasetoAuthenticator::PUBLIC], |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would be good to have a default value on these too. Good defaults help users succeed with less effort.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can set defaults to v4 local here as well.
| case PasetoAuthenticator::PUBLIC: | ||
| /** @var Builder $token extracted */ | ||
| /** @var AsymmetricSecretKey $privateKey extracted */ | ||
| extract($this->buildPublicToken(new Version4())); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This feels risky to me. Having a method arbitrarily update local scope has not worked out well for us in the past. Can we be more explicit with what is being set in local scope here.
| $authenticator = $this->getMockBuilder(PasetoAuthenticator::class) | ||
| ->setConstructorArgs([ | ||
| $this->identifiers, | ||
| [ | ||
| 'purpose' => PasetoAuthenticator::LOCAL, | ||
| 'version' => 'v4', | ||
| ], | ||
| ]) | ||
| ->onlyMethods([ | ||
| 'getPayLoad', | ||
| ]) | ||
| ->getMock(); | ||
|
|
||
| $authenticator->expects($this->once()) | ||
| ->method('getPayLoad') | ||
| ->willReturn((new JsonToken())->setSubject('')); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this need to use a mock? There is a lot of code being dedicated to mocking to avoid creating a value we could generate with fewer lines of code.
Could we try having a helper method that generates a key pair and creates an authenticator using that key pair. You would also need a method that generates a token for a given input.
This is a bit of plumbing work to do but in the long term it helps with maintenance as we don't have to worry about changes in a mocking library. Additionally it also gives us more confidence in the tests as we are testing more of the subsytem all together.
|
@cnizzardini Can you follow up on this? |
|
Its on my mind, some personal items getting in the way of freetime dev
right now. Gimme a few days.
…On Tue, Jun 21, 2022 at 3:24 PM othercorey ***@***.***> wrote:
@cnizzardini <https://github.com/cnizzardini> Can you follow up on this?
—
Reply to this email directly, view it on GitHub
<#538 (comment)>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/AABJ2HTI5QLF42KM6QV7XJTVQIJFLANCNFSM5XFS2CZA>
.
You are receiving this because you were mentioned.Message ID:
***@***.***>
|
|
@markstory can you take another look? |
| * | ||
| * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) | ||
| * @link https://cakephp.org CakePHP(tm) Project | ||
| * @since 3.0.0 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| * @since 3.0.0 | |
| * @since 2.10.0 |
| 'tokenPrefix' => 'bearer', | ||
| 'returnPayload' => true, | ||
| 'version' => 'v4', | ||
| 'purpose' => 'local', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| 'purpose' => 'local', | |
| 'purpose' => self::LOCAL, |
| /** | ||
| * @var \ParagonIE\Paseto\ProtocolInterface|null | ||
| */ | ||
| private $version; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| private $version; | |
| protected $version; |
|
|
||
| $this->version = $this->whichVersion(); | ||
|
|
||
| if ($this->version === null) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The whichVersion() method itself can throw an exception instead of returning null.
|
This pull request is stale because it has been open 30 days with no activity. Remove the |
This is a rough draft implementation of PASETO (Platform Agnostic Security Tokens). This supports local symmetric keys and public asymmetric keys, but does not yet have support for keyrings and PASERK which gives functionality similar to JWKS. If there is interest in this I can add support for those items as well as unit tests and docs.
Usage:
Usage is fairly similar to the JWT Authenticator with the addition of a few keys:
There is also a
versionwhich takes an implementation ofProtocolInterfacebut defaults to the latest version in paseto (currently v4).Using the public option is a little PITA to generate keys and I'd like to get a shell command added to the main paseto lib to easily generate the public/private key pairs.
Building a local token:
Reference: