nostrb 0.1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6f6159cbd2c246ae573103bd68e56e7cd97b0d3d81d36d8e0816abc5bd23c3c1
4
+ data.tar.gz: 8bcf8fe77b3baa61882ea849df549b2800ebbf5e788d55c2c1c00efddc99e1f8
5
+ SHA512:
6
+ metadata.gz: 369121a3fa1b9ead9bc245a3bd5f18ce6a6e2cc3571e1c91019afb4752845de7edbef086b755ad3b90d4b1a072997b2cce8047e19c4db293cc14328332febb56
7
+ data.tar.gz: 8b19fa2c2b738e53711fdb4bbdac6a196ce8499b8a212b742be734318a526bd495bed6ddfd97f771c005323ee2583f6207feea8f79850b95e7c3aced24ac3cec
data/Rakefile ADDED
@@ -0,0 +1,24 @@
1
+ require 'rake/testtask'
2
+
3
+ Rake::TestTask.new :test do |t|
4
+ t.pattern = "test/*.rb"
5
+ t.warning = true
6
+ end
7
+
8
+ task :relay do |t|
9
+ sh "ruby -I lib examples/relay.rb"
10
+ end
11
+
12
+ task default: [:test, :relay]
13
+
14
+ begin
15
+ require 'buildar'
16
+
17
+ Buildar.new do |b|
18
+ b.gemspec_file = 'nostrb.gemspec'
19
+ b.version_file = 'VERSION'
20
+ b.use_git = true
21
+ end
22
+ rescue LoadError
23
+ warn "buildar tasks unavailable"
24
+ end
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.1.0.1
@@ -0,0 +1,154 @@
1
+ require 'nostrb'
2
+
3
+ module Nostrb
4
+ class Event
5
+
6
+ # Event
7
+ # content: any string
8
+ # kind: 0..65535
9
+ # tags: Array[Array[string]]
10
+ # pubkey: 64 hex chars (32B binary)
11
+
12
+ def self.digest(ary) = Nostrb.digest(Nostrb.json(Nostrb.ary!(ary)))
13
+
14
+ def self.tag_values(tag, tags)
15
+ tags.select { |a| a[0] == tag }.map { |a| a[1] }
16
+ end
17
+
18
+ def self.d_tag(tags)
19
+ tag_values('d', tags).first
20
+ end
21
+
22
+ attr_reader :content, :kind, :tags, :pk
23
+
24
+ def initialize(content = '', kind: 1, tags: [], pk:)
25
+ @content = Nostrb.txt!(content)
26
+ @kind = Nostrb.kind!(kind)
27
+ @tags = Nostrb.tags!(tags)
28
+ @pk = Nostrb.key!(pk)
29
+ end
30
+
31
+ alias_method :to_s, :content
32
+
33
+ def serialize(created_at)
34
+ [0, self.pubkey, Nostrb.int!(created_at), @kind, @tags, @content]
35
+ end
36
+
37
+ def to_a = serialize(Time.now.to_i)
38
+ def pubkey = SchnorrSig.bin2hex(@pk)
39
+ def digest(created_at) = Event.digest(serialize(created_at))
40
+ def sign(sk) = SignedEvent.new(self, sk)
41
+
42
+ #
43
+ # Tags
44
+ #
45
+
46
+ # add an array of 2+ strings to @tags
47
+ def add_tag(tag, value, *rest)
48
+ @tags.push([Nostrb.txt!(tag), Nostrb.txt!(value)] +
49
+ rest.each { |s| Nostrb.txt!(s) })
50
+ end
51
+
52
+ # add an event tag based on event id, hex encoded
53
+ def ref_event(eid_hex, *rest)
54
+ add_tag('e', Nostrb.id!(eid_hex), *rest)
55
+ end
56
+
57
+ # add a pubkey tag based on pubkey, 64 bytes hex encoded
58
+ def ref_pubkey(pubkey, *rest)
59
+ add_tag('p', Nostrb.pubkey!(pubkey), *rest)
60
+ end
61
+
62
+ # kind: and pubkey: required
63
+ def ref_replace(*rest, kind:, pubkey:, d_tag: '')
64
+ val = [Nostrb.kind!(kind), Nostrb.pubkey!(pubkey), d_tag].join(':')
65
+ add_tag('a', val, *rest)
66
+ end
67
+ end
68
+
69
+ # SignedEvent
70
+ # id: 64 hex chars (32B binary)
71
+ # created_at: unix seconds, integer
72
+ # sig: 128 hex chars (64B binary)
73
+
74
+ class SignedEvent
75
+ class Error < RuntimeError; end
76
+ class IdCheck < Error; end
77
+ class SignatureCheck < Error; end
78
+
79
+ def self.validate!(parsed)
80
+ Nostrb.check!(parsed, Hash)
81
+ Nostrb.txt!(parsed.fetch("content"))
82
+ Nostrb.pubkey!(parsed.fetch("pubkey"))
83
+ Nostrb.kind!(parsed.fetch("kind"))
84
+ Nostrb.tags!(parsed.fetch("tags"))
85
+ Nostrb.int!(parsed.fetch("created_at"))
86
+ Nostrb.id!(parsed.fetch("id"))
87
+ Nostrb.sig!(parsed.fetch("sig"))
88
+ parsed
89
+ end
90
+
91
+ def self.digest(valid) = Nostrb.digest(Nostrb.json(serialize(valid)))
92
+
93
+ def self.serialize(valid)
94
+ Array[ 0,
95
+ valid["pubkey"],
96
+ valid["created_at"],
97
+ valid["kind"],
98
+ valid["tags"],
99
+ valid["content"], ]
100
+ end
101
+
102
+ # Validate the id (optional) and signature
103
+ # May raise explicitly: IdCheck, SignatureCheck
104
+ # May raise implicitly: Nostrb::SizeError, EncodingError, TypeError,
105
+ # SchnorrSig::Error
106
+ # Return a _completely validated_ hash
107
+ def self.verify(valid, check_id: true)
108
+ id, pubkey, sig = valid["id"], valid["pubkey"], valid["sig"]
109
+
110
+ # extract binary values for signature verification
111
+ digest = SchnorrSig.hex2bin id
112
+ pk = SchnorrSig.hex2bin pubkey
113
+ signature = SchnorrSig.hex2bin sig
114
+
115
+ # verify the signature
116
+ unless SchnorrSig.verify?(pk, digest, signature)
117
+ raise(SignatureCheck, sig)
118
+ end
119
+ # (optional) verify the id / digest
120
+ raise(IdCheck, id) if check_id and digest != SignedEvent.digest(valid)
121
+ valid
122
+ end
123
+
124
+ attr_reader :event, :created_at, :digest, :signature
125
+
126
+ # sk is used to generate @signature and then discarded
127
+ def initialize(event, sk)
128
+ @event = Nostrb.check!(event, Event)
129
+ @created_at = Time.now.to_i
130
+ @digest = @event.digest(@created_at)
131
+ @signature = SchnorrSig.sign(Nostrb.key!(sk), @digest)
132
+ end
133
+
134
+ def content = @event.content
135
+ def kind = @event.kind
136
+ def tags = @event.tags
137
+ def pubkey = @event.pubkey
138
+ def to_s = @event.to_s
139
+ def serialize = @event.serialize(@created_at)
140
+
141
+ def id = SchnorrSig.bin2hex(@digest)
142
+ def sig = SchnorrSig.bin2hex(@signature)
143
+
144
+ def to_h
145
+ Hash[ "content" => @event.content,
146
+ "kind" => @event.kind,
147
+ "tags" => @event.tags,
148
+ "pubkey" => @event.pubkey,
149
+ "created_at" => @created_at,
150
+ "id" => self.id,
151
+ "sig" => self.sig ]
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,144 @@
1
+ module Nostrb
2
+ module Seconds
3
+ def milliseconds(i) = i / 1000r
4
+ def seconds(i) = i
5
+ def minutes(i) = 60 * i
6
+ def hours(i) = 60 * minutes(i)
7
+ def days(i) = 24 * hours(i)
8
+ def weeks(i) = 7 * days(i)
9
+ def months(i) = years(i) / 12
10
+ def years(i) = 365 * days(i)
11
+
12
+ def process(hsh)
13
+ seconds = 0
14
+ [:seconds, :minutes, :hours, :days, :weeks, :months, :years].each { |p|
15
+ seconds += send(p, hsh[p]) if hsh.key?(p)
16
+ }
17
+ seconds
18
+ end
19
+ end
20
+ Seconds.extend(Seconds)
21
+
22
+ class Filter
23
+ TAG = /\A#([a-zA-Z])\z/
24
+
25
+ def self.ago(hsh)
26
+ Time.now.to_i - Seconds.process(hsh)
27
+ end
28
+
29
+ def self.ingest(hash)
30
+ f = Filter.new
31
+
32
+ if ids = hash.delete("ids")
33
+ f.add_ids(*ids)
34
+ end
35
+ if authors = hash.delete("authors")
36
+ f.add_authors(*authors)
37
+ end
38
+ if kinds = hash.delete("kinds")
39
+ f.add_kinds(*kinds)
40
+ end
41
+ if since = hash.delete("since")
42
+ f.since = since
43
+ end
44
+ if _until = hash.delete("until")
45
+ f.until = _until
46
+ end
47
+ if limit = hash.delete("limit")
48
+ f.limit = limit
49
+ end
50
+
51
+ # anything left in hash should only be single letter tags
52
+ hash.each { |tag, ary|
53
+ if matches = tag.match(TAG)
54
+ f.add_tag(matches[1], ary)
55
+ else
56
+ warn "unmatched tag: #{tag}"
57
+ end
58
+ }
59
+ f
60
+ end
61
+
62
+ attr_reader :ids, :authors, :kinds, :tags, :limit
63
+
64
+ def initialize
65
+ @ids = []
66
+ @authors = []
67
+ @kinds = []
68
+ @tags = {}
69
+ @since = nil
70
+ @until = nil
71
+ @limit = nil
72
+ end
73
+
74
+ def add_ids(*event_ids)
75
+ @ids += event_ids.each { |id| Nostrb.id!(id) }
76
+ end
77
+
78
+ def add_authors(*pubkeys)
79
+ @authors += pubkeys.each { |pubkey| Nostrb.pubkey!(pubkey) }
80
+ end
81
+
82
+ def add_kinds(*kinds)
83
+ @kinds += kinds.each { |k| Nostrb.kind!(k) }
84
+ end
85
+
86
+ def add_tag(letter, list)
87
+ @tags[Nostrb.txt!(letter, length: 1)] =
88
+ Nostrb.ary!(list, max: 99).each { |s| Nostrb.txt!(s) }
89
+ end
90
+
91
+ def since(hsh = nil) = hsh.nil? ? @since : (@since = Filter.ago(hsh))
92
+ def since=(int)
93
+ @since = int.nil? ? nil : Nostrb.int!(int)
94
+ end
95
+
96
+ def until(hsh = nil) = hsh.nil? ? @until : (@until = Filter.ago(hsh))
97
+ def until=(int)
98
+ @until = int.nil? ? nil : Nostrb.int!(int)
99
+ end
100
+
101
+ def limit=(int)
102
+ @limit = int.nil? ? nil : Nostrb.int!(int)
103
+ end
104
+
105
+ # Input
106
+ # Ruby hash as returned from SignedEvent.validate!
107
+ def match?(valid)
108
+ return false if !@ids.empty? and !@ids.include?(valid["id"])
109
+ return false if !@authors.empty? and !@authors.include?(valid["pubkey"])
110
+ return false if !@kinds.empty? and !@kinds.include?(valid["kind"])
111
+ return false if !@since.nil? and @since > valid["created_at"]
112
+ return false if !@until.nil? and @until < valid["created_at"]
113
+ if !@tags.empty?
114
+ tags = valid["tags"]
115
+ @tags.each { |letter, ary|
116
+ tag_match = false
117
+ tags.each { |(tag, val)|
118
+ next if tag_match
119
+ if tag == letter
120
+ return false if !ary.include?(val)
121
+ tag_match = true
122
+ end
123
+ }
124
+ return false unless tag_match
125
+ }
126
+ end
127
+ true
128
+ end
129
+
130
+ def to_h
131
+ h = Hash.new
132
+ h["ids"] = @ids if !@ids.empty?
133
+ h["authors"] = @authors if !@authors.empty?
134
+ h["kinds"] = @kinds if !@kinds.empty?
135
+ @tags.each { |letter, ary|
136
+ h['#' + letter.to_s] = ary if !ary.empty?
137
+ }
138
+ h["since"] = @since unless @since.nil?
139
+ h["until"] = @until unless @until.nil?
140
+ h["limit"] = @limit unless @limit.nil?
141
+ h
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,19 @@
1
+ require 'json'
2
+
3
+ module Nostrb
4
+ # per NIP-01
5
+ JSON_OPTIONS = {
6
+ allow_nan: false,
7
+ max_nesting: 4, # event is 3 deep, wire format is 4 deep
8
+ script_safe: false,
9
+ ascii_only: false,
10
+ array_nl: '',
11
+ object_nl: '',
12
+ indent: '',
13
+ space: '',
14
+ space_before: '',
15
+ }
16
+
17
+ def self.parse(json) = JSON.parse(json, **JSON_OPTIONS)
18
+ def self.json(object) = JSON.generate(object, **JSON_OPTIONS)
19
+ end
@@ -0,0 +1,71 @@
1
+ require 'nostrb'
2
+
3
+ module Nostrb
4
+ # NIP-05
5
+ module Names
6
+ LOCAL = /\A[_a-z0-0\-.]+\z/i
7
+ DOMAIN = /\A[a-z0-9\-.]+\z/i
8
+
9
+ # given bob@example.com, return [bob, example.com]
10
+ def self.identifier(str)
11
+ a = str.split('@')
12
+ raise "unexpected #{a.inspect}" unless a.length == 2
13
+ raise "bad local: #{a[0]}" unless LOCAL.match a[0]
14
+ raise "bad domain #{a[1]}" unless DOMAIN.match a[1]
15
+ a
16
+ end
17
+
18
+ # content: <JSON>
19
+ # kind: 0 (set_metadata)
20
+ # JSON:
21
+ # name:
22
+ # about:
23
+ # picture:
24
+ # nip05: bob@example.com
25
+ def self.extract_nip05(json)
26
+ addr = Nostrb.parse(json)['nip05']
27
+ identifier(addr) if addr
28
+ end
29
+
30
+ # when we get bob's profile back,
31
+ # if it has a nip05 field
32
+ # visit: https://example.com/.well-known/nostr.json?name=bob
33
+ def self.well_known_url(local, domain)
34
+ format("https://%s/.well-known/nostr.json?name=%s", domain, local)
35
+ end
36
+
37
+ # look for a pubkey:
38
+ # {"names":{"bob":"b0b0...b0"}}
39
+ # the pubkey at the URL should match the pubkey from the profile event
40
+ def self.extract_names(json)
41
+ hsh = Nostrb.parse(json).fetch("names")
42
+ hsh.each { |name, pubkey|
43
+ Nostrb.txt!(name)
44
+ Nostrb.pubkey!(pubkey)
45
+ }
46
+ hsh
47
+ end
48
+
49
+ # or even relays too:
50
+ # {"names":{"bob":"b0b0...b0"}
51
+ # "relays":{
52
+ # "b0b0...b0":["wss://relay.example.com/","wss://relay2.example.com/"]
53
+ # }
54
+ # }
55
+ def self.extract_relays(json)
56
+ hsh = Nostrb.parse(json).fetch("relays")
57
+ hsh.each { |pubkey, urls|
58
+ Nostrb.pubkey!(pubkey)
59
+ Nostrb.ary!(urls)
60
+ urls.each { |u| Nostrb.txt!(u) }
61
+ }
62
+ hsh
63
+ end
64
+
65
+ # or, if we see bob@example.com
66
+ # visit: https://example.com/.well-known/nostr.json?name=bob
67
+ # get the pubkey
68
+ # subscribe to that user's profile events
69
+ # check if that user has nip05 and it matches bob@example.com
70
+ end
71
+ end
data/lib/nostrb/oj.rb ADDED
@@ -0,0 +1,6 @@
1
+ require 'oj'
2
+
3
+ module Nostrb
4
+ def self.parse(json) = Oj.load(json, mode: :strict)
5
+ def self.json(object) = Oj.dump(object, mode: :strict)
6
+ end
@@ -0,0 +1,142 @@
1
+ require 'nostrb/event'
2
+ require 'nostrb/filter'
3
+ require 'nostrb/sqlite'
4
+ require 'set' # jruby wants this
5
+
6
+ # Kind:
7
+ # 1,4..44,1000..9999: regular -- relay stores all
8
+ # 0,3: replaceable -- relay stores only the last message from pubkey
9
+ # 2: deprecated
10
+ # 10_000..19_999: replaceable -- relay stores latest(pubkey, kind)
11
+ # 20_000..29_999: ephemeral -- relay doesn't store
12
+ # 30_000..39_999: parameterized replaceable -- latest(pubkey, kind, dtag)
13
+
14
+ # for replaceable events with same timestamp, lowest id wins
15
+
16
+ module Nostrb
17
+ class Server
18
+ def self.event(sid, event) = ["EVENT", Nostrb.sid!(sid), event.to_h]
19
+ def self.ok(eid, msg = "", ok: true)
20
+ ["OK", Nostrb.id!(eid), !!ok, ok ? Nostrb.txt!(msg) : Nostrb.help!(msg)]
21
+ end
22
+ def self.eose(sid) = ["EOSE", Nostrb.sid!(sid)]
23
+ def self.closed(sid, msg) = ["CLOSED", Nostrb.sid!(sid), Nostrb.help!(msg)]
24
+ def self.notice(msg) = ["NOTICE", Nostrb.txt!(msg)]
25
+ def self.error(e) = notice(message(e))
26
+
27
+ def self.message(excp)
28
+ format("%s: %s", excp.class.name.split('::').last, excp.message)
29
+ end
30
+
31
+ def initialize(db_filename = nil, storage: :sqlite)
32
+ case storage
33
+ when :sqlite
34
+ mod = Nostrb::SQLite
35
+ when :sequel
36
+ require 'nostrb/sequel'
37
+ mod = Nostrb::Sequel
38
+ else
39
+ raise "unexpected: #{storage.inspect}"
40
+ end
41
+ db_filename ||= mod::Storage::FILENAME
42
+ @reader = mod::Reader.new(db_filename)
43
+ @writer = mod::Writer.new(db_filename)
44
+ end
45
+
46
+ # accepts a single json array
47
+ # returns a ruby array of response strings (json array)
48
+ def ingest(json)
49
+ begin
50
+ a = Nostrb.ary!(Nostrb.parse(json))
51
+ case a[0]
52
+ when 'EVENT'
53
+ [handle_event(Nostrb.check!(a[1], Hash))]
54
+ when 'REQ'
55
+ sid = Nostrb.sid!(a[1])
56
+ filters = a[2..-1].map { |f| Filter.ingest(f) }
57
+ handle_req(sid, *filters)
58
+ when 'CLOSE'
59
+ [handle_close(Nostrb.sid!(a[1]))]
60
+ else
61
+ [Server.notice("unexpected: #{a[0].inspect}")]
62
+ end
63
+ rescue StandardError => e
64
+ [Server.error(e)]
65
+ end
66
+ end
67
+
68
+ # return a single response
69
+ def handle_event(hsh)
70
+ begin
71
+ hsh = SignedEvent.validate!(hsh)
72
+ rescue Nostrb::Error, KeyError, RuntimeError => e
73
+ return Server.error(e)
74
+ end
75
+
76
+ eid = hsh.fetch('id')
77
+
78
+ begin
79
+ hsh = SignedEvent.verify(hsh)
80
+ case hsh['kind']
81
+ when 1, (4..44), (1000..9999)
82
+ # regular, store all
83
+ @writer.add_event(hsh)
84
+ when 0, 3, (10_000..19_999)
85
+ # replaceable, store latest (pubkey, kind)
86
+ @writer.add_r_event(hsh)
87
+ when 20_000..29_999
88
+ # ephemeral, don't store
89
+ when 30_000..30_999
90
+ # parameterized replaceable, store latest (pubkey, kind, dtag)
91
+ # TODO: implement dtag stuff
92
+ @writer.add_r_event(hsh)
93
+ else
94
+ raise(SignedEvent::Error, "kind: #{hsh['kind']}")
95
+ end
96
+
97
+ Server.ok(eid)
98
+ rescue SignedEvent::Error => e
99
+ Server.ok(eid, Server.message(e), ok: false)
100
+ rescue Nostrb::Error, KeyError, RuntimeError => e
101
+ Server.error(e)
102
+ end
103
+ end
104
+
105
+ # return an array of response
106
+ # filter1:
107
+ # ids: [id1, id2]
108
+ # authors: [pubkey1]
109
+ # filter2:
110
+ # ids: [id3, id4]
111
+ # authors: [pubkey2]
112
+
113
+ # run filter1
114
+ # for any fields specified (ids, authors)
115
+ # if any values match, the event is a match
116
+ # all fields provided must match for the event to match
117
+ # ids must have a match and authors must have a match
118
+
119
+ # run filter2 just like filter1
120
+ # the result set is the union of filter1 and filter2
121
+
122
+ def handle_req(sid, *filters)
123
+ responses = Set.new
124
+
125
+ filters.each { |f|
126
+ @reader.process_events(f).each { |h|
127
+ responses << Server.event(sid, h) if f.match? h
128
+ }
129
+ @reader.process_r_events(f).each { |h|
130
+ responses << Server.event(sid, h) if f.match? h
131
+ }
132
+ }
133
+ responses = responses.to_a
134
+ responses << Server.eose(sid)
135
+ end
136
+
137
+ # single response
138
+ def handle_close(sid)
139
+ Server.closed(sid, "reason: CLOSE requested")
140
+ end
141
+ end
142
+ end