Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.65% covered (success)
95.65%
22 / 23
87.50% covered (warning)
87.50%
7 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jwk
95.65% covered (success)
95.65%
22 / 23
87.50% covered (warning)
87.50%
7 / 8
16
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 rsaToPem
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 base64UrlDecode
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 base64UrlEncode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 integer
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 sequence
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 bitString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 length
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace LambdaTwelve\OneRecord\Auth\Jwt;
6
7use InvalidArgumentException;
8
9/**
10 * Converts an RSA JSON Web Key (RFC 7517, kty "RSA" with n and e) to the PEM
11 * SubjectPublicKeyInfo that OpenSSL verifies with. Done by hand because the
12 * DER involved is small and a dependency for it would be the only one.
13 */
14final class Jwk
15{
16    private const string RSA_ENCRYPTION_OID = "\x06\x09\x2a\x86\x48\x86\xf7\x0d\x01\x01\x01";
17
18    private function __construct() {}
19
20    /**
21     * @param array<string, mixed> $jwk
22     */
23    public static function rsaToPem(array $jwk): string
24    {
25        if (($jwk['kty'] ?? null) !== 'RSA' || !\is_string($jwk['n'] ?? null) || !\is_string($jwk['e'] ?? null)) {
26            throw new InvalidArgumentException('Not an RSA JWK with n and e.');
27        }
28        $modulus = self::base64UrlDecode($jwk['n']);
29        $exponent = self::base64UrlDecode($jwk['e']);
30        if ($modulus === '' || $exponent === '') {
31            throw new InvalidArgumentException('Malformed RSA JWK.');
32        }
33
34        $rsaPublicKey = self::sequence(self::integer($modulus) . self::integer($exponent));
35        $algorithm = self::sequence(self::RSA_ENCRYPTION_OID . "\x05\x00");
36        $subjectPublicKeyInfo = self::sequence($algorithm . self::bitString($rsaPublicKey));
37
38        return "-----BEGIN PUBLIC KEY-----\n" . chunk_split(base64_encode($subjectPublicKeyInfo), 64, "\n") . "-----END PUBLIC KEY-----\n";
39    }
40
41    public static function base64UrlDecode(string $data): string
42    {
43        $decoded = base64_decode(strtr($data, '-_', '+/') . str_repeat('=', (4 - \strlen($data) % 4) % 4), true);
44
45        return $decoded === false ? '' : $decoded;
46    }
47
48    public static function base64UrlEncode(string $data): string
49    {
50        return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
51    }
52
53    private static function integer(string $bytes): string
54    {
55        // DER integers are signed: a leading 1 bit needs a zero byte so the value stays positive.
56        if ((\ord($bytes[0]) & 0x80) !== 0) {
57            $bytes = "\x00" . $bytes;
58        }
59
60        return "\x02" . self::length(\strlen($bytes)) . $bytes;
61    }
62
63    private static function sequence(string $content): string
64    {
65        return "\x30" . self::length(\strlen($content)) . $content;
66    }
67
68    private static function bitString(string $content): string
69    {
70        return "\x03" . self::length(\strlen($content) + 1) . "\x00" . $content;
71    }
72
73    private static function length(int $length): string
74    {
75        if ($length < 0x80) {
76            return \chr($length);
77        }
78        $bytes = ltrim(pack('N', $length), "\x00");
79
80        return \chr(0x80 | \strlen($bytes)) . $bytes;
81    }
82}