Implement NIP-19 bech32-encoded private and public keys

https://github.com/nostr-protocol/nips/blob/master/19.md
This commit is contained in:
Wilson Silva
2023-11-20 09:59:27 +07:00
parent 3fffcd1a4e
commit 3520cf8219
58 changed files with 1189 additions and 104 deletions

View File

@@ -1,5 +1,6 @@
# frozen_string_literal: true
require_relative 'nostr/errors'
require_relative 'nostr/crypto'
require_relative 'nostr/version'
require_relative 'nostr/keygen'
@@ -14,6 +15,9 @@ require_relative 'nostr/event'
require_relative 'nostr/events/encrypted_direct_message'
require_relative 'nostr/client'
require_relative 'nostr/user'
require_relative 'nostr/key'
require_relative 'nostr/private_key'
require_relative 'nostr/public_key'
# Encapsulates all the gem's logic
module Nostr

View File

@@ -30,8 +30,8 @@ module Nostr
# 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 sender_private_key [PrivateKey] 32-bytes hex-encoded private key of the creator.
# @param recipient_public_key [PublicKey] 32-bytes hex-encoded public key of the recipient.
# @param plain_text [String] The text to be encrypted
#
# @return [String] Encrypted text.
@@ -54,8 +54,8 @@ module Nostr
# 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 sender_public_key [PublicKey] 32-bytes hex-encoded public key of the message creator.
# @param recipient_private_key [PrivateKey] 32-bytes hex-encoded public key of the recipient.
# @param encrypted_text [String] The text to be decrypted
#
# @return [String] Decrypted text.
@@ -84,7 +84,7 @@ module Nostr
# event.sig # => a signature
#
# @param event [Event] The event to be signed
# @param private_key [String] 32-bytes hex-encoded private key.
# @param private_key [PrivateKey] 32-bytes hex-encoded private key.
#
# @return [Event] An unsigned event.
#
@@ -107,8 +107,8 @@ module Nostr
#
# @api private
#
# @param private_key [String] 32-bytes hex-encoded private key.
# @param public_key [String] 32-bytes hex-encoded public key.
# @param private_key [PrivateKey] 32-bytes hex-encoded private key.
# @param public_key [PublicKey] 32-bytes hex-encoded public key.
#
# @return [String] A shared key used in the event's content encryption and decryption.
#

8
lib/nostr/errors.rb Normal file
View File

@@ -0,0 +1,8 @@
# frozen_string_literal: true
require_relative 'errors/error'
require_relative 'errors/key_validation_error'
require_relative 'errors/invalid_hrp_error'
require_relative 'errors/invalid_key_type_error'
require_relative 'errors/invalid_key_length_error'
require_relative 'errors/invalid_key_format_error'

View File

@@ -0,0 +1,7 @@
# frozen_string_literal: true
module Nostr
# Base error class
class Error < StandardError
end
end

View File

@@ -0,0 +1,21 @@
# frozen_string_literal: true
module Nostr
# Raised when the human readable part of a Bech32 string is invalid
#
# @api public
#
class InvalidHRPError < KeyValidationError
# Initializes the error
#
# @example
# InvalidHRPError.new('example wrong hrp', 'nsec')
#
# @param given_hrp [String] The given human readable part of the Bech32 string
# @param allowed_hrp [String] The allowed human readable part of the Bech32 string
#
def initialize(given_hrp, allowed_hrp)
super("Invalid hrp: #{given_hrp}. The allowed hrp value for this kind of entity is '#{allowed_hrp}'.")
end
end
end

View File

@@ -0,0 +1,20 @@
# frozen_string_literal: true
module Nostr
# Raised when the private key is in an invalid format
#
# @api public
#
class InvalidKeyFormatError < KeyValidationError
# Initializes the error
#
# @example
# InvalidKeyFormatError.new('private'')
#
# @param [String] key_kind The kind of key that is invalid (public or private)
#
def initialize(key_kind)
super("Only lowercase hexadecimal characters are allowed in #{key_kind} keys.")
end
end
end

View File

@@ -0,0 +1,20 @@
# frozen_string_literal: true
module Nostr
# Raised when the private key's length is not 64 characters
#
# @api public
#
class InvalidKeyLengthError < KeyValidationError
# Initializes the error
#
# @example
# InvalidKeyLengthError.new('private'')
#
# @param [String] key_kind The kind of key that is invalid (public or private)
#
def initialize(key_kind)
super("Invalid #{key_kind} key length. It should have 64 characters.")
end
end
end

View File

@@ -0,0 +1,18 @@
# frozen_string_literal: true
module Nostr
# Raised when the private key is not a string
#
# @api public
#
class InvalidKeyTypeError < KeyValidationError
# Initializes the error
#
# @example
# InvalidKeyTypeError.new('private'')
#
# @param [String] key_kind The kind of key that is invalid (public or private)
#
def initialize(key_kind) = super("Invalid #{key_kind} key type")
end
end

View File

@@ -0,0 +1,6 @@
# frozen_string_literal: true
module Nostr
# Base class for all key validation errors
class KeyValidationError < Error; end
end

View File

@@ -159,11 +159,11 @@ module Nostr
# pubkey = '48df4af6e240ac5f7c5de89bf5941b249880be0e87d03685b178ccb1a315f52e'
# event.add_pubkey_reference(pubkey)
#
# @param pubkey [String] 32-bytes hex-encoded public key.
# @param pubkey [PublicKey] 32-bytes hex-encoded public key.
#
# @return [Array<String>] The event's updated list of tags
#
def add_pubkey_reference(pubkey) = tags.push(['p', pubkey])
def add_pubkey_reference(pubkey) = tags.push(['p', pubkey.to_s])
# Signs an event with the user's private key
#
@@ -172,7 +172,7 @@ module Nostr
# @example Signing an event
# event.sign(private_key)
#
# @param private_key [String] 32-bytes hex-encoded private key.
# @param private_key [PrivateKey] 32-bytes hex-encoded private key.
#
# @return [Event] A signed event.
#

View File

@@ -26,8 +26,9 @@ module Nostr
# )
#
# @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 sender_private_key [PrivateKey] 32-bytes hex-encoded private key of the message's author.
# @param recipient_public_key [PublicKey] 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

102
lib/nostr/key.rb Normal file
View File

@@ -0,0 +1,102 @@
# frozen_string_literal: true
require 'bech32'
module Nostr
# Abstract class for all keys
#
# @api private
#
class Key < String
# The regular expression for hexadecimal lowercase characters
#
# @return [Regexp] The regular expression for hexadecimal lowercase characters
#
FORMAT = /^[a-f0-9]+$/
# The length of the key in hex format
#
# @return [Integer] The length of the key in hex format
#
LENGTH = 64
# Instantiates a new key. Can't be used directly because this is an abstract class. Raises a +ValidationError+
#
# @see Nostr::PrivateKey
# @see Nostr::PublicKey
#
# @param [String] hex_value Hex-encoded value of the key
#
# @raise [ValidationError]
#
def initialize(hex_value)
validate_hex_value(hex_value)
super(hex_value)
end
# Instantiates a key from a bech32 string
#
# @api public
#
# @example
# bech32_key = 'nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5'
# bech32_key.to_key # => #<Nostr::PublicKey:0x000000010601e3c8 @hex_value="...">
#
# @raise [Nostr::InvalidHRPError] if the bech32 string is invalid.
#
# @param [String] bech32_value The bech32 string representation of the key.
#
# @return [Key] the key.
#
def self.from_bech32(bech32_value)
entity = Bech32::Nostr::NIP19.decode(bech32_value)
raise InvalidHRPError.new(entity.hrp, hrp) unless entity.hrp == hrp
new(entity.data)
end
# Abstract method to be implemented by subclasses to provide the HRP (npub, nsec)
#
# @api private
#
# @return [String] The HRP
#
def self.hrp
raise 'Subclasses must implement this method'
end
# Converts the key to a bech32 string representation
#
# @api public
#
# @example Converting a private key to a bech32 string
# public_key = Nostr::PrivateKey.new('67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa')
# public_key.to_bech32 # => 'nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5'
#
# @example Converting a public key to a bech32 string
# public_key = Nostr::PublicKey.new('7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e')
# public_key.to_bech32 # => 'npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg'
#
# @return [String] The bech32 string representation of the key
#
def to_bech32 = Bech32::Nostr::BareEntity.new(self.class.hrp, self).encode
protected
# Validates the hex value during initialization
#
# @api private
#
# @param [String] _hex_value The hex value of the key
#
# @raise [KeyValidationError] When the hex value is invalid
#
# @return [void]
#
def validate_hex_value(_hex_value)
raise 'Subclasses must implement this method'
end
end
end

View File

@@ -10,7 +10,7 @@ module Nostr
# @example
# keypair.private_key # => '893c4cc8088924796b41dc788f7e2f746734497010b1a9f005c1faad7074b900'
#
# @return [String]
# @return [PrivateKey]
#
attr_reader :private_key
@@ -21,7 +21,7 @@ module Nostr
# @example
# keypair.public_key # => '2d7661527d573cc8e84f665fa971dd969ba51e2526df00c149ff8e40a58f9558'
#
# @return [String]
# @return [PublicKey]
#
attr_reader :public_key
@@ -31,16 +31,40 @@ module Nostr
#
# @example
# keypair = Nostr::KeyPair.new(
# private_key: '893c4cc8088924796b41dc788f7e2f746734497010b1a9f005c1faad7074b900',
# public_key: '2d7661527d573cc8e84f665fa971dd969ba51e2526df00c149ff8e40a58f9558',
# private_key: Nostr::PrivateKey.new('893c4cc8088924796b41dc788f7e2f746734497010b1a9f005c1faad7074b900'),
# public_key: Nostr::PublicKey.new('2d7661527d573cc8e84f665fa971dd969ba51e2526df00c149ff8e40a58f9558'),
# )
#
# @param private_key [String] 32-bytes hex-encoded private key.
# @param public_key [String] 32-bytes hex-encoded public key.
# @param private_key [PrivateKey] 32-bytes hex-encoded private key.
# @param public_key [PublicKey] 32-bytes hex-encoded public key.
#
# @raise ArgumentError when the private key is not a +PrivateKey+
# @raise ArgumentError when the public key is not a +PublicKey+
#
def initialize(private_key:, public_key:)
validate_keys(private_key, public_key)
@private_key = private_key
@public_key = public_key
end
private
# Validates the keys
#
# @api private
#
# @param private_key [PrivateKey] 32-bytes hex-encoded private key.
# @param public_key [PublicKey] 32-bytes hex-encoded public key.
#
# @raise ArgumentError when the private key is not a +PrivateKey+
# @raise ArgumentError when the public key is not a +PublicKey+
#
# @return [void]
#
def validate_keys(private_key, public_key)
raise ArgumentError, 'private_key is not an instance of PrivateKey' unless private_key.is_a?(Nostr::PrivateKey)
raise ArgumentError, 'public_key is not an instance of PublicKey' unless public_key.is_a?(Nostr::PublicKey)
end
end
end

View File

@@ -44,10 +44,11 @@ module Nostr
# private_key = keygen.generate_private_key
# private_key # => '893c4cc8088924796b41dc788f7e2f746734497010b1a9f005c1faad7074b900'
#
# @return [String] A 32-bytes hex-encoded private key.
# @return [PrivateKey] A 32-bytes hex-encoded private key.
#
def generate_private_key
(SecureRandom.random_number(group.order - 1) + 1).to_s(16)
hex_value = (SecureRandom.random_number(group.order - 1) + 1).to_s(16)
PrivateKey.new(hex_value)
end
# Extracts a public key from a private key
@@ -59,10 +60,36 @@ module Nostr
# public_key = keygen.extract_public_key(private_key)
# public_key # => '2d7661527d573cc8e84f665fa971dd969ba51e2526df00c149ff8e40a58f9558'
#
# @return [String] A 32-bytes hex-encoded public key.
# @param [PrivateKey] private_key A 32-bytes hex-encoded private key.
#
# @raise [ArgumentError] if the private key is not an instance of +PrivateKey+
#
# @return [PublicKey] A 32-bytes hex-encoded public key.
#
def extract_public_key(private_key)
group.generator.multiply_by_scalar(private_key.to_i(16)).x.to_s(16).rjust(64, '0')
validate_private_key(private_key)
hex_value = group.generator.multiply_by_scalar(private_key.to_i(16)).x.to_s(16).rjust(64, '0')
PublicKey.new(hex_value)
end
# Builds a key pair from an existing private key
#
# @api public
#
# @example
# private_key = Nostr::PrivateKey.new('893c4cc8088924796b41dc788f7e2f746734497010b1a9f005c1faad7074b900')
# keygen.get_key_pair_from_private_key(private_key)
#
# @param private_key [PrivateKey] 32-bytes hex-encoded private key.
#
# @raise [ArgumentError] if the private key is not an instance of +PrivateKey+
#
# @return [Nostr::KeyPair]
#
def get_key_pair_from_private_key(private_key)
validate_private_key(private_key)
public_key = extract_public_key(private_key)
KeyPair.new(private_key:, public_key:)
end
private
@@ -74,5 +101,17 @@ module Nostr
# @return [ECDSA::Group]
#
attr_reader :group
# Validates that the private key is an instance of +PrivateKey+
#
# @api private
#
# @raise [ArgumentError] if the private key is not an instance of +PrivateKey+
#
# @return [void]
#
def validate_private_key(private_key)
raise ArgumentError, 'private_key is not an instance of PrivateKey' unless private_key.is_a?(Nostr::PrivateKey)
end
end
end

36
lib/nostr/private_key.rb Normal file
View File

@@ -0,0 +1,36 @@
# frozen_string_literal: true
module Nostr
# 32-bytes lowercase hex-encoded private key
class PrivateKey < Key
# Human-readable part of the Bech32 encoded address
#
# @api private
#
# @return [String] The human-readable part of the Bech32 encoded address
#
def self.hrp
'nsec'
end
private
# Validates the hex value of the private key
#
# @api private
#
# @param [String] hex_value The private key in hex format
#
# @raise InvalidKeyTypeError when the private key is not a string
# @raise InvalidKeyLengthError when the private key's length is not 64 characters
# @raise InvalidKeyFormatError when the private key is in an invalid format
#
# @return [void]
#
def validate_hex_value(hex_value)
raise InvalidKeyTypeError, 'private' unless hex_value.is_a?(String)
raise InvalidKeyLengthError, 'private' unless hex_value.size == Key::LENGTH
raise InvalidKeyFormatError, 'private' unless hex_value.match(Key::FORMAT)
end
end
end

36
lib/nostr/public_key.rb Normal file
View File

@@ -0,0 +1,36 @@
# frozen_string_literal: true
module Nostr
# 32-bytes lowercase hex-encoded public key
class PublicKey < Key
# Human-readable part of the Bech32 encoded address
#
# @api private
#
# @return [String] The human-readable part of the Bech32 encoded address
#
def self.hrp
'npub'
end
private
# Validates the hex value of the public key
#
# @api private
#
# @param [String] hex_value The public key in hex format
#
# @raise InvalidKeyTypeError when the public key is not a string
# @raise InvalidKeyLengthError when the public key's length is not 64 characters
# @raise InvalidKeyFormatError when the public key is in an invalid format
#
# @return [void]
#
def validate_hex_value(hex_value)
raise InvalidKeyTypeError, 'public' unless hex_value.is_a?(String)
raise InvalidKeyLengthError, 'public' unless hex_value.size == Key::LENGTH
raise InvalidKeyFormatError, 'public' unless hex_value.match(Key::FORMAT)
end
end
end