From 735b49f4e7d952a059c8292c2468f255ae398821 Mon Sep 17 00:00:00 2001 From: SMIRNOVAM3 Date: Mon, 4 Dec 2023 11:32:03 +0300 Subject: [PATCH] feat: public NKey in connect for verifying the signature For information, see: https://docs.nats.io/reference/reference-protocols/nats-protocol#syntax-1 The algorithm was reverse-engineered from nats.py client: https://github.com/nats-io/nats.py/blob/3dbc3cfbfae32174b5000a53398140631d488636/nats/aio/client.py#L603C3-L603C3 Tested against NATS 2.9.15 --- src/Authenticator.php | 1 + src/Client.php | 1 + src/Message/Connect.php | 1 + src/NKeys/Authenticator.php | 5 ++ src/NKeys/{Base32Decoder.php => Base32.php} | 53 +++++++++++++- src/NKeys/CRC16.php | 57 +++++++++++++++ src/NKeys/SecretKey.php | 71 +++++++++++++++---- tests/Unit/NKeys/AuthenticatorTest.php | 19 ++++- .../{Base32DecoderTest.php => Base32Test.php} | 40 ++++++++--- tests/Unit/NKeys/CRC16Test.php | 29 ++++++++ tests/Unit/NKeys/SecretKeyTest.php | 28 ++++++-- 11 files changed, 278 insertions(+), 27 deletions(-) rename src/NKeys/{Base32Decoder.php => Base32.php} (73%) create mode 100644 src/NKeys/CRC16.php rename tests/Unit/NKeys/{Base32DecoderTest.php => Base32Test.php} (56%) create mode 100644 tests/Unit/NKeys/CRC16Test.php diff --git a/src/Authenticator.php b/src/Authenticator.php index f06bade..ffec8cf 100644 --- a/src/Authenticator.php +++ b/src/Authenticator.php @@ -10,6 +10,7 @@ abstract class Authenticator { abstract public function sign(string $nonce): string; + abstract public function getPublicKey(): string; public static function create(Configuration $configuration): ?self { diff --git a/src/Client.php b/src/Client.php index ebcab94..836bb06 100644 --- a/src/Client.php +++ b/src/Client.php @@ -104,6 +104,7 @@ public function connect(): self } if (isset($this->info->nonce) && $this->authenticator) { $this->connect->sig = $this->authenticator->sign($this->info->nonce); + $this->connect->nkey = $this->authenticator->getPublicKey(); } $this->send($this->connect); diff --git a/src/Message/Connect.php b/src/Message/Connect.php index cf7abcd..71fc0b2 100644 --- a/src/Message/Connect.php +++ b/src/Message/Connect.php @@ -20,6 +20,7 @@ class Connect extends Prototype public string $tls_required; public string $user; public string $version; + public string $nkey; public function render(): string { diff --git a/src/NKeys/Authenticator.php b/src/NKeys/Authenticator.php index 3e4c66e..e776e0d 100644 --- a/src/NKeys/Authenticator.php +++ b/src/NKeys/Authenticator.php @@ -18,4 +18,9 @@ public function sign(string $nonce): string return base64_encode($signature); } + + public function getPublicKey(): string + { + return $this->key->getPublicKey(); + } } diff --git a/src/NKeys/Base32Decoder.php b/src/NKeys/Base32.php similarity index 73% rename from src/NKeys/Base32Decoder.php rename to src/NKeys/Base32.php index 8459f2e..0dfaf2c 100644 --- a/src/NKeys/Base32Decoder.php +++ b/src/NKeys/Base32.php @@ -9,7 +9,7 @@ /** * @see https://github.com/selective-php/base32 */ -class Base32Decoder +class Base32 { /** * @var array @@ -88,6 +88,57 @@ class Base32Decoder '7' => '31', ]; + /** + * Encodes data with base32. + * + * @param string $input The original data, as a string + * @param bool $padding Use padding false when encoding for urls + * + * @return string The Base32 encoded string + */ + public function encode(string $input, bool $padding = true): string + { + if ($input === '') { + return ''; + } + + $input = str_split($input); + $binaryString = ''; + + $inputCount = count($input); + for ($i = 0; $i < $inputCount; $i++) { + $binaryString .= str_pad(base_convert((string) ord($input[$i]), 10, 2), 8, '0', STR_PAD_LEFT); + } + + $fiveBitBinaryArray = str_split($binaryString, 5); + $base32 = ''; + $i = 0; + $fiveCount = count($fiveBitBinaryArray); + + while ($i < $fiveCount) { + $base32 .= self::MAP[base_convert(str_pad($fiveBitBinaryArray[$i], 5, '0'), 2, 10)]; + $i++; + } + + $x = strlen($binaryString) % 40; + if ($padding && $x !== 0) { + if ($x === 8) { + return $base32 . str_repeat(self::MAP[32], 6); + } + if ($x === 16) { + return $base32 . str_repeat(self::MAP[32], 4); + } + if ($x === 24) { + return $base32 . str_repeat(self::MAP[32], 3); + } + if ($x === 32) { + return $base32 . self::MAP[32]; + } + } + + return $base32; + } + /** * Decodes data encoded with base32. * @throws InvalidArgumentException diff --git a/src/NKeys/CRC16.php b/src/NKeys/CRC16.php new file mode 100644 index 0000000..b86c4dc --- /dev/null +++ b/src/NKeys/CRC16.php @@ -0,0 +1,57 @@ +> 8) ^ $c) & 0x00FF]; + } + return $crc; + } +} diff --git a/src/NKeys/SecretKey.php b/src/NKeys/SecretKey.php index 2997963..f4f7ab8 100644 --- a/src/NKeys/SecretKey.php +++ b/src/NKeys/SecretKey.php @@ -16,16 +16,40 @@ class SecretKey private const PREFIX_BYTE_ACCOUNT = 0; private const PREFIX_BYTE_USER = 20 << 3; - public function __construct(public readonly string $value) - { + private ?string $publicKey = null; + + public function __construct( + public readonly string $value, + private readonly string $verifyingKey, + private readonly int $prefix + ) { if (strlen($this->value) !== SODIUM_CRYPTO_SIGN_SECRETKEYBYTES) { throw new InvalidArgumentException("Invalid secret key provided"); } + if (strlen($this->verifyingKey) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) { + throw new InvalidArgumentException("Invalid verifying key provided"); + } + self::validatePrefix($this->prefix); + } + + private static function validatePrefix(int $prefix): void + { + if ( + !in_array($prefix, [ + self::PREFIX_BYTE_SERVER, + self::PREFIX_BYTE_CLUSTER, + self::PREFIX_BYTE_OPERATOR, + self::PREFIX_BYTE_ACCOUNT, + self::PREFIX_BYTE_USER, + ]) + ) { + throw new InvalidArgumentException("Invalid seed prefix"); + } } public static function fromSeed(string $seed): self { - $decoded = (new Base32Decoder())->decode($seed); + $decoded = (new Base32())->decode($seed); // Validate seed $b1 = ord($decoded[0]) & 0xf8; @@ -33,15 +57,8 @@ public static function fromSeed(string $seed): self if ($b1 !== self::PREFIX_BYTE_SEED) { throw new InvalidArgumentException("Invalid seed"); - } elseif (!in_array($b2, [ - self::PREFIX_BYTE_SERVER, - self::PREFIX_BYTE_CLUSTER, - self::PREFIX_BYTE_OPERATOR, - self::PREFIX_BYTE_ACCOUNT, - self::PREFIX_BYTE_USER, - ])) { - throw new InvalidArgumentException("Invalid seed prefix"); } + self::validatePrefix($b2); // Deterministically derive the key pair from a single key $rawSeed = substr($decoded, 2, -2); @@ -50,6 +67,36 @@ public static function fromSeed(string $seed): self // Extract the Ed25519 secret key from a keypair $secretKey = sodium_crypto_sign_secretkey($keyPair); - return new self($secretKey); + // Extract the Ed25519 public key from a keypair + $verifyingKey = sodium_crypto_sign_publickey($keyPair); + + return new self($secretKey, $verifyingKey, $b2); + } + + public function getPublicKey(): string + { + if ($this->publicKey !== null) { + return $this->publicKey; + } + // Bytearray with Ed25519 public key + $verifyingKeyBytes = unpack('C*', $this->verifyingKey); + + // Prepending prefix byte + array_unshift($verifyingKeyBytes, $this->prefix); + + // Calculating CRC16 + $crc = CRC16::hash($verifyingKeyBytes); + // CRC16 int to bytes in little endian unsigned short + $crcBytesLE = unpack('C*', pack('v', $crc)); + + // Appending CRC16 LE to our bytearray + $verifyingKeyBytes = array_merge($verifyingKeyBytes, $crcBytesLE); + + // Converting bytearray back to string + $publicKeyString = call_user_func_array("pack", array_merge(["C*"], $verifyingKeyBytes)); + + // Hashing public key as base32 + $this->publicKey = (new Base32())->encode($publicKeyString); + return $this->publicKey; } } diff --git a/tests/Unit/NKeys/AuthenticatorTest.php b/tests/Unit/NKeys/AuthenticatorTest.php index e0a48f5..be68ffe 100644 --- a/tests/Unit/NKeys/AuthenticatorTest.php +++ b/tests/Unit/NKeys/AuthenticatorTest.php @@ -13,7 +13,9 @@ class AuthenticatorTest extends TestCase public function testSign() { $key = new SecretKey( - hex2bin("05de91c9b25408111262d7f4aa769b6d0c83e796d18cc9e1ecd16cdaf573d0876dbdcb0a7b213d6c04f55b6436afaf224ee52fba6cc9ba4da573b13ba8102012") + hex2bin("05de91c9b25408111262d7f4aa769b6d0c83e796d18cc9e1ecd16cdaf573d0876dbdcb0a7b213d6c04f55b6436afaf224ee52fba6cc9ba4da573b13ba8102012"), + hex2bin("6dbdcb0a7b213d6c04f55b6436afaf224ee52fba6cc9ba4da573b13ba8102012"), + 20 << 3 ); $authenticator = new Authenticator($key); @@ -24,4 +26,19 @@ public function testSign() $this->assertEquals($expected, $result); } + + public function testPublicKey() + { + $key = new SecretKey( + hex2bin("05de91c9b25408111262d7f4aa769b6d0c83e796d18cc9e1ecd16cdaf573d0876dbdcb0a7b213d6c04f55b6436afaf224ee52fba6cc9ba4da573b13ba8102012"), + hex2bin("6dbdcb0a7b213d6c04f55b6436afaf224ee52fba6cc9ba4da573b13ba8102012"), + 20 << 3 + ); + $authenticator = new Authenticator($key); + + $result = $authenticator->getPublicKey(); + $expected = "UBW33SYKPMQT23AE6VNWINVPV4RE5ZJPXJWMTOSNUVZ3CO5ICAQBEIPK"; + + $this->assertEquals($expected, $result); + } } diff --git a/tests/Unit/NKeys/Base32DecoderTest.php b/tests/Unit/NKeys/Base32Test.php similarity index 56% rename from tests/Unit/NKeys/Base32DecoderTest.php rename to tests/Unit/NKeys/Base32Test.php index 1380ecb..a3cf621 100644 --- a/tests/Unit/NKeys/Base32DecoderTest.php +++ b/tests/Unit/NKeys/Base32Test.php @@ -4,33 +4,57 @@ namespace Tests\Unit\NKeys; -use Basis\Nats\NKeys\Base32Decoder; +use Basis\Nats\NKeys\Base32; use InvalidArgumentException; use Tests\TestCase; -class Base32DecoderTest extends TestCase +class Base32Test extends TestCase { /** * @dataProvider dataProvider */ public function testDecode(string $input, string $expected) { - $decoder = new Base32Decoder(); + $base32 = new Base32(); - $result = $decoder->decode($input); + $result = $base32->decode($input); $this->assertEquals($expected, bin2hex($result)); } + /** + * @dataProvider dataProvider + */ + public function testEncode(string $expected, string $input) + { + $base32 = new Base32(); + + $result = $base32->encode(hex2bin($input), false); + + $this->assertEquals($expected, $result); + } + + /** + * @dataProvider dataProvider + */ + public function testEncodePadding(string $expected, string $input) + { + $base32 = new Base32(); + + $result = $base32->encode(hex2bin($input)); + + $this->assertEquals($expected . '======', $result); + } + /** * @dataProvider invalidInputProvider */ public function testDecodeInvalid($input) { - $decoder = new Base32Decoder(); + $base32 = new Base32(); $this->expectException(InvalidArgumentException::class); - $decoder->decode($input); + $base32->decode($input); } public function invalidInputProvider(): array @@ -44,9 +68,9 @@ public function invalidInputProvider(): array public function testDecodeEmpty() { - $decoder = new Base32Decoder(); + $base32 = new Base32(); - $this->assertEquals("", $decoder->decode("")); + $this->assertEquals("", $base32->decode("")); } public function dataProvider(): array diff --git a/tests/Unit/NKeys/CRC16Test.php b/tests/Unit/NKeys/CRC16Test.php new file mode 100644 index 0000000..78f0d7a --- /dev/null +++ b/tests/Unit/NKeys/CRC16Test.php @@ -0,0 +1,29 @@ +assertEquals($expected, $result); + } + + public function dataProvider(): array + { + return [ + [unpack('C*', hex2bin("6dbdcb0a7b213d6c04f55b6436afaf224ee52fba6cc9ba4da573b13ba8102012")), 38323], + [unpack('C*', hex2bin("2bf5af21cc4d2f04b821e0773ca032e50134d4dc628e5e260c105db958a3ab97")), 49357], + ]; + } +} diff --git a/tests/Unit/NKeys/SecretKeyTest.php b/tests/Unit/NKeys/SecretKeyTest.php index 4908a9e..8c902b9 100644 --- a/tests/Unit/NKeys/SecretKeyTest.php +++ b/tests/Unit/NKeys/SecretKeyTest.php @@ -10,10 +10,29 @@ class SecretKeyTest extends TestCase { - public function testConstructionWithInvalidArgument() + private static $VALID_SECRET_KEY_HEX = "05de91c9b25408111262d7f4aa769b6d0c83e796d18cc9e1ecd16cdaf573d0876dbdcb0a7b213d6c04f55b6436afaf224ee52fba6cc9ba4da573b13ba8102012"; + private static $VALID_VERIFYING_KEY_HEX = "6dbdcb0a7b213d6c04f55b6436afaf224ee52fba6cc9ba4da573b13ba8102012"; + private static $VALID_PUBLIC_KEY = "UBW33SYKPMQT23AE6VNWINVPV4RE5ZJPXJWMTOSNUVZ3CO5ICAQBEIPK"; + + public function testConstructionWithInvalidPrivateKeyArgument() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Invalid secret key provided"); + new SecretKey("", "", 0); + } + + public function testConstructionWithInvalidVerifyingKeyArgument() { $this->expectException(InvalidArgumentException::class); - new SecretKey(""); + $this->expectExceptionMessage("Invalid verifying key provided"); + new SecretKey(hex2bin(self::$VALID_SECRET_KEY_HEX), "", 0); + } + + public function testConstructionWithInvalidPrefixArgument() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Invalid seed prefix"); + new SecretKey(hex2bin(self::$VALID_SECRET_KEY_HEX), hex2bin(self::$VALID_VERIFYING_KEY_HEX), 42); } /** @@ -38,8 +57,7 @@ public function testFromSeed() $seed = "SUAALXURZGZFICARCJRNP5FKO2NW2DED46LNDDGJ4HWNC3G26VZ5BBZAME"; $key = SecretKey::fromSeed($seed); - $expectedSecretKey = "05de91c9b25408111262d7f4aa769b6d0c83e796d18cc9e1ecd16cdaf573d0876dbdcb0a7b213d6c04f55b6436afaf224ee52fba6cc9ba4da573b13ba8102012"; - - $this->assertEquals($expectedSecretKey, bin2hex($key->value)); + $this->assertEquals(self::$VALID_SECRET_KEY_HEX, bin2hex($key->value)); + $this->assertEquals(self::$VALID_PUBLIC_KEY, $key->getPublicKey()); } }