Skip to content

Conversation

@cnizzardini
Copy link
Contributor

@cnizzardini cnizzardini commented May 27, 2022

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:

        $service->loadIdentifier('Authentication.PasetoSubject');
        $service->loadAuthenticator('Authentication.Paseto', [
            'purpose' => PasetoAuthenticator::LOCAL, // or PasetoAuthenticator::PUBLIC
        ]);

There is also a version which takes an implementation of ProtocolInterface but 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:

        /** @var ResultInterface $result **/
        $key = SymmetricKey::v4(Security::getSalt());

        $token = (new Builder())
            ->setKey($key)
            ->setSubject($result->getData()->get('id'))
            ->setVersion(new Version4)
            ->setPurpose(Purpose::local())
            // Set it to expire in one day
            ->setIssuedAt()
            ->setNotBefore()
            ->setExpiration(
                (new DateTime())->add(new DateInterval('P01D'))
            )
            // Store arbitrary data
            ->setClaims([
                'claim_data' => 'is encrypted',
            ])
            ->setFooterArray([
                'footer_data' => 'is unencrypted but tamper proof'
            ]);

Reference:

*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 1.0.0
Copy link
Member

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());
Copy link
Member

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?

Copy link
Contributor Author

@cnizzardini cnizzardini Jun 1, 2022

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.

Copy link
Member

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 😄

Copy link
Member

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()]
Copy link
Member

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?

Copy link
Contributor Author

@cnizzardini cnizzardini Jun 1, 2022

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,
Copy link
Contributor Author

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,
Copy link
Contributor Author

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"

@cnizzardini cnizzardini changed the base branch from 2.x to 2.next June 2, 2022 23:20
}

if (empty($this->getConfig('secretKey'))) {
if (!class_exists(\Cake\Utility\Security::class)) {
Copy link
Contributor Author

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) {
Copy link
Contributor Author

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?

Copy link
Member

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",
Copy link
Contributor Author

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).

@othercorey
Copy link
Member

@cnizzardini After fixing the phpstan error, can you squash the commits?

@cnizzardini cnizzardini force-pushed the paseto branch 2 times, most recently from 7ece209 to 3d73585 Compare June 7, 2022 21:43
Comment on lines +81 to +87
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');
}
Copy link
Member

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.

Copy link
Contributor Author

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) {
Copy link
Member

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(
Copy link
Member

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.

Copy link
Contributor Author

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':
Copy link
Member

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],
Copy link
Member

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.

Copy link
Contributor Author

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()));
Copy link
Member

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.

Comment on lines 370 to 385
$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(''));
Copy link
Member

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.

@othercorey
Copy link
Member

@cnizzardini Can you follow up on this?

@cnizzardini
Copy link
Contributor Author

cnizzardini commented Jun 21, 2022 via email

@cnizzardini cnizzardini requested a review from markstory July 7, 2022 01:50
@othercorey
Copy link
Member

@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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* @since 3.0.0
* @since 2.10.0

'tokenPrefix' => 'bearer',
'returnPayload' => true,
'version' => 'v4',
'purpose' => 'local',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
'purpose' => 'local',
'purpose' => self::LOCAL,

/**
* @var \ParagonIE\Paseto\ProtocolInterface|null
*/
private $version;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
private $version;
protected $version;


$this->version = $this->whichVersion();

if ($this->version === null) {
Copy link
Member

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.

@github-actions
Copy link

This pull request is stale because it has been open 30 days with no activity. Remove the stale label or comment on this issue, or it will be closed in 15 days

@github-actions github-actions bot added the stale label Feb 16, 2023
@github-actions github-actions bot closed this Mar 3, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants