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 +4 -4
- data/CHANGELOG.md +12 -0
- data/Gemfile.lock +2 -2
- data/README.md +1 -1
- data/lib/siwe/adapter.rb +3 -14
- data/lib/siwe/config.rb +8 -20
- data/lib/siwe/eip6492.rb +0 -3
- data/lib/siwe/message.rb +33 -6
- data/lib/siwe/parser.rb +29 -20
- data/lib/siwe/rpc.rb +6 -4
- data/lib/siwe/util.rb +122 -0
- data/lib/siwe/version.rb +1 -1
- metadata +1 -2
- data/siwe-rb.gemspec +0 -33
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 94f4cdbde4fcf4c3f8e2e30c26390a0ed6a56600297f9a843d651e48009a64ea
|
|
4
|
+
data.tar.gz: 7b065dbbb37260fae1520c4a8f3d89e9917ebcd8b187e6aa0467104591b9b7cd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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
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
|
-
#
|
|
12
|
-
#
|
|
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)
|
|
7
|
-
#
|
|
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
|
|
11
|
+
attr_reader :rpc_url, :rpc, :adapter
|
|
12
12
|
|
|
13
|
-
def initialize(rpc_url: nil, rpc: nil, adapter: 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
|
|
26
|
+
attr_accessor :rpc_url, :rpc, :adapter
|
|
33
27
|
|
|
34
|
-
def initialize(rpc_url: nil, rpc: nil, adapter: 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
|
-
|
|
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::
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
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
|
-
|
|
24
|
-
|
|
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 =
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
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.
|
|
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
|