diff --git a/lib/nostr.rb b/lib/nostr.rb index ca6c4b0..3320c11 100644 --- a/lib/nostr.rb +++ b/lib/nostr.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require_relative 'nostr/crypto' require_relative 'nostr/version' require_relative 'nostr/keygen' require_relative 'nostr/client_message_type' diff --git a/lib/nostr/crypto.rb b/lib/nostr/crypto.rb new file mode 100644 index 0000000..1e290de --- /dev/null +++ b/lib/nostr/crypto.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Nostr + # Performs cryptographic operations on a +Nostr::Event+. + class Crypto + # Uses the private key to generate an event id and sign the event + # + # @api public + # + # @example Signing an event + # crypto = Nostr::Crypto.new + # crypto.sign(event, private_key) + # event.id # => an id + # event.sig # => a signature + # + # @param event [Event] The event to be signed + # @param private_key [String] 32-bytes hex-encoded private key. + # + # @return [Event] An unsigned event. + # + 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*') + + event.id = event_digest + event.sig = event_signature + + event + end + + private + + # Generates a SHA256 hash of a +Nostr::Event+ + # + # @api private + # + # @param event [Event] The event to be hashed + # + # @return [String] A SHA256 digest of the event + # + def hash_event(event) + Digest::SHA256.hexdigest(JSON.dump(event.serialize)) + end + end +end diff --git a/lib/nostr/event.rb b/lib/nostr/event.rb index 3e41fb5..8ff80c7 100644 --- a/lib/nostr/event.rb +++ b/lib/nostr/event.rb @@ -70,9 +70,13 @@ module Nostr # @example Getting the event id # event.id # => 'ccf9fdf3e1466d7c20969c71ec98defcf5f54aee088513e1b73ccb7bd770d460' # + # @example Setting the event id + # event.id = 'ccf9fdf3e1466d7c20969c71ec98defcf5f54aee088513e1b73ccb7bd770d460' + # event.id # => 'ccf9fdf3e1466d7c20969c71ec98defcf5f54aee088513e1b73ccb7bd770d460' + # # @return [String|nil] # - attr_reader :id + attr_accessor :id # 64-bytes signature of the sha256 hash of the serialized event data, which is # the same as the "id" field @@ -82,9 +86,13 @@ module Nostr # @example Getting the event signature # event.sig # => '' # + # @example Setting the event signature + # event.sig = '058613f8d34c053294cc28b7f9e1f8f0e80fd1ac94fb20f2da6ca514e7360b39' + # event.sig # => '058613f8d34c053294cc28b7f9e1f8f0e80fd1ac94fb20f2da6ca514e7360b39' + # # @return [String|nil] # - attr_reader :sig + attr_accessor :sig # Instantiates a new Event # @@ -130,6 +138,22 @@ module Nostr @content = content end + # Signs an event with the user's private key + # + # @api public + # + # @example Signing an event + # event.sign(private_key) + # + # @param private_key [String] 32-bytes hex-encoded private key. + # + # @return [Event] A signed event. + # + def sign(private_key) + crypto = Crypto.new + crypto.sign_event(self, private_key) + end + # Serializes the event, to obtain a SHA256 digest of it # # @api public diff --git a/lib/nostr/user.rb b/lib/nostr/user.rb index ccc7bee..89246d0 100644 --- a/lib/nostr/user.rb +++ b/lib/nostr/user.rb @@ -57,36 +57,8 @@ module Nostr # @return [Event] # def create_event(event_attributes) - event_fragment = EventFragment.new(**event_attributes.merge(pubkey: keypair.public_key)) - event_sha256 = Digest::SHA256.hexdigest(JSON.dump(event_fragment.serialize)) - - signature = sign(event_sha256) - - Event.new( - id: event_sha256, - pubkey: event_fragment.pubkey, - created_at: event_fragment.created_at, - kind: event_fragment.kind, - tags: event_fragment.tags, - content: event_fragment.content, - sig: signature - ) - end - - private - - # Signs an event with the user's private key - # - # @api private - # - # @param event_sha256 [String] The SHA256 hash of the event. - # - # @return [String] The signature of the event. - # - def sign(event_sha256) - hex_private_key = Array(keypair.private_key).pack('H*') - hex_message = Array(event_sha256).pack('H*') - Schnorr.sign(hex_message, hex_private_key).encode.unpack1('H*') + event = Event.new(**event_attributes.merge(pubkey: keypair.public_key)) + event.sign(keypair.private_key) end end end diff --git a/sig/nostr/crypto.rbs b/sig/nostr/crypto.rbs new file mode 100644 index 0000000..82b04c8 --- /dev/null +++ b/sig/nostr/crypto.rbs @@ -0,0 +1,9 @@ +module Nostr + class Crypto + def sign_event: (Event, String) -> Event + + private + + def hash_event:(Event) -> String + end +end diff --git a/sig/nostr/event.rbs b/sig/nostr/event.rbs index 993e60e..2d51461 100644 --- a/sig/nostr/event.rbs +++ b/sig/nostr/event.rbs @@ -5,8 +5,8 @@ module Nostr attr_reader kind: Integer attr_reader tags: Array[String] attr_reader content: String - attr_reader id: String?|nil - attr_reader sig: String?|nil + attr_accessor id: String?|nil + attr_accessor sig: String?|nil def initialize: (pubkey: String, kind: Integer, content: String, ?created_at: Integer, ?tags: Array[String], ?id: String|nil, ?sig: String|nil) -> void def serialize: -> [Integer, String, Integer, Integer, Array[String], String] @@ -21,5 +21,7 @@ module Nostr sig: String?|nil } def ==: (Event other) -> bool + + def sign:(String) -> Event end end diff --git a/spec/nostr/crypto_spec.rb b/spec/nostr/crypto_spec.rb new file mode 100644 index 0000000..923965e --- /dev/null +++ b/spec/nostr/crypto_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Nostr::Crypto do + let(:crypto) { described_class.new } + + describe '#sign_event' do + let(:keypair) do + Nostr::KeyPair.new( + public_key: '8a9d69c56e3c691bec8f9565e4dcbe38ae1d88fffeec3ce66b9f47558a3aa8ca', + private_key: '3185a47e3802f956ca5a2b4ea606c1d51c7610f239617e8f0f218d55bdf2b757' + ) + end + + let(:event) do + Nostr::Event.new( + kind: Nostr::EventKind::TEXT_NOTE, + content: 'Your feedback is appreciated, now pay $8', + pubkey: keypair.public_key + ) + end + + it 'signs an event' do + signed_event = crypto.sign_event(event, keypair.private_key) + + aggregate_failures do + expect(signed_event.id.length).to eq(64) + expect(signed_event.sig.length).to eq(128) + end + end + end +end diff --git a/spec/nostr/event_spec.rb b/spec/nostr/event_spec.rb index 62f0980..c63d650 100644 --- a/spec/nostr/event_spec.rb +++ b/spec/nostr/event_spec.rb @@ -121,6 +121,15 @@ RSpec.describe Nostr::Event do end end + describe '#id=' do + it 'sets the event id' do + new_id = '20f31a9b2a0ced48a167add9732ccade1dca5e34b44316e37da4af33bc8946a9' + + event.id = new_id + expect(event.id).to eq(new_id) + end + end + describe '#pubkey' do it 'exposes the event pubkey' do expect(event.pubkey).to eq('ccf9fdf3e1466d7c20969c71ec98defcf5f54aee088513e1b73ccb7bd770d460') @@ -156,6 +165,41 @@ RSpec.describe Nostr::Event do end end + describe '#sig=' do + it 'sets the event signature' do + new_signature = '058613f8d34c053294cc28b7f9e1f8f0e80fd1ac94fb20f2da6ca514e7360b39' \ + '63d0086171f842ffebf1f7790ce147b4811a15ef3f59c76ec1324b970cc57ffe' + + event.sig = new_signature + expect(event.sig).to eq(new_signature) + end + end + + describe '#sign' do + let(:event) do + described_class.new( + pubkey: 'ccf9fdf3e1466d7c20969c71ec98defcf5f54aee088513e1b73ccb7bd770d460', + kind: Nostr::EventKind::TEXT_NOTE, + content: 'Your feedback is appreciated, now pay $8' + ) + end + let(:keypair) do + Nostr::KeyPair.new( + public_key: '8a9d69c56e3c691bec8f9565e4dcbe38ae1d88fffeec3ce66b9f47558a3aa8ca', + private_key: '3185a47e3802f956ca5a2b4ea606c1d51c7610f239617e8f0f218d55bdf2b757' + ) + end + + it 'signs the event' do + event.sign(keypair.private_key) + + aggregate_failures do + expect(event.id.length).to eq(64) + expect(event.sig.length).to eq(128) + end + end + end + describe '#tags' do it 'exposes the event tags' do expect(event.tags).to eq(