Skip to content

Commit

Permalink
feat: Add NIP-04 and NIP-44 encryption nostrver-se#83
Browse files Browse the repository at this point in the history
  • Loading branch information
dsbaars committed Feb 14, 2025
1 parent d77f714 commit ca243d0
Show file tree
Hide file tree
Showing 7 changed files with 732 additions and 0 deletions.
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
93 changes: 93 additions & 0 deletions src/Encryption/Nip04.php
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;
}
}
188 changes: 188 additions & 0 deletions src/Encryption/Nip44.php
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);
}
}
84 changes: 84 additions & 0 deletions src/Examples/encrypted-messages.php
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";
Loading

0 comments on commit ca243d0

Please sign in to comment.