Implement NIP-04: Encrypted Direct Messages
This commit is contained in:
@@ -3,6 +3,72 @@
|
||||
module Nostr
|
||||
# Performs cryptographic operations on a +Nostr::Event+.
|
||||
class Crypto
|
||||
# Numeric base of the OpenSSL big number used in an event content's encryption.
|
||||
#
|
||||
# @return [Integer]
|
||||
#
|
||||
BN_BASE = 16
|
||||
|
||||
# Name of the cipher curve used in an event content's encryption.
|
||||
#
|
||||
# @return [String]
|
||||
#
|
||||
CIPHER_CURVE = 'secp256k1'
|
||||
|
||||
# Name of the cipher algorithm used in an event content's encryption.
|
||||
#
|
||||
# @return [String]
|
||||
#
|
||||
CIPHER_ALGORITHM = 'aes-256-cbc'
|
||||
|
||||
# Encrypts a piece of text
|
||||
#
|
||||
# @api public
|
||||
#
|
||||
# @example Encrypting an event's content
|
||||
# crypto = Nostr::Crypto.new
|
||||
# encrypted = crypto.encrypt_text(sender_private_key, recipient_public_key, 'Feedback appreciated. Now pay $8')
|
||||
# encrypted # => "wrYQaHDfpOEvyJELSCg1vzsywmlJTz8NqH03eFW44s8iQs869jtSb26Lr4s23gmY?iv=v38vAJ3LlJAGZxbmWU4qAg=="
|
||||
#
|
||||
# @param sender_private_key [String] 32-bytes hex-encoded private key of the creator.
|
||||
# @param recipient_public_key [String] 32-bytes hex-encoded public key of the recipient.
|
||||
# @param plain_text [String] The text to be encrypted
|
||||
#
|
||||
# @return [String] Encrypted text.
|
||||
#
|
||||
def encrypt_text(sender_private_key, recipient_public_key, plain_text)
|
||||
cipher = OpenSSL::Cipher.new(CIPHER_ALGORITHM).encrypt
|
||||
cipher.iv = iv = cipher.random_iv
|
||||
cipher.key = compute_shared_key(sender_private_key, recipient_public_key)
|
||||
encrypted_text = cipher.update(plain_text) + cipher.final
|
||||
encrypted_text = "#{Base64.encode64(encrypted_text)}?iv=#{Base64.encode64(iv)}"
|
||||
encrypted_text.gsub("\n", '')
|
||||
end
|
||||
|
||||
# Decrypts a piece of text
|
||||
#
|
||||
# @api public
|
||||
#
|
||||
# @example Encrypting an event's content
|
||||
# crypto = Nostr::Crypto.new
|
||||
# encrypted # => "wrYQaHDfpOEvyJELSCg1vzsywmlJTz8NqH03eFW44s8iQs869jtSb26Lr4s23gmY?iv=v38vAJ3LlJAGZxbmWU4qAg=="
|
||||
# decrypted = crypto.decrypt_text(recipient_private_key, sender_public_key, encrypted)
|
||||
#
|
||||
# @param sender_public_key [String] 32-bytes hex-encoded public key of the message creator.
|
||||
# @param recipient_private_key [String] 32-bytes hex-encoded public key of the recipient.
|
||||
# @param encrypted_text [String] The text to be decrypted
|
||||
#
|
||||
# @return [String] Decrypted text.
|
||||
#
|
||||
def decrypt_text(recipient_private_key, sender_public_key, encrypted_text)
|
||||
base64_encoded_text, iv = encrypted_text.split('?iv=')
|
||||
cipher = OpenSSL::Cipher.new(CIPHER_ALGORITHM).decrypt
|
||||
cipher.iv = Base64.decode64(iv)
|
||||
cipher.key = compute_shared_key(recipient_private_key, sender_public_key)
|
||||
plain_text = cipher.update(Base64.decode64(base64_encoded_text)) + cipher.final
|
||||
plain_text.force_encoding('UTF-8')
|
||||
end
|
||||
|
||||
# Uses the private key to generate an event id and sign the event
|
||||
#
|
||||
# @api public
|
||||
@@ -33,6 +99,35 @@ module Nostr
|
||||
|
||||
private
|
||||
|
||||
# Finds a shared key between two keys
|
||||
#
|
||||
# @api private
|
||||
#
|
||||
# @param private_key [String] 32-bytes hex-encoded private key.
|
||||
# @param public_key [String] 32-bytes hex-encoded public key.
|
||||
#
|
||||
# @return [String] A shared key used in the event's content encryption and decryption.
|
||||
#
|
||||
def compute_shared_key(private_key, public_key)
|
||||
group = OpenSSL::PKey::EC::Group.new(CIPHER_CURVE)
|
||||
|
||||
private_key_bn = OpenSSL::BN.new(private_key, BN_BASE)
|
||||
public_key_bn = OpenSSL::BN.new("02#{public_key}", BN_BASE)
|
||||
public_key_point = OpenSSL::PKey::EC::Point.new(group, public_key_bn)
|
||||
|
||||
asn1 = OpenSSL::ASN1::Sequence(
|
||||
[
|
||||
OpenSSL::ASN1::Integer.new(1),
|
||||
OpenSSL::ASN1::OctetString(private_key_bn.to_s(2)),
|
||||
OpenSSL::ASN1::ObjectId(CIPHER_CURVE, 0, :EXPLICIT),
|
||||
OpenSSL::ASN1::BitString(public_key_point.to_octet_string(:uncompressed), 1, :EXPLICIT)
|
||||
]
|
||||
)
|
||||
|
||||
pkey = OpenSSL::PKey::EC.new(asn1.to_der)
|
||||
pkey.dh_compute_key(public_key_point)
|
||||
end
|
||||
|
||||
# Generates a SHA256 hash of a +Nostr::Event+
|
||||
#
|
||||
# @api private
|
||||
|
||||
@@ -31,5 +31,13 @@ module Nostr
|
||||
# @return [Integer]
|
||||
#
|
||||
CONTACT_LIST = 3
|
||||
|
||||
# A special event with kind 4, meaning "encrypted direct message". An event of this kind has its +content+
|
||||
# equal to the base64-encoded, aes-256-cbc encrypted string of anything a user wants to write, encrypted using a
|
||||
# shared cipher generated by combining the recipient's public-key with the sender's private-key.
|
||||
#
|
||||
# @return [Integer]
|
||||
#
|
||||
ENCRYPTED_DIRECT_MESSAGE = 4
|
||||
end
|
||||
end
|
||||
|
||||
53
lib/nostr/events/encrypted_direct_message.rb
Normal file
53
lib/nostr/events/encrypted_direct_message.rb
Normal file
@@ -0,0 +1,53 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Nostr
|
||||
# Classes of event kinds.
|
||||
module Events
|
||||
# An event whose +content+ is encrypted. It can only be decrypted by the owner of the private key that pairs
|
||||
# the event's +pubkey+.
|
||||
class EncryptedDirectMessage < Event
|
||||
# Instantiates a new encrypted direct message
|
||||
#
|
||||
# @api public
|
||||
#
|
||||
# @example Instantiating a new encrypted direct message
|
||||
# Nostr::Events::EncryptedDirectMessage.new(
|
||||
# sender_private_key: 'ccf9fdf3e1466d7c20969c71ec98defcf5f54aee088513e1b73ccb7bd770d460',
|
||||
# recipient_public_key: '48df4af6e240ac5f7c5de89bf5941b249880be0e87d03685b178ccb1a315f52e',
|
||||
# plain_text: 'Your feedback is appreciated, now pay $8',
|
||||
# )
|
||||
#
|
||||
# @example Instantiating a new encrypted direct message that references a previous direct message
|
||||
# Nostr::Events::EncryptedDirectMessage.new(
|
||||
# sender_private_key: 'ccf9fdf3e1466d7c20969c71ec98defcf5f54aee088513e1b73ccb7bd770d460',
|
||||
# recipient_public_key: '48df4af6e240ac5f7c5de89bf5941b249880be0e87d03685b178ccb1a315f52e',
|
||||
# plain_text: 'Your feedback is appreciated, now pay $8',
|
||||
# previous_direct_message: 'ccf9fdf3e1466d7c20969c71ec98defcf5f54aee088513e1b73ccb7bd770d460'
|
||||
# )
|
||||
#
|
||||
# @param plain_text [String] The +content+ of the encrypted message.
|
||||
# @param sender_private_key [String] 32-bytes hex-encoded private key of the message's author.
|
||||
# @param recipient_public_key [String] 32-bytes hex-encoded public key of the recipient of the encrypted message.
|
||||
# @param previous_direct_message [String] 32-bytes hex-encoded id identifying the previous message in a
|
||||
# conversation or a message we are explicitly replying to (such that contextual, more organized conversations
|
||||
# may happen
|
||||
#
|
||||
def initialize(plain_text:, sender_private_key:, recipient_public_key:, previous_direct_message: nil)
|
||||
crypto = Crypto.new
|
||||
keygen = Keygen.new
|
||||
|
||||
encrypted_content = crypto.encrypt_text(sender_private_key, recipient_public_key, plain_text)
|
||||
sender_public_key = keygen.extract_public_key(sender_private_key)
|
||||
|
||||
super(
|
||||
pubkey: sender_public_key,
|
||||
kind: Nostr::EventKind::ENCRYPTED_DIRECT_MESSAGE,
|
||||
content: encrypted_content,
|
||||
)
|
||||
|
||||
add_pubkey_reference(recipient_public_key)
|
||||
add_event_reference(previous_direct_message) if previous_direct_message
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user