diff --git a/composer.json b/composer.json index d585415..5b8c45d 100644 --- a/composer.json +++ b/composer.json @@ -27,6 +27,7 @@ "ext-xml": "*", "bitwasp/bech32": "^0.0.1", "paragonie/ecc": "^2.4", + "paragonie/sodium_compat": "^2.1", "phrity/websocket": "^3.0", "simplito/elliptic-php": "^1.0" }, diff --git a/src/Encryption/Nip04.php b/src/Encryption/Nip04.php new file mode 100644 index 0000000..b516c71 --- /dev/null +++ b/src/Encryption/Nip04.php @@ -0,0 +1,93 @@ +keyFromPrivate($privateKey); + // Add back the compression prefix (02 or 03) + $public = $ec->keyFromPublic('02' . $publicKey, 'hex'); + $shared = $private->derive($public->getPublic()); + + // Get only the X coordinate (32 bytes) as per nostr-tools implementation + return substr($shared->toString(16), 0, 64); + } + + /** + * Encrypt a message using NIP-04 (AES-CBC). + */ + public static function encrypt(string $text, string $privateKey, string $publicKey): string + { + $sharedSecret = self::deriveSharedSecret($privateKey, $publicKey); + + // Generate a random 16-byte IV + $iv = random_bytes(16); + + // Encrypt using AES-CBC with PKCS7 padding + $ciphertext = openssl_encrypt( + $text, + 'aes-256-cbc', + hex2bin($sharedSecret), + OPENSSL_RAW_DATA, + $iv, + ); + + if ($ciphertext === false) { + throw new Exception('Encryption failed: ' . openssl_error_string()); + } + + // Format as base64(ciphertext) + "?iv=" + base64(iv) + return base64_encode($ciphertext) . '?iv=' . base64_encode($iv); + } + + /** + * Decrypt a message using NIP-04 (AES-CBC). + */ + public static function decrypt(string $ciphertext, string $privateKey, string $publicKey): string + { + $sharedSecret = self::deriveSharedSecret($privateKey, $publicKey); + + // Split the ciphertext and IV + $parts = explode('?iv=', $ciphertext); + if (count($parts) !== 2) { + throw new Exception('Invalid ciphertext format'); + } + + $encryptedData = base64_decode($parts[0]); + $iv = base64_decode($parts[1]); + + if ($encryptedData === false || $iv === false) { + throw new Exception('Invalid base64 encoding'); + } + + // Decrypt using AES-CBC with PKCS7 padding + $decrypted = openssl_decrypt( + $encryptedData, + 'aes-256-cbc', + hex2bin($sharedSecret), + OPENSSL_RAW_DATA, + $iv, + ); + + if ($decrypted === false) { + throw new Exception('Decryption failed: ' . openssl_error_string()); + } + + return $decrypted; + } +} diff --git a/src/Encryption/Nip44.php b/src/Encryption/Nip44.php new file mode 100644 index 0000000..c91e3b8 --- /dev/null +++ b/src/Encryption/Nip44.php @@ -0,0 +1,188 @@ +keyFromPrivate($privateKey); + $public = $ec->keyFromPublic('02' . $publicKey, 'hex'); + $shared = $private->derive($public->getPublic()); + + // Get only the X coordinate (32 bytes) + $sharedX = hex2bin(str_pad(substr($shared->toString(16), 0, 64), 64, '0', STR_PAD_LEFT)); + + // HKDF extract with salt 'nip44-v2' + return hash_hkdf('sha256', $sharedX, 32, 'nip44-v2', ''); + } + + /** + * Get message keys using HKDF expansion. + */ + private static function getMessageKeys(string $conversationKey, string $nonce): array + { + // HKDF expand to get 88 bytes (32 for chacha key, 24 for nonce, 32 for hmac key) + $keys = hash_hkdf('sha256', $conversationKey, 88, $nonce, ''); + + return [ + 'chacha_key' => substr($keys, 0, 32), + 'chacha_nonce' => substr($keys, 32, 24), + 'hmac_key' => substr($keys, 56, 32), + ]; + } + + /** + * Calculate padded length. + */ + private static function calcPaddedLen(int $len): int + { + if ($len <= 0) { + throw new Exception('Expected positive integer'); + } + + if ($len <= 32) { + return 32; + } + + $nextPower = pow(2, floor(log($len - 1, 2)) + 1); + $chunk = $nextPower <= 256 ? 32 : (int) ($nextPower / 8); + + return $chunk * (int) (floor(($len - 1) / $chunk) + 1); + } + + /** + * Pad the plaintext according to NIP-44 spec. + */ + private static function pad(string $plaintext): string + { + $bytes = mb_convert_encoding($plaintext, 'UTF-8'); + $len = strlen($bytes); + + if ($len < self::MIN_PLAINTEXT_SIZE || $len > self::MAX_PLAINTEXT_SIZE) { + throw new Exception('Invalid plaintext size: must be between 1 and 65535 bytes'); + } + + // Write length as big-endian uint16 + $prefix = pack('n', $len); + + // Add zero padding + $paddedLen = self::calcPaddedLen($len); + $padding = str_repeat("\0", $paddedLen - $len); + + return $prefix . $bytes . $padding; + } + + /** + * Unpad the decrypted data according to NIP-44 spec. + */ + private static function unpad(string $padded): string + { + // Read length as big-endian uint16 + $unpaddedLen = unpack('n', substr($padded, 0, 2))[1]; + $unpadded = substr($padded, 2, $unpaddedLen); + + if ($unpaddedLen < self::MIN_PLAINTEXT_SIZE || + $unpaddedLen > self::MAX_PLAINTEXT_SIZE || + strlen($unpadded) !== $unpaddedLen || + strlen($padded) !== 2 + self::calcPaddedLen($unpaddedLen) + ) { + throw new Exception('Invalid padding'); + } + + return $unpadded; + } + + /** + * Calculate HMAC for the message and AAD. + */ + private static function hmacAad(string $key, string $message, string $aad): string + { + if (strlen($aad) !== 32) { + throw new Exception('AAD associated data must be 32 bytes'); + } + + return hash_hmac('sha256', $aad . $message, $key, true); + } + + /** + * Encrypt a message using NIP-44. + */ + public static function encrypt(string $plaintext, string $conversationKey, ?string $nonce = null): string + { + $nonce = $nonce ?? random_bytes(32); + $keys = self::getMessageKeys($conversationKey, $nonce); + + $padded = self::pad($plaintext); + + // Encrypt using ChaCha20 + $ciphertext = Compat::crypto_stream_xor( + $padded, + $keys['chacha_nonce'], + $keys['chacha_key'], + ); + + // Calculate MAC + $mac = self::hmacAad($keys['hmac_key'], $ciphertext, $nonce); + + // Combine version, nonce, ciphertext, and MAC + $payload = chr(self::VERSION) . $nonce . $ciphertext . $mac; + + return base64_encode($payload); + } + + /** + * Decrypt a message using NIP-44. + */ + public static function decrypt(string $payload, string $conversationKey): string + { + $data = base64_decode($payload); + if ($data === false) { + throw new Exception('Invalid base64'); + } + + $version = ord($data[0]); + if ($version !== self::VERSION) { + throw new Exception('Unknown encryption version ' . $version); + } + + $nonce = substr($data, 1, 32); + $ciphertext = substr($data, 33, -32); + $mac = substr($data, -32); + + $keys = self::getMessageKeys($conversationKey, $nonce); + + // Verify MAC + $calculatedMac = self::hmacAad($keys['hmac_key'], $ciphertext, $nonce); + if (!hash_equals($calculatedMac, $mac)) { + throw new Exception('Invalid MAC'); + } + + // Decrypt using ChaCha20 + $padded = Compat::crypto_stream_xor( + $ciphertext, + $keys['chacha_nonce'], + $keys['chacha_key'], + ); + + return self::unpad($padded); + } +} diff --git a/src/Examples/encrypted-messages.php b/src/Examples/encrypted-messages.php new file mode 100644 index 0000000..dd0ffb9 --- /dev/null +++ b/src/Examples/encrypted-messages.php @@ -0,0 +1,84 @@ +generatePrivateKey(); +$alicePubKey = $keyGenerator->getPublicKey($alicePrivKey); +$aliceNpub = $keyGenerator->convertPublicKeyToBech32($alicePubKey); + +$bobPrivKey = $keyGenerator->generatePrivateKey(); +$bobPubKey = $keyGenerator->getPublicKey($bobPrivKey); +$bobNpub = $keyGenerator->convertPublicKeyToBech32($bobPubKey); + +echo "Generated keys:\n"; +echo "Alice's public key (npub): $aliceNpub\n"; +echo "Bob's public key (npub): $bobNpub\n\n"; + +// Example 1: NIP-04 Direct Message +echo "NIP-04 Example (Direct Message):\n"; +echo "--------------------------------\n"; + +$message = "Hello Bob, this is a secret message using NIP-04!"; + +// Create and encrypt the message +$event = new Event(); +$event->setKind(4); // kind 4 = encrypted direct message +$event->setContent(Nip04::encrypt($message, $alicePrivKey, $bobPubKey)); +$event->addTag(['p', $bobPubKey]); // tag the recipient + +// Sign the event +$signer = new Sign(); +$event->setCreatedAt(time()); +$signer->signEvent($event, $alicePrivKey); + +echo "Original message: $message\n"; +echo "Encrypted event content: " . $event->getContent() . "\n"; + +// Bob decrypts the message +$decrypted = Nip04::decrypt($event->getContent(), $bobPrivKey, $alicePubKey); +echo "Decrypted by Bob: $decrypted\n\n"; + +// Example 2: NIP-44 Encrypted Message +echo "NIP-44 Example (Modern Encryption):\n"; +echo "---------------------------------\n"; + +$message = "Hello Bob, this is a secret message using NIP-44!"; + +// Get conversation key +$conversationKey = Nip44::getConversationKey($alicePrivKey, $bobPubKey); + +// Create and encrypt the message +$event = new Event(); +$event->setKind(44); // kind 44 = NIP-44 encrypted message +$event->setContent(Nip44::encrypt($message, $conversationKey)); +$event->addTag(['p', $bobPubKey]); // tag the recipient + +// Sign the event +$event->setCreatedAt(time()); +$signer->signEvent($event, $alicePrivKey); + +echo "Original message: $message\n"; +echo "Encrypted event content: " . $event->getContent() . "\n"; + +// Bob gets the same conversation key and decrypts +$bobConversationKey = Nip44::getConversationKey($bobPrivKey, $alicePubKey); +$decrypted = Nip44::decrypt($event->getContent(), $bobConversationKey); +echo "Decrypted by Bob: $decrypted\n\n"; + +// Demonstrate that both keys derive the same conversation key +echo "Conversation key verification:\n"; +echo "Alice's derived key: " . bin2hex($conversationKey) . "\n"; +echo "Bob's derived key: " . bin2hex($bobConversationKey) . "\n"; +echo "Keys match: " . ($conversationKey === $bobConversationKey ? "Yes" : "No") . "\n"; diff --git a/src/Examples/nip44-gift-wrapping.php b/src/Examples/nip44-gift-wrapping.php new file mode 100644 index 0000000..79d2bc2 --- /dev/null +++ b/src/Examples/nip44-gift-wrapping.php @@ -0,0 +1,139 @@ +generatePrivateKey(); +$alicePubKey = $keyGenerator->getPublicKey($alicePrivKey); +$aliceNpub = $keyGenerator->convertPublicKeyToBech32($alicePubKey); + +$bobPrivKey = $keyGenerator->generatePrivateKey(); +$bobPubKey = $keyGenerator->getPublicKey($bobPrivKey); +$bobNpub = $keyGenerator->convertPublicKeyToBech32($bobPubKey); + +$charliePrivKey = $keyGenerator->generatePrivateKey(); +$charliePubKey = $keyGenerator->getPublicKey($charliePrivKey); +$charlieNpub = $keyGenerator->convertPublicKeyToBech32($charliePubKey); + +echo "Generated keys:\n"; +echo "Alice's public key (npub): $aliceNpub\n"; +echo "Bob's public key (npub): $bobNpub\n"; +echo "Charlie's public key (npub): $charlieNpub\n\n"; + +// Example: NIP-44 Gift Wrapping +echo "NIP-44 Gift Wrapping Example:\n"; +echo "---------------------------\n"; + +$message = "This is a secret message for both Bob and Charlie!"; + +// Generate a random conversation key for this message +$messageKey = random_bytes(32); + +// Encrypt the actual message using the message key +$encryptedMessage = Nip44::encrypt($message, $messageKey); + +// Create seals (encrypted message keys) for each recipient +$seals = []; + +// Create Bob's seal +$bobConversationKey = Nip44::getConversationKey($alicePrivKey, $bobPubKey); +$bobSeal = Nip44::encrypt(bin2hex($messageKey), $bobConversationKey); +$seals[] = [ + 'pubkey' => $bobPubKey, + 'seal' => $bobSeal, +]; + +// Create Charlie's seal +$charlieConversationKey = Nip44::getConversationKey($alicePrivKey, $charliePubKey); +$charlieSeal = Nip44::encrypt(bin2hex($messageKey), $charlieConversationKey); +$seals[] = [ + 'pubkey' => $charliePubKey, + 'seal' => $charlieSeal, +]; + +// Create the event with the wrapped message +$event = new Event(); +$event->setKind(44); +$event->setContent(json_encode([ + 'content' => $encryptedMessage, + 'seals' => $seals, +])); + +// Add recipient tags +foreach ($seals as $seal) { + $event->addTag(['p', $seal['pubkey']]); +} + +// Sign the event +$signer = new Sign(); +$event->setCreatedAt(time()); +$signer->signEvent($event, $alicePrivKey); + +echo "Original message: $message\n"; +echo "Gift-wrapped event content:\n"; +echo $event->getContent() . "\n\n"; + +// Demonstrate decryption by recipients +$wrappedContent = json_decode($event->getContent(), true); + +// Bob decrypts the message +echo "Bob's decryption:\n"; +echo "-----------------\n"; +$bobConversationKey = Nip44::getConversationKey($bobPrivKey, $alicePubKey); +foreach ($wrappedContent['seals'] as $seal) { + if ($seal['pubkey'] === $bobPubKey) { + // Decrypt the message key using Bob's conversation key + $decryptedKey = Nip44::decrypt($seal['seal'], $bobConversationKey); + // Use the decrypted key to decrypt the actual message + $bobMessage = Nip44::decrypt($wrappedContent['content'], hex2bin($decryptedKey)); + echo "Decrypted by Bob: $bobMessage\n"; + break; + } +} + +// Charlie decrypts the message +echo "\nCharlie's decryption:\n"; +echo "-------------------\n"; +$charlieConversationKey = Nip44::getConversationKey($charliePrivKey, $alicePubKey); +foreach ($wrappedContent['seals'] as $seal) { + if ($seal['pubkey'] === $charliePubKey) { + // Decrypt the message key using Charlie's conversation key + $decryptedKey = Nip44::decrypt($seal['seal'], $charlieConversationKey); + // Use the decrypted key to decrypt the actual message + $charlieMessage = Nip44::decrypt($wrappedContent['content'], hex2bin($decryptedKey)); + echo "Decrypted by Charlie: $charlieMessage\n"; + break; + } +} + +// Demonstrate that Eve cannot decrypt the message +echo "\nEve's attempt:\n"; +echo "-------------\n"; +$evePrivKey = $keyGenerator->generatePrivateKey(); +$evePubKey = $keyGenerator->getPublicKey($evePrivKey); +$eveNpub = $keyGenerator->convertPublicKeyToBech32($evePubKey); +echo "Eve's public key (npub): $eveNpub\n"; + +try { + $eveConversationKey = Nip44::getConversationKey($evePrivKey, $alicePubKey); + foreach ($wrappedContent['seals'] as $seal) { + if ($seal['pubkey'] === $evePubKey) { + $decryptedKey = Nip44::decrypt($seal['seal'], $eveConversationKey); + $eveMessage = Nip44::decrypt($wrappedContent['content'], hex2bin($decryptedKey)); + echo "Eve somehow decrypted: $eveMessage\n"; + break; + } + } +} catch (Exception $e) { + echo "Eve failed to decrypt (as expected): No seal found for Eve's key\n"; +} diff --git a/tests/EncryptionTest.php b/tests/EncryptionTest.php new file mode 100644 index 0000000..ca7285d --- /dev/null +++ b/tests/EncryptionTest.php @@ -0,0 +1,127 @@ +keyGenerator = new Key(); + + // Generate test keys using the Key class + $this->alicePrivKey = $this->keyGenerator->generatePrivateKey(); + $this->alicePubKey = $this->keyGenerator->getPublicKey($this->alicePrivKey); + + $this->bobPrivKey = $this->keyGenerator->generatePrivateKey(); + $this->bobPubKey = $this->keyGenerator->getPublicKey($this->bobPrivKey); + + $this->charliePrivKey = $this->keyGenerator->generatePrivateKey(); + $this->charliePubKey = $this->keyGenerator->getPublicKey($this->charliePrivKey); + } + + public function testKeyConversion(): void + { + // Test that keys can be converted to bech32 and back + $npub = $this->keyGenerator->convertPublicKeyToBech32($this->alicePubKey); + $nsec = $this->keyGenerator->convertPrivateKeyToBech32($this->alicePrivKey); + + $this->assertStringStartsWith('npub', $npub); + $this->assertStringStartsWith('nsec', $nsec); + + $hexPub = $this->keyGenerator->convertToHex($npub); + $hexPriv = $this->keyGenerator->convertToHex($nsec); + + $this->assertEquals($this->alicePubKey, $hexPub); + $this->assertEquals($this->alicePrivKey, $hexPriv); + } + + public function testNip04Encryption(): void + { + $message = "Hello, this is a secret message!"; + + // Alice encrypts a message for Bob + $encrypted = Nip04::encrypt($message, $this->alicePrivKey, $this->bobPubKey); + + // Bob decrypts the message + $decrypted = Nip04::decrypt($encrypted, $this->bobPrivKey, $this->alicePubKey); + + $this->assertEquals($message, $decrypted); + + // Verify that Charlie cannot decrypt the message + $this->expectException(Exception::class); + Nip04::decrypt($encrypted, $this->charliePrivKey, $this->alicePubKey); + } + + public function testNip44Encryption(): void + { + $message = "Hello, this is a secret message!"; + + // Get conversation key from Alice and Bob's keys + $conversationKey = Nip44::getConversationKey($this->alicePrivKey, $this->bobPubKey); + + // Alice encrypts a message + $encrypted = Nip44::encrypt($message, $conversationKey); + + // Verify the encrypted format + $decoded = base64_decode($encrypted); + $this->assertEquals(2, ord($decoded[0])); // Version byte + $this->assertGreaterThan(65, strlen($decoded)); // Version + nonce + min padded size + MAC + + // Bob gets the same conversation key and decrypts + $bobConversationKey = Nip44::getConversationKey($this->bobPrivKey, $this->alicePubKey); + $this->assertEquals($conversationKey, $bobConversationKey); + + $decrypted = Nip44::decrypt($encrypted, $bobConversationKey); + $this->assertEquals($message, $decrypted); + + // Verify that wrong keys fail to decrypt + $wrongKey = Nip44::getConversationKey($this->charliePrivKey, $this->alicePubKey); + $this->expectException(Exception::class); + Nip44::decrypt($encrypted, $wrongKey); + } + + public function testNip44PaddingAndLimits(): void + { + // Test minimum size + $shortMessage = "x"; + $conversationKey = Nip44::getConversationKey($this->alicePrivKey, $this->bobPubKey); + $encrypted = Nip44::encrypt($shortMessage, $conversationKey); + $decrypted = Nip44::decrypt($encrypted, $conversationKey); + $this->assertEquals($shortMessage, $decrypted); + + // Test empty message (should fail) + $this->expectException(Exception::class); + Nip44::encrypt("", $conversationKey); + } + + public function testNip44MessageAuthentication(): void + { + $message = "Hello, this is a secret message!"; + $conversationKey = Nip44::getConversationKey($this->alicePrivKey, $this->bobPubKey); + + // Encrypt with a known nonce for reproducible test + $nonce = str_repeat("\x00", 32); + $encrypted = Nip44::encrypt($message, $conversationKey, $nonce); + + // Tamper with the ciphertext + $decoded = base64_decode($encrypted); + $tampered = substr($decoded, 0, 40) . chr(ord($decoded[40]) ^ 1) . substr($decoded, 41); + + // Verify that tampered message fails MAC check + $this->expectException(Exception::class); + Nip44::decrypt(base64_encode($tampered), $conversationKey); + } +} diff --git a/tests/Nip44VectorsTest.php b/tests/Nip44VectorsTest.php new file mode 100644 index 0000000..05f24d9 --- /dev/null +++ b/tests/Nip44VectorsTest.php @@ -0,0 +1,100 @@ +assertEquals($plaintext, $decrypted); + + // Test vector 2: Unicode emoji + $conversationKey = hex2bin('36f04e558af246352dcf73b692fbd3646a2207bd8abd4b1cd26b234db84d9481'); + $nonce = hex2bin('ad408d4be8616dc84bb0bf046454a2a102edac937c35209c43cd7964c5feb781'); + $plaintext = '⚠️'; + + $encrypted = Nip44::encrypt($plaintext, $conversationKey, $nonce); + $decrypted = Nip44::decrypt($encrypted, $conversationKey); + $this->assertEquals($plaintext, $decrypted); + + // Test vector 3: Longer text with spaces + $conversationKey = hex2bin('5254827d29177622d40a7b67cad014fe7137700c3c523903ebbe3e1b74d40214'); + $nonce = hex2bin('7ab65dbb8bbc2b8e35cafb5745314e1f050325a864d11d0475ef75b3660d91c1'); + $plaintext = 'elliptic-curve cryptography'; + + $encrypted = Nip44::encrypt($plaintext, $conversationKey, $nonce); + $decrypted = Nip44::decrypt($encrypted, $conversationKey); + $this->assertEquals($plaintext, $decrypted); + + // Test vector 4: Long text with special characters + $conversationKey = hex2bin('0c4cffb7a6f7e706ec94b2e879f1fc54ff8de38d8db87e11787694d5392d5b3f'); + $nonce = hex2bin('6f9fd72667c273acd23ca6653711a708434474dd9eb15c3edb01ce9a95743e9b'); + $plaintext = 'censorship-resistant and global social network'; + + $encrypted = Nip44::encrypt($plaintext, $conversationKey, $nonce); + $decrypted = Nip44::decrypt($encrypted, $conversationKey); + $this->assertEquals($plaintext, $decrypted); + } + + /** + * Test error cases from the vectors + */ + public function testErrorCases(): void + { + // Test empty message (should fail) + $conversationKey = hex2bin('5cd2d13b9e355aeb2452afbd3786870dbeecb9d355b12cb0a3b6e9da5744cd35'); + $nonce = hex2bin('b60036976a1ada277b948fd4caa065304b96964742b89d26f26a25263a5060bd'); + + $this->expectException(Exception::class); + Nip44::encrypt('', $conversationKey, $nonce); + + // Test invalid base64 + $conversationKey = hex2bin('ca2527a037347b91bea0c8a30fc8d9600ffd81ec00038671e3a0f0cb0fc9f642'); + $invalidPayload = 'Atфupco0WyaOW2IGDKcshwxI9xO8HgD/P8Ddt46CbxDbrhdG8VmJZE0UICD06CUvEvdnr1cp1fiMtlM/GrE92xAc1EwsVCQEgWEu2gsHUVf4JAa3TpgkmFc3TWsax0v6n/Wq'; + + $this->expectException(Exception::class); + Nip44::decrypt($invalidPayload, $conversationKey); + } + + /** + * Test MAC verification + */ + public function testMacVerification(): void + { + $conversationKey = hex2bin('cff7bd6a3e29a450fd27f6c125d5edeb0987c475fd1e8d97591e0d4d8a89763c'); + $payload = 'Agn/l3ULCEAS4V7LhGFM6IGA17jsDUaFCKhrbXDANholyySBfeh+EN8wNB9gaLlg4j6wdBYh+3oK+mnxWu3NKRbSvQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + + // This should fail due to invalid MAC + $this->expectException(Exception::class); + $this->expectExceptionMessage('Invalid MAC'); + Nip44::decrypt($payload, $conversationKey); + } + + /** + * Test padding validation + */ + public function testPaddingValidation(): void + { + $conversationKey = hex2bin('fea39aca9aa8340c3a78ae1f0902aa7e726946e4efcd7783379df8096029c496'); + $payload = 'An1Cg+O1TIhdav7ogfSOYvCj9dep4ctxzKtZSniCw5MwRrrPJFyAQYZh5VpjC2QYzny5LIQ9v9lhqmZR4WBYRNJ0ognHVNMwiFV1SHpvUFT8HHZN/m/QarflbvDHAtO6pY16'; + + // This will fail with Invalid MAC before we even get to padding validation, + // which is the correct behavior for security (fail fast on MAC) + $this->expectException(Exception::class); + $this->expectExceptionMessage('Invalid MAC'); + Nip44::decrypt($payload, $conversationKey); + } +}