Skip to content

Commit c55bb2b

Browse files
Merge pull request #627 from JeneaVranceanu/fix/eip4361-invalid-fields
fix: detailed response of parsing EIP4361
2 parents 796f866 + c0447af commit c55bb2b

File tree

2 files changed

+216
-14
lines changed

2 files changed

+216
-14
lines changed

Sources/web3swift/Utils/EIP/EIP4361.swift

Lines changed: 86 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,33 +11,92 @@ import Core
1111
public typealias SIWE = EIP4361
1212

1313
fileprivate let datetimePattern = "[0-9]{4}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])[Tt]([01][0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9]|60)(.[0-9]+)?(([Zz])|([+|-]([01][0-9]|2[0-3]):[0-5][0-9]))"
14-
fileprivate let uriPattern = "(([^:?#]+):)?(([^?#]*))?([^?#]*)(\\?([^#]*))?(#(.*))?"
14+
fileprivate let uriPattern = "(([^:?#\\s]+):)?(([^?#\\s]*))?([^?#\\s]*)(\\?([^#\\s]*))?(#(.*))?"
1515

1616
/// Sign-In with Ethereum protocol and parser implementation.
17-
/// EIP-4361:
17+
///
18+
/// EIP-4361:
1819
/// - https://eips.ethereum.org/EIPS/eip-4361
1920
/// - https://github.com/ethereum/EIPs/blob/master/EIPS/eip-4361.md
2021
///
2122
/// Thanks to spruceid for SIWE implementation that was rewritten here in Swift: https://github.com/spruceid/siwe/blob/main/packages/siwe-parser/lib/regex.ts
23+
///
24+
/// ## How to use?
25+
///
26+
/// The best approach on how to get an instance of `EIP4361` is by calling the function ``EIP4361/validate(_:)``
27+
/// which in return gives you all the information you need for checking which SIWE attributes are missing or invalid,
28+
/// and parsed `EIP4361` object itself if the raw message indeed was a SIWE message.
29+
///
30+
/// ```swift
31+
/// let validationResponse = EIP4361.validate(rawStringMessage)
32+
/// guard validationResponse.isEIP4361 else { return }
33+
///
34+
/// if validationResponse.isValid {
35+
/// // Safe to force unwrap the `eip4361`
36+
/// validationResponse.eip4361!
37+
/// ...
38+
/// } else {
39+
/// // e.g. present user with an error message ...
40+
/// // or
41+
/// // use `validationResponse.parsedFields` to check which fields are missing or invalid
42+
/// let isAddressValid = EthereumAddress(validationResponse[.address] ?? "") != nil
43+
/// ...
44+
/// }
45+
/// ```
2246
public final class EIP4361 {
2347

24-
private static let domain = "(?<domain>([^?#]*)) wants you to sign in with your Ethereum account:"
25-
private static let address = "\\n(?<address>0x[a-zA-Z0-9]{40})\\n\\n"
26-
private static let statementParagraph = "((?<statement>[^\\n]+)\\n)?"
27-
private static let uri = "\\nURI: (?<uri>(\(uriPattern))?)"
28-
private static let version = "\\nVersion: (?<version>1)"
29-
private static let chainId = "\\nChain ID: (?<chainId>[0-9a-fA-F]+)"
30-
private static let nonce = "\\nNonce: (?<nonce>[a-zA-Z0-9]{8,})"
31-
private static let issuedAt = "\\nIssued At: (?<issuedAt>(\(datetimePattern)))"
32-
private static let expirationTime = "(\\nExpiration Time: (?<expirationTime>(\(datetimePattern))))?"
33-
private static let notBefore = "(\\nNot Before: (?<notBefore>(\(datetimePattern))))?"
34-
private static let requestId = "(\\nRequest ID: (?<requestId>[-._~!$&'()*+,;=:@%a-zA-Z0-9]*))?"
35-
private static let resourcesParagraph = "(\\nResources:(?<resources>(\\n- (\(uriPattern))?)+))?"
48+
public enum EIP4361Field: String {
49+
case domain = "domain"
50+
case address = "address"
51+
case statement = "statement"
52+
case uri = "uri"
53+
case version = "version"
54+
case chainId = "chainId"
55+
case nonce = "nonce"
56+
case issuedAt = "issuedAt"
57+
case expirationTime = "expirationTime"
58+
case notBefore = "notBefore"
59+
case requestId = "requestId"
60+
case resources = "resources"
61+
}
62+
63+
private static let domain = "(?<\(EIP4361Field.domain.rawValue)>([^?#]*)) wants you to sign in with your Ethereum account:"
64+
private static let address = "\\n(?<\(EIP4361Field.address.rawValue)>0x[a-zA-Z0-9]{40})\\n\\n"
65+
private static let statementParagraph = "((?<\(EIP4361Field.statement.rawValue)>[^\\n]+)\\n)?"
66+
private static let uri = "\\nURI: (?<\(EIP4361Field.uri.rawValue)>(\(uriPattern))?)"
67+
private static let version = "\\nVersion: (?<\(EIP4361Field.version.rawValue)>[0-9]+)"
68+
private static let chainId = "\\nChain ID: (?<\(EIP4361Field.chainId.rawValue)>[0-9a-fA-F]+)"
69+
private static let nonce = "\\nNonce: (?<\(EIP4361Field.nonce.rawValue)>[a-zA-Z0-9]{8,})"
70+
private static let issuedAt = "\\nIssued At: (?<\(EIP4361Field.issuedAt.rawValue)>(\(datetimePattern)))"
71+
private static let expirationTime = "(\\nExpiration Time: (?<\(EIP4361Field.expirationTime.rawValue)>(\(datetimePattern))))?"
72+
private static let notBefore = "(\\nNot Before: (?<\(EIP4361Field.notBefore.rawValue)>(\(datetimePattern))))?"
73+
private static let requestId = "(\\nRequest ID: (?<\(EIP4361Field.requestId.rawValue)>[-._~!$&'()*+,;=:@%a-zA-Z0-9]*))?"
74+
private static let resourcesParagraph = "(\\nResources:(?<\(EIP4361Field.resources.rawValue)>(\\n- (\(uriPattern))?)+))?"
3675

3776
private static var eip4361Pattern: String {
3877
"^\(domain)\(address)\(statementParagraph)\(uri)\(version)\(chainId)\(nonce)\(issuedAt)\(expirationTime)\(notBefore)\(requestId)\(resourcesParagraph)$"
3978
}
4079

80+
private static var eip4361OptionalPattern: String {
81+
"^\(domain)(\(address))?\(statementParagraph)(\(uri))?(\(version))?(\(chainId))?(\(nonce))?(\(issuedAt))?\(expirationTime)\(notBefore)\(requestId)\(resourcesParagraph)$"
82+
}
83+
84+
public static func validate(_ message: String) -> EIP4361ValidationResponse {
85+
let siweConstantMessageRegex = try! NSRegularExpression(pattern: "^\(domain)\\n")
86+
guard siweConstantMessageRegex.firstMatch(in: message, range: message.fullNSRange) != nil else {
87+
return EIP4361ValidationResponse(isEIP4361: false, eip4361: nil, capturedFields: [:])
88+
}
89+
90+
let eip4361Regex = try! NSRegularExpression(pattern: eip4361OptionalPattern)
91+
var capturedFields: [EIP4361Field: String] = [:]
92+
for (key, value) in eip4361Regex.captureGroups(string: message) {
93+
capturedFields[.init(rawValue: key)!] = value
94+
}
95+
return EIP4361ValidationResponse(isEIP4361: true,
96+
eip4361: EIP4361(message),
97+
capturedFields: capturedFields)
98+
}
99+
41100
/// `domain` is the RFC 3986 authority that is requesting the signing.
42101
public let domain: String
43102
/// `address` is the Ethereum address performing the signing conformant to capitalization encoded checksum specified in EIP-55 where applicable.
@@ -76,6 +135,7 @@ public final class EIP4361 {
76135
let uri = URL(string: rawUri),
77136
let rawVersion = groups["version"],
78137
let version = BigUInt(rawVersion, radix: 10) ?? BigUInt(rawVersion, radix: 16),
138+
version == 1,
79139
let rawChainId = groups["chainId"],
80140
let chainId = BigUInt(rawChainId, radix: 10) ?? BigUInt(rawChainId, radix: 16),
81141
let nonce = groups["nonce"],
@@ -134,3 +194,15 @@ public final class EIP4361 {
134194
}
135195
}
136196

197+
/// A structure that holds the information about Sign In With Ethereum message and allows you to check
198+
/// if the raw message is indeed a SIWE message, if it's a valid SIWE, which fields are present in the message
199+
/// and if it's a valid message holds a reference to fully parsed ``EIP4361`` object.
200+
public struct EIP4361ValidationResponse {
201+
public let isEIP4361: Bool
202+
public let eip4361: EIP4361?
203+
public let capturedFields: [EIP4361.EIP4361Field: String]
204+
205+
public var isValid: Bool {
206+
eip4361 != nil
207+
}
208+
}

Tests/web3swiftTests/localTests/EIP4361Test.swift

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,134 @@ class EIP4361Test: XCTestCase {
3737
URL(string: "https://example.com/my-web2-claim.json")!])
3838
XCTAssertEqual(siweMessage.description, rawSiweMessage)
3939
}
40+
41+
func test_EIP4361StaticValidationFunc() {
42+
let rawSiweMessage = "service.invalid wants you to sign in with your Ethereum account:\n0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2\n\nI accept the ServiceOrg Terms of Service: https://service.invalid/tos\n\nURI: https://service.invalid/login\nVersion: 1\nChain ID: 1\nNonce: 32891756\nIssued At: 2021-09-30T16:25:24.345Z\nExpiration Time: 2021-09-29T15:25:24.234Z\nNot Before: 2021-10-28T14:25:24.123Z\nRequest ID: random-request-id_STRING!@$%%&\nResources:\n- ipfs://bafybeiemxf5abjwjbikoz4mc3a3dla6ual3jsgpdr4cjr3oz3evfyavhwq/\n- https://example.com/my-web2-claim.json"
43+
44+
let validationResponse = EIP4361.validate(rawSiweMessage)
45+
46+
guard validationResponse.isValid else {
47+
XCTFail("Failed to parse SIWE message.")
48+
return
49+
}
50+
51+
let siweMessage = validationResponse.eip4361!
52+
53+
let dateFormatter = ISO8601DateFormatter()
54+
dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
55+
XCTAssertEqual(siweMessage.domain, "service.invalid")
56+
XCTAssertEqual(siweMessage.address, EthereumAddress("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2")!)
57+
XCTAssertEqual(siweMessage.statement, "I accept the ServiceOrg Terms of Service: https://service.invalid/tos")
58+
XCTAssertEqual(siweMessage.uri, URL(string: "https://service.invalid/login")!)
59+
XCTAssertEqual(siweMessage.version, 1)
60+
XCTAssertEqual(siweMessage.chainId, 1)
61+
XCTAssertEqual(siweMessage.nonce, "32891756")
62+
XCTAssertEqual(siweMessage.issuedAt, dateFormatter.date(from: "2021-09-30T16:25:24.345Z")!)
63+
XCTAssertEqual(siweMessage.expirationTime, dateFormatter.date(from: "2021-09-29T15:25:24.234Z")!)
64+
XCTAssertEqual(siweMessage.notBefore, dateFormatter.date(from: "2021-10-28T14:25:24.123Z")!)
65+
XCTAssertEqual(siweMessage.requestId, "random-request-id_STRING!@$%%&")
66+
XCTAssertEqual(siweMessage.resources, [URL(string: "ipfs://bafybeiemxf5abjwjbikoz4mc3a3dla6ual3jsgpdr4cjr3oz3evfyavhwq/")!,
67+
URL(string: "https://example.com/my-web2-claim.json")!])
68+
XCTAssertEqual(siweMessage.description, rawSiweMessage)
69+
}
70+
71+
func test_validEIP4361_noOptionalFields() {
72+
let rawSiweMessage = "service.invalid wants you to sign in with your Ethereum account:\n0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2\n\nI accept the ServiceOrg Terms of Service: https://service.invalid/tos\n\nURI: https://service.invalid/login\nVersion: 1\nChain ID: 1\nNonce: 32891756\nIssued At: 2021-09-30T16:25:24.345Z"
73+
74+
let validationResponse = EIP4361.validate(rawSiweMessage)
75+
guard validationResponse.isValid else {
76+
XCTFail("Failed to parse valid SIWE message.")
77+
return
78+
}
79+
80+
XCTAssertNotNil(validationResponse.eip4361)
81+
XCTAssertNil(validationResponse.capturedFields[.expirationTime])
82+
XCTAssertNil(validationResponse.capturedFields[.notBefore])
83+
XCTAssertNil(validationResponse.capturedFields[.requestId])
84+
XCTAssertNil(validationResponse.capturedFields[.resources])
85+
}
86+
87+
func test_invalidEIP4361_missingAddress() {
88+
let rawSiweMessage = "service.invalid wants you to sign in with your Ethereum account:\nI accept the ServiceOrg Terms of Service: https://service.invalid/tos\n\nURI: https://service.invalid/login\nVersion: 1\nChain ID: 1\nNonce: 32891756\nIssued At: 2021-09-30T16:25:24.345Z\nExpiration Time: 2021-09-29T15:25:24.234Z\nNot Before: 2021-10-28T14:25:24.123Z\nRequest ID: random-request-id_STRING!@$%%&\nResources:\n- ipfs://bafybeiemxf5abjwjbikoz4mc3a3dla6ual3jsgpdr4cjr3oz3evfyavhwq/\n- https://example.com/my-web2-claim.json"
89+
90+
let validationResponse = EIP4361.validate(rawSiweMessage)
91+
guard validationResponse.isEIP4361 && !validationResponse.isValid else {
92+
XCTFail("Failed to parse SIWE message. isEIP4361 must be `true` but the SIWE must be invalid.")
93+
return
94+
}
95+
96+
XCTAssertNil(validationResponse.capturedFields[.address])
97+
}
98+
99+
func test_invalidEIP4361_missingUri() {
100+
let rawSiweMessage = "service.invalid wants you to sign in with your Ethereum account:\n0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2\n\nI accept the ServiceOrg Terms of Service: https://service.invalid/tos\n\nVersion: 1\nChain ID: 1\nNonce: 32891756\nIssued At: 2021-09-30T16:25:24.345Z\nExpiration Time: 2021-09-29T15:25:24.234Z\nNot Before: 2021-10-28T14:25:24.123Z\nRequest ID: random-request-id_STRING!@$%%&\nResources:\n- ipfs://bafybeiemxf5abjwjbikoz4mc3a3dla6ual3jsgpdr4cjr3oz3evfyavhwq/\n- https://example.com/my-web2-claim.json"
101+
102+
let validationResponse = EIP4361.validate(rawSiweMessage)
103+
guard validationResponse.isEIP4361 && !validationResponse.isValid else {
104+
XCTFail("Failed to parse SIWE message. isEIP4361 must be `true` but the SIWE must be invalid.")
105+
return
106+
}
107+
108+
XCTAssertNil(validationResponse.capturedFields[.uri])
109+
}
110+
111+
func test_invalidEIP4361_missingVersion() {
112+
let rawSiweMessage = "service.invalid wants you to sign in with your Ethereum account:\n0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2\n\nI accept the ServiceOrg Terms of Service: https://service.invalid/tos\n\nURI: https://service.invalid/login\nChain ID: 1\nNonce: 32891756\nIssued At: 2021-09-30T16:25:24.345Z\nExpiration Time: 2021-09-29T15:25:24.234Z\nNot Before: 2021-10-28T14:25:24.123Z\nRequest ID: random-request-id_STRING!@$%%&\nResources:\n- ipfs://bafybeiemxf5abjwjbikoz4mc3a3dla6ual3jsgpdr4cjr3oz3evfyavhwq/\n- https://example.com/my-web2-claim.json"
113+
114+
let validationResponse = EIP4361.validate(rawSiweMessage)
115+
guard validationResponse.isEIP4361 && !validationResponse.isValid else {
116+
XCTFail("Failed to parse SIWE message. isEIP4361 must be `true` but the SIWE must be invalid.")
117+
return
118+
}
119+
120+
XCTAssertNil(validationResponse.capturedFields[.version])
121+
}
122+
123+
func test_invalidEIP4361_missingChainId() {
124+
let rawSiweMessage = "service.invalid wants you to sign in with your Ethereum account:\n0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2\n\nI accept the ServiceOrg Terms of Service: https://service.invalid/tos\n\nURI: https://service.invalid/login\nVersion: 1\nNonce: 32891756\nIssued At: 2021-09-30T16:25:24.345Z\nExpiration Time: 2021-09-29T15:25:24.234Z\nNot Before: 2021-10-28T14:25:24.123Z\nRequest ID: random-request-id_STRING!@$%%&\nResources:\n- ipfs://bafybeiemxf5abjwjbikoz4mc3a3dla6ual3jsgpdr4cjr3oz3evfyavhwq/\n- https://example.com/my-web2-claim.json"
125+
126+
let validationResponse = EIP4361.validate(rawSiweMessage)
127+
guard validationResponse.isEIP4361 && !validationResponse.isValid else {
128+
XCTFail("Failed to parse SIWE message. isEIP4361 must be `true` but the SIWE must be invalid.")
129+
return
130+
}
131+
132+
XCTAssertNil(validationResponse.capturedFields[.chainId])
133+
}
134+
135+
func test_invalidEIP4361_missingNonce() {
136+
let rawSiweMessage = "service.invalid wants you to sign in with your Ethereum account:\n0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2\n\nI accept the ServiceOrg Terms of Service: https://service.invalid/tos\n\nURI: https://service.invalid/login\nVersion: 1\nChain ID: 1\nIssued At: 2021-09-30T16:25:24.345Z\nExpiration Time: 2021-09-29T15:25:24.234Z\nNot Before: 2021-10-28T14:25:24.123Z\nRequest ID: random-request-id_STRING!@$%%&\nResources:\n- ipfs://bafybeiemxf5abjwjbikoz4mc3a3dla6ual3jsgpdr4cjr3oz3evfyavhwq/\n- https://example.com/my-web2-claim.json"
137+
138+
let validationResponse = EIP4361.validate(rawSiweMessage)
139+
guard validationResponse.isEIP4361 && !validationResponse.isValid else {
140+
XCTFail("Failed to parse SIWE message. isEIP4361 must be `true` but the SIWE must be invalid.")
141+
return
142+
}
143+
144+
XCTAssertNil(validationResponse.capturedFields[.nonce])
145+
}
146+
147+
func test_invalidEIP4361_missingIssuedAt() {
148+
let rawSiweMessage = "service.invalid wants you to sign in with your Ethereum account:\n0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2\n\nI accept the ServiceOrg Terms of Service: https://service.invalid/tos\n\nURI: https://service.invalid/login\nVersion: 1\nChain ID: 1\nNonce: 32891756\nExpiration Time: 2021-09-29T15:25:24.234Z\nNot Before: 2021-10-28T14:25:24.123Z\nRequest ID: random-request-id_STRING!@$%%&\nResources:\n- ipfs://bafybeiemxf5abjwjbikoz4mc3a3dla6ual3jsgpdr4cjr3oz3evfyavhwq/\n- https://example.com/my-web2-claim.json"
149+
150+
let validationResponse = EIP4361.validate(rawSiweMessage)
151+
guard validationResponse.isEIP4361 && !validationResponse.isValid else {
152+
XCTFail("Failed to parse SIWE message. isEIP4361 must be `true` but the SIWE must be invalid.")
153+
return
154+
}
155+
156+
XCTAssertNil(validationResponse.capturedFields[.issuedAt])
157+
}
158+
159+
func test_invalidEIP4361_wrongVersionNumber() {
160+
let rawSiweMessage = "service.invalid wants you to sign in with your Ethereum account:\n0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2\n\nI accept the ServiceOrg Terms of Service: https://service.invalid/tos\n\nURI: https://service.invalid/login\nVersion: 123\nChain ID: 1\nNonce: 32891756\nIssued At: 2021-09-30T16:25:24.345Z\nExpiration Time: 2021-09-29T15:25:24.234Z\nNot Before: 2021-10-28T14:25:24.123Z\nRequest ID: random-request-id_STRING!@$%%&\nResources:\n- ipfs://bafybeiemxf5abjwjbikoz4mc3a3dla6ual3jsgpdr4cjr3oz3evfyavhwq/\n- https://example.com/my-web2-claim.json"
161+
162+
let validationResponse = EIP4361.validate(rawSiweMessage)
163+
guard validationResponse.isEIP4361 && !validationResponse.isValid else {
164+
XCTFail("Failed to parse SIWE message. isEIP4361 must be `true` but the SIWE must be invalid.")
165+
return
166+
}
167+
168+
XCTAssertEqual(validationResponse.capturedFields[.version], "123")
169+
}
40170
}

0 commit comments

Comments
 (0)