Skip to content
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

Tighten string to non-empty-string where possible #936

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ vendor/composer/installed.json: composer.json composer.lock

.PHONY: phpunit
phpunit:
@php -d assert.exception=1 -d zend.assertions=1 vendor/bin/phpunit
@php -d assert.exception=1 -d zend.assertions=1 vendor/bin/phpunit $(PHPUNIT_FLAGS)

.PHONY: infection
infection:
Expand Down
4 changes: 4 additions & 0 deletions src/Decoder.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ interface Decoder
/**
* Decodes from JSON, validating the errors
*
* @param non-empty-string $json
*
* @throws CannotDecodeContent When something goes wrong while decoding.
*/
public function jsonDecode(string $json): mixed;
Expand All @@ -19,6 +21,8 @@ public function jsonDecode(string $json): mixed;
*
* @link http://tools.ietf.org/html/rfc4648#section-5
*
* @return ($data is non-empty-string ? non-empty-string : string)
*
* @throws CannotDecodeContent When something goes wrong while decoding.
*/
public function base64UrlDecode(string $data): string;
Expand Down
4 changes: 4 additions & 0 deletions src/Encoder.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ interface Encoder
/**
* Encodes to JSON, validating the errors
*
* @return non-empty-string
*
* @throws CannotEncodeContent When something goes wrong while encoding.
*/
public function jsonEncode(mixed $data): string;
Expand All @@ -18,6 +20,8 @@ public function jsonEncode(mixed $data): string;
* Encodes to base64url
*
* @link http://tools.ietf.org/html/rfc4648#section-5
*
* @return ($data is non-empty-string ? non-empty-string : string)
*/
public function base64UrlEncode(string $data): string;
}
7 changes: 6 additions & 1 deletion src/Encoding/JoseEncoder.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Lcobucci\JWT\Encoder;
use Lcobucci\JWT\SodiumBase64Polyfill;

use function assert;
use function json_decode;
use function json_encode;

Expand All @@ -23,10 +24,14 @@ final class JoseEncoder implements Encoder, Decoder
public function jsonEncode(mixed $data): string
{
try {
return json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
$jsonEncoded = json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
throw CannotEncodeContent::jsonIssues($exception);
}

assert($jsonEncoded !== '');
Copy link
Collaborator

Choose a reason for hiding this comment

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

Hmm, I would expect psalm and phpstan to have better type inference here 🤔


return $jsonEncoded;
}

public function jsonDecode(string $json): mixed
Expand Down
1 change: 1 addition & 0 deletions src/JwtFacade.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ public function issue(
return $customiseBuilder($builder, $now)->getToken($signer, $signingKey);
}

/** @param non-empty-string $jwt */
public function parse(
string $jwt,
SignedWith $signedWith,
Expand Down
2 changes: 2 additions & 0 deletions src/Parser.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ interface Parser
/**
* Parses the JWT and returns a token
*
* @param non-empty-string $jwt
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think a parser should accept an empty string here, with CannotDecodeContent as an outcome 🤔

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

An empty string JWT is always an error, just like a boolean would be, that's why there's a string native type ATM.
Why not preventing also empty string at all?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Need to think about it, but I see a CannotDecodeContent exception as quite valuable on an empty string scenario here: forcing further verification on the client side seems wasteful, at this stage.

I agree that a string&empty will always result in a crash here: just wondering whether forcing an additional upfront assertion is added value.

In the following (valid) code:

function foo(string $token) {
    Assert::notEmpty($token);
    $parsed = $this->parser->parse($token);

    // ...

If a CannotDecodeContent is thrown (explaining that the content was empty) is the same value of AssertionFailed, except with an additional piece of code for it.

I'm just trying to figure out the ergonomics of the library here:

  • correctness leads to string&empty -> never
  • usability leads to non-empty-string|(string&empty) -> Token|Exception

Copy link
Collaborator Author

@Slamdunk Slamdunk Oct 11, 2022

Choose a reason for hiding this comment

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

Now I understand better your doubts.

I can only say the sooner the type is safe, the better:

  • if the library allows an empty string, my whole app may allow it
  • if the library denies an empty string, I have to design the app upfront to get rid of it. This basically means input validation before consumption

Copy link
Collaborator

Choose a reason for hiding this comment

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

There is one good thing coming from this new type restriction: the existence of the token must be verified by the consumer.

Anyway, I'm just presenting the options: I think that the parser (being a parser) is responsible for upcasting what comes in (or throwing), so it's our decision to decide how wide the API is supposed to be.

The parser makes the type safe anyway, so I have doubts on the value of an early assertion, in this specific case.

Copy link
Collaborator

Choose a reason for hiding this comment

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

That said, we can experiment with this, and then later-on relax the type constraint, if we consider it excessive.

*
* @throws CannotDecodeContent When something goes wrong while decoding.
* @throws InvalidTokenStructure When token string structure is invalid.
* @throws UnsupportedHeaderFound When parsed token has an unsupported header.
Expand Down
6 changes: 6 additions & 0 deletions src/Signer.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,16 @@ interface Signer
{
/**
* Returns the algorithm id
*
* @return non-empty-string
*/
public function algorithmId(): string;

/**
* Creates a hash for the given payload
*
* @param non-empty-string $payload
*
* @throws CannotSignPayload When payload signing fails.
* @throws InvalidKeyProvided When issue key is invalid/incompatible.
* @throws ConversionFailed When signature could not be converted.
Expand All @@ -27,6 +31,8 @@ public function sign(string $payload, Key $key): string;
/**
* Returns if the expected hash matches with the data and key
*
* @param non-empty-string $payload
*
* @throws InvalidKeyProvided When issue key is invalid/incompatible.
* @throws ConversionFailed When signature could not be converted.
*/
Expand Down
6 changes: 5 additions & 1 deletion src/Signer/Blake2b.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

use Lcobucci\JWT\Signer;

use function assert;
use function hash_equals;
use function sodium_crypto_generichash;
use function strlen;
Expand All @@ -26,7 +27,10 @@ public function sign(string $payload, Key $key): string
throw InvalidKeyProvided::tooShort(self::MINIMUM_KEY_LENGTH_IN_BITS, $actualKeyLength);
}

return sodium_crypto_generichash($payload, $key->contents());
$hash = sodium_crypto_generichash($payload, $key->contents());
assert($hash !== '');

return $hash;
}

public function verify(string $expected, string $payload, Key $key): bool
Expand Down
5 changes: 5 additions & 0 deletions src/Signer/Ecdsa.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
use Lcobucci\JWT\Signer\Ecdsa\MultibyteStringConverter;
use Lcobucci\JWT\Signer\Ecdsa\SignatureConverter;

use function assert;

use const OPENSSL_KEYTYPE_EC;

abstract class Ecdsa extends OpenSSL
Expand All @@ -19,6 +21,7 @@ public static function create(): Ecdsa
return new static(new MultibyteStringConverter()); // @phpstan-ignore-line
}

/** @return non-empty-string */
final public function sign(string $payload, Key $key): string
{
return $this->converter->fromAsn1(
Expand All @@ -29,6 +32,8 @@ final public function sign(string $payload, Key $key): string

final public function verify(string $expected, string $payload, Key $key): bool
{
assert($expected !== '');
Copy link
Collaborator

Choose a reason for hiding this comment

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

Wait, is the signature not supposed to be non-empty upfront?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Not until we allow \Lcobucci\JWT\Signer\None to exist.

\Lcobucci\JWT\Signer::verify API must take into account all available signers.


return $this->verifySignature(
$this->converter->toAsn1($expected, $this->pointLength()),
$payload,
Expand Down
2 changes: 2 additions & 0 deletions src/Signer/Ecdsa/MultibyteStringConverter.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ public function toAsn1(string $points, int $length): string
. self::ASN1_INTEGER . dechex($lengthS) . $pointS,
);
assert(is_string($asn1));
assert($asn1 !== '');

return $asn1;
}
Expand Down Expand Up @@ -109,6 +110,7 @@ public function fromAsn1(string $signature, int $length): string

$points = hex2bin(str_pad($pointR, $length, '0', STR_PAD_LEFT) . str_pad($pointS, $length, '0', STR_PAD_LEFT));
assert(is_string($points));
assert($points !== '');

return $points;
}
Expand Down
6 changes: 6 additions & 0 deletions src/Signer/Ecdsa/SignatureConverter.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,19 @@ interface SignatureConverter
/**
* Converts the signature generated by OpenSSL into what JWA defines
*
* @return non-empty-string
*
* @throws ConversionFailed When there was an issue during the format conversion.
*/
public function fromAsn1(string $signature, int $length): string;

/**
* Converts the JWA signature into something OpenSSL understands
*
* @param non-empty-string $points
*
* @return non-empty-string
*
* @throws ConversionFailed When there was an issue during the format conversion.
*/
public function toAsn1(string $points, int $length): string;
Expand Down
7 changes: 6 additions & 1 deletion src/Signer/Eddsa.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use Lcobucci\JWT\Signer;
use SodiumException;

use function assert;
use function sodium_crypto_sign_detached;
use function sodium_crypto_sign_verify_detached;

Expand All @@ -19,10 +20,14 @@ public function algorithmId(): string
public function sign(string $payload, Key $key): string
{
try {
return sodium_crypto_sign_detached($payload, $key->contents());
$signature = sodium_crypto_sign_detached($payload, $key->contents());
} catch (SodiumException $sodiumException) {
throw new InvalidKeyProvided($sodiumException->getMessage(), 0, $sodiumException);
}

assert($signature !== '');

return $signature;
}

public function verify(string $expected, string $payload, Key $key): bool
Expand Down
1 change: 1 addition & 0 deletions src/Signer/Hmac.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ final public function verify(string $expected, string $payload, Key $key): bool
return hash_equals($expected, $this->sign($payload, $key));
}

/** @return non-empty-string */
abstract public function algorithm(): string;

/** @return positive-int */
Expand Down
2 changes: 2 additions & 0 deletions src/Signer/OpenSSL.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ abstract class OpenSSL implements Signer
];

/**
* @return non-empty-string
*
* @throws CannotSignPayload
* @throws InvalidKeyProvided
*/
Expand Down
14 changes: 12 additions & 2 deletions src/SodiumBase64Polyfill.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ final class SodiumBase64Polyfill
public const SODIUM_BASE64_VARIANT_URLSAFE = 5;
public const SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING = 7;

/** @return ($decoded is non-empty-string ? non-empty-string : string) */
public static function bin2base64(string $decoded, int $variant): string
{
if (! function_exists('sodium_bin2base64')) {
Expand All @@ -32,6 +33,7 @@ public static function bin2base64(string $decoded, int $variant): string
return sodium_bin2base64($decoded, $variant);
}

/** @return ($decoded is non-empty-string ? non-empty-string : string) */
public static function bin2base64Fallback(string $decoded, int $variant): string
{
$encoded = base64_encode($decoded);
Expand All @@ -53,7 +55,11 @@ public static function bin2base64Fallback(string $decoded, int $variant): string
return $encoded;
}

/** @throws CannotDecodeContent */
/**
* @return ($encoded is non-empty-string ? non-empty-string : string)
*
* @throws CannotDecodeContent
*/
public static function base642bin(string $encoded, int $variant): string
{
if (! function_exists('sodium_base642bin')) {
Expand All @@ -67,7 +73,11 @@ public static function base642bin(string $encoded, int $variant): string
}
}

/** @throws CannotDecodeContent */
/**
* @return ($encoded is non-empty-string ? non-empty-string : string)
*
* @throws CannotDecodeContent
*/
public static function base642binFallback(string $encoded, int $variant): string
{
if (
Expand Down
2 changes: 2 additions & 0 deletions src/Token.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ public function isExpired(DateTimeInterface $now): bool;

/**
* Returns an encoded representation of the token
*
* @return non-empty-string
*/
public function toString(): string;
}
10 changes: 10 additions & 0 deletions src/Token/InvalidTokenStructure.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ public static function missingOrNotEnoughSeparators(): self
return new self('The JWT string must have two dots');
}

public static function missingHeaderPart(): self
{
return new self('The JWT string is missing the Header part');
}

public static function missingClaimsPart(): self
{
return new self('The JWT string is missing the Claim part');
}

public static function arrayExpected(string $part): self
{
return new self($part . ' must be an array');
Expand Down
14 changes: 14 additions & 0 deletions src/Token/Parser.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ public function parse(string $jwt): TokenInterface
{
[$encodedHeaders, $encodedClaims, $encodedSignature] = $this->splitJwt($jwt);

if ($encodedHeaders === '') {
throw InvalidTokenStructure::missingHeaderPart();
}

if ($encodedClaims === '') {
throw InvalidTokenStructure::missingClaimsPart();
}

$header = $this->parseHeader($encodedHeaders);

return new Plain(
Expand All @@ -39,6 +47,8 @@ public function parse(string $jwt): TokenInterface
/**
* Splits the JWT string into an array
*
* @param non-empty-string $jwt
*
* @return string[]
*
* @throws InvalidTokenStructure When JWT doesn't have all parts.
Expand All @@ -57,6 +67,8 @@ private function splitJwt(string $jwt): array
/**
* Parses the header from a string
*
* @param non-empty-string $data
*
* @return mixed[]
*
* @throws UnsupportedHeaderFound When an invalid header is informed.
Expand Down Expand Up @@ -84,6 +96,8 @@ private function parseHeader(string $data): array
/**
* Parses the claim set from a string
*
* @param non-empty-string $data
*
* @return mixed[]
*
* @throws InvalidTokenStructure When parsed content isn't an array or contains non-parseable dates.
Expand Down
2 changes: 2 additions & 0 deletions src/UnencryptedToken.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ public function signature(): Signature;

/**
* Returns the token payload
*
* @return non-empty-string
*/
public function payload(): string;
}
1 change: 1 addition & 0 deletions test/functional/MaliciousTamperingPreventionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ public function preventRegressionsThatAllowsMaliciousTampering(): void
);
}

/** @return non-empty-string */
private function createMaliciousToken(string $token): string
{
$dec = new JoseEncoder();
Expand Down
2 changes: 2 additions & 0 deletions test/functional/RFC6978VectorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ final class RFC6978VectorTest extends TestCase
* @covers \Lcobucci\JWT\Signer\Ecdsa\Sha384
* @covers \Lcobucci\JWT\Signer\Ecdsa\Sha512
* @covers \Lcobucci\JWT\Signer\OpenSSL
*
* @param non-empty-string $payload
*/
public function theVectorsFromRFC6978CanBeVerified(
Ecdsa $signer,
Expand Down
1 change: 1 addition & 0 deletions test/unit/JwtFacadeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ protected function setUp(): void
$this->issuer = 'bar';
}

/** @return non-empty-string */
private function createToken(): string
{
return (new JwtFacade(clock: $this->clock))->issue(
Expand Down
Loading