siwe-rb 0.1.2 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2f43daa18ea4cad6fad589c66ec33ab0e42fe605371705b9f6b6b9c42a00d619
4
- data.tar.gz: 251ff4c5139a8632090db0db4480020bfe954a595d855d13e9a45ede412feaf4
3
+ metadata.gz: 94f4cdbde4fcf4c3f8e2e30c26390a0ed6a56600297f9a843d651e48009a64ea
4
+ data.tar.gz: 7b065dbbb37260fae1520c4a8f3d89e9917ebcd8b187e6aa0467104591b9b7cd
5
5
  SHA512:
6
- metadata.gz: 82a18c3a0584046085715bd316d8eda59628b6b92696f2b219adb8e3115546cdcbf2deb3c72996a36550a8fa0966a25a15acfeacbc1416386c153b9a8d771c5a
7
- data.tar.gz: 88b136c8941313f6d11cf9ea6bb03c538d2e00f09ea4b40fa5806810eb2efbb381f14d738cb2266c36151c9104cab1e0af37f23c7c44e17441e984afc90163dd
6
+ metadata.gz: 2241b00fc2ab6ccd140d3fab0983b9a112e6812fda40485c75320ce680a62feb461d6d0197cb5166dce43b0f6b8e2223d0b60d605f735e91dcb7e7076d2ec101
7
+ data.tar.gz: 68d02a5ba04da51c6c74ff934ccb2e1f7d47ca62a948df4b35dd1b9134075651753345902d5c4b5f47e6a5bcbd5c14dd1005274c981401de2c8fcb6b0164c1b8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.0] — 2026-05-26
4
+
5
+ Spec-conformance pass. Several behaviour changes; the only one that breaks API contract is the strict chain-id requirement on the smart-wallet path (3).
6
+
7
+ 1. **RFC 3986 enforcement for `domain`, `URI`, and resource URIs.** The previous regexes accepted malformed authorities (`example.com:abc`), IP-literal hosts with invalid IPv4 octets (`uri://[::0.0.0.256]/p`), and URI characters outside RFC 3986 (`https://example.com/path?q=one|two`). The shared test-vector suite's `invalid_uris` / `invalid_resources` grammar cases were silently passing because the spec wrapper called `vec["msg"]` on raw-string entries; both the parser and the spec are fixed.
8
+ 2. **Expiration boundary now matches sibling implementations.** A message is rejected at the exact `Expiration Time` (not one instant after), aligning with TypeScript / Python / Rust.
9
+ 3. **Smart-wallet chain binding is enforced, not best-effort.** ERC-4361 requires ERC-1271 verification to happen on the chain matching the message's `Chain ID`. Previously, an RPC that lacked `chain_id` or whose `eth_chainId` probe failed would silently skip the check. Now `:invalid_signature_chain_id` is raised in either case. **Breaking** for callers using a custom RPC client without a `chain_id` method, or relying on `chain_id` returning `nil` on probe failure.
10
+ 4. **`request-id` field tightened to `*pchar`.** The parser previously accepted `/` and `?` (ABNF query/fragment characters) which are not pchar.
11
+ 5. **Empty / nil signatures now raise `:invalid_params`** instead of `:invalid_signature` — same `Siwe::Error` class, more specific type.
12
+ 6. **Narrower `rescue` in EOA recovery.** Adapter misconfiguration no longer disappears behind `:invalid_signature`; only `eth`-gem signature/chain errors and arg/type errors are swallowed.
13
+ 7. **Removed unused public surface.** `Config#verification_fallback`, `Eip6492::EIP1271_MAGIC_VALUE`, and `Adapter::EthGem#{hash_message,get_address}` are gone. None were called anywhere.
14
+
3
15
  ## [0.1.2] — 2026-05-04
4
16
 
5
17
  - Add top-level convenience methods `Siwe.parse(str)` (alias for `Siwe::Message.parse`) and `Siwe.eip6492_signature?(hex)` (alias for `Siwe::Eip6492.signature?`), mirroring the TS package's root-level exports.
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- siwe-rb (0.1.2)
4
+ siwe-rb (0.2.0)
5
5
  eth (>= 0.5.11, < 1.0)
6
6
 
7
7
  GEM
@@ -202,7 +202,7 @@ CHECKSUMS
202
202
  simplecov (0.22.0) sha256=fe2622c7834ff23b98066bb0a854284b2729a569ac659f82621fc22ef36213a5
203
203
  simplecov-html (0.13.2) sha256=bd0b8e54e7c2d7685927e8d6286466359b6f16b18cb0df47b508e8d73c777246
204
204
  simplecov_json_formatter (0.1.4) sha256=529418fbe8de1713ac2b2d612aa3daa56d316975d307244399fa4838c601b428
205
- siwe-rb (0.1.2)
205
+ siwe-rb (0.2.0)
206
206
  unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
207
207
  unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
208
208
  webmock (3.26.2) sha256=774556f2ea6371846cca68c01769b2eac0d134492d21f6d0ab5dd643965a4c90
data/README.md CHANGED
@@ -140,4 +140,4 @@ SIWE_RPC_URL=https://ethereum-rpc.publicnode.com bundle exec rspec --tag live_rp
140
140
 
141
141
  ## License
142
142
 
143
- MIT or Apache-2.0, at your option.
143
+ MIT.
data/lib/siwe/adapter.rb CHANGED
@@ -7,25 +7,14 @@ require_relative "error_type"
7
7
 
8
8
  module Siwe
9
9
  module Adapter
10
- # Default crypto adapter using the eth gem.
11
- # Implements the SiweConfig interface from the TS reference implementation:
12
- # verify_message recover signer address from EIP-191 signed message
13
- # hash_message → EIP-191 personal_sign hash
14
- # get_address → normalize to EIP-55 checksum address
10
+ # Default crypto adapter using the eth gem. The verification path only needs
11
+ # signer recovery any object implementing `#verify_message(message, signature)`
12
+ # returning the recovered EIP-55 address can be plugged in via Config.new(adapter:).
15
13
  class EthGem
16
14
  def verify_message(message, signature)
17
15
  public_key = Eth::Signature.personal_recover(message, signature)
18
16
  Eth::Util.public_key_to_address(public_key).to_s
19
17
  end
20
-
21
- def hash_message(message)
22
- prefixed = Eth::Signature.prefix_message(message)
23
- Eth::Util.keccak256(prefixed)
24
- end
25
-
26
- def get_address(addr)
27
- Eth::Address.new(addr).to_s
28
- end
29
18
  end
30
19
 
31
20
  DEFAULT = EthGem.new
data/lib/siwe/config.rb CHANGED
@@ -3,48 +3,36 @@
3
3
  require_relative "adapter"
4
4
 
5
5
  module Siwe
6
- # Verification config — pluggable adapter (crypto), RPC URL or RPC client (smart wallet),
7
- # and optional verification fallback.
6
+ # Verification config — pluggable adapter (crypto) plus RPC URL or RPC client
7
+ # for the smart-wallet (ERC-1271 / ERC-6492) verification path.
8
8
  #
9
9
  # Set globally via Siwe.configure { |c| ... } or per-call as `verify(config: ...)`.
10
10
  class Config
11
- attr_reader :rpc_url, :rpc, :adapter, :verification_fallback
11
+ attr_reader :rpc_url, :rpc, :adapter
12
12
 
13
- def initialize(rpc_url: nil, rpc: nil, adapter: nil, verification_fallback: nil)
13
+ def initialize(rpc_url: nil, rpc: nil, adapter: nil)
14
14
  @rpc_url = rpc_url
15
15
  @rpc = rpc
16
16
  @adapter = adapter || Siwe::Adapter::DEFAULT
17
- @verification_fallback = verification_fallback
18
17
  end
19
18
 
20
19
  def to_h
21
- {
22
- rpc_url: @rpc_url,
23
- rpc: @rpc,
24
- adapter: @adapter,
25
- verification_fallback: @verification_fallback
26
- }
20
+ { rpc_url: @rpc_url, rpc: @rpc, adapter: @adapter }
27
21
  end
28
22
 
29
23
  # Mutable struct used inside Siwe.configure { |c| c.rpc_url = ... }.
30
24
  # Caller mutates fields, then build returns a frozen Config.
31
25
  class Builder
32
- attr_accessor :rpc_url, :rpc, :adapter, :verification_fallback
26
+ attr_accessor :rpc_url, :rpc, :adapter
33
27
 
34
- def initialize(rpc_url: nil, rpc: nil, adapter: nil, verification_fallback: nil)
28
+ def initialize(rpc_url: nil, rpc: nil, adapter: nil)
35
29
  @rpc_url = rpc_url
36
30
  @rpc = rpc
37
31
  @adapter = adapter
38
- @verification_fallback = verification_fallback
39
32
  end
40
33
 
41
34
  def build
42
- Config.new(
43
- rpc_url: @rpc_url,
44
- rpc: @rpc,
45
- adapter: @adapter,
46
- verification_fallback: @verification_fallback
47
- )
35
+ Config.new(rpc_url: @rpc_url, rpc: @rpc, adapter: @adapter)
48
36
  end
49
37
  end
50
38
  end
data/lib/siwe/eip6492.rb CHANGED
@@ -10,9 +10,6 @@ module Siwe
10
10
  # 32-byte magic suffix appended to EIP-6492 wrapped signatures.
11
11
  MAGIC_SUFFIX = "6492649264926492649264926492649264926492649264926492649264926492"
12
12
 
13
- # ERC-1271 magic value returned by `isValidSignature(bytes32,bytes)`.
14
- EIP1271_MAGIC_VALUE = "1626ba7e"
15
-
16
13
  # Off-chain universal signature validator. Deployed via eth_call (no actual
17
14
  # deployment) to verify EOA, ERC-1271, and EIP-6492 signatures in one call.
18
15
  #
data/lib/siwe/message.rb CHANGED
@@ -156,7 +156,9 @@ module Siwe
156
156
 
157
157
  def check_temporal!(time_str)
158
158
  check_at = time_str ? Time.iso8601(time_str) : Time.now.utc
159
- if @expiration_time && Time.iso8601(@expiration_time) < check_at
159
+ # `expiration-time` is the instant the message becomes invalid (ERC-4361). Reject at
160
+ # the boundary too — matches TS/Python/Rust which all use check_time >= expiration.
161
+ if @expiration_time && Time.iso8601(@expiration_time) <= check_at
160
162
  raise Error.new(ErrorType::EXPIRED_MESSAGE, expected: @expiration_time, received: check_at.iso8601)
161
163
  end
162
164
  return unless @not_before && Time.iso8601(@not_before) > check_at
@@ -167,8 +169,8 @@ module Siwe
167
169
  end
168
170
 
169
171
  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
+ if signature.nil? || signature.to_s.empty?
173
+ raise Error.new(ErrorType::INVALID_PARAMS, expected: "non-empty signature", received: signature.inspect)
172
174
  end
173
175
 
174
176
  recovered = recover_eoa(signature, cfg)
@@ -180,9 +182,21 @@ module Siwe
180
182
  raise Error.new(ErrorType::INVALID_SIGNATURE, expected: @address, received: recovered.to_s)
181
183
  end
182
184
 
185
+ # Errors specific to signature recovery failures from the `eth` gem. Other
186
+ # exceptions (e.g. an adapter raising on misconfiguration, network errors)
187
+ # are intentionally not rescued — they signal programmer error or transport
188
+ # failure, not "this signature is bad".
189
+ EOA_RECOVERY_ERRORS = [
190
+ Eth::Signature::SignatureError,
191
+ Eth::Chain::ReplayProtectionError,
192
+ ArgumentError,
193
+ TypeError
194
+ ].freeze
195
+ private_constant :EOA_RECOVERY_ERRORS
196
+
183
197
  def recover_eoa(signature, cfg)
184
198
  cfg.adapter.verify_message(prepare_message, signature)
185
- rescue StandardError
199
+ rescue *EOA_RECOVERY_ERRORS
186
200
  nil
187
201
  end
188
202
 
@@ -201,11 +215,24 @@ module Siwe
201
215
  nil
202
216
  end
203
217
 
218
+ # ERC-4361 requires ERC-1271 verification to happen on the chain matching the
219
+ # message's `Chain ID`. We refuse to fall back to the smart-wallet path unless
220
+ # the RPC reports a chain id and it matches. A custom RPC client that does not
221
+ # expose chain_id must add it; a silent skip here would let an attacker validate
222
+ # against any chain a misconfigured RPC happens to be pointed at.
204
223
  def check_chain_id_match!(rpc)
205
- return unless rpc.respond_to?(:chain_id)
224
+ unless rpc.respond_to?(:chain_id)
225
+ raise Error.new(ErrorType::INVALID_SIGNATURE_CHAIN_ID,
226
+ expected: @chain_id.to_s,
227
+ received: "rpc client does not expose chain_id")
228
+ end
206
229
 
207
230
  rpc_chain = rpc.chain_id
208
- return if rpc_chain.nil? || rpc_chain == @chain_id
231
+ if rpc_chain.nil?
232
+ raise Error.new(ErrorType::INVALID_SIGNATURE_CHAIN_ID,
233
+ expected: @chain_id.to_s, received: "nil")
234
+ end
235
+ return if rpc_chain == @chain_id
209
236
 
210
237
  raise Error.new(ErrorType::INVALID_SIGNATURE_CHAIN_ID,
211
238
  expected: @chain_id.to_s, received: rpc_chain.to_s)
data/lib/siwe/parser.rb CHANGED
@@ -10,18 +10,17 @@ module Siwe
10
10
  # Returns a hash of typed fields plus an array of non-fatal warnings.
11
11
  class Parser
12
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/
13
+ SCHEME_REGEX = Util::SCHEME_REGEX
16
14
  ADDRESS_REGEX = /\A0x[0-9a-fA-F]{40}\z/
17
15
  NONCE_REGEX = /\A[A-Za-z0-9]{8,}\z/
18
16
  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.
17
+ # ERC-4361 statement = reserved / unreserved / " " per RFC 3986. The regex unions
18
+ # ALPHA + DIGIT with the punctuation in those sets; excludes LF, ", %, and other
19
+ # characters outside RFC 3986. Built via Regexp.new to avoid accidental string
20
+ # interpolation of `#$&` inside a regex literal.
22
21
  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/
22
+ # request-id = *pchar per ABNF — no "/" or "?".
23
+ REQUEST_ID_REGEX = Util::PCHAR_REGEX
25
24
 
26
25
  def self.parse(str)
27
26
  new(str).parse
@@ -46,7 +45,7 @@ module Siwe
46
45
 
47
46
  statement = parse_statement_block(lines[2...uri_idx])
48
47
 
49
- uri = parse_field(lines[uri_idx], "URI: ", :invalid_uri, URI_REGEX)
48
+ uri = parse_uri_field(lines[uri_idx], "URI: ")
50
49
  version = parse_field(lines[uri_idx + 1], "Version: ", :invalid_message_version, /\A1\z/)
51
50
  chain_str = parse_field(lines[uri_idx + 2], "Chain ID: ", :unable_to_parse, CHAIN_REGEX)
52
51
  nonce = parse_field(lines[uri_idx + 3], "Nonce: ", :invalid_nonce, NONCE_REGEX)
@@ -101,7 +100,7 @@ module Siwe
101
100
 
102
101
  private
103
102
 
104
- def parse_header(line) # rubocop:disable Metrics/AbcSize
103
+ def parse_header(line)
105
104
  raise unable_to_parse("missing header") if line.nil? || line.empty?
106
105
  raise unable_to_parse("malformed header") unless line.end_with?(HEADER_SUFFIX)
107
106
 
@@ -112,22 +111,22 @@ module Siwe
112
111
  scheme, _, domain = prefix.partition("://")
113
112
  raise unable_to_parse("invalid scheme: #{scheme.inspect}") unless SCHEME_REGEX.match?(scheme)
114
113
  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
114
 
115
+ validate_domain!(domain)
120
116
  [scheme, domain]
121
117
  else
122
- unless DOMAIN_REGEX.match?(prefix)
123
- raise Error.new(ErrorType::INVALID_DOMAIN, expected: "RFC 3986 authority",
124
- received: prefix)
125
- end
126
-
118
+ validate_domain!(prefix)
127
119
  [nil, prefix]
128
120
  end
129
121
  end
130
122
 
123
+ def validate_domain!(domain)
124
+ return if Util.valid_authority?(domain) && !domain.empty?
125
+
126
+ raise Error.new(ErrorType::INVALID_DOMAIN, expected: "RFC 3986 authority",
127
+ received: domain)
128
+ end
129
+
131
130
  def parse_address(line)
132
131
  raise unable_to_parse("missing address line") if line.nil?
133
132
  unless ADDRESS_REGEX.match?(line)
@@ -188,12 +187,22 @@ module Siwe
188
187
  value
189
188
  end
190
189
 
190
+ def parse_uri_field(line, prefix)
191
+ raise unable_to_parse("missing #{prefix.strip}") if line.nil?
192
+ raise unable_to_parse("expected #{prefix.strip} line") unless line.start_with?(prefix)
193
+
194
+ value = line[prefix.length..]
195
+ raise Error.new(ErrorType::INVALID_URI, expected: "RFC 3986 URI", received: value) unless Util.valid_uri?(value)
196
+
197
+ value
198
+ end
199
+
191
200
  def parse_resources(lines, start_idx)
192
201
  idx = start_idx
193
202
  out = []
194
203
  while idx < lines.length && lines[idx].start_with?("- ")
195
204
  uri = lines[idx][2..]
196
- unless URI_REGEX.match?(uri)
205
+ unless Util.valid_uri?(uri)
197
206
  raise Error.new(ErrorType::INVALID_URI, expected: "RFC 3986 resource URI", received: uri)
198
207
  end
199
208
 
data/lib/siwe/rpc.rb CHANGED
@@ -25,6 +25,11 @@ module Siwe
25
25
  @timeout = timeout
26
26
  end
27
27
 
28
+ # Returns the chain id (Integer). Lazy-fetched via eth_chainId on first call and
29
+ # cached for the lifetime of this client. Raises Siwe::Error :rpc_error on probe
30
+ # failure rather than returning nil — callers in the smart-wallet path rely on
31
+ # this to enforce ERC-4361's chain binding (a silent nil could otherwise validate
32
+ # a signature against the wrong chain).
28
33
  def chain_id
29
34
  @chain_id ||= fetch_chain_id
30
35
  end
@@ -41,10 +46,7 @@ module Siwe
41
46
  private
42
47
 
43
48
  def fetch_chain_id
44
- hex = rpc_request("eth_chainId", [])
45
- hex.to_i(16)
46
- rescue Error
47
- nil
49
+ rpc_request("eth_chainId", []).to_i(16)
48
50
  end
49
51
 
50
52
  def rpc_request(method, params)
data/lib/siwe/util.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "ipaddr"
3
4
  require "securerandom"
4
5
  require "time"
5
6
  require "date"
@@ -23,6 +24,20 @@ module Siwe
23
24
  \z
24
25
  /x
25
26
 
27
+ # Character classes per RFC 3986. Anything outside these is rejected.
28
+ # unreserved + sub-delims + pct-encoded marker, used in reg-name.
29
+ HOST_CHAR_REGEX = /\A(?:[A-Za-z0-9\-._~!$&'()*+,;=]|%[0-9A-Fa-f]{2})*\z/
30
+ USERINFO_CHAR_REGEX = /\A(?:[A-Za-z0-9\-._~!$&'()*+,;=:]|%[0-9A-Fa-f]{2})*\z/
31
+ # pchar = unreserved / pct-encoded / sub-delims / ":" / "@". Used for request-id
32
+ # (ABNF "request-id = *pchar") and segments within a path.
33
+ PCHAR_REGEX = /\A(?:[A-Za-z0-9\-._~!$&'()*+,;=:@]|%[0-9A-Fa-f]{2})*\z/
34
+ # path = *( pchar / "/" ). No "?" or "#".
35
+ PATH_REGEX = %r{\A(?:[A-Za-z0-9\-._~!$&'()*+,;=:@/]|%[0-9A-Fa-f]{2})*\z}
36
+ # query / fragment = *( pchar / "/" / "?" ).
37
+ QUERY_FRAGMENT_REGEX = %r{\A(?:[A-Za-z0-9\-._~!$&'()*+,;=:@/?]|%[0-9A-Fa-f]{2})*\z}
38
+ PORT_REGEX = /\A\d*\z/
39
+ SCHEME_REGEX = /\A[A-Za-z][A-Za-z0-9+\-.]*\z/
40
+
26
41
  def generate_nonce
27
42
  SecureRandom.alphanumeric(NONCE_LENGTH)
28
43
  end
@@ -67,5 +82,112 @@ module Siwe
67
82
  rescue StandardError
68
83
  nil
69
84
  end
85
+
86
+ # Validate an RFC 3986 authority: [userinfo "@"] host [":" port].
87
+ # Used for both the SIWE `domain` field and any URI's authority component.
88
+ # Empty authority is valid (empty reg-name).
89
+ def valid_authority?(str)
90
+ return false if str.nil?
91
+ return true if str.empty?
92
+
93
+ userinfo, host_port = str.include?("@") ? str.split("@", 2) : [nil, str]
94
+ return false if userinfo && !USERINFO_CHAR_REGEX.match?(userinfo)
95
+
96
+ host, port = split_host_port(host_port)
97
+ return false if host.nil?
98
+ return false if port && !PORT_REGEX.match?(port)
99
+
100
+ valid_host?(host)
101
+ end
102
+
103
+ # Validate an absolute RFC 3986 URI: scheme ":" hier-part [ "?" query ] [ "#" fragment ].
104
+ def valid_uri?(str)
105
+ return false if str.nil? || str.empty?
106
+
107
+ scheme, rest = str.split(":", 2)
108
+ return false if rest.nil?
109
+ return false unless SCHEME_REGEX.match?(scheme)
110
+
111
+ m = rest.match(/\A(?<hier>[^?#]*)(?:\?(?<query>[^#]*))?(?:\#(?<frag>.*))?\z/)
112
+ return false if m.nil?
113
+ return false unless valid_hier_part?(m[:hier])
114
+ return false if m[:query] && !QUERY_FRAGMENT_REGEX.match?(m[:query])
115
+ return false if m[:frag] && !QUERY_FRAGMENT_REGEX.match?(m[:frag])
116
+
117
+ true
118
+ end
119
+
120
+ # host = IP-literal / IPv4address / reg-name. Distinguishes by leading "[".
121
+ def valid_host?(host)
122
+ if host.start_with?("[") && host.end_with?("]")
123
+ valid_ip_literal?(host[1..-2])
124
+ else
125
+ HOST_CHAR_REGEX.match?(host)
126
+ end
127
+ end
128
+
129
+ # IP-literal = IPv6address / IPvFuture (latter is rare and unused in SIWE — reject).
130
+ def valid_ip_literal?(content)
131
+ return false if content.nil? || content.empty?
132
+ return false if content.start_with?("v", "V") # IPvFuture not supported
133
+
134
+ ipv6 = content
135
+ if content.include?(".")
136
+ last_colon = content.rindex(":")
137
+ return false if last_colon.nil?
138
+
139
+ ipv4 = content[(last_colon + 1)..]
140
+ return false unless valid_dotted_ipv4?(ipv4)
141
+
142
+ # IPAddr rejects leading zeros in IPv4 octets ("zero-filled ambiguous"), but the
143
+ # SIWE test vectors treat them as valid. Rewrite the IPv4 tail as two h16 groups.
144
+ octets = ipv4.split(".").map(&:to_i)
145
+ g1 = format("%x", (octets[0] << 8) | octets[1])
146
+ g2 = format("%x", (octets[2] << 8) | octets[3])
147
+ ipv6 = "#{content[0..last_colon]}#{g1}:#{g2}"
148
+ end
149
+
150
+ IPAddr.new(ipv6).ipv6?
151
+ rescue IPAddr::InvalidAddressError
152
+ false
153
+ end
154
+
155
+ def valid_dotted_ipv4?(addr)
156
+ octets = addr.split(".", -1)
157
+ return false unless octets.length == 4
158
+
159
+ octets.all? { |o| o.match?(/\A\d{1,3}\z/) && o.to_i <= 255 }
160
+ end
161
+
162
+ def split_host_port(str)
163
+ if str.start_with?("[")
164
+ close = str.index("]")
165
+ return [nil, nil] if close.nil?
166
+
167
+ host = str[0..close]
168
+ rest = str[(close + 1)..]
169
+ if rest.empty?
170
+ [host, nil]
171
+ elsif rest.start_with?(":")
172
+ [host, rest[1..]]
173
+ else
174
+ [nil, nil]
175
+ end
176
+ elsif (colon = str.rindex(":"))
177
+ [str[0...colon], str[(colon + 1)..]]
178
+ else
179
+ [str, nil]
180
+ end
181
+ end
182
+
183
+ def valid_hier_part?(hier)
184
+ if hier.start_with?("//")
185
+ rest = hier[2..]
186
+ authority_end = rest.index("/") || rest.length
187
+ valid_authority?(rest[0...authority_end]) && PATH_REGEX.match?(rest[authority_end..])
188
+ else
189
+ PATH_REGEX.match?(hier)
190
+ end
191
+ end
70
192
  end
71
193
  end
data/lib/siwe/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Siwe
4
- VERSION = "0.1.2"
4
+ VERSION = "0.2.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: siwe-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - EthID.org
@@ -56,7 +56,6 @@ files:
56
56
  - lib/siwe/smart_wallet.rb
57
57
  - lib/siwe/util.rb
58
58
  - lib/siwe/version.rb
59
- - siwe-rb.gemspec
60
59
  homepage: https://github.com/signinwithethereum/siwe-rb
61
60
  licenses:
62
61
  - MIT
data/siwe-rb.gemspec DELETED
@@ -1,33 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "lib/siwe/version"
4
-
5
- Gem::Specification.new do |spec|
6
- spec.name = "siwe-rb"
7
- spec.version = Siwe::VERSION
8
- spec.authors = ["EthID.org"]
9
- spec.email = ["jalil@ethfollow.xyz"]
10
-
11
- spec.summary = "Sign-In with Ethereum (EIP-4361) for Ruby"
12
- spec.description = "EIP-4361 message construction, parsing, and signature verification, " \
13
- "with built-in support for ERC-1271 and EIP-6492 smart contract wallets."
14
- spec.homepage = "https://github.com/signinwithethereum/siwe-rb"
15
- spec.license = "MIT"
16
- spec.required_ruby_version = ">= 3.3"
17
-
18
- spec.metadata["homepage_uri"] = spec.homepage
19
- spec.metadata["source_code_uri"] = spec.homepage
20
- spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
21
- spec.metadata["rubygems_mfa_required"] = "true"
22
-
23
- spec.files = Dir.chdir(File.expand_path(__dir__)) do
24
- `git ls-files -z`.split("\x0").reject do |f|
25
- f == __FILE__ ||
26
- f.start_with?("spec/", "test/", "features/", "bin/", "gems/", "script/") ||
27
- f.match?(/\A\.(?:git|github|rubocop|ruby-version|rspec)/)
28
- end
29
- end
30
- spec.require_paths = ["lib"]
31
-
32
- spec.add_dependency "eth", ">= 0.5.11", "< 1.0"
33
- end