├── .gitignore ├── .ruby-version ├── Gemfile ├── Gemfile.lock ├── LICENSE.md ├── README.md ├── lib ├── bech32.rb ├── client.rb ├── context.rb ├── crypto_tools.rb ├── event.rb ├── event_wizard.rb ├── filter.rb ├── key.rb ├── kind.rb ├── message_handler.rb ├── nostr_ruby.rb ├── signer.rb └── version.rb └── nostr_ruby.gemspec /.gitignore: -------------------------------------------------------------------------------- 1 | # Also see https://github.com/github/gitignore/blob/master/Ruby.gitignore 2 | 3 | Gemfile.lock 4 | *.gem 5 | .bundle 6 | .env 7 | rdoc 8 | tmp 9 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.1.2 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | nostr_ruby (0.2.0) 5 | base64 (~> 0.1.1) 6 | bech32 (~> 1.4.0) 7 | bip-schnorr (~> 0.4.0) 8 | faye-websocket (~> 0.11) 9 | json (~> 2.6.2) 10 | unicode-emoji (~> 3.3.1) 11 | 12 | GEM 13 | remote: https://rubygems.org/ 14 | specs: 15 | base64 (0.1.1) 16 | bech32 (1.4.2) 17 | thor (>= 1.1.0) 18 | bip-schnorr (0.4.0) 19 | ecdsa (~> 1.2.0) 20 | ecdsa (1.2.0) 21 | eventmachine (1.2.7) 22 | faye-websocket (0.11.3) 23 | eventmachine (>= 0.12.0) 24 | websocket-driver (>= 0.5.1) 25 | json (2.6.3) 26 | thor (1.2.1) 27 | unicode-emoji (3.3.1) 28 | unicode-version (~> 1.0) 29 | unicode-version (1.3.0) 30 | websocket-driver (0.7.6) 31 | websocket-extensions (>= 0.1.0) 32 | websocket-extensions (0.1.5) 33 | 34 | PLATFORMS 35 | arm64-darwin-21 36 | 37 | DEPENDENCIES 38 | nostr_ruby! 39 | 40 | BUNDLED WITH 41 | 2.3.14 42 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT LICENSE 2 | 3 | Copyright (c) 2023 Daniele Tonon 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nostr Ruby 2 | 3 | A ruby library to interact with the [Nostr Protocol](https://github.com/nostr-protocol/nostr). 4 | 5 | > [!Warning] 6 | > This version in work in progress and breaks the v0.2.0 API 7 | 8 | 9 | ## Installation 10 | 11 | Add this line to your application's Gemfile: 12 | 13 | ```ruby 14 | # Gemfile 15 | gem 'nostr_ruby' 16 | ``` 17 | 18 | And then execute: 19 | 20 | ```shell 21 | $ bundle 22 | ``` 23 | 24 | Or install it yourself as: 25 | ```shell 26 | $ gem install nostr_ruby 27 | ``` 28 | 29 | ## Usage 30 | 31 | ### Manage keys 32 | 33 | ```ruby 34 | require "nostr_ruby" 35 | 36 | sk = Nostr::Key.generate_private_key 37 | # => "8090fb3fe26e27d539ee349d70890d338c5e2e8b459e04c8e97658f03d2f9f33" 38 | 39 | pk = Nostr::Key.get_public_key(sk) 40 | # => "e7ded9bd42e7c74fcc6465962b919b7efcd5774ac6bea2ae6b81b2caa9d4d2e6" 41 | ``` 42 | 43 | ### Decode entities 44 | 45 | ```ruby 46 | 47 | puplic_key = Nostr::Bech32.decode(npub) 48 | # => {:hrp=>"npub", :data=>"e7ded9bd42e7c74fcc6465962b919b7efcd5774ac6bea2ae6b81b2caa9d4d2e6"} 49 | 50 | nprofile_data = Nostr::Bech32.decode("nprofile1qqs8hhhhhc3dmrje73squpz255ape7t448w86f7ltqemca7m0p99spgprpmhxue69uhkgar0dehkutnwdaehgu339e3k7mf06ras84") 51 | # => {:hrp=>"nprofile", :data=>{:pubkey=>["7bdef7be22dd8e59f4600e044aa53a1cf975a9dc7d27df5833bc77db784a5805"], :relay=>["wss://dtonon.nostr1.com/"]}} 52 | 53 | note_data = Nostr::Bech32.decode("note1xzce08egncw3mcm8l8edas6rrhgfj9l5uwwv2hz03zym0m9eg5hsxuyajp") 54 | # => {:hrp=>"note", :data=>"30b1979f289e1d1de367f9f2dec3431dd09917f4e39cc55c4f8889b7ecb9452f"} 55 | 56 | nevent_data = Nostr::Bech32.decode("nevent1qqsrpvvhnu5fu8gaudnlnuk7cdp3m5yezl6w88x9t38c3zdhaju52tcpzpmhxue69uhkztnwdaejumr0dshsz9nhwden5te0vfjhvmewdehhxarjxyhxxmmd9uq3wamnwvaz7tmzd96xxmmfdejhytnnda3kjctv9ulrdeva") 57 | # => {:hrp=>"nevent", :data=> {:id=>["30b1979f289e1d1de367f9f2dec3431dd09917f4e39cc55c4f8889b7ecb9452f"], :relay=>["wss://a.nos.lol/", "wss://bevo.nostr1.com/", "wss://bitcoiner.social/"]}} 58 | 59 | naddr_data = Nostr::Bech32.decode("naddr1qvzqqqr4gupzq77777lz9hvwt86xqrsyf2jn588ewk5aclf8mavr80rhmduy5kq9qqdkc6t5w3kx2ttvdamx2ttxdaez6mr0denj6en0wfkkzaqxq5r99") 60 | # => => {:hrp=>"naddr", :data=>{:kind=>[30023], :author=>["7bdef7be22dd8e59f4600e044aa53a1cf975a9dc7d27df5833bc77db784a5805"], :identifier=>["little-love-for-long-format"]}} 61 | ``` 62 | 63 | ### Encode entities 64 | 65 | ```ruby 66 | 67 | nsec = Nostr::Bech32.encode_nsec(sk) 68 | # => "nsec1szg0k0lzdcna2w0wxjwhpzgdxwx9ut5tgk0qfj8fwev0q0f0nuessml5ur" 69 | 70 | npub = Nostr::Bech32.encode_npub(pk) 71 | # => "npub1ul0dn02zulr5lnryvktzhyvm0m7d2a62c6l29tntsxev42w56tnqksrtfu" 72 | 73 | nprofile = Nostr::Bech32.encode_nprofile( 74 | pubkey: "7bdef7be22dd8e59f4600e044aa53a1cf975a9dc7d27df5833bc77db784a5805", 75 | relays: ["wss://dtonon.nostr1.com"], 76 | ) 77 | # => "nprofile1qqs8hhhhhc3dmrje73squpz255ape7t448w86f7ltqemca7m0p99spgpzamhxue69uhkgar0dehkutnwdaehgu339e3k7mg60me8x" 78 | 79 | note = Nostr::Bech32.encode_note("30b1979f289e1d1de367f9f2dec3431dd09917f4e39cc55c4f8889b7ecb9452f") 80 | # => "note1xzce08egncw3mcm8l8edas6rrhgfj9l5uwwv2hz03zym0m9eg5hsxuyajp" 81 | 82 | nevent = Nostr::Bech32.encode_nevent( 83 | id: "30b1979f289e1d1de367f9f2dec3431dd09917f4e39cc55c4f8889b7ecb9452f", 84 | relays: ["wss://nos.lol"], 85 | ) 86 | # => "nevent1qqsrpvvhnu5fu8gaudnlnuk7cdp3m5yezl6w88x9t38c3zdhaju52tcpp4mhxue69uhkummn9ekx7mqrqsqqqqqpux7e9q" 87 | 88 | naddr = Nostr::Bech32.encode_naddr( 89 | author: "7bdef7be22dd8e59f4600e044aa53a1cf975a9dc7d27df5833bc77db784a5805", 90 | identifier: "little-love-for-long-format", 91 | kind: 30023 92 | ) 93 | # => "naddr1qgs8hhhhhc3dmrje73squpz255ape7t448w86f7ltqemca7m0p99spgrqsqqqa28qqdkc6t5w3kx2ttvdamx2ttxdaez6mr0denj6en0wfkkzaqn2tjdj" 94 | ``` 95 | 96 | ### Initialize Client 97 | 98 | ```ruby 99 | require "nostr_ruby" 100 | 101 | # Detailed version 102 | s = Nostr::Signer.new(private_key: Nostr::Key.generate_private_key) 103 | c = Nostr::Client.new(signer: s) 104 | 105 | # Compact version, under the hood Client creates a Signer 106 | c = Nostr::Client.new(private_key: Nostr::Key.generate_private_key) 107 | 108 | c.private_key 109 | # => "7402b4b1ee09fb37b64ec2a958f1b7815d904c6dd44227bdef7912ef201af97d" 110 | 111 | c.public_key 112 | # => "a19f3c16b6e857d2b673c67eea293431fc175895513ca2f687a717152a5da466" 113 | 114 | c.nsec 115 | # => "nsec1wsptfv0wp8an0djwc2543udhs9weqnrd63pz00000yfw7gq6l97snckpdq" 116 | 117 | c.npub 118 | # => "npub15x0nc94kapta9dnncelw52f5x87pwky42y729a585ut322ja53nq72yrcr" 119 | ``` 120 | 121 | ### Create, sign and send an event 122 | 123 | ```ruby 124 | # Initialize a client, under the hood Client creates a Signer 125 | c = Nostr::Client.new( 126 | private_key: "7402b4b1ee09fb37b64ec2a958f1b7815d904c6dd44227bdef7912ef201af97d", 127 | relay: "wss://nos.lol" 128 | ) 129 | 130 | # Initialize an event 131 | e = Nostr::Event.new( 132 | kind: ..., 133 | pubkey: ..., 134 | created_at: ..., 135 | tags: ..., 136 | content: ..., 137 | pow: ..., 138 | delegation: ..., 139 | ) 140 | 141 | # Sign the event 142 | e = c.sign(e) 143 | 144 | # - - - - - - - - - - - - - - 145 | # Full async mode 146 | 147 | # Set the open callback 148 | c.on :connect do |event| 149 | puts 'Publish event...' 150 | end 151 | 152 | # Set the response callback 153 | c.on :ok do |event| 154 | puts "Event id: #{event.id}" 155 | puts "Accepted: #{event.success}" 156 | puts "Message: #{event.message}" 157 | end 158 | 159 | # Connect and send the event 160 | c.connect 161 | c.publish(e) 162 | # Do more things 163 | c.close 164 | 165 | # - - - - - - - - - - - - - - 166 | # Compact sync mode 167 | 168 | c.connect 169 | c.publish_and_wait(e) 170 | c.close 171 | 172 | ``` 173 | 174 | ### Set the profile 175 | 176 | ```ruby 177 | metadata = { 178 | name: "Mr Robot", 179 | about: "I walk around the city", 180 | picture: "https://upload.wikimedia.org/wikipedia/commons/3/35/Mr_robot_photo.jpg", 181 | nip05: "mrrobot@mrrobot.com" 182 | } 183 | 184 | e = Nostr::Event.new( 185 | kind: Nostr::Kind::METADATA, 186 | pubkey: c.public_key, 187 | content: metadata.to_json, 188 | ) 189 | ``` 190 | 191 | ### Post a note 192 | ```ruby 193 | e = Nostr::Event.new( 194 | kind: Nostr::Kind::SHORT_NOTE, 195 | pubkey: c.public_key, 196 | content: "Hello Nostr!", 197 | ) 198 | ``` 199 | 200 | ### Share a contact list 201 | ```ruby 202 | contact_list = [ 203 | ["54399b6d8200813bfc53177ad4f13d6ab712b6b23f91aefbf5da45aeb5c96b08", "wss://alicerelay.com/", "alice"], 204 | ["850708b7099215bf9a1356d242c2354939e9a844c1359d3b5209592a0b420452", "wss://bobrelay.com/nostr", "bob"], 205 | ["f7f4b0072368460a09138bf3966fb1c59d0bdadfc3aff4e59e6896194594a82a", "ws://carolrelay.com/ws", "carol"] 206 | ] 207 | 208 | e = Nostr::Event.new( 209 | kind: Nostr::Kind::CONTACT_LIST, 210 | pubkey: c.public_key, 211 | tags: contact_list.map { |c| ['p'] + c }, 212 | ) 213 | ``` 214 | 215 | ### Delete an event 216 | ```ruby 217 | event_to_delete = "b91b3fb40128112c38dc54168b9f601c22bf8fcae6e70bb2a5f53e7f3ae44388" 218 | e = Nostr::Event.new( 219 | kind: Nostr::Kind::DELETION, 220 | pubkey: c.public_key, 221 | tags: [["e", event_to_delete]], 222 | ) 223 | ``` 224 | 225 | ### React to an event 226 | ```ruby 227 | e = Nostr::Event.new( 228 | kind: Nostr::Kind::REACTION, 229 | pubkey: c.public_key, 230 | content: "+", 231 | tags: [["e", target_event]], 232 | ) 233 | 234 | # You can also use emoji 235 | e2 = Nostr::Event.new( 236 | kind: Nostr::Kind::REACTION, 237 | pubkey: c.public_key, 238 | content: "🔥", 239 | tags: [["e", target_event]], 240 | ) 241 | ``` 242 | 243 | ### Create events with a PoW difficulty 244 | ```ruby 245 | # Just add the `pow` argument 246 | e = Nostr::Event.new( 247 | kind: Nostr::Kind::SHORT_NOTE, 248 | pubkey: c.public_key, 249 | content: "Hello Nostr!", 250 | pow: 15, 251 | ) 252 | ``` 253 | 254 | ### Create an event with a NIP-26 delegation 255 | ```ruby 256 | delegator = Nostr::Client.new(private_key: delegator_key) 257 | 258 | delegatee = "b1d8dfd69fe8795042dbbc4d3f85938a01d4740c54d2daf11088c75c50ff19d9" 259 | conditions = "kind=1&created_at>#{Time.now.to_i}&created_at<#{(Time.now + 60*60).to_i}" 260 | delegation_tag = delegator.generate_delegation_tag( 261 | to: delegatee, 262 | conditions: conditions 263 | ) 264 | 265 | # The `delegation_tag` is given to the delegatee so it can use it 266 | 267 | delegatee = Nostr::Client.new(private_key: delegatee_key) 268 | 269 | e = Nostr::Event.new( 270 | kind: Nostr::Kind::SHORT_NOTE, 271 | pubkey: delegatee.public_key, 272 | content: "Hello Nostr!", 273 | delegation: delegation_tag, 274 | ) 275 | 276 | delegatee.sign(e) 277 | ``` 278 | ### Send a direct message 279 | Warning: This uses NIP-04, that will be deprecated in favor of NIP-17 280 | ```ruby 281 | 282 | e = Nostr::Event.new( 283 | kind: Nostr::Kind::DIRECT_MESSAGE, 284 | pubkey: c.public_key, 285 | tags: [["p", Nostr::Bech32.decode(recipient)[:data]]], 286 | content: "Hello Alice!" 287 | ) 288 | ``` 289 | 290 | ### Decrypt a direct message 291 | Warning: This uses NIP-04, that will be deprecated in favor of NIP-17 292 | ```ruby 293 | payload = { 294 | :kind=>4, 295 | :pubkey=>"a19f3c16b6e857d2b673c67eea293431fc175895513ca2f687a717152a5da466", 296 | :created_at=>1725387307, 297 | :tags=>[["p", "e7ded9bd42e7c74fcc6465962b919b7efcd5774ac6bea2ae6b81b2caa9d4d2e6"]], 298 | :content=>"Nd7n/wId1oiprUCC4WWwNw==?iv=7gIRExcyO1xystretLIPnQ==", 299 | :id=>"b91b3fb40128112c38dc54168b9f601c22bf8fcae6e70bb2a5f53e7f3ae44388", 300 | :sig=>"73edf5a6acbefdd3d76f28ba90faaabe348a24c798f8fa33797eec29e2404c33a455815a59472ecd023441df38d815f83d81b95b8cb2f2c88a52982c8f7301e9" 301 | } 302 | e = Nostr::Event.new(**payload) 303 | 304 | c.decrypt(e) 305 | puts e.content 306 | => "Hello Alice!" 307 | ``` 308 | 309 | ### Create a subscribtion to receive events 310 | 311 | ```ruby 312 | filter = Nostr::Filter.new( 313 | kinds: [1], 314 | authors: ["a19f3c16b6e857d2b673c67eea293431fc175895513ca2f687a717152a5da466"], 315 | since: Time.now - (60*60*24), # 24 hours ago 316 | limit: 10 317 | ) 318 | 319 | c.on :connect do |message| 320 | puts "Connected!" 321 | end 322 | 323 | c.on :event do |message| 324 | puts ">> #{message.content}" 325 | end 326 | 327 | c.on :eose do |message| 328 | puts "Finished subscription #{message.subscription_id}" 329 | c.close 330 | end 331 | 332 | c.on :close do |message| 333 | puts "Connection closed" 334 | end 335 | 336 | c.subscribe(filter: filter) 337 | c.connect 338 | ``` -------------------------------------------------------------------------------- /lib/bech32.rb: -------------------------------------------------------------------------------- 1 | require 'bech32' 2 | require 'bech32/nostr' 3 | require 'bech32/nostr/entity' 4 | 5 | module Nostr 6 | class Bech32 7 | 8 | def self.decode(bech32_entity) 9 | e = ::Bech32::Nostr::NIP19.decode(bech32_entity) 10 | 11 | case e 12 | in ::Bech32::Nostr::BareEntity 13 | { hrp: e.hrp, data: e.data } 14 | in ::Bech32::Nostr::TLVEntity 15 | { hrp: e.hrp, data: transform_entries(e.entries) } 16 | end 17 | end 18 | 19 | def self.encode(hrp, data) 20 | ::Bech32::Nostr::BareEntity.new(hrp, data).encode 21 | end 22 | 23 | def self.encode_npub(data) 24 | encode("npub", data) 25 | end 26 | 27 | def self.encode_nsec(data) 28 | encode("nsec", data) 29 | end 30 | 31 | def self.encode_nprofile(pubkey:, relays: []) 32 | entry_relays = relays.map do |relay_url| 33 | ::Bech32::Nostr::TLVEntry.new(::Bech32::Nostr::TLVEntity::TYPE_RELAY, relay_url) 34 | end 35 | 36 | pubkey_entry = ::Bech32::Nostr::TLVEntry.new(::Bech32::Nostr::TLVEntity::TYPE_SPECIAL, pubkey) 37 | entries = [pubkey_entry, *entry_relays].compact 38 | ::Bech32::Nostr::TLVEntity.new(::Bech32::Nostr::NIP19::HRP_PROFILE, entries).encode 39 | end 40 | 41 | def self.encode_note(data) 42 | encode("note", data) 43 | end 44 | 45 | def self.encode_nevent(id:, relays: [], kind: nil) 46 | entry_relays = relays.map do |r| 47 | ::Bech32::Nostr::TLVEntry.new(::Bech32::Nostr::TLVEntity::TYPE_RELAY, r) 48 | end 49 | 50 | entry_id = ::Bech32::Nostr::TLVEntry.new(::Bech32::Nostr::TLVEntity::TYPE_SPECIAL, id) 51 | entry_kind = kind ? ::Bech32::Nostr::TLVEntry.new(::Bech32::Nostr::TLVEntity::TYPE_KIND, kind) : nil 52 | 53 | entries = [entry_id, *entry_relays, entry_kind].compact 54 | ::Bech32::Nostr::TLVEntity.new(::Bech32::Nostr::NIP19::HRP_EVENT, entries).encode 55 | end 56 | 57 | def self.encode_naddr(author:, relays: [], kind: nil, identifier: nil) 58 | entry_relays = relays.map do |r| 59 | ::Bech32::Nostr::TLVEntry.new(::Bech32::Nostr::TLVEntity::TYPE_RELAY, r) 60 | end 61 | 62 | entry_author = ::Bech32::Nostr::TLVEntry.new(::Bech32::Nostr::TLVEntity::TYPE_AUTHOR, author) 63 | entry_kind = ::Bech32::Nostr::TLVEntry.new(::Bech32::Nostr::TLVEntity::TYPE_KIND, kind) 64 | entry_identifier = ::Bech32::Nostr::TLVEntry.new(::Bech32::Nostr::TLVEntity::TYPE_SPECIAL, identifier) 65 | 66 | entries = [entry_author, *entry_relays, entry_kind, entry_identifier].compact 67 | ::Bech32::Nostr::TLVEntity.new(::Bech32::Nostr::NIP19::HRP_EVENT_COORDINATE, entries).encode 68 | end 69 | 70 | private 71 | 72 | # Helper method to transform entries into a hash 73 | def self.transform_entries(entries) 74 | entries.each_with_object({}) do |entry, hash| 75 | label = entry.instance_variable_get(:@label).to_sym 76 | value = entry.instance_variable_get(:@value) 77 | 78 | hash[label] ||= [] 79 | hash[label] << value 80 | end 81 | end 82 | 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /lib/client.rb: -------------------------------------------------------------------------------- 1 | require_relative 'event_wizard' 2 | require 'faye/websocket' 3 | 4 | module Nostr 5 | 6 | class Client 7 | include EventWizard 8 | 9 | attr_reader :signer 10 | attr_reader :relay 11 | attr_reader :subscriptions 12 | 13 | 14 | def initialize(signer: nil, private_key: nil, relay: nil, context: Context.new(timeout: 5)) 15 | initialize_event_emitter 16 | 17 | if signer 18 | @signer = signer 19 | elsif private_key 20 | @signer = Nostr::Signer.new(private_key: private_key) 21 | end 22 | 23 | @relay = relay 24 | @context = context 25 | 26 | @running = false 27 | @expected_response_id = nil 28 | @response_condition = ConditionVariable.new 29 | @response_mutex = Mutex.new 30 | @event_to_publish = nil 31 | 32 | @subscriptions = {} 33 | @outbound_channel = EventMachine::Channel.new 34 | @inbound_channel = EventMachine::Channel.new 35 | 36 | @inbound_channel.subscribe do |msg| 37 | case msg[:type] 38 | when :open 39 | emit :connect, msg[:relay] 40 | when :message 41 | parsed_data = Nostr::MessageHandler.handle(msg[:data]) 42 | emit :message, parsed_data 43 | emit :event, parsed_data if parsed_data.type == "EVENT" 44 | emit :ok, parsed_data if parsed_data.type == "OK" 45 | emit :eose, parsed_data if parsed_data.type == "EOSE" 46 | emit :closed, parsed_data if parsed_data.type == "CLOSED" 47 | emit :notice, parsed_data if parsed_data.type == "NOTICE" 48 | when :error 49 | emit :error, msg[:message] 50 | when :close 51 | emit :close, msg[:code], msg[:reason] 52 | end 53 | end 54 | end 55 | 56 | def nsec 57 | signer.nsec 58 | end 59 | 60 | def private_key 61 | signer.private_key 62 | end 63 | 64 | def npub 65 | signer.npub 66 | end 67 | 68 | def public_key 69 | signer.public_key 70 | end 71 | 72 | def sign(event) 73 | signer.sign(event) 74 | end 75 | 76 | def decrypt(event) 77 | signer.decrypt(event) 78 | end 79 | 80 | def generate_delegation_tag(to:, conditions:) 81 | signer.generate_delegation_tag(to, conditions) 82 | end 83 | 84 | def connect(context: @context) 85 | @thread = Thread.new do 86 | EM.run do 87 | @ws_client = Faye::WebSocket::Client.new(@relay) 88 | 89 | @outbound_channel.subscribe { |msg| @ws_client.send(msg) && emit(:send, msg) } 90 | 91 | @ws_client.on :open do 92 | @running = true 93 | @inbound_channel.push(type: :open, relay: @relay) 94 | end 95 | 96 | @ws_client.on :message do |event| 97 | @inbound_channel.push(type: :message, data: event.data) 98 | end 99 | 100 | @ws_client.on :error do |event| 101 | @inbound_channel.push(type: :error, message: event.message) 102 | end 103 | 104 | @ws_client.on :close do |event| 105 | context.cancel 106 | @inbound_channel.push(type: :close, code: event.code, reason: event.reason) 107 | end 108 | 109 | end 110 | end 111 | 112 | # Wait for the connection to be established or for the context to be canceled 113 | if context 114 | context.wait { @running } 115 | end 116 | 117 | end 118 | 119 | def running? 120 | @running 121 | end 122 | 123 | def close 124 | @running = false 125 | EM.next_tick do 126 | @ws_client.close if @ws_client 127 | EM.add_timer(0.1) do 128 | EM.stop if EM.reactor_running? 129 | end 130 | end 131 | end 132 | 133 | def publish(event) 134 | return false unless running? 135 | @outbound_channel.push(['EVENT', event.to_json].to_json) 136 | return true 137 | end 138 | 139 | def publish_and_wait(event, context: @context, close_on_finish: false) 140 | return false unless running? 141 | 142 | response = nil 143 | @outbound_channel.push(['EVENT', event.to_json].to_json) 144 | 145 | response_thread = Thread.new do 146 | context.wait do 147 | @response_mutex.synchronize do 148 | @response_condition.wait(@response_mutex) # Wait for a response 149 | end 150 | end 151 | end 152 | 153 | @inbound_channel.subscribe do |message| 154 | parsed_data = Nostr::MessageHandler.handle(message[:data]) 155 | if parsed_data.type == "OK" && parsed_data.event_id == event.id 156 | response = parsed_data 157 | @response_condition.signal 158 | end 159 | end 160 | 161 | response_thread.join 162 | close if close_on_finish 163 | 164 | response 165 | end 166 | 167 | def subscribe(subscription_id: SecureRandom.hex, filter: Filter.new) 168 | @subscriptions[subscription_id] = filter 169 | @outbound_channel.push(["REQ", subscription_id, filter.to_h].to_json) 170 | @subscriptions[subscription_id] 171 | subscription_id 172 | end 173 | 174 | def unsubscribe(subscription_id) 175 | @subscriptions.delete(subscription_id) 176 | @outbound_channel.push(["CLOSE", subscription_id].to_json) 177 | end 178 | 179 | def unsubscribe_all 180 | @subscriptions.each{|s| unsubscribe(s[0])} 181 | end 182 | 183 | end 184 | end -------------------------------------------------------------------------------- /lib/context.rb: -------------------------------------------------------------------------------- 1 | class Context 2 | attr_reader :canceled, :timeout 3 | 4 | def initialize(timeout: nil) 5 | @timeout = timeout 6 | @canceled = false 7 | @mutex = Mutex.new 8 | @condition = ConditionVariable.new 9 | 10 | # Start a timer if a timeout is specified 11 | if @timeout 12 | @start_time = Time.now 13 | end 14 | end 15 | 16 | def cancel 17 | @mutex.synchronize do 18 | @canceled = true 19 | @condition.broadcast 20 | end 21 | end 22 | 23 | def timed_out? 24 | return false unless @timeout 25 | 26 | # Check the elapsed time without locking the mutex 27 | Time.now - @start_time > @timeout 28 | end 29 | 30 | def wait(&block) 31 | reset # Reset the context state before waiting 32 | loop do 33 | break if block.call 34 | if timed_out? 35 | raise StandardError.new("Operation timed out after #{timeout} seconds") 36 | end 37 | sleep(0.1) # Sleep briefly to avoid busy-waiting 38 | end 39 | end 40 | 41 | def reset 42 | @mutex.synchronize do 43 | @canceled = false 44 | @start_time = Time.now if @timeout 45 | end 46 | end 47 | 48 | end 49 | -------------------------------------------------------------------------------- /lib/crypto_tools.rb: -------------------------------------------------------------------------------- 1 | module CryptoTools 2 | 3 | def self.calculate_shared_key(priv_key_a, pub_key_b) 4 | ec = OpenSSL::PKey::EC.new('secp256k1') 5 | ec.private_key = OpenSSL::BN.new(priv_key_a, 16) 6 | pub_key_hex = "02#{pub_key_b}" 7 | pub_key_bn = OpenSSL::BN.new(pub_key_hex, 16) 8 | secret_point = OpenSSL::PKey::EC::Point.new(ec.group, pub_key_bn) 9 | ec.dh_compute_key(secret_point) 10 | end 11 | 12 | def self.aes_256_cbc_encrypt(priv_key_a, pub_key_b, payload) 13 | cipher = OpenSSL::Cipher::Cipher.new('aes-256-cbc') 14 | cipher.encrypt 15 | cipher.iv = iv = cipher.random_iv 16 | cipher.key = calculate_shared_key(priv_key_a, pub_key_b) 17 | encrypted_text = cipher.update(payload) 18 | encrypted_text << cipher.final 19 | encrypted_text = "#{Base64.encode64(encrypted_text)}?iv=#{Base64.encode64(iv)}" 20 | encrypted_text.gsub("\n", '') 21 | end 22 | 23 | def self.aes_256_cbc_decrypt(priv_key_a, pub_key_b, payload, iv) 24 | cipher = OpenSSL::Cipher::Cipher.new('aes-256-cbc') 25 | cipher.decrypt 26 | cipher.iv = Base64.decode64(iv) 27 | cipher.key = calculate_shared_key(priv_key_a, pub_key_b) 28 | (cipher.update(Base64.decode64(payload)) + cipher.final).force_encoding('UTF-8') 29 | end 30 | 31 | end -------------------------------------------------------------------------------- /lib/event.rb: -------------------------------------------------------------------------------- 1 | module Nostr 2 | class Event 3 | include CryptoTools 4 | 5 | ATTRIBUTES = [:kind, :pubkey, :created_at, :tags, :content, :id, :sig, :pow, :delegation, :recipient] 6 | 7 | # Create attr_reader for each attribute name 8 | ATTRIBUTES.each do |attribute| 9 | attr_reader attribute 10 | end 11 | 12 | attr_reader :errors 13 | 14 | class ValidationError < StandardError; end 15 | 16 | def initialize( 17 | kind:, 18 | pubkey: nil, 19 | created_at: nil, 20 | tags: [], 21 | content: nil, 22 | id: nil, 23 | sig: nil, 24 | pow: nil, 25 | delegation: nil, 26 | subscription_id: nil 27 | ) 28 | @pubkey = pubkey 29 | @created_at = created_at ? created_at : Time.now.utc.to_i 30 | @kind = kind 31 | @tags = tags 32 | @content = content 33 | @id = id 34 | @sig = sig 35 | 36 | @pow = pow 37 | @delegation = delegation 38 | 39 | end 40 | 41 | # Create setter methods for each attribute name 42 | ATTRIBUTES.each do |attribute| 43 | define_method("#{attribute}=") do |value| 44 | return if instance_variable_get("@#{attribute}") == value 45 | instance_variable_set("@#{attribute}", value) 46 | reset! unless attribute == :id || attribute == :sig 47 | end 48 | end 49 | 50 | def type 51 | "EVENT" 52 | end 53 | 54 | def content=(content) 55 | return if @content == content 56 | @content = content 57 | reset! 58 | end 59 | 60 | def has_tag?(tag) 61 | @tags.each_slice(2).any? { |e| e.first == tag } 62 | end 63 | 64 | def to_json 65 | { 66 | 'kind': @kind, 67 | 'pubkey': @pubkey, 68 | 'created_at': @created_at, 69 | 'tags': @tags, 70 | 'content': @content, 71 | 'id': @id, 72 | 'sig': @sig, 73 | } 74 | end 75 | 76 | def match_pow_difficulty? 77 | self.match_pow_difficulty?(@id, pow) 78 | end 79 | 80 | def self.match_pow_difficulty?(event_id, pow) 81 | pow.nil? || pow == [event_id].pack("H*").unpack("B*")[0].index('1') 82 | end 83 | 84 | def serialize 85 | [ 86 | 0, 87 | @pubkey, 88 | @created_at, 89 | @kind, 90 | @tags, 91 | @content 92 | ] 93 | end 94 | 95 | def signable? 96 | @errors = [] 97 | 98 | # Check mandatory fields 99 | @errors << "Kind is missing" if @kind.nil? 100 | @errors << "Created at is missing" if @created_at.nil? 101 | 102 | # Type validations 103 | @errors << "Pubkey must be a string" if @pubkey && !@pubkey.is_a?(String) 104 | @errors << "Kind must be an integer" unless @kind.is_a?(Integer) 105 | if @created_at 106 | # Check if it's a valid Unix timestamp or can be converted to one 107 | begin 108 | timestamp = if @created_at.is_a?(Time) 109 | @created_at.to_i 110 | elsif @created_at.is_a?(Integer) 111 | @created_at 112 | elsif @created_at.respond_to?(:to_time) 113 | @created_at.to_time.to_i 114 | else 115 | raise ArgumentError 116 | end 117 | 118 | # Validate timestamp range 119 | @errors << "Created at is not a valid timestamp" unless 120 | timestamp.is_a?(Integer) && 121 | timestamp >= 0 122 | rescue 123 | @errors << "Created at must be a valid datetime or Unix timestamp" 124 | end 125 | end 126 | @errors << "Tags must be an array" unless @tags.is_a?(Array) 127 | 128 | @errors << "Content must be a string" if @content && !@content.is_a?(String) 129 | @errors << "ID must be a string" if @id && !@id.is_a?(String) 130 | @errors << "Signature must be a string" if @sig && !@sig.is_a?(String) 131 | @errors << "POW must be an integer" if @pow && !@pow.is_a?(Integer) 132 | @errors << "Delegation must be an array" if @delegation && !@delegation.is_a?(Array) 133 | 134 | if @errors.any? 135 | raise ValidationError, @errors.join(", ") 136 | end 137 | 138 | true 139 | end 140 | 141 | def valid? 142 | begin 143 | signable? 144 | rescue ValidationError => e 145 | return false 146 | end 147 | 148 | # Additional checks for a valid signed event 149 | @errors = [] 150 | @errors << "ID is missing" if @id.nil? 151 | @errors << "Signature is missing" if @sig.nil? 152 | @errors << "Pubkey is missing" if @pubkey.nil? 153 | 154 | if @errors.any? 155 | raise ValidationError, @errors.join(", ") 156 | end 157 | 158 | true 159 | end 160 | 161 | def self.from_message(message) 162 | subscription_id = message[1] 163 | event_data = message[2] 164 | 165 | event = new( 166 | subscription_id: subscription_id, 167 | kind: event_data["kind"], 168 | pubkey: event_data["pubkey"], 169 | created_at: event_data["created_at"], 170 | tags: event_data["tags"], 171 | content: event_data["content"], 172 | id: event_data["id"], 173 | sig: event_data["sig"], 174 | pow: event_data["nonce"]&.last&.to_i 175 | ) 176 | raise ArgumentError, "Event is not valid" unless event.valid? 177 | return event 178 | end 179 | 180 | private 181 | 182 | def reset! 183 | @id = nil 184 | @sign = nil 185 | end 186 | 187 | end 188 | end -------------------------------------------------------------------------------- /lib/event_wizard.rb: -------------------------------------------------------------------------------- 1 | module EventWizard 2 | def initialize_event_emitter 3 | @listeners = Hash.new { |hash, key| hash[key] = [] } 4 | end 5 | 6 | def on(event, &callback) 7 | # Prevent adding the same callback multiple times 8 | unless @listeners[event].include?(callback) 9 | @listeners[event] << callback 10 | end 11 | end 12 | 13 | def emit(event, *args) 14 | @listeners[event].each { |callback| callback.call(*args) } 15 | end 16 | 17 | def off(event, callback) 18 | return unless @listeners[event] 19 | @listeners[event].delete(callback) 20 | end 21 | 22 | def replace(event, old_callback, new_callback) 23 | return unless @listeners[event] 24 | index = @listeners[event].index(old_callback) 25 | @listeners[event][index] = new_callback if index 26 | end 27 | 28 | def clear(event) 29 | @listeners.delete(event) 30 | end 31 | end -------------------------------------------------------------------------------- /lib/filter.rb: -------------------------------------------------------------------------------- 1 | module Nostr 2 | class Filter 3 | 4 | attr_reader :id 5 | attr_reader :kinds 6 | attr_reader :authors 7 | attr_reader :tags 8 | attr_reader :since 9 | attr_reader :until 10 | attr_reader :limit 11 | attr_reader :search 12 | ('a'..'z').each { |char| attr_reader char.to_sym } 13 | ('A'..'Z').each { |char| attr_reader char.to_sym } 14 | 15 | def initialize(ids: nil, kinds: nil, authors: nil, since: nil, limit: nil, search: nil, **params) 16 | @id = id 17 | @kinds = kinds 18 | @authors = authors 19 | @tags = tags 20 | @since = since.nil? ? nil : since.to_i 21 | @limit = limit 22 | @search = search 23 | @until = params[:until].nil? ? nil : params[:until].to_i # this is an hack to permit the use of the 'until' param, since it is a reserved word 24 | 25 | # Handle additional parameters with a-zA-Z names 26 | params.each do |key, value| 27 | if key.to_s.match?(/\A[a-zA-Z]\z/) 28 | instance_variable_set("@#{key}", value) 29 | end 30 | end 31 | end 32 | 33 | def to_h 34 | result = { 35 | id: @id, 36 | authors: @authors, 37 | kinds: @kinds, 38 | since: @since, 39 | until: @until, 40 | limit: @limit, 41 | search: @search 42 | }.compact 43 | 44 | ('a'..'z').each do |char| 45 | var_value = instance_variable_get("@#{char}") 46 | result["##{char}"] = var_value unless var_value.nil? 47 | end 48 | ('A'..'Z').each do |char| 49 | var_value = instance_variable_get("@#{char}") 50 | result["##{char}"] = var_value unless var_value.nil? 51 | end 52 | 53 | result 54 | end 55 | 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/key.rb: -------------------------------------------------------------------------------- 1 | module Nostr 2 | class Key 3 | 4 | def self.generate_private_key 5 | group = ECDSA::Group::Secp256k1 6 | (1 + SecureRandom.random_number(group.order - 1)).to_s(16).rjust(64, '0') 7 | end 8 | 9 | def self.get_public_key(private_key) 10 | group = ECDSA::Group::Secp256k1 11 | group.generator.multiply_by_scalar(private_key.to_i(16)).x.to_s(16).rjust(64, '0') 12 | end 13 | 14 | end 15 | end -------------------------------------------------------------------------------- /lib/kind.rb: -------------------------------------------------------------------------------- 1 | module Nostr 2 | module Kind 3 | METADATA = 0 4 | SHORT_NOTE = 1 5 | CONTACT_LIST = 3 6 | DIRECT_MESSAGE = 4 7 | DELETION = 5 8 | REACTION = 7 9 | end 10 | end -------------------------------------------------------------------------------- /lib/message_handler.rb: -------------------------------------------------------------------------------- 1 | module Nostr 2 | class MessageHandler 3 | def self.handle(message) 4 | 5 | message = JSON.parse(message) rescue ["?", message] 6 | type = message[0] 7 | strategy_class = case type 8 | when 'EVENT' then EventMessageStrategy 9 | when 'OK' then OkMessageStrategy 10 | when 'EOSE' then EoseMessageStrategy 11 | when 'CLOSED' then ClosedMessageStrategy 12 | when 'NOTICE' then NoticeMessageStrategy 13 | else UnknownMessageStrategy 14 | end 15 | 16 | processed_data = strategy_class.new(message).process 17 | type == "EVENT" ? processed_data : ParsedData.new(processed_data) 18 | 19 | end 20 | end 21 | 22 | class BaseMessageStrategy 23 | def initialize(message) 24 | @message = message 25 | end 26 | 27 | def process 28 | raise NotImplementedError 29 | end 30 | end 31 | 32 | class EventMessageStrategy < BaseMessageStrategy 33 | def process 34 | Event.from_message(@message) 35 | end 36 | end 37 | 38 | class OkMessageStrategy < BaseMessageStrategy 39 | def process 40 | { 41 | type: 'OK', 42 | event_id: @message[1], 43 | success: @message[2], 44 | message: @message[3] 45 | } 46 | end 47 | end 48 | 49 | class EoseMessageStrategy < BaseMessageStrategy 50 | def process 51 | { 52 | type: 'EOSE', 53 | subscription_id: @message[1] 54 | } 55 | end 56 | end 57 | 58 | class ClosedMessageStrategy < BaseMessageStrategy 59 | def process 60 | { 61 | type: 'CLOSED', 62 | subscription_id: @message[1], 63 | reason: @message[2] 64 | } 65 | end 66 | end 67 | 68 | class NoticeMessageStrategy < BaseMessageStrategy 69 | def process 70 | { 71 | type: 'NOTICE', 72 | message: @message[1] 73 | } 74 | end 75 | end 76 | 77 | class UnknownMessageStrategy < BaseMessageStrategy 78 | def process 79 | { 80 | type: 'UNKNOWN', 81 | raw_message: @message 82 | } 83 | end 84 | end 85 | end 86 | 87 | class ParsedData 88 | def initialize(data) 89 | @data = data 90 | end 91 | 92 | def type 93 | @data[:type] 94 | end 95 | 96 | def method_missing(method_name, *args, &block) 97 | if @data.key?(method_name) 98 | @data[method_name] 99 | else 100 | super 101 | end 102 | end 103 | 104 | def respond_to_missing?(method_name, include_private = false) 105 | @data.key?(method_name) || super 106 | end 107 | end -------------------------------------------------------------------------------- /lib/nostr_ruby.rb: -------------------------------------------------------------------------------- 1 | require_relative 'version' 2 | require_relative 'crypto_tools' 3 | 4 | require 'ecdsa' 5 | require 'schnorr' 6 | require 'json' 7 | require 'base64' 8 | require 'bech32' 9 | require 'unicode/emoji' 10 | require 'websocket-client-simple' 11 | 12 | require_relative 'bech32' 13 | require_relative 'context' 14 | require_relative 'kind' 15 | require_relative 'key' 16 | require_relative 'event' 17 | require_relative 'filter' 18 | require_relative 'signer' 19 | require_relative 'client' 20 | require_relative 'message_handler' 21 | 22 | module Nostr 23 | 24 | end -------------------------------------------------------------------------------- /lib/signer.rb: -------------------------------------------------------------------------------- 1 | module Nostr 2 | class Signer 3 | 4 | attr_reader :private_key 5 | attr_reader :public_key 6 | 7 | def initialize(private_key:) 8 | @private_key = private_key 9 | unless @public_key 10 | @public_key = Nostr::Key::get_public_key(@private_key) 11 | end 12 | end 13 | 14 | def nsec 15 | Nostr::Bech32.encode_nsec(@private_key) 16 | end 17 | 18 | def npub 19 | Nostr::Bech32.encode_npub(@public_key) 20 | end 21 | 22 | def sign(event) 23 | 24 | raise ArgumentError, "Event is not signable" unless event.signable? 25 | 26 | event.pubkey = @public_key if event.pubkey.nil? || event.pubkey.empty? 27 | 28 | raise ArgumentError, "Pubkey doesn't match the private key" unless event.pubkey == @public_key 29 | 30 | if event.kind == Nostr::Kind::DIRECT_MESSAGE 31 | dm_recipient = event.tags.select{|t| t[0] == "p"}.first[1] 32 | event.content = CryptoTools.aes_256_cbc_encrypt(@private_key, dm_recipient, event.content) 33 | end 34 | 35 | if event.delegation 36 | event.tags << event.delegation 37 | end 38 | 39 | event_sha256_digest = nil 40 | if event.pow 41 | nonce = 1 42 | loop do 43 | nonce_tag = ['nonce', nonce.to_s, event.pow.to_s] 44 | nonced_serialized_event = event.serialize.clone 45 | nonced_serialized_event[4] = nonced_serialized_event[4] + [nonce_tag] 46 | event_sha256_digest = Digest::SHA256.hexdigest(JSON.dump(nonced_serialized_event)) 47 | if Nostr::Event.match_pow_difficulty?(event_sha256_digest, event.pow) 48 | event.tags << nonce_tag 49 | break 50 | end 51 | nonce += 1 52 | end 53 | else 54 | event_sha256_digest = Digest::SHA256.hexdigest(JSON.dump(event.serialize)) 55 | end 56 | 57 | event.id = event_sha256_digest 58 | binary_private_key = Array(@private_key).pack('H*') 59 | binary_message = Array(event.id).pack('H*') 60 | event.sig = Schnorr.sign(binary_message, binary_private_key).encode.unpack('H*')[0] 61 | event 62 | end 63 | 64 | def decrypt(event) 65 | case event.kind 66 | when Nostr::Kind::DIRECT_MESSAGE 67 | data = event.content.split('?iv=')[0] 68 | iv = event.content.split('?iv=')[1] 69 | dm_recipient = event.tags.select{|t| t[0] == "p"}.first[1] 70 | event.content = CryptoTools.aes_256_cbc_decrypt(@private_key, dm_recipient, data, iv) 71 | event 72 | else 73 | raise "Unable to decrypt a kind #{event.kind} event" 74 | end 75 | end 76 | 77 | def generate_delegation_tag(to:, conditions:) 78 | delegation_message_sha256 = Digest::SHA256.hexdigest("nostr:delegation:#{to}:#{conditions}") 79 | signature = Schnorr.sign(Array(delegation_message_sha256).pack('H*'), Array(@private_key).pack('H*')).encode.unpack('H*')[0] 80 | [ 81 | "delegation", 82 | @public_key, 83 | conditions, 84 | signature 85 | ] 86 | end 87 | 88 | end 89 | end -------------------------------------------------------------------------------- /lib/version.rb: -------------------------------------------------------------------------------- 1 | module Nostr 2 | VERSION = '0.3.0' 3 | end 4 | -------------------------------------------------------------------------------- /nostr_ruby.gemspec: -------------------------------------------------------------------------------- 1 | $:.unshift File.expand_path('../lib', __FILE__) 2 | require 'version' 3 | 4 | Gem::Specification.new do |s| 5 | s.name = 'nostr_ruby' 6 | s.version = Nostr::VERSION 7 | s.summary = 'A Ruby library to interact with the Nostr protocol' 8 | s.description = 'NostrRuby is a Ruby library to interact with the Nostr protocol. At this stage the focus is the creation of public events and private encrypted messages.' 9 | s.authors = ['Daniele Tonon'] 10 | s.homepage = 'https://github.com/dtonon/nostr-ruby' 11 | s.licenses = ['MIT'] 12 | s.files = Dir.glob('{bin/*,lib/**/*,[A-Z]*}') 13 | s.platform = Gem::Platform::RUBY 14 | s.require_paths = ['lib'] 15 | 16 | s.add_dependency 'base64', '~> 0.1.1' 17 | s.add_dependency 'bech32', '~> 1.4.0' 18 | s.add_dependency 'bip-schnorr', '~> 0.4.0' 19 | s.add_dependency 'json', '~> 2.6.2' 20 | s.add_dependency 'unicode-emoji', '~> 3.3.1' 21 | s.add_dependency 'faye-websocket', '~> 0.11' 22 | end 23 | --------------------------------------------------------------------------------