forked from nostrver-se/nostr-php
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Add NIP-04 and NIP-44 encryption nostrver-se#83
- Loading branch information
Showing
7 changed files
with
732 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,93 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace swentel\nostr\Encryption; | ||
|
||
use Elliptic\EC; | ||
use Exception; | ||
|
||
/** | ||
* NIP-04 encryption implementation. | ||
* Based on the reference implementation from nostr-tools. | ||
*/ | ||
class Nip04 | ||
{ | ||
/** | ||
* Derive a shared secret using secp256k1. | ||
*/ | ||
private static function deriveSharedSecret(string $privateKey, string $publicKey): string | ||
{ | ||
$ec = new EC('secp256k1'); | ||
$private = $ec->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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,188 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace swentel\nostr\Encryption; | ||
|
||
use Elliptic\EC; | ||
use Exception; | ||
use ParagonIE\Sodium\Compat; | ||
|
||
/** | ||
* NIP-44 encryption implementation. | ||
* Based on the reference implementation from nostr-tools. | ||
*/ | ||
class Nip44 | ||
{ | ||
private const VERSION = 2; | ||
private const MIN_PLAINTEXT_SIZE = 1; | ||
private const MAX_PLAINTEXT_SIZE = 0xffff; | ||
|
||
/** | ||
* Get conversation key using HKDF with shared secret. | ||
*/ | ||
public static function getConversationKey(string $privateKey, string $publicKey): string | ||
{ | ||
$ec = new EC('secp256k1'); | ||
$private = $ec->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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,84 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
require_once __DIR__ . '/../../vendor/autoload.php'; | ||
|
||
use swentel\nostr\Encryption\Nip04; | ||
use swentel\nostr\Encryption\Nip44; | ||
use swentel\nostr\Event\Event; | ||
use swentel\nostr\Sign\Sign; | ||
use swentel\nostr\Key\Key; | ||
|
||
// Initialize key generator | ||
$keyGenerator = new Key(); | ||
|
||
// Generate keys for our participants | ||
$alicePrivKey = $keyGenerator->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"; |
Oops, something went wrong.