siwe-rb 0.1.0
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 +7 -0
- data/CHANGELOG.md +16 -0
- data/Gemfile +13 -0
- data/Gemfile.lock +211 -0
- data/LICENSE-MIT +21 -0
- data/README.md +142 -0
- data/Rakefile +12 -0
- data/lib/siwe/adapter.rb +33 -0
- data/lib/siwe/config.rb +51 -0
- data/lib/siwe/eip6492.rb +38 -0
- data/lib/siwe/error.rb +22 -0
- data/lib/siwe/error_type.rb +65 -0
- data/lib/siwe/message.rb +276 -0
- data/lib/siwe/parser.rb +220 -0
- data/lib/siwe/response.rb +13 -0
- data/lib/siwe/rpc.rb +92 -0
- data/lib/siwe/smart_wallet.rb +63 -0
- data/lib/siwe/util.rb +71 -0
- data/lib/siwe/version.rb +5 -0
- data/lib/siwe.rb +37 -0
- data/siwe-rb.gemspec +33 -0
- metadata +85 -0
data/lib/siwe/message.rb
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
require_relative "error"
|
|
7
|
+
require_relative "error_type"
|
|
8
|
+
require_relative "parser"
|
|
9
|
+
require_relative "response"
|
|
10
|
+
require_relative "util"
|
|
11
|
+
|
|
12
|
+
module Siwe
|
|
13
|
+
# An EIP-4361 Sign-In with Ethereum message.
|
|
14
|
+
# Construct via `Siwe::Message.new(**fields)` or parse via `Siwe::Message.parse(string)`.
|
|
15
|
+
# Render via `#prepare_message` (alias `#to_eip4361`, `#to_s`).
|
|
16
|
+
class Message
|
|
17
|
+
FIELDS = %i[
|
|
18
|
+
scheme domain address statement uri version chain_id nonce
|
|
19
|
+
issued_at expiration_time not_before request_id resources
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
attr_reader(*FIELDS, :warnings)
|
|
23
|
+
|
|
24
|
+
def self.parse(str)
|
|
25
|
+
result = Parser.parse(str)
|
|
26
|
+
msg = allocate
|
|
27
|
+
msg.send(:init_from_parser, result)
|
|
28
|
+
msg
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.from_json(json_str)
|
|
32
|
+
data = JSON.parse(json_str, symbolize_names: true)
|
|
33
|
+
new(**data.slice(*FIELDS))
|
|
34
|
+
rescue JSON::ParserError => e
|
|
35
|
+
raise Error.new(ErrorType::UNABLE_TO_PARSE, message: "Invalid JSON: #{e.message}")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def initialize(domain:, address:, uri:, chain_id:, nonce: nil, version: "1",
|
|
39
|
+
scheme: nil, statement: nil, issued_at: nil,
|
|
40
|
+
expiration_time: nil, not_before: nil, request_id: nil, resources: nil)
|
|
41
|
+
@warnings = []
|
|
42
|
+
@scheme = scheme
|
|
43
|
+
@domain = domain
|
|
44
|
+
@address = normalize_address(address)
|
|
45
|
+
@statement = statement
|
|
46
|
+
@uri = uri
|
|
47
|
+
@version = version
|
|
48
|
+
@chain_id = coerce_chain_id(chain_id)
|
|
49
|
+
@nonce = nonce || Util.generate_nonce
|
|
50
|
+
@issued_at = issued_at || Time.now.utc.iso8601
|
|
51
|
+
@expiration_time = expiration_time
|
|
52
|
+
@not_before = not_before
|
|
53
|
+
@request_id = request_id
|
|
54
|
+
@resources = resources
|
|
55
|
+
|
|
56
|
+
validate_required!
|
|
57
|
+
roundtrip_validate!
|
|
58
|
+
freeze
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def prepare_message
|
|
62
|
+
header_prefix = @scheme ? "#{@scheme}://#{@domain}" : @domain
|
|
63
|
+
header = "#{header_prefix} wants you to sign in with your Ethereum account:"
|
|
64
|
+
|
|
65
|
+
prefix = "#{header}\n#{@address}"
|
|
66
|
+
prefix = if @statement.nil?
|
|
67
|
+
"#{prefix}\n\n"
|
|
68
|
+
else
|
|
69
|
+
"#{prefix}\n\n#{@statement}\n"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
suffix = "URI: #{@uri}\nVersion: #{@version}\nChain ID: #{@chain_id}"
|
|
73
|
+
suffix << "\nNonce: #{@nonce}"
|
|
74
|
+
suffix << "\nIssued At: #{@issued_at}" if @issued_at
|
|
75
|
+
suffix << "\nExpiration Time: #{@expiration_time}" if @expiration_time
|
|
76
|
+
suffix << "\nNot Before: #{@not_before}" if @not_before
|
|
77
|
+
suffix << "\nRequest ID: #{@request_id}" unless @request_id.nil?
|
|
78
|
+
if @resources
|
|
79
|
+
suffix << "\nResources:"
|
|
80
|
+
@resources.each { |r| suffix << "\n- #{r}" }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
"#{prefix}\n#{suffix}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
alias to_eip4361 prepare_message
|
|
87
|
+
alias to_s prepare_message
|
|
88
|
+
|
|
89
|
+
# Verify a signature against this message and the verification params.
|
|
90
|
+
# Returns Siwe::Response — never raises on verification failure.
|
|
91
|
+
def verify(signature:, domain:, nonce:, scheme: nil, uri: nil, chain_id: nil,
|
|
92
|
+
request_id: nil, time: nil, config: nil, strict: false)
|
|
93
|
+
cfg = config || Siwe.config
|
|
94
|
+
|
|
95
|
+
check_param_mismatches!(domain: domain, nonce: nonce, scheme: scheme,
|
|
96
|
+
uri: uri, chain_id: chain_id, request_id: request_id, strict: strict)
|
|
97
|
+
check_temporal!(time)
|
|
98
|
+
check_signature!(signature, cfg)
|
|
99
|
+
|
|
100
|
+
Response.new(success: true, error: nil, data: self)
|
|
101
|
+
rescue Error => e
|
|
102
|
+
Response.new(success: false, error: e, data: self)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Verify a signature; raises Siwe::Error on failure, returns self on success.
|
|
106
|
+
def verify!(**)
|
|
107
|
+
response = verify(**)
|
|
108
|
+
raise response.error if response.failure?
|
|
109
|
+
|
|
110
|
+
self
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def to_h
|
|
114
|
+
FIELDS.to_h { |f| [f, instance_variable_get("@#{f}")] }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def to_json(*)
|
|
118
|
+
to_h.to_json(*)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def ==(other)
|
|
122
|
+
other.is_a?(Message) && to_h == other.to_h
|
|
123
|
+
end
|
|
124
|
+
alias eql? ==
|
|
125
|
+
|
|
126
|
+
def hash
|
|
127
|
+
to_h.hash
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize
|
|
133
|
+
def check_param_mismatches!(domain:, nonce:, scheme:, uri:, chain_id:, request_id:, strict:)
|
|
134
|
+
raise Error.new(ErrorType::MISSING_DOMAIN) if domain.nil? || domain.empty?
|
|
135
|
+
raise Error.new(ErrorType::MISSING_NONCE) if nonce.nil? || nonce.empty?
|
|
136
|
+
|
|
137
|
+
raise Error.new(ErrorType::DOMAIN_MISMATCH, expected: @domain, received: domain) if @domain != domain
|
|
138
|
+
if scheme && @scheme != scheme
|
|
139
|
+
raise Error.new(ErrorType::SCHEME_MISMATCH, expected: @scheme.to_s, received: scheme)
|
|
140
|
+
end
|
|
141
|
+
raise Error.new(ErrorType::NONCE_MISMATCH, expected: @nonce, received: nonce) if @nonce != nonce
|
|
142
|
+
|
|
143
|
+
raise Error.new(ErrorType::MISSING_URI) if strict && uri.nil?
|
|
144
|
+
raise Error.new(ErrorType::URI_MISMATCH, expected: @uri, received: uri) if uri && @uri != uri
|
|
145
|
+
|
|
146
|
+
raise Error.new(ErrorType::MISSING_CHAIN_ID) if strict && chain_id.nil?
|
|
147
|
+
if chain_id && @chain_id != chain_id
|
|
148
|
+
raise Error.new(ErrorType::CHAIN_ID_MISMATCH, expected: @chain_id.to_s, received: chain_id.to_s)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
return unless request_id && @request_id != request_id
|
|
152
|
+
|
|
153
|
+
raise Error.new(ErrorType::REQUEST_ID_MISMATCH, expected: @request_id.to_s, received: request_id.to_s)
|
|
154
|
+
end
|
|
155
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize
|
|
156
|
+
|
|
157
|
+
def check_temporal!(time_str)
|
|
158
|
+
check_at = time_str ? Time.iso8601(time_str) : Time.now.utc
|
|
159
|
+
if @expiration_time && Time.iso8601(@expiration_time) < check_at
|
|
160
|
+
raise Error.new(ErrorType::EXPIRED_MESSAGE, expected: @expiration_time, received: check_at.iso8601)
|
|
161
|
+
end
|
|
162
|
+
return unless @not_before && Time.iso8601(@not_before) > check_at
|
|
163
|
+
|
|
164
|
+
raise Error.new(ErrorType::NOT_YET_VALID_MESSAGE, expected: @not_before, received: check_at.iso8601)
|
|
165
|
+
rescue ArgumentError => e
|
|
166
|
+
raise Error.new(ErrorType::INVALID_TIME_FORMAT, message: e.message)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def check_signature!(signature, cfg)
|
|
170
|
+
if signature.nil? || signature.empty?
|
|
171
|
+
raise Error.new(ErrorType::INVALID_SIGNATURE, expected: "non-empty signature", received: signature.inspect)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
recovered = recover_eoa(signature, cfg)
|
|
175
|
+
return if recovered && recovered.downcase == @address.downcase
|
|
176
|
+
|
|
177
|
+
# EOA recovery failed or address mismatch — try smart-wallet path if configured.
|
|
178
|
+
return if smart_wallet_valid?(signature, cfg)
|
|
179
|
+
|
|
180
|
+
raise Error.new(ErrorType::INVALID_SIGNATURE, expected: @address, received: recovered.to_s)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def recover_eoa(signature, cfg)
|
|
184
|
+
cfg.adapter.verify_message(prepare_message, signature)
|
|
185
|
+
rescue StandardError
|
|
186
|
+
nil
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def smart_wallet_valid?(signature, cfg)
|
|
190
|
+
rpc = resolve_rpc(cfg)
|
|
191
|
+
return false if rpc.nil?
|
|
192
|
+
|
|
193
|
+
check_chain_id_match!(rpc)
|
|
194
|
+
SmartWallet.verify(rpc: rpc, address: @address, message: prepare_message, signature: signature)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def resolve_rpc(cfg)
|
|
198
|
+
return cfg.rpc if cfg.rpc
|
|
199
|
+
return Rpc::HttpClient.new(cfg.rpc_url) if cfg.rpc_url
|
|
200
|
+
|
|
201
|
+
nil
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def check_chain_id_match!(rpc)
|
|
205
|
+
return unless rpc.respond_to?(:chain_id)
|
|
206
|
+
|
|
207
|
+
rpc_chain = rpc.chain_id
|
|
208
|
+
return if rpc_chain.nil? || rpc_chain == @chain_id
|
|
209
|
+
|
|
210
|
+
raise Error.new(ErrorType::INVALID_SIGNATURE_CHAIN_ID,
|
|
211
|
+
expected: @chain_id.to_s, received: rpc_chain.to_s)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def init_from_parser(result)
|
|
215
|
+
result[:fields].each { |k, v| instance_variable_set("@#{k}", v) }
|
|
216
|
+
@warnings = result[:warnings]
|
|
217
|
+
freeze
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def normalize_address(addr)
|
|
221
|
+
raise Error.new(ErrorType::INVALID_ADDRESS, expected: "valid EIP-55 address", received: addr.to_s) if addr.nil?
|
|
222
|
+
|
|
223
|
+
case Util.address_case(addr)
|
|
224
|
+
when :checksum
|
|
225
|
+
addr
|
|
226
|
+
when :lower, :upper
|
|
227
|
+
@warnings << "address is not EIP-55 checksummed - #{addr}"
|
|
228
|
+
Util.checksum_address(addr) || addr
|
|
229
|
+
when :invalid_checksum
|
|
230
|
+
raise Error.new(ErrorType::INVALID_ADDRESS, expected: "valid EIP-55 address", received: addr)
|
|
231
|
+
else
|
|
232
|
+
raise Error.new(ErrorType::INVALID_ADDRESS, expected: "0x + 40 hex chars", received: addr.to_s)
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def coerce_chain_id(value)
|
|
237
|
+
case value
|
|
238
|
+
when Integer then value
|
|
239
|
+
when String
|
|
240
|
+
unless value.match?(/\A\d+\z/)
|
|
241
|
+
raise Error.new(ErrorType::UNABLE_TO_PARSE, expected: "integer chain ID", received: value)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
value.to_i
|
|
245
|
+
else
|
|
246
|
+
raise Error.new(ErrorType::UNABLE_TO_PARSE, expected: "integer chain ID", received: value.inspect)
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def validate_required!
|
|
251
|
+
if @domain.nil? || @domain.to_s.empty?
|
|
252
|
+
raise Error.new(ErrorType::INVALID_DOMAIN, expected: "non-empty domain",
|
|
253
|
+
received: @domain.inspect)
|
|
254
|
+
end
|
|
255
|
+
if @uri.nil? || @uri.to_s.empty?
|
|
256
|
+
raise Error.new(ErrorType::INVALID_URI, expected: "non-empty URI",
|
|
257
|
+
received: @uri.inspect)
|
|
258
|
+
end
|
|
259
|
+
unless @version == "1"
|
|
260
|
+
raise Error.new(ErrorType::INVALID_MESSAGE_VERSION, expected: "1",
|
|
261
|
+
received: @version.to_s)
|
|
262
|
+
end
|
|
263
|
+
return if Parser::NONCE_REGEX.match?(@nonce.to_s)
|
|
264
|
+
|
|
265
|
+
raise Error.new(ErrorType::INVALID_NONCE, expected: "alphanumeric 8+ chars",
|
|
266
|
+
received: @nonce.to_s)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def roundtrip_validate!
|
|
270
|
+
Parser.parse(prepare_message)
|
|
271
|
+
rescue Error => e
|
|
272
|
+
raise Error.new(e.type, expected: e.expected, received: e.received,
|
|
273
|
+
message: "Constructed message fails to parse: #{e.message}")
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
data/lib/siwe/parser.rb
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "error"
|
|
4
|
+
require_relative "error_type"
|
|
5
|
+
require_relative "util"
|
|
6
|
+
|
|
7
|
+
module Siwe
|
|
8
|
+
# Parser for the EIP-4361 Sign-In with Ethereum message format.
|
|
9
|
+
# Validates structure line-by-line and per-field via ABNF-aligned regexes.
|
|
10
|
+
# Returns a hash of typed fields plus an array of non-fatal warnings.
|
|
11
|
+
class Parser
|
|
12
|
+
HEADER_SUFFIX = " wants you to sign in with your Ethereum account:"
|
|
13
|
+
SCHEME_REGEX = /\A([A-Za-z][A-Za-z0-9+\-.]*)\z/
|
|
14
|
+
# RFC 3986 authority (without scheme://) — userinfo, host (reg-name/IPv4/IP-literal), port.
|
|
15
|
+
DOMAIN_REGEX = /\A[A-Za-z0-9\-._~%!$&'()*+,;=:@\[\]]+\z/
|
|
16
|
+
ADDRESS_REGEX = /\A0x[0-9a-fA-F]{40}\z/
|
|
17
|
+
NONCE_REGEX = /\A[A-Za-z0-9]{8,}\z/
|
|
18
|
+
CHAIN_REGEX = /\A[0-9]+\z/
|
|
19
|
+
# Statement chars per ABNF: %d32-33 %d35-36 %d38-59 %d61 %d63-64 %d91 %d93 %d95 %d126.
|
|
20
|
+
# Note: explicitly excludes %d34 (") and %d37 (%). Built via Regexp.new to avoid
|
|
21
|
+
# accidental string interpolation of `#$&` inside a regex literal.
|
|
22
|
+
STATEMENT_REGEX = Regexp.new('\A[a-zA-Z0-9 !\x23\x24\x26-\x3B\x3D\x3F\x40\x5B\x5D\x5F\x7E]*\z')
|
|
23
|
+
REQUEST_ID_REGEX = %r{\A[A-Za-z0-9\-._~%!$&'()*+,;=:@/?]*\z}
|
|
24
|
+
URI_REGEX = /\A[A-Za-z][A-Za-z0-9+\-.]*:[^\s]*\z/
|
|
25
|
+
|
|
26
|
+
def self.parse(str)
|
|
27
|
+
new(str).parse
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(str)
|
|
31
|
+
@str = str
|
|
32
|
+
@warnings = []
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def parse # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
36
|
+
raise unable_to_parse("message is not a string") unless @str.is_a?(String)
|
|
37
|
+
|
|
38
|
+
lines = @str.split("\n", -1)
|
|
39
|
+
raise unable_to_parse("message too short") if lines.length < 6
|
|
40
|
+
|
|
41
|
+
scheme, domain = parse_header(lines[0])
|
|
42
|
+
address = parse_address(lines[1])
|
|
43
|
+
|
|
44
|
+
uri_idx = lines.index { |l| l.start_with?("URI: ") }
|
|
45
|
+
raise unable_to_parse("URI: line not found") if uri_idx.nil? || uri_idx < 3
|
|
46
|
+
|
|
47
|
+
statement = parse_statement_block(lines[2...uri_idx])
|
|
48
|
+
|
|
49
|
+
uri = parse_field(lines[uri_idx], "URI: ", :invalid_uri, URI_REGEX)
|
|
50
|
+
version = parse_field(lines[uri_idx + 1], "Version: ", :invalid_message_version, /\A1\z/)
|
|
51
|
+
chain_str = parse_field(lines[uri_idx + 2], "Chain ID: ", :unable_to_parse, CHAIN_REGEX)
|
|
52
|
+
nonce = parse_field(lines[uri_idx + 3], "Nonce: ", :invalid_nonce, NONCE_REGEX)
|
|
53
|
+
issued_at = parse_iso_field(lines[uri_idx + 4], "Issued At: ", "issuedAt")
|
|
54
|
+
|
|
55
|
+
cursor = uri_idx + 5
|
|
56
|
+
expiration_time = nil
|
|
57
|
+
not_before = nil
|
|
58
|
+
request_id = nil
|
|
59
|
+
resources = nil
|
|
60
|
+
|
|
61
|
+
if cursor < lines.length && lines[cursor]&.start_with?("Expiration Time: ")
|
|
62
|
+
expiration_time = parse_iso_field(lines[cursor], "Expiration Time: ", "expirationTime")
|
|
63
|
+
cursor += 1
|
|
64
|
+
end
|
|
65
|
+
if cursor < lines.length && lines[cursor]&.start_with?("Not Before: ")
|
|
66
|
+
not_before = parse_iso_field(lines[cursor], "Not Before: ", "notBefore")
|
|
67
|
+
cursor += 1
|
|
68
|
+
end
|
|
69
|
+
if cursor < lines.length && lines[cursor]&.start_with?("Request ID: ")
|
|
70
|
+
request_id = parse_field(lines[cursor], "Request ID: ", :unable_to_parse, REQUEST_ID_REGEX)
|
|
71
|
+
cursor += 1
|
|
72
|
+
end
|
|
73
|
+
if cursor < lines.length && lines[cursor] == "Resources:"
|
|
74
|
+
cursor += 1
|
|
75
|
+
resources = parse_resources(lines, cursor)
|
|
76
|
+
cursor += resources.length
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
validate_trailing(lines, cursor)
|
|
80
|
+
classify_address!(address)
|
|
81
|
+
|
|
82
|
+
{
|
|
83
|
+
fields: {
|
|
84
|
+
scheme: scheme,
|
|
85
|
+
domain: domain,
|
|
86
|
+
address: address,
|
|
87
|
+
statement: statement,
|
|
88
|
+
uri: uri,
|
|
89
|
+
version: version,
|
|
90
|
+
chain_id: chain_str.to_i,
|
|
91
|
+
nonce: nonce,
|
|
92
|
+
issued_at: issued_at,
|
|
93
|
+
expiration_time: expiration_time,
|
|
94
|
+
not_before: not_before,
|
|
95
|
+
request_id: request_id,
|
|
96
|
+
resources: resources
|
|
97
|
+
},
|
|
98
|
+
warnings: @warnings
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def parse_header(line) # rubocop:disable Metrics/AbcSize
|
|
105
|
+
raise unable_to_parse("missing header") if line.nil? || line.empty?
|
|
106
|
+
raise unable_to_parse("malformed header") unless line.end_with?(HEADER_SUFFIX)
|
|
107
|
+
|
|
108
|
+
prefix = line[0...(line.length - HEADER_SUFFIX.length)]
|
|
109
|
+
raise unable_to_parse("empty domain") if prefix.empty?
|
|
110
|
+
|
|
111
|
+
if prefix.include?("://")
|
|
112
|
+
scheme, _, domain = prefix.partition("://")
|
|
113
|
+
raise unable_to_parse("invalid scheme: #{scheme.inspect}") unless SCHEME_REGEX.match?(scheme)
|
|
114
|
+
raise unable_to_parse("empty domain") if domain.empty?
|
|
115
|
+
unless DOMAIN_REGEX.match?(domain)
|
|
116
|
+
raise Error.new(ErrorType::INVALID_DOMAIN, expected: "RFC 3986 authority",
|
|
117
|
+
received: domain)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
[scheme, domain]
|
|
121
|
+
else
|
|
122
|
+
unless DOMAIN_REGEX.match?(prefix)
|
|
123
|
+
raise Error.new(ErrorType::INVALID_DOMAIN, expected: "RFC 3986 authority",
|
|
124
|
+
received: prefix)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
[nil, prefix]
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def parse_address(line)
|
|
132
|
+
raise unable_to_parse("missing address line") if line.nil?
|
|
133
|
+
unless ADDRESS_REGEX.match?(line)
|
|
134
|
+
raise Error.new(ErrorType::INVALID_ADDRESS, expected: "0x + 40 hex chars",
|
|
135
|
+
received: line)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
line
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def classify_address!(address)
|
|
142
|
+
case Util.address_case(address)
|
|
143
|
+
when :checksum, :lower, :upper
|
|
144
|
+
@warnings << "address is not EIP-55 checksummed - #{address}" if Util.address_case(address) != :checksum
|
|
145
|
+
when :invalid_checksum
|
|
146
|
+
raise Error.new(ErrorType::INVALID_ADDRESS, expected: "EIP-55 checksum address",
|
|
147
|
+
received: address)
|
|
148
|
+
else
|
|
149
|
+
raise Error.new(ErrorType::INVALID_ADDRESS, expected: "0x + 40 hex chars",
|
|
150
|
+
received: address)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def parse_statement_block(stmt_lines)
|
|
155
|
+
case stmt_lines.length
|
|
156
|
+
when 2
|
|
157
|
+
raise unable_to_parse("malformed statement block") unless stmt_lines.all?(&:empty?)
|
|
158
|
+
|
|
159
|
+
nil
|
|
160
|
+
when 3
|
|
161
|
+
first, stmt, last = stmt_lines
|
|
162
|
+
raise unable_to_parse("statement must be enclosed by blank lines") unless first.empty? && last.empty?
|
|
163
|
+
raise unable_to_parse("statement must not contain LF") if stmt.include?("\n")
|
|
164
|
+
raise unable_to_parse("statement contains disallowed characters") unless STATEMENT_REGEX.match?(stmt)
|
|
165
|
+
|
|
166
|
+
stmt
|
|
167
|
+
else
|
|
168
|
+
raise unable_to_parse("malformed statement block (#{stmt_lines.length} lines)")
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def parse_field(line, prefix, error_type, regex)
|
|
173
|
+
raise unable_to_parse("missing #{prefix.strip}") if line.nil?
|
|
174
|
+
raise unable_to_parse("expected #{prefix.strip} line") unless line.start_with?(prefix)
|
|
175
|
+
|
|
176
|
+
value = line[prefix.length..]
|
|
177
|
+
raise Error.new(error_type, expected: prefix.strip, received: value) unless regex.match?(value)
|
|
178
|
+
|
|
179
|
+
value
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def parse_iso_field(line, prefix, name)
|
|
183
|
+
value = parse_field(line, prefix, :invalid_time_format, /.+/)
|
|
184
|
+
unless Util.valid_iso8601?(value)
|
|
185
|
+
raise Error.new(ErrorType::INVALID_TIME_FORMAT, expected: "ISO 8601 #{name}", received: value)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
value
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def parse_resources(lines, start_idx)
|
|
192
|
+
idx = start_idx
|
|
193
|
+
out = []
|
|
194
|
+
while idx < lines.length && lines[idx].start_with?("- ")
|
|
195
|
+
uri = lines[idx][2..]
|
|
196
|
+
unless URI_REGEX.match?(uri)
|
|
197
|
+
raise Error.new(ErrorType::INVALID_URI, expected: "RFC 3986 resource URI", received: uri)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
out << uri
|
|
201
|
+
idx += 1
|
|
202
|
+
end
|
|
203
|
+
out
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def validate_trailing(lines, cursor)
|
|
207
|
+
while cursor < lines.length
|
|
208
|
+
unless lines[cursor].empty?
|
|
209
|
+
raise unable_to_parse("trailing content at line #{cursor + 1}: #{lines[cursor].inspect}")
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
cursor += 1
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def unable_to_parse(detail)
|
|
217
|
+
Error.new(ErrorType::UNABLE_TO_PARSE, message: "Unable to parse the message: #{detail}")
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
data/lib/siwe/rpc.rb
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
require_relative "error"
|
|
8
|
+
require_relative "error_type"
|
|
9
|
+
|
|
10
|
+
module Siwe
|
|
11
|
+
module Rpc
|
|
12
|
+
# Minimal JSON-RPC client for eth_call. Default implementation used when
|
|
13
|
+
# Siwe.configure { |c| c.rpc_url = ... } is set. Anything that responds to
|
|
14
|
+
# `eth_call(to:, data:, block:)` (and optionally `chain_id`) can be plugged
|
|
15
|
+
# in via `c.rpc = ...` to use a different transport (web3.rb, eth-rpc, etc.).
|
|
16
|
+
class HttpClient
|
|
17
|
+
attr_reader :url, :timeout
|
|
18
|
+
|
|
19
|
+
def initialize(url, chain_id: nil, timeout: 10)
|
|
20
|
+
raise ArgumentError, "rpc url is required" if url.nil? || url.empty?
|
|
21
|
+
|
|
22
|
+
@url = url
|
|
23
|
+
@uri = URI(url)
|
|
24
|
+
@chain_id = chain_id
|
|
25
|
+
@timeout = timeout
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def chain_id
|
|
29
|
+
@chain_id ||= fetch_chain_id
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Send eth_call. Returns the result as a hex string WITHOUT the 0x prefix.
|
|
33
|
+
# When +to+ is nil, omits it from params (used by EIP-6492 deploy-and-call).
|
|
34
|
+
# Raises Siwe::Error :rpc_error on transport / HTTP / JSON-RPC failure.
|
|
35
|
+
def eth_call(to:, data:, block: "latest")
|
|
36
|
+
call_params = { data: data }
|
|
37
|
+
call_params[:to] = to if to
|
|
38
|
+
rpc_request("eth_call", [call_params, block])
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def fetch_chain_id
|
|
44
|
+
hex = rpc_request("eth_chainId", [])
|
|
45
|
+
hex.to_i(16)
|
|
46
|
+
rescue Error
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def rpc_request(method, params)
|
|
51
|
+
body = { jsonrpc: "2.0", id: 1, method: method, params: params }.to_json
|
|
52
|
+
response = post(body)
|
|
53
|
+
parse_response(response)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def post(body)
|
|
57
|
+
http = Net::HTTP.new(@uri.host, @uri.port)
|
|
58
|
+
http.use_ssl = (@uri.scheme == "https")
|
|
59
|
+
http.open_timeout = @timeout
|
|
60
|
+
http.read_timeout = @timeout
|
|
61
|
+
|
|
62
|
+
path = @uri.request_uri
|
|
63
|
+
path = "/" if path.empty?
|
|
64
|
+
request = Net::HTTP::Post.new(path, "Content-Type" => "application/json")
|
|
65
|
+
request.body = body
|
|
66
|
+
http.request(request)
|
|
67
|
+
rescue StandardError => e
|
|
68
|
+
raise Error.new(ErrorType::RPC_ERROR, message: "RPC transport error: #{e.class}: #{e.message}")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def parse_response(response)
|
|
72
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
73
|
+
raise Error.new(ErrorType::RPC_ERROR, message: "RPC HTTP #{response.code}: #{response.message}")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
body = JSON.parse(response.body)
|
|
77
|
+
raise Error.new(ErrorType::RPC_ERROR, message: "RPC error: #{body["error"].inspect}") if body["error"]
|
|
78
|
+
|
|
79
|
+
result = body["result"]
|
|
80
|
+
raise Error.new(ErrorType::RPC_ERROR, message: "RPC returned no result") if result.nil?
|
|
81
|
+
|
|
82
|
+
strip_hex_prefix(result.to_s)
|
|
83
|
+
rescue JSON::ParserError => e
|
|
84
|
+
raise Error.new(ErrorType::RPC_ERROR, message: "Invalid RPC JSON: #{e.message}")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def strip_hex_prefix(hex)
|
|
88
|
+
hex.start_with?("0x") ? hex[2..] : hex
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "eth"
|
|
4
|
+
|
|
5
|
+
require_relative "eip6492"
|
|
6
|
+
require_relative "error"
|
|
7
|
+
require_relative "error_type"
|
|
8
|
+
|
|
9
|
+
module Siwe
|
|
10
|
+
# Smart-wallet signature verification via the EIP-6492 off-chain universal
|
|
11
|
+
# validator. A single eth_call (deploy-and-call) covers both deployed ERC-1271
|
|
12
|
+
# wallets (e.g. Safe) and counterfactual EIP-6492-wrapped signatures
|
|
13
|
+
# (e.g. Coinbase Smart Wallet).
|
|
14
|
+
module SmartWallet
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# Verify a smart-wallet signature.
|
|
18
|
+
# rpc — anything responding to `eth_call(to:, data:, block:)`
|
|
19
|
+
# address — claimed signer (smart contract or factory-deployed address)
|
|
20
|
+
# message — EIP-4361 message string (will be EIP-191 hashed internally)
|
|
21
|
+
# signature — hex-encoded signature, may or may not include 0x prefix,
|
|
22
|
+
# may include the EIP-6492 magic-suffix wrapper
|
|
23
|
+
# Returns true (valid) or false (validator returned non-1).
|
|
24
|
+
# Re-raises Siwe::Error from the rpc client on transport errors.
|
|
25
|
+
def verify(rpc:, address:, message:, signature:) # rubocop:disable Naming/PredicateMethod
|
|
26
|
+
hash_hex = bin_to_hex(eip191_hash(message))
|
|
27
|
+
sig_hex = strip_hex_prefix(signature)
|
|
28
|
+
|
|
29
|
+
args = Eth::Abi.encode(
|
|
30
|
+
%w[address bytes32 bytes],
|
|
31
|
+
[address, Eth::Util.hex_to_bin(hash_hex), Eth::Util.hex_to_bin(sig_hex)]
|
|
32
|
+
)
|
|
33
|
+
data = "0x#{Eip6492::VALIDATOR_BYTECODE}#{Eth::Util.bin_to_hex(args)}"
|
|
34
|
+
|
|
35
|
+
result = rpc.eth_call(to: nil, data: data)
|
|
36
|
+
result_indicates_valid?(result)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Hash a message per EIP-191 (`personal_sign`). Returns binary keccak256.
|
|
40
|
+
def eip191_hash(message)
|
|
41
|
+
prefixed = Eth::Signature.prefix_message(message)
|
|
42
|
+
Eth::Util.keccak256(prefixed)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Validator returns 0x01 (possibly zero-padded to 32 bytes) for valid,
|
|
46
|
+
# 0x00 for invalid. The default RPC client strips the 0x prefix; for
|
|
47
|
+
# callers that don't, normalize here.
|
|
48
|
+
def result_indicates_valid?(hex)
|
|
49
|
+
return false if hex.nil?
|
|
50
|
+
|
|
51
|
+
hex = strip_hex_prefix(hex.to_s)
|
|
52
|
+
hex.gsub(/\A0+/, "") == "1"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def strip_hex_prefix(hex)
|
|
56
|
+
hex.start_with?("0x") ? hex[2..] : hex
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def bin_to_hex(bin)
|
|
60
|
+
bin.unpack1("H*")
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|