nostrb 0.1.0.1

Sign up to get free protection for your applications and to get access to all the features.
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