Allow the verification of signatures and events

Added the methods:
- Event#verify_signature
- Crypto#check_sig!
- Crypto#valid_sig?
- Crypto#sign_message

Fixed a primitive obsession by introducing a Signature class to ensure that signatures are valid Nostr signatures.
This commit is contained in:
Wilson Silva
2024-03-14 22:03:26 +00:00
parent f8893f9b0e
commit 01010c763f
25 changed files with 637 additions and 47 deletions

View File

@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Nostr
# Performs cryptographic operations on a +Nostr::Event+.
# Performs cryptographic operations.
class Crypto
# Numeric base of the OpenSSL big number used in an event content's encryption.
#
@@ -90,17 +90,93 @@ module Nostr
#
def sign_event(event, private_key)
event_digest = hash_event(event)
hex_private_key = Array(private_key).pack('H*')
hex_message = Array(event_digest).pack('H*')
event_signature = Schnorr.sign(hex_message, hex_private_key).encode.unpack1('H*')
signature = sign_message(event_digest, private_key)
event.id = event_digest
event.sig = event_signature
event.sig = signature
event
end
# Signs a message using the Schnorr signature algorithm
#
# @api public
#
# @example Signing a message
# crypto = Nostr::Crypto.new
# message = 'Viva la libertad carajo'
# private_key = Nostr::PrivateKey.new('7d1e4219a5e7d8342654c675085bfbdee143f0eb0f0921f5541ef1705a2b407d')
# signature = crypto.sign_message(message, private_key)
# signature # => 'b2115694a576f5bdcebf8c0951a3c7adcfbdb17b11cb9e6d6b7017691138bc6' \
# '38fee642a7bd26f71b313a7057181294198900a9770d1435e43f182acf3d34c26'
#
# @param [String] message The message to be signed
# @param [PrivateKey] private_key The private key used for signing
#
# @return [Signature] A signature object containing the signature as a 64-byte hexadecimal string.
#
def sign_message(message, private_key)
hex_private_key = Array(private_key).pack('H*')
hex_message = Array(message).pack('H*')
hex_signature = Schnorr.sign(hex_message, hex_private_key).encode.unpack1('H*')
Signature.new(hex_signature.to_s)
end
# Verifies the given {Signature} and returns true if it is valid
#
# @api public
#
# @example Checking a signature
# public_key = Nostr::PublicKey.new('15678d8fbc126fa326fac536acd5a6dcb5ef64b3d939abe31d6830cba6cd26d6')
# private_key = Nostr::PrivateKey.new('7d1e4219a5e7d8342654c675085bfbdee143f0eb0f0921f5541ef1705a2b407d')
# message = 'Viva la libertad carajo'
# crypto = Nostr::Crypto.new
# signature = crypto.sign_message(message, private_key)
# valid = crypto.valid_sig?(message, public_key, signature)
# valid # => true
#
# @see #check_sig!
#
# @param [String] message A message to be signed with binary format.
# @param [PublicKey] public_key The public key with binary format.
# @param [Signature] signature The signature with binary format.
#
# @return [Boolean] whether signature is valid.
#
def valid_sig?(message, public_key, signature)
signature = Schnorr::Signature.decode([signature].pack('H*'))
Schnorr.valid_sig?([message].pack('H*'), [public_key].pack('H*'), signature.encode)
end
# Verifies the given {Signature} and raises an +Schnorr::InvalidSignatureError+ if it is invalid
#
# @api public
#
# @example Checking a signature
# public_key = Nostr::PublicKey.new('15678d8fbc126fa326fac536acd5a6dcb5ef64b3d939abe31d6830cba6cd26d6')
# private_key = Nostr::PrivateKey.new('7d1e4219a5e7d8342654c675085bfbdee143f0eb0f0921f5541ef1705a2b407d')
# message = 'Viva la libertad carajo'
# crypto = Nostr::Crypto.new
# signature = crypto.sign_message(message, private_key)
# valid = crypto.valid_sig?(message, public_key, signature)
# valid # => true
#
# @see #valid_sig?
#
# @param [String] message A message to be signed with binary format.
# @param [PublicKey] public_key The public key with binary format.
# @param [Signature] signature The signature with binary format.
#
# @raise [Schnorr::InvalidSignatureError] if the signature is invalid.
#
# @return [Boolean] whether signature is valid.
#
def check_sig!(message, public_key, signature)
signature = Schnorr::Signature.decode([signature].pack('H*'))
Schnorr.check_sig!([message].pack('H*'), [public_key].pack('H*'), signature.encode)
end
private
# Finds a shared key between two keys

View File

@@ -6,3 +6,7 @@ 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'
require_relative 'errors/signature_validation_error'
require_relative 'errors/invalid_signature_type_error'
require_relative 'errors/invalid_signature_length_error'
require_relative 'errors/invalid_signature_format_error'

View File

@@ -0,0 +1,18 @@
# frozen_string_literal: true
module Nostr
# Raised when the signature is in an invalid format
#
# @api public
#
class InvalidSignatureFormatError < SignatureValidationError
# Initializes the error
#
# @example
# InvalidSignatureFormatError.new
#
def initialize
super('Only lowercase hexadecimal characters are allowed in signatures.')
end
end
end

View File

@@ -0,0 +1,18 @@
# frozen_string_literal: true
module Nostr
# Raised when the signature's length is not 128 characters
#
# @api public
#
class InvalidSignatureLengthError < SignatureValidationError
# Initializes the error
#
# @example
# InvalidSignatureLengthError.new
#
def initialize
super('Invalid signature length. It should have 128 characters.')
end
end
end

View File

@@ -0,0 +1,16 @@
# frozen_string_literal: true
module Nostr
# Raised when the signature is not a string
#
# @api public
#
class InvalidSignatureTypeError < SignatureValidationError
# Initializes the error
#
# @example
# InvalidSignatureTypeError.new
#
def initialize = super('Invalid signature type')
end
end

View File

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

View File

@@ -181,6 +181,34 @@ module Nostr
crypto.sign_event(self, private_key)
end
# Verifies if the signature of the event is valid. A valid signature means that the event was signed by the owner
#
# @api public
#
# @example Verifying the signature of an event
# event = Nostr::Event.new(
# id: '90b75b78daf883ae57fbcc414d43faa028560b3211ee58e4ea82bf395bb82042',
# pubkey: Nostr::PublicKey.new('2d7661527d573cc8e84f665fa971dd969ba51e2526df00c149ff8e40a58f9558'),
# created_at: 1667422587,
# kind: Nostr::EventKind::TEXT_NOTE,
# content: 'Your feedback is appreciated, now pay $8',
# sig: '32f18adebe942e19b171c1c7d2fb27ce794dfea4155e289dca7952b43ed1ec39' \
# '1d3dc198ba2761bc6d40c737a6eaf4edcc8963acabd3bfcebd04f16637025bdc'
# )
#
# event.verify_signature # => true
#
# @return [Boolean] Whether the signature is valid or not.
#
def verify_signature
crypto = Crypto.new
return false if id.nil? || pubkey.nil?
return false if sig.nil? # FIXME: See https://github.com/soutaro/steep/issues/1079
crypto.valid_sig?(id, pubkey, sig)
end
# Serializes the event, to obtain a SHA256 digest of it
#
# @api public

67
lib/nostr/signature.rb Normal file
View File

@@ -0,0 +1,67 @@
# frozen_string_literal: true
module Nostr
# 64-bytes lowercase hex of the signature of the sha256 hash of the serialized event data,
# which is the same as the "id" field
class Signature < 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 signature in hex format
#
# @return [Integer] The length of the signature in hex format
#
LENGTH = 128
# Instantiates a new Signature
#
# @api public
#
# @example Instantiating a new signature
# Nostr::Signature.new(
# 'f418c97b50cc68227e82f4f3a79d79eb2b7a0fa517859c86e1a8fa91e3741b7f' \
# '06e070c44129227b83fcbe93cecb02a346804a4080ce47685ecad60ab4f5f128'
# )
#
# @param [String] hex_value Hex-encoded value of the signature
#
# @raise [SignatureValidationError]
#
def initialize(hex_value)
validate(hex_value)
super(hex_value)
end
private
# Hex-encoded value of the signature
#
# @api private
#
# @return [String] hex_value Hex-encoded value of the signature
#
attr_reader :hex_value
# Validates the hex value of the signature
#
# @api private
#
# @param [String] hex_value The signature in hex format
#
# @raise InvalidSignatureTypeError when the signature is not a string
# @raise InvalidSignatureLengthError when the signature's length is not 128 characters
# @raise InvalidSignatureFormatError when the signature is in an invalid format
#
# @return [void]
#
def validate(hex_value)
raise InvalidSignatureTypeError unless hex_value.is_a?(String)
raise InvalidSignatureLengthError unless hex_value.size == LENGTH
raise InvalidSignatureFormatError unless hex_value.match(FORMAT)
end
end
end